Reconstruire l'export PDF + DOCX : WeasyPrint, filtres Lua Pandoc et faire confiance au frontend

Ingénierie · 11 min de lecture

Pour un Ă©diteur de documents, « Exporter » ne peut signifier qu'une seule chose : ce que vous voyez est ce qui sort du fichier. Tout le reste donne une impression de dysfonctionnement, mĂȘme lorsque le problĂšme est minime — un titre centrĂ© Ă  l'Ă©cran mais alignĂ© Ă  gauche dans le PDF, un paragraphe qui se coupe diffĂ©remment parce que les mĂ©triques de police du serveur ne concordent pas avec celles du navigateur, un DOCX exportĂ© dont le titre bleu est devenu noir parce que Word ne parle pas le CSS.

Au cours des deux derniĂšres semaines, nous avons réécrit l'intĂ©gralitĂ© du pipeline d'export derriĂšre AgentDoc – l'Ă©diteur de documents nativement conçu pour l'IA (aussi : agent doc, agentdocs, docedit) – pour que le WYSIWYG soit rĂ©ellement vrai. Deux formats, deux moteurs, une seule idĂ©e architecturale : le frontend est dĂ©jĂ  la source de vĂ©ritĂ© pour la mise en page, donc le serveur devrait la suivre, pas la recalculer.

L'ancienne conception et pourquoi elle restait subtilement erronée

Avant la réécriture, l'export PDF et l'export DOCX fonctionnaient en envoyant le markdown brut du document au backend, oĂč un moteur de rendu allait (a) le repaginer cĂŽtĂ© serveur, (b) appliquer un CSS qui Ă©tait un sous-ensemble maintenu Ă  la main du style.css de l'Ă©diteur, et (c) transmettre le rĂ©sultat Ă  WeasyPrint ou Ă  un gĂ©nĂ©rateur docx rapide. Trois modes de dĂ©faillance en dĂ©coulaient :

La pagination cÎté serveur du contenu de l'éditeur est un problÚme de mise en cache déguisé en problÚme de mise en page. Le navigateur a déjà fait le travail ; envoyer le résultat vers le bas est moins coûteux que de le refaire.

Le changement architectural : le frontend fournit une html_map

L'Ă©diteur sait dĂ©jĂ  oĂč chaque saut de page tombe. Son paginateur exĂ©cute la mise en page, parcourt le DOM et produit un tableau de fragments HTML – un par page – plus les mĂ©tadonnĂ©es de mise en page actives (page_size, margin_mm). Tout ce dont nous avions besoin, c'Ă©tait d'un moyen d'envoyer cela aux points de terminaison d'export.

Le nouveau contrat d'export : lorsque l'utilisateur clique sur « Exporter en PDF » ou « Exporter en DOCX », le frontend joint la carte de pagination (html_map) au corps de la requĂȘte. Le serveur la traite comme la mise en page faisant autoritĂ© et cesse d'essayer de calculer la sienne.

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
}

Le gĂ©nĂ©rateur de PDF enveloppe chaque entrĂ©e dans un bloc .page-content de taille fixe, force un saut de page strict entre elles via le CSS break-after: page, et laisse WeasyPrint effectuer la rastĂ©risation proprement dite. L'en-tĂȘte et le pied de page passent par le mĂ©canisme running() de WeasyPrint afin de se positionner dans les boĂźtes de marge haut-centre / bas-centre sur chaque page physique – y compris la rare deuxiĂšme page physique sur laquelle une entrĂ©e de carte surdimensionnĂ©e pourrait dĂ©border.

Deux pipelines, un repli

Toutes les requĂȘtes d'export ne proviennent pas du navigateur. Des agents externes appelant notre serveur MCP peuvent Ă©mettre un appel d'outil « tĂ©lĂ©charge ce document » avant qu'un humain n'ait jamais ouvert le document dans l'Ă©diteur – ce qui signifie qu'il n'y a pas encore de html_map, parce qu'aucun navigateur n'a effectuĂ© le travail de mise en page.

Pour ce cas, le serveur se rabat sur la pagination native de WeasyPrint via les rĂšgles @page. Nous avions dĂ©jĂ  un repli auparavant, mais il comportait le bug qui a dĂ©clenchĂ© la réécriture en premier lieu : une rĂšgle de dĂ©coupage Ă  hauteur fixe censĂ©e gĂ©rer le cas html_map se dĂ©clenchait aussi sur le chemin de repli, et tout contenu au-delĂ  de la premiĂšre page Ă©tait silencieusement tronquĂ©. Des enquĂȘtes comme celle-lĂ  sont la raison pour laquelle nous maintenons dĂ©sormais deux chemins de code distincts plutĂŽt qu'une seule gĂ©nĂ©ralisation astucieuse.

DOCX : Pandoc + un reference.docx construit Ă  la main

Le DOCX mĂ©rite un traitement particulier parce que Word ne parle pas du tout le CSS. L'approche naĂŻve – HTML → une bibliothĂšque quelconque → .docx – jette Ă  la poubelle chaque classe sur chaque span et vous donne un document qui contient le bon texte mais aucune mise en forme.

Nous utilisons Pandoc pour le corps, python-docx pour les Ă©lĂ©ments spĂ©cifiques Ă  Word que Pandoc ne peut pas atteindre (en-tĂȘte de page, pied de page), et un reference.docx construit Ă  la main comme modĂšle de style.

L'astuce du reference.docx

Le drapeau --reference-doc=
 de Pandoc vous permet de fournir un document Word dont les styles dĂ©finissent vos « Titre 1 », « Normal », « Titre », etc. Pandoc effectue le rendu du markdown, applique ces styles, et le rĂ©sultat hĂ©rite de tout ce que vous avez mis dans la rĂ©fĂ©rence – polices, couleurs, hauteur de ligne, marges, indentation des listes.

Nous générons reference.docx à partir d'un petit script de build à usage unique (templates/build_reference_docx.py) qui émet la référence par défaut de Pandoc puis la corrige avec python-docx de sorte que :

Classes en ligne : le filtre Lua

Le gĂ©nĂ©rateur docx de Pandoc supprime les attributs de style CSS sur les Ă©lĂ©ments Span. C'est trĂšs bien pour du markdown simple mais c'est un problĂšme pour AgentDoc, oĂč chaque style en ligne est encodĂ© sous forme de classe dĂ©calĂ©e sur un span : [text]{.color-red}, [text]{.highlight-yellow}, [text]{.font-lora .size-xl}, etc.

La solution : un petit filtre Lua Pandoc (templates/inline_styles.lua) qui parcourt l'AST pendant la passe du générateur docx et traduit les classes en propriétés de run OOXML brutes :

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 couverture est exhaustive Ă  dessein :

Les combinaisons s'empilent. Un span comme [text]{.color-red .size-xl .font-lora} émet un seul run avec les trois propriétés définies dans un unique <w:rPr>.

En-tĂȘte et pied de page : la post-passe python-docx

Le gĂ©nĂ©rateur docx de Pandoc produit un corps mais ne remplit pas l'en-tĂȘte / le pied de page de section. Notre solution de contournement prĂ©cĂ©dente – les intĂ©grer sous forme de lignes d'introduction / de conclusion en italique – Ă©tait une catastrophe ergonomique : tout utilisateur qui exportait une vraie lettre voyait le texte de son en-tĂȘte flotter au-dessus de l'adresse du destinataire.

AprĂšs l'exĂ©cution de Pandoc, une post-passe python-docx ouvre le docx rĂ©sultant, parcourt section.header / section.footer, et Ă©crit le markdown d'en-tĂȘte/pied de page via un petit analyseur en ligne. L'analyseur gĂšre le gras (**
**), l'italique (*
*), et la mĂȘme syntaxe de span dĂ©calĂ©e que le corps utilise (color-X, highlight-X, decoration-*) – mappĂ©s respectivement sur les couleurs de run, les surlignages et les propriĂ©tĂ©s gras/italique <w:rPr> de Word.

Le rĂ©sultat se trouve dans l'en-tĂȘte de section Word rĂ©el, ce qui signifie qu'il apparaĂźt sur chaque page et s'imprime correctement – y compris lors des repaginations que Word effectue lui-mĂȘme lorsque le destinataire ouvre le document.

Cloisonnement des workflows : T+ obtient le nouvel export, T reste stable au bit prĂšs

L'export DOCX est, délibérément, une fonctionnalité disponible uniquement sur le Workflow T+. T reste stable au bit prÚs parce que les benchmarks de granularité des outils ont été exécutés contre sa surface d'outils exacte, et nous voulons que ces fichiers CSV restent reproductibles. T+ est la voie de production mutable post-benchmark et reçoit les ajouts.

Cela est appliqué à deux endroits pour rendre les fuites impossibles :

Ce que nous n'avons explicitement pas fait

Deux choses sont restées non faites à dessein ; les deux sont des compromis qu'il vaut la peine d'expliciter :

Les tests d'intégration qui détectent réellement les régressions

Ce qui fait tenir cette réécriture, c'est un ensemble de scripts d'audit manuels qui introspectent la sortie rendue :

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

Ce ne sont pas des tests unitaires ; ce sont des outils que nous exĂ©cutons aprĂšs des changements non triviaux pour confirmer que le fichier exportĂ© possĂšde bien la couleur que nous attendons sur le titre que nous attendons. Nous avons ajoutĂ© poppler-utils et pypdf au Dockerfile du backend pour qu'ils fonctionnent aussi sur l'image de production, ce qui signifie que nous pouvons les exĂ©cuter contre de vrais documents utilisateur lors de l'enquĂȘte sur une rĂ©clamation.

Ce que nous avons appris et qui mĂ©rite d'ĂȘtre conservĂ©

Trois leçons durables de cette réécriture :

Le prochain article de cette sĂ©rie porte sur l'architecture vocale en prioritĂ© – y compris des travaux rĂ©cents de fiabilitĂ© sur la reprise de session Gemini Live qui mĂ©ritent aussi un article Ă  part entiĂšre. Si un dĂ©tail particulier vous intĂ©resse plus en profondeur, le widget de retour en bas Ă  droite de chaque page de ce site arrive directement dans notre boĂźte de rĂ©ception.

← Notes de version – avril 2026 GranularitĂ© des outils dans les agents LLM →