Files
FRASA-CAS/scripts/generar-pdf-documento.py
Consult AS Technology e8d1c57ad5 docs: publicar el documento de alcance como pagina web privada en /doc/alcance-2f9k7d/
- 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.
2026-09-21 19:16:48 +00:00

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()