diff --git a/docs/CRM360-FRASA_Alcance-vs-Entregado.pdf b/docs/CRM360-FRASA_Alcance-vs-Entregado.pdf index 8c551e7..418b80f 100644 Binary files a/docs/CRM360-FRASA_Alcance-vs-Entregado.pdf and b/docs/CRM360-FRASA_Alcance-vs-Entregado.pdf differ diff --git a/scripts/generar-pdf-documento.py b/scripts/generar-pdf-documento.py new file mode 100644 index 0000000..4bbace3 --- /dev/null +++ b/scripts/generar-pdf-documento.py @@ -0,0 +1,233 @@ +#!/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'