Files
FRASA-CAS/scripts/generar-pdf-documento.py
Consult AS Technology 31abeec362 docs: generador reutilizable de PDF con portada institucional
Script que convierte cualquier documento de docs/ a PDF (portada con logotipo y
tabla de datos, encabezados de tabla repetidos, pie numerado y control de saltos
de pagina). Genera el logotipo con transparencia real: el PNG del repo trae fondo
gris y sobre la portada blanca dibujaba un rectangulo.

Con esto el PDF del documento de alcance se puede regenerar tras cualquier
cambio del markdown:
  /root/.venv-tools/bin/python scripts/generar-pdf-documento.py docs/ALCANCE-VS-ENTREGADO.md
2026-09-21 19:06:57 +00:00

234 lines
8.3 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
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.
"""
destino = pathlib.Path(tempfile.gettempdir()) / 'frasa-logo-transparente.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.save(destino)
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()