Reconstruyendo la exportación a PDF + DOCX: WeasyPrint, filtros Lua de Pandoc y confiar en el frontend
Para un editor de documentos, "Exportar" tiene que significar una sola cosa: lo que ves es lo que sale del archivo. Cualquier otra cosa se siente rota, incluso cuando lo roto es pequeño: un encabezado que está centrado en pantalla pero alineado a la izquierda en el PDF, un párrafo que se ajusta de forma distinta porque las métricas de fuente del servidor no coinciden con las del navegador, un DOCX exportado cuyo encabezado azul se volvió negro porque Word no habla CSS.
Durante las últimas dos semanas reescribimos toda la canalización de exportación detrás de AgentDoc – el editor de documentos nativo de IA (también: agent doc, agentdocs, docedit) – para que el WYSIWYG fuera realmente cierto. Dos formatos, dos motores, una idea arquitectónica: el frontend ya es la fuente de verdad para el diseño, así que el servidor debería seguirlo, no volver a derivarlo.
El diseño antiguo y por qué seguía estando sutilmente mal
Antes de la reescritura, tanto la exportación a PDF como a DOCX funcionaban enviando el markdown en bruto del documento al
backend, donde un renderizador (a) lo volvía a paginar en el servidor, (b) aplicaba un CSS que
era un subconjunto mantenido a mano del style.css del editor, y (c) entregaba el
resultado a WeasyPrint o a un escritor docx rápido. De ahí derivaban tres modos de fallo:
- La paginación no coincidía. El paginador del navegador medía cada bloque en el DOM real con fuentes reales cargadas; el servidor medía un DOM diferente con fuentes sutilmente distintas y anchos disponibles diferentes. Los documentos de diez páginas salían de forma fiable como de nueve u once, y la página donde aparecía un encabezado se desplazaba.
- Divergencia del CSS. El CSS de exportación era una copia de
style.csscon ediciones aplicadas de forma perezosa. Las tablas tenían encabezados centrados en el editor y alineados a la izquierda en el PDF;<blockquote>tenía un borde izquierdo en pantalla y no en la exportación. Estábamos depurando selectores individuales en lugar de la arquitectura. - DOCX era una bestia completamente distinta. El anterior camino rápido y chapucero insertaba el encabezado/pie como texto en cursiva en la parte superior e inferior del cuerpo, descartaba el color y las clases de fuente, y producía algo sobre lo que los usuarios de Word eran corteses pero que en realidad no usaban.
La paginación del contenido del editor en el servidor es un problema de caché disfrazado de problema de diseño. El navegador ya hizo el trabajo; enviar el resultado hacia abajo es más barato que rehacerlo.
El cambio arquitectónico: el frontend suministra un html_map
El editor ya sabe dónde cae cada salto de página. Su paginador ejecuta el diseño, recorre
el DOM y produce un array de fragmentos de HTML – uno por página – más los
metadatos del diseño activo (page_size, margin_mm). Todo lo que necesitábamos era una forma de
enviar eso a los endpoints de exportación.
El nuevo contrato de exportación: cuando el usuario hace clic en "Exportar PDF" o "Exportar DOCX", el frontend
adjunta el mapa de paginación (html_map) al cuerpo de la solicitud. El servidor
lo trata como el diseño autoritativo y deja de intentar calcular el suyo propio.
POST /api/doc/{id}/pdf
{
"html_map": [
"<div class='page-content'>…page 1 HTML…</div>",
"<div class='page-content'>…page 2 HTML…</div>",
…
],
"header_html": "…",
"footer_html": "…",
"page_size": "A4",
"margin_mm": 20
}
El generador de PDF envuelve cada entrada en un bloque .page-content de tamaño fijo,
fuerza un salto de página duro entre ellas mediante el CSS break-after: page, y deja que
WeasyPrint haga la rasterización real. El encabezado y el pie pasan por la
maquinaria running() de WeasyPrint para que aterricen en los recuadros de margen
superior-central / inferior-central de cada página física – incluida la rara segunda página física a la que
una entrada de mapa sobredimensionada pudiera desbordarse.
Dos canalizaciones, un respaldo
No todas las solicitudes de exportación vienen del navegador. Los agentes externos que llaman a nuestro servidor MCP
pueden emitir una llamada de herramienta "descarga este documento" antes de que un humano haya abierto el
documento en el editor – lo que significa que todavía no hay html_map, porque ningún
navegador ha hecho el trabajo de diseño.
Para ese caso, el servidor recurre a la paginación nativa de WeasyPrint mediante reglas @page.
Ya teníamos un respaldo antes, pero tenía el error que desencadenó la reescritura en
primer lugar: una regla de recorte de altura fija pensada para manejar el caso de html_map también se
disparaba en el camino de respaldo, y cualquier contenido más allá de la primera página se truncaba en silencio.
Investigaciones como esa son la razón por la que ahora mantenemos dos rutas de código distintas en lugar de una
generalización ingeniosa.
DOCX: Pandoc + un reference.docx construido a mano
DOCX merece un tratamiento especial porque Word no habla CSS en absoluto. El enfoque ingenuo – HTML → alguna librería → .docx – descarta cada clase en cada span y te da un documento que tiene el texto correcto pero ninguno de los estilos.
Usamos Pandoc para el cuerpo, python-docx para los
detalles específicos de Word que Pandoc no puede alcanzar (encabezado de página, pie de página), y un
reference.docx construido a mano como plantilla de estilo.
El truco del reference.docx
El flag --reference-doc=… de Pandoc te permite suministrar un documento de Word cuyos estilos
definen tu "Heading 1", "Normal", "Title", etc. Pandoc renderiza el
markdown, aplica estos estilos, y el resultado hereda todo lo que pusiste en la
referencia – fuentes, colores, altura de línea, márgenes, sangría de listas.
Generamos reference.docx a partir de un pequeño script de build de una sola pasada
(templates/build_reference_docx.py) que emite la referencia por defecto de Pandoc
y luego la parchea con python-docx para que:
- Heading 1 / 2 / 3 / Title usen Playfair Display, en negrita, en el navy text-primary
de nuestro editor (
#111827) – exactamente el mismo color que el encabezado tiene en el navegador. - Normal / List Paragraph / Body usen Inter 11pt en slate (
#374151), con altura de línea 1.7 y 0.25" de sangría de lista – ajustados contra el editor. - Los estilos de Header / Footer sean Inter 10pt con el fino borde inferior que el editor renderiza mediante CSS, para que el divisor visual sobreviva al cambio de formato.
- El
fontTable.xmlse amplíe con una entrada<w:font>por cada tipografía del editor, cada una con una pista<w:altName>que apunta a la familia más cercana del estándar de Word (Inter → Calibri, Playfair Display → Cambria, Roboto Mono → Consolas, etc.). Los usuarios de Word sin nuestras fuentes exactas instaladas ven la sustitución alternativa en lugar de caer a Times New Roman.
Clases en línea: el filtro Lua
El escritor docx de Pandoc descarta los atributos de estilo CSS en los elementos Span. Eso está
bien para markdown plano pero es un problema para AgentDoc, donde cada estilo en línea se codifica
como una clase stand-off en un span: [text]{.color-red},
[text]{.highlight-yellow}, [text]{.font-lora .size-xl}, etc.
La solución: un pequeño filtro Lua de Pandoc
(templates/inline_styles.lua) que se ejecuta sobre el AST durante la pasada del escritor docx
y traduce las clases a propiedades de run de OOXML en bruto:
function Span(el)
for _, cls in ipairs(el.classes) do
if cls:match("^color%-") then
local hex = COLORS[cls:sub(7)]
if hex then
return pandoc.RawInline("openxml",
'<w:rPr><w:color w:val="'..hex..'"/></w:rPr>'
), el.content
end
elseif cls:match("^highlight%-") then …
elseif cls:match("^font%-") then …
elseif cls:match("^size%-") then …
end
end
end
La cobertura es exhaustiva a propósito:
- Colores: 17 colores con nombre del editor mapeados al hex real que usa
el CSS, no a los hex de las palabras clave del W3C.
color-redes el alizarina más suave del editor#E74C3C, no#FF0000;color-bluees#3498DB, no#0000FF. Un encabezado azul en el navegador ahora se renderiza como el mismo azul en Word. - Resaltados: el conjunto completo de resaltados con nombre de Word (amarillo / verde / cian / magenta / azul / rojo más las variantes oscuras/claras); cualquier cosa fuera de ese conjunto cae a amarillo en lugar de descartarse en silencio.
- Fuentes: 12 entradas que emiten
<w:rFonts ascii="…" hAnsi="…" cs="…" eastAsia="…"/>para cada tipografía del editor. - Tamaños: 7 tokens (
xs..3xl) emitidos como valores<w:sz w:val="halfPt"/>, derivados en em contra la línea base del cuerpo de 13pt que usa reference.docx. - Decoraciones:
decoration-bold/-italic/-strikethroughse traducen a markdown estándar antes de Pandoc, de modo que obtenemos runs reales de<w:b/>,<w:i/>etc. en lugar de OOXML en bruto.
Las combinaciones se apilan. Un span como [text]{.color-red .size-xl .font-lora}
emite un único run con las tres propiedades establecidas en un solo <w:rPr>.
Encabezado y pie: la post-pasada de python-docx
El escritor docx de Pandoc produce un cuerpo pero no rellena el encabezado / pie de la sección. Nuestra solución anterior – insertarlos como líneas de apertura / cierre en cursiva – era un desastre ergonómico: cualquier usuario que exportara una carta real veía su texto de encabezado flotando sobre la dirección del destinatario.
Después de que Pandoc se ejecuta, una post-pasada de python-docx abre el docx resultante, recorre
section.header / section.footer, y escribe el
markdown del encabezado/pie a través de un pequeño parser en línea. El parser maneja la negrita (**…**),
la cursiva (*…*), y la misma sintaxis de span stand-off que usa el cuerpo
(color-X, highlight-X, decoration-*) – mapeados respectivamente a
colores de run, resaltados y propiedades de negrita/cursiva <w:rPr>
de Word.
El resultado vive en el encabezado de sección real de Word, lo que significa que aparece en cada página y se imprime correctamente – incluso en las re-paginaciones que Word hace por sí mismo cuando el destinatario abre el documento.
Gating de flujo de trabajo: T+ obtiene la nueva exportación, T se mantiene bit-estable
La exportación a DOCX es, deliberadamente, una función solo disponible en el Workflow T+. T se mantiene bit-estable porque los benchmarks de granularidad de herramientas se ejecutaron contra su superficie de herramientas exacta, y queremos que esos archivos CSV sigan siendo reproducibles. T+ es la vía de producción mutable posterior al benchmark y recibe las adiciones.
Esto se aplica en dos lugares para hacer imposibles las fugas:
agent/routers/chat.pydefineT_PLUS_EXCLUSIVE_TOOLS = {"trigger_docx_download"}; la rama de T excluye explícitamente ese conjunto para que una herramienta recién registrada no pueda aterrizar accidentalmente en el esquema congelado de T.mcp_workflow_middleware.WORKFLOW_T_DENYtambién lista la herramienta, de modo que los agentes externos (que están fijados a la superficie de T por diseño) tampoco la ven a través del endpoint MCP.
Lo que explícitamente no hicimos
Dos cosas quedaron sin hacer a propósito; ambas son compensaciones que vale la pena explicitar:
- Incrustación de fuentes. Una solución verdaderamente 1:1 incrustaría los TTF reales de
Inter / Playfair Display / etc. en cada DOCX generado. También añadiría
~400 KB por exportación, más una revisión legal por fuente para su redistribución. En su lugar nos apoyamos en la
sustitución de fuentes de Word + las pistas
altNameen nuestro fontTable ampliado. La mayoría de las familias del editor son Google Fonts populares que muchos usuarios de Office 365 ya tienen. - Una-canalización-para-gobernarlas-a-todas. Ahora mantenemos dos rutas de código de PDF distintas (html_map vs. respaldo) y una ruta de DOCX separada. Generalizar más permitiría que ese tipo de error de recorte de altura fija de antes volviera a ocurrir en silencio. Dos rutas de código más pruebas de integración le ganan a una ingeniosa.
Las pruebas de integración que de verdad atrapan regresiones
Lo que hace que esta reescritura se mantenga es un conjunto de scripts de auditoría manual que inspeccionan la salida renderizada:
backend/tests/manual_pdf_audit.py # uses pypdf to read text + font metadata
backend/tests/manual_docx_audit.py # walks the OOXML and checks run properties
backend/tests/manual_format_parity.py # diffs editor classes against Word run props
Estas no son pruebas unitarias; son herramientas que ejecutamos tras cambios no triviales para confirmar que
el archivo exportado realmente tiene el color que esperamos en el encabezado que esperamos. Añadimos
poppler-utils y pypdf al Dockerfile del backend para que funcionen
también en la imagen de producción, lo que significa que podemos ejecutarlas contra documentos de usuarios reales
al investigar una queja.
Lo que aprendimos que vale la pena conservar
Tres lecciones duraderas de esta reescritura:
- No vuelvas a derivar lo que el cliente ya calculó. Si el navegador paginó el documento, envía la paginación hacia abajo. Cualquier recálculo en el servidor diferirá de formas sutiles y pasarás semanas persiguiendo las diferencias.
- Usa el propio mecanismo de plantillas del formato en lugar de pelear con él. El
--reference-doc=…de Pandoc es la escotilla de escape correcta para DOCX. Las reglas@pagede WeasyPrint son la escotilla de escape correcta para PDF. Las librerías de HTML-a-DOCX hechas a mano siempre pierden contra Pandoc + un documento de referencia en cualquier salida no trivial. - Dos rutas de código y una prueba que atrapa la diferencia es mejor que una ruta de código en la que tienes que confiar. El costo de las rutas paralelas es pequeño; el costo de un error de truncamiento silencioso en el respaldo es grande.
La siguiente publicación de esta serie trata sobre la arquitectura voice-first – incluyendo algún trabajo reciente de fiabilidad sobre la reanudación de sesiones de Gemini Live que también merece un artículo propio. Si hay algún detalle en particular sobre el que te gustaría más profundidad, el widget de comentarios en la parte inferior derecha de cada página de este sitio va directo a nuestra bandeja de entrada.