- 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.
266 lines
9.6 KiB
Python
266 lines
9.6 KiB
Python
#!/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/<slug>/index.html + docs/web/<slug>/<documento>.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('<table>', '<div class="tabla-scroll"><table>')
|
|
return cuerpo.replace('</table>', '</table></div>')
|
|
|
|
|
|
def ficha_html(datos: list[tuple[str, str]]) -> str:
|
|
if not datos:
|
|
return ''
|
|
filas = '\n'.join(
|
|
f' <div><dt>{html_mod.escape(etiqueta)}</dt><dd>{html_mod.escape(valor)}</dd></div>'
|
|
for etiqueta, valor in datos
|
|
)
|
|
return f' <dl class="ficha">\n{filas}\n </dl>\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/<slug>)')
|
|
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"""<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
|
<meta name="color-scheme" content="light dark" />
|
|
<title>{html_mod.escape(principal)} — {html_mod.escape(secundario or subtitulo)}</title>
|
|
<link rel="icon" href="logo.png" />
|
|
<style>{CSS_WEB}</style>
|
|
</head>
|
|
<body>
|
|
<header class="barra">
|
|
<img src="logo.png" alt="CRM360 FRASA" />
|
|
<div class="rotulo">
|
|
<strong>{html_mod.escape(principal)}</strong>
|
|
<span>{html_mod.escape(secundario or subtitulo)}</span>
|
|
</div>
|
|
<a class="boton" href="{nombre_pdf}" download>Descargar PDF</a>
|
|
</header>
|
|
|
|
<main>
|
|
<article class="hoja">
|
|
<p class="sello">Documento confidencial · Preparado para el cliente</p>
|
|
<h1>{html_mod.escape(principal)}</h1>
|
|
<p class="bajada">{html_mod.escape(secundario or subtitulo)}</p>
|
|
{ficha_html(datos)}{cuerpo}
|
|
</article>
|
|
</main>
|
|
|
|
<footer class="pie">Documento preparado por {PROVEEDOR}</footer>
|
|
</body>
|
|
</html>
|
|
"""
|
|
(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()
|