#!/usr/bin/env python3 """Publica un documento de `docs/` como página web no listada. Lee el mismo markdown que el generador de PDF, arma una página con el diseño institucional (barra superior con logotipo, hoja blanca, tablas adaptables y modo oscuro) y deja junto a ella el PDF para descargar. El resultado se sirve por NGINX desde una ruta privada: no se enlaza desde ninguna app, así que sólo se llega con el enlace. Uso: /root/.venv-tools/bin/python scripts/generar-web-documento.py \\ docs/ALCANCE-VS-ENTREGADO.md --slug alcance-2f9k7d Salida: docs/web//index.html + docs/web//.pdf + logo.png 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 html as html_mod import importlib.util import pathlib import re import shutil import subprocess import sys import markdown RAIZ = pathlib.Path(__file__).resolve().parent.parent AQUI = pathlib.Path(__file__).resolve().parent SCRIPT_PDF = AQUI / 'generar-pdf-documento.py' PROVEEDOR = 'Consult (AS) Technology' def cargar_generador_pdf(): """Importa el generador de PDF (el nombre del archivo lleva guiones).""" especificacion = importlib.util.spec_from_file_location('generador_pdf', SCRIPT_PDF) if especificacion is None or especificacion.loader is None: raise SystemExit(f'No se pudo cargar {SCRIPT_PDF}') modulo = importlib.util.module_from_spec(especificacion) especificacion.loader.exec_module(modulo) return modulo CSS_WEB = """ :root { --marca: #023681; --acento: #1B4332; --acento-claro: #2D6A4F; --texto: #1F2937; --texto-suave: #6B7280; --fondo: #EEF1F0; --hoja: #FFFFFF; --borde: #DCE1E8; --zebra: #F6F8F7; } @media (prefers-color-scheme: dark) { :root { --marca: #7FA8E8; --acento: #4ADE9B; --acento-claro: #6EE7B7; --texto: #E2E8F0; --texto-suave: #94A3B8; --fondo: #0B1220; --hoja: #131C2B; --borde: #2A3648; --zebra: #182233; } } * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; } body { margin: 0; background: var(--fondo); color: var(--texto); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-size: 16px; line-height: 1.65; } .barra { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 16px; padding: 12px max(20px, calc((100vw - 1040px) / 2)); background: var(--hoja); border-bottom: 3px solid var(--acento); } .barra img { height: 38px; display: block; } .barra .rotulo { display: flex; flex-direction: column; line-height: 1.25; margin-right: auto; } .barra .rotulo strong { font-size: 15px; letter-spacing: .2px; } .barra .rotulo span { font-size: 12.5px; color: var(--texto-suave); } @media (max-width: 640px) { .barra .rotulo span { display: none; } } .barra a.boton { display: inline-flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 999px; background: var(--marca); color: #fff; text-decoration: none; font-size: 14px; font-weight: 600; white-space: nowrap; } .barra a.boton:hover { filter: brightness(1.12); } @media (prefers-color-scheme: dark) { .barra a.boton { color: #0B1220; } } main { max-width: 1040px; margin: 28px auto 56px; padding: 0 20px; } .hoja { background: var(--hoja); border: 1px solid var(--borde); border-radius: 14px; padding: 44px 52px 36px; box-shadow: 0 1px 3px rgba(15, 23, 42, .06), 0 12px 32px -18px rgba(15, 23, 42, .25); } @media (max-width: 720px) { .hoja { padding: 26px 20px; border-radius: 10px; } } .sello { display: inline-block; margin: 0 0 18px; padding: 5px 12px; border-radius: 999px; background: var(--zebra); border: 1px solid var(--borde); font-size: 12px; letter-spacing: .4px; text-transform: uppercase; color: var(--texto-suave); } h1 { font-size: 30px; line-height: 1.22; margin: 0 0 6px; color: var(--acento); letter-spacing: -.4px; } p.bajada { font-size: 17px; color: var(--texto-suave); margin: 0 0 26px; } .ficha { border: 1px solid var(--borde); border-radius: 10px; overflow: hidden; margin: 0 0 34px; } .ficha div { display: flex; gap: 16px; padding: 10px 16px; border-bottom: 1px solid var(--borde); } .ficha div:last-child { border-bottom: none; } .ficha div:nth-child(odd) { background: var(--zebra); } .ficha dt { flex: 0 0 210px; margin: 0; font-size: 12px; letter-spacing: .5px; text-transform: uppercase; color: var(--texto-suave); padding-top: 3px; } .ficha dd { margin: 0; font-weight: 600; font-size: 14.5px; } @media (max-width: 620px) { .ficha div { display: block; } .ficha dt { margin-bottom: 2px; } } h2 { font-size: 21px; color: var(--acento); margin: 44px 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--acento-claro); scroll-margin-top: 80px; } h3 { font-size: 17px; color: var(--acento-claro); margin: 30px 0 10px; } p { margin: 0 0 14px; } ul, ol { margin: 0 0 16px; padding-left: 24px; } li { margin-bottom: 6px; } strong { color: var(--acento); } @media (prefers-color-scheme: dark) { strong { color: var(--texto); } } hr { border: none; border-top: 1px solid var(--borde); margin: 34px 0; } .tabla-scroll { overflow-x: auto; margin: 16px 0 26px; border: 1px solid var(--borde); border-radius: 10px; } table { width: 100%; border-collapse: collapse; font-size: 14px; } th { background: var(--acento); color: #fff; text-align: left; padding: 10px 12px; font-size: 13px; font-weight: 600; white-space: nowrap; } td { padding: 9px 12px; border-top: 1px solid var(--borde); vertical-align: top; } tbody tr:nth-child(even) td { background: var(--zebra); } td:first-child { white-space: nowrap; } @media (max-width: 620px) { td:first-child { white-space: normal; } } footer.pie { max-width: 1040px; margin: 0 auto 40px; padding: 0 20px; font-size: 13px; color: var(--texto-suave); text-align: center; } @media print { body { background: #fff; } .barra { position: static; border-bottom: 1px solid #ccc; } .barra a.boton { display: none; } main { max-width: none; margin: 0; padding: 0; } .hoja { border: none; box-shadow: none; padding: 0; } h2 { break-after: avoid; } .tabla-scroll { overflow: visible; border: none; } table { font-size: 11pt; } tr { break-inside: avoid; } } """ def envolver_tablas(cuerpo: str) -> str: """Las tablas se envuelven para poder desplazarse en pantallas chicas.""" cuerpo = cuerpo.replace('', '
') return cuerpo.replace('
', '') def ficha_html(datos: list[tuple[str, str]]) -> str: if not datos: return '' filas = '\n'.join( f'
{html_mod.escape(etiqueta)}
{html_mod.escape(valor)}
' for etiqueta, valor in datos ) return f'
\n{filas}\n
\n' def main() -> None: parser = argparse.ArgumentParser(description='Publica un documento de docs/ como página web') parser.add_argument('markdown', type=pathlib.Path, help='ruta del .md de entrada') parser.add_argument('--slug', required=True, help='carpeta de la ruta privada (p. ej. alcance-2f9k7d)') parser.add_argument('--salida', type=pathlib.Path, help='carpeta destino (por defecto docs/web/)') args = parser.parse_args() generador = cargar_generador_pdf() origen = args.markdown.resolve() destino = args.salida or RAIZ / 'docs' / 'web' / args.slug destino.mkdir(parents=True, exist_ok=True) titulo, subtitulo, datos, cuerpo_md = generador.partir_encabezado(origen.read_text(encoding='utf-8')) principal, _, secundario = titulo.partition(' — ') # El PDF es la versión para imprimir y compartir: se genera aquí mismo. # Conserva el nombre del markdown de origen (sin prefijo duplicado del título). nombre_pdf = f'{origen.stem}.pdf' ruta_pdf = destino / nombre_pdf subprocess.run( [sys.executable, str(SCRIPT_PDF), str(origen), '--salida', str(ruta_pdf)], check=True, capture_output=True, ) logo_destino = destino / 'logo.png' shutil.copy2(generador.logo_con_transparencia(), logo_destino) cuerpo = envolver_tablas(markdown.markdown(cuerpo_md, extensions=['tables', 'sane_lists', 'attr_list'])) anio = next((v for k, v in datos if 'fecha' in k.lower()), '') pie = f'{PROVEEDOR} · {anio}' if anio else PROVEEDOR pagina = f""" {html_mod.escape(principal)} — {html_mod.escape(secundario or subtitulo)}
CRM360 FRASA
{html_mod.escape(principal)} {html_mod.escape(secundario or subtitulo)}
Descargar PDF

Documento confidencial · Preparado para el cliente

{html_mod.escape(principal)}

{html_mod.escape(secundario or subtitulo)}

{ficha_html(datos)}{cuerpo}
""" (destino / 'index.html').write_text(pagina, encoding='utf-8') print(f'Página generada: {destino / "index.html"} ({(destino / "index.html").stat().st_size} bytes)') print(f'PDF para descarga: {ruta_pdf} ({ruta_pdf.stat().st_size} bytes)') if __name__ == '__main__': main()