Reconstruyendo la exportación a PDF + DOCX: WeasyPrint, filtros Lua de Pandoc y confiar en el frontend

Ingeniería · 11 min de lectura

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 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:

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:

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:

Lo que explícitamente no hicimos

Dos cosas quedaron sin hacer a propósito; ambas son compensaciones que vale la pena explicitar:

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:

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.

← Notas de la versión – Abril de 2026 Granularidad de herramientas en agentes LLM →