- scripts/generar-web-documento.py arma la pagina (barra con logotipo, boton de descarga del PDF, hoja centrada, tablas con scroll en pantallas chicas, modo oscuro automatico y estilos de impresion) y reutiliza el generador de PDF. - La pagina se sirve con un prefijo privado en NGINX: no se enlaza desde ninguna app, lleva X-Robots-Tag y meta noindex, y la cache se desactiva para que los cambios se vean al momento. - Se sincroniza deploy/nginx-host.conf con lo que estaba instalado en el host (faltaba el cache de assets immutable) y se agrega la ruta privada. - El logotipo se reduce a tamano de pantalla: venia de un JPEG de 959 px y era el 90% del peso del PDF (495 KB -> 240 KB), sin cambiar el diseno.
243 lines
8.7 KiB
Python
243 lines
8.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Genera el PDF de un documento de `docs/` con portada institucional.
|
|
|
|
Toma un archivo markdown cuyo encabezado tenga esta forma:
|
|
|
|
# Título del documento
|
|
**Subtítulo o bajada**
|
|
| | |
|
|
|---|---|
|
|
| **Etiqueta** | Valor |
|
|
|
|
y produce un PDF A4 con portada (logo + tabla de datos), tablas con encabezado
|
|
verde, pie con numeración y saltos de página que no parten filas ni dejan
|
|
títulos huérfanos.
|
|
|
|
Uso:
|
|
/root/.venv-tools/bin/python scripts/generar-pdf-documento.py docs/ALCANCE-VS-ENTREGADO.md
|
|
|
|
Requiere weasyprint, markdown y Pillow, que en esta máquina viven en el
|
|
entorno aislado /root/.venv-tools (no en el python del sistema).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import pathlib
|
|
import re
|
|
import tempfile
|
|
from collections import deque
|
|
|
|
import markdown
|
|
from PIL import Image
|
|
from weasyprint import CSS, HTML
|
|
|
|
RAIZ = pathlib.Path(__file__).resolve().parent.parent
|
|
LOGO_FUENTE = RAIZ / 'apps' / 'landing' / 'public' / 'logo.png'
|
|
PROVEEDOR = 'Consult (AS) Technology'
|
|
COLOR_FONDO_LOGO = (247, 247, 247)
|
|
TOLERANCIA_FONDO = 8
|
|
|
|
|
|
DIMENSION_LOGO = (480, 360)
|
|
|
|
|
|
def logo_con_transparencia() -> pathlib.Path:
|
|
"""El logo del repo trae fondo gris; se aplana a alfa 0 desde los bordes.
|
|
|
|
Se rellena solo desde el exterior para no borrar los grises interiores del
|
|
arte (contrafuertes de letras). Sin transparencia, la portada blanca dibuja
|
|
un rectángulo gris alrededor del logotipo.
|
|
|
|
La salida se reduce a `DIMENSION_LOGO`: el original de 959 px pesa 440 KB
|
|
(viene de un JPEG, con ruido que el PNG no comprime) y se dibuja a 24 mm, así
|
|
que sobra resolución. El nombre del archivo incluye el tamaño para no reusar
|
|
una versión vieja con otras medidas.
|
|
"""
|
|
destino = pathlib.Path(tempfile.gettempdir()) / f'frasa-logo-transparente-{DIMENSION_LOGO[0]}x{DIMENSION_LOGO[1]}.png'
|
|
if destino.exists():
|
|
return destino
|
|
|
|
imagen = Image.open(LOGO_FUENTE).convert('RGBA')
|
|
ancho, alto = imagen.size
|
|
pixeles = imagen.load()
|
|
|
|
def es_fondo(p: tuple[int, ...]) -> bool:
|
|
return all(abs(p[i] - COLOR_FONDO_LOGO[i]) <= TOLERANCIA_FONDO for i in range(3))
|
|
|
|
visto = bytearray(ancho * alto)
|
|
cola: deque[tuple[int, int]] = deque()
|
|
for x in range(ancho):
|
|
cola.append((x, 0))
|
|
cola.append((x, alto - 1))
|
|
for y in range(alto):
|
|
cola.append((0, y))
|
|
cola.append((ancho - 1, y))
|
|
|
|
while cola:
|
|
x, y = cola.popleft()
|
|
indice = y * ancho + x
|
|
if visto[indice]:
|
|
continue
|
|
visto[indice] = 1
|
|
if not es_fondo(pixeles[x, y]):
|
|
continue
|
|
pixeles[x, y] = (*COLOR_FONDO_LOGO, 0)
|
|
if x > 0:
|
|
cola.append((x - 1, y))
|
|
if x < ancho - 1:
|
|
cola.append((x + 1, y))
|
|
if y > 0:
|
|
cola.append((x, y - 1))
|
|
if y < alto - 1:
|
|
cola.append((x, y + 1))
|
|
|
|
imagen.thumbnail(DIMENSION_LOGO)
|
|
imagen.save(destino, optimize=True)
|
|
return destino
|
|
|
|
|
|
def partir_encabezado(texto: str) -> tuple[str, str, list[tuple[str, str]], str]:
|
|
"""Separa título, subtítulo, tabla de datos y cuerpo del markdown."""
|
|
corte = re.search(r'^## ', texto, flags=re.MULTILINE)
|
|
if corte is None:
|
|
raise SystemExit('El markdown necesita al menos una sección "## ..."')
|
|
encabezado, cuerpo = texto[: corte.start()], texto[corte.start() :]
|
|
|
|
titulo = re.search(r'^# (.+)$', encabezado, flags=re.MULTILINE)
|
|
if titulo is None:
|
|
raise SystemExit('El markdown necesita un título "# ..."')
|
|
subtitulo = re.search(r'^\*\*(.+)\*\*$', encabezado, flags=re.MULTILINE)
|
|
|
|
datos: list[tuple[str, str]] = []
|
|
for linea in encabezado.splitlines():
|
|
celdas = [c.strip() for c in linea.strip().strip('|').split('|')]
|
|
if len(celdas) != 2:
|
|
continue
|
|
etiqueta = celdas[0].replace('**', '').strip()
|
|
valor = celdas[1].replace('**', '').strip()
|
|
if not etiqueta or not valor or set(etiqueta) <= {'-'}:
|
|
continue
|
|
datos.append((etiqueta, valor))
|
|
|
|
return titulo.group(1).strip(), (subtitulo.group(1).strip() if subtitulo else ''), datos, cuerpo
|
|
|
|
|
|
CSS_TXT = """
|
|
@page {
|
|
size: A4;
|
|
margin: 20mm 15mm 18mm 15mm;
|
|
@bottom-left {
|
|
content: "CRM360 FRASA · Consult (AS) Technology";
|
|
font-family: 'DejaVu Sans', sans-serif; font-size: 8pt; color: #6B7280;
|
|
}
|
|
@bottom-right {
|
|
content: "Página " counter(page) " de " counter(pages);
|
|
font-family: 'DejaVu Sans', sans-serif; font-size: 8pt; color: #6B7280;
|
|
}
|
|
}
|
|
@page :first { margin: 0; @bottom-left { content: none; } @bottom-right { content: none; } }
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
font-family: 'DejaVu Sans', 'Helvetica', sans-serif;
|
|
font-size: 9.8pt; line-height: 1.52; color: #1F2937; margin: 0;
|
|
}
|
|
|
|
.portada { height: 297mm; padding: 26mm 20mm 18mm 20mm; page-break-after: always; }
|
|
.portada .barra { height: 6px; background: #1B4332; margin-bottom: 14mm; }
|
|
.portada img.logo { height: 24mm; margin-bottom: 10mm; }
|
|
.portada .eyebrow {
|
|
font-size: 9.5pt; letter-spacing: 2.2px; text-transform: uppercase;
|
|
color: #2D6A4F; font-weight: bold; margin-bottom: 4mm;
|
|
}
|
|
.portada h1 {
|
|
font-size: 29pt; line-height: 1.1; color: #10261C; margin: 0 0 2mm 0;
|
|
border: none; padding: 0;
|
|
}
|
|
.portada h1 .kicker {
|
|
display: block; font-size: 15pt; font-weight: normal; color: #2D6A4F;
|
|
margin-top: 3.5mm; line-height: 1.25;
|
|
}
|
|
.portada h2.sub {
|
|
font-size: 11.5pt; font-weight: normal; color: #4B5563;
|
|
margin: 6mm 0 15mm 0; border: none; padding: 0;
|
|
}
|
|
.portada table { width: 100%; border: none; font-size: 9pt; border-collapse: collapse; }
|
|
.portada table td {
|
|
border: none; border-bottom: 0.6pt solid #E5E7EB; padding: 2.6mm 0; vertical-align: top;
|
|
}
|
|
.portada table td:first-child {
|
|
width: 50mm; color: #6B7280; text-transform: uppercase; font-size: 7.8pt;
|
|
letter-spacing: 0.6px; padding-right: 4mm;
|
|
}
|
|
.portada table td:last-child { font-weight: bold; color: #10261C; }
|
|
.portada .pie { position: absolute; bottom: 18mm; font-size: 8.5pt; color: #6B7280; }
|
|
|
|
h2 {
|
|
font-size: 14.5pt; color: #10261C; margin: 9mm 0 3mm 0; padding-bottom: 1.6mm;
|
|
border-bottom: 1.4pt solid #2D6A4F; page-break-after: avoid;
|
|
}
|
|
h2:first-of-type { margin-top: 0; }
|
|
h3 { font-size: 11.4pt; color: #2D6A4F; margin: 6mm 0 2.5mm 0; page-break-after: avoid; }
|
|
p { margin: 0 0 3mm 0; text-align: justify; }
|
|
ul, ol { margin: 0 0 3.5mm 0; padding-left: 6mm; }
|
|
li { margin-bottom: 1.2mm; }
|
|
strong { color: #10261C; }
|
|
hr { border: none; border-top: 0.6pt solid #E5E7EB; margin: 6mm 0; break-after: avoid; break-inside: avoid; }
|
|
|
|
table { width: 100%; border-collapse: collapse; margin: 3mm 0 5mm 0; font-size: 8.8pt; page-break-inside: auto; }
|
|
thead { display: table-header-group; }
|
|
tr { page-break-inside: avoid; }
|
|
th {
|
|
background: #1B4332; color: #FFFFFF; text-align: left; padding: 2mm 2.2mm;
|
|
font-size: 8.6pt; border: 0.5pt solid #1B4332;
|
|
}
|
|
td { border: 0.5pt solid #DCE1E8; padding: 1.8mm 2.2mm; vertical-align: top; }
|
|
tbody tr:nth-child(even) td { background: #F6F8F7; }
|
|
td:first-child { white-space: nowrap; }
|
|
table td:empty { background: #FBFCFB; }
|
|
em { color: #4B5563; }
|
|
"""
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description='Genera el PDF de un documento de docs/')
|
|
parser.add_argument('markdown', type=pathlib.Path, help='ruta del .md de entrada')
|
|
parser.add_argument('--salida', type=pathlib.Path, help='ruta del .pdf (por defecto, junto al .md)')
|
|
args = parser.parse_args()
|
|
|
|
origen = args.markdown.resolve()
|
|
destino = args.salida or origen.with_suffix('.pdf')
|
|
titulo, subtitulo, datos, cuerpo_md = partir_encabezado(origen.read_text(encoding='utf-8'))
|
|
# Títulos largos con guión largo se parten en dos niveles para que la portada
|
|
# no deje una palabra sola en una tercera línea.
|
|
principal, _, secundario = titulo.partition(' — ')
|
|
kicker = secundario.strip()
|
|
|
|
filas = '\n'.join(f' <tr><td>{k}</td><td>{v}</td></tr>' for k, v in datos)
|
|
cubierta = f"""
|
|
<section class="portada">
|
|
<div class="barra"></div>
|
|
<img class="logo" src="{logo_con_transparencia().as_uri()}" alt="CRM360 FRASA" />
|
|
<div class="eyebrow">{PROVEEDOR}</div>
|
|
<h1>{principal}{f'<span class="kicker">{kicker}</span>' if kicker else ''}</h1>
|
|
<div class="pie">Documento confidencial · Preparado para el cliente</div>
|
|
<table>
|
|
{filas}
|
|
</table>
|
|
</section>
|
|
"""
|
|
|
|
cuerpo = markdown.markdown(cuerpo_md, extensions=['tables', 'sane_lists', 'attr_list'])
|
|
html = (
|
|
'<!DOCTYPE html><html lang="es"><head><meta charset="utf-8" />'
|
|
f'<title>{titulo}</title></head><body>{cubierta}{cuerpo}</body></html>'
|
|
)
|
|
|
|
HTML(string=html, base_url=str(RAIZ)).write_pdf(destino, stylesheets=[CSS(string=CSS_TXT)])
|
|
print(f'PDF generado: {destino} ({destino.stat().st_size} bytes)')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|