#!/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' {k}{v}' for k, v in datos) cubierta = f"""
{PROVEEDOR}

{principal}{f'{kicker}' if kicker else ''}

Documento confidencial · Preparado para el cliente
{filas}
""" cuerpo = markdown.markdown(cuerpo_md, extensions=['tables', 'sane_lists', 'attr_list']) html = ( '' f'{titulo}{cubierta}{cuerpo}' ) 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()