Reconstruire l'export PDF + DOCX : WeasyPrint, filtres Lua Pandoc et faire confiance au frontend
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 divergeait. Le paginateur du navigateur mesurait chaque bloc dans un DOM rĂ©el avec les vraies polices chargĂ©es ; le serveur mesurait un DOM diffĂ©rent avec des polices subtilement diffĂ©rentes et des largeurs disponibles diffĂ©rentes. Des documents de dix pages ressortaient rĂ©guliĂšrement en neuf ou onze, et la page oĂč un titre apparaissait se dĂ©calait.
- Divergence du CSS. Le CSS d'export était une copie de
style.cssavec des modifications appliquĂ©es de maniĂšre paresseuse. Les tableaux avaient des en-tĂȘtes centrĂ©s dans l'Ă©diteur et alignĂ©s Ă gauche dans le PDF ;<blockquote>avait une bordure gauche Ă l'Ă©cran et pas dans l'export. Nous dĂ©boguions des sĂ©lecteurs individuels au lieu de l'architecture. - Le DOCX Ă©tait une tout autre bĂȘte. L'ancien chemin rapide et bĂąclĂ© intĂ©grait l'en-tĂȘte/pied de page sous forme de texte en italique en haut et en bas du corps, supprimait les classes de couleur et de police, et produisait quelque chose dont les utilisateurs de Word parlaient poliment mais qu'ils n'utilisaient pas rĂ©ellement.
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 :
- Titre 1 / 2 / 3 / Titre utilisent Playfair Display, en gras, dans le bleu marine
text-primary de notre éditeur (
#111827) â exactement la mĂȘme couleur que le titre a dans le navigateur. - Normal / Paragraphe de liste / Corps utilisent Inter 11pt en ardoise (
#374151), avec une hauteur de ligne de 1.7 et une indentation de liste de 0,25" â ajustĂ©s sur l'Ă©diteur. - Les styles En-tĂȘte / Pied de page sont en Inter 10pt avec la fine bordure infĂ©rieure que l'Ă©diteur affiche via CSS, afin que le sĂ©parateur visuel survive au changement de format.
- Le
fontTable.xmlest Ă©tendu avec une entrĂ©e<w:font>par police de caractĂšres de l'Ă©diteur, chacune portant un indice<w:altName>pointant vers la famille standard de Word la plus proche (Inter â Calibri, Playfair Display â Cambria, Roboto Mono â Consolas, etc.). Les utilisateurs de Word sans nos polices exactes installĂ©es voient la substitution alt au lieu de revenir Ă Times New Roman.
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 :
- Couleurs : 17 couleurs nommées de l'éditeur mappées sur le hex réel utilisé
par le CSS, pas sur les hex des mots-clés W3C.
color-redest l'alizarine plus douce de l'Ă©diteur#E74C3C, pas#FF0000;color-blueest#3498DB, pas#0000FF. Un titre bleu dans le navigateur s'affiche dĂ©sormais comme le mĂȘme bleu dans Word. - Surlignages : l'ensemble complet des surlignages nommĂ©s de Word (jaune / vert / cyan / magenta / bleu / rouge plus les variantes foncĂ©es/claires) ; tout ce qui se trouve en dehors de cet ensemble retombe sur le jaune plutĂŽt que de disparaĂźtre silencieusement.
- Polices : 12 entrées émettant
<w:rFonts ascii="âŠ" hAnsi="âŠ" cs="âŠ" eastAsia="âŠ"/>pour chaque police de caractĂšres de l'Ă©diteur. - Tailles : 7 jetons (
xs..3xl) émis sous forme de valeurs<w:sz w:val="halfPt"/>, dérivées en em par rapport à la ligne de base de corps de 13pt utilisée par reference.docx. - Décorations :
decoration-bold/-italic/-strikethroughsont traduites en markdown standard avant Pandoc, de sorte que nous obtenons de vrais runs<w:b/>,<w:i/>, etc. au lieu d'OOXML brut.
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 :
agent/routers/chat.pydéfinitT_PLUS_EXCLUSIVE_TOOLS = {"trigger_docx_download"}; la branche de T exclut explicitement cet ensemble afin qu'un outil nouvellement enregistré ne puisse pas atterrir accidentellement dans le schéma gelé de T.mcp_workflow_middleware.WORKFLOW_T_DENYliste aussi l'outil, de sorte que les agents externes (qui sont épinglés à la surface de T par conception) ne le voient pas non plus via le point de terminaison MCP.
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 :
- L'intégration des polices. Une correction véritablement 1:1 intégrerait les
véritables TTF Inter / Playfair Display / etc. dans chaque DOCX généré. Cela ajouterait aussi
~400 Ko par export, plus une revue juridique par police pour la redistribution. Nous nous
appuyons Ă la place sur la substitution de police de Word + les indices
altNamedans notre fontTable étendue. La plupart des familles de l'éditeur sont des Google Fonts populaires que de nombreux utilisateurs d'Office 365 possÚdent déjà . - Un pipeline pour les gouverner tous. Nous maintenons désormais deux chemins de code PDF distincts (html_map vs. repli) et un chemin DOCX séparé. Généraliser davantage permettrait au type de bug de découpage à hauteur fixe d'auparavant de réapparaßtre silencieusement. Deux chemins de code plus des tests d'intégration valent mieux qu'un seul astucieux.
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 :
- Ne recalculez pas ce que le client a déjà calculé. Si le navigateur a paginé le document, envoyez la pagination vers le bas. Tout recalcul cÎté serveur divergera de maniÚre subtile et vous passerez des semaines à courir aprÚs les divergences.
- Utilisez le mécanisme de modÚle propre au format au lieu de le combattre.
--reference-doc=âŠde Pandoc est la bonne Ă©chappatoire pour le DOCX. Les rĂšgles@pagede WeasyPrint sont la bonne Ă©chappatoire pour le PDF. Les bibliothĂšques HTML-vers-DOCX faites maison perdent toujours face Ă Pandoc + un document de rĂ©fĂ©rence sur toute sortie non triviale. - Deux chemins de code et un test qui dĂ©tecte la diffĂ©rence valent mieux qu'un seul chemin de code auquel vous devez faire confiance. Le coĂ»t des chemins parallĂšles est faible ; le coĂ»t d'un bug de troncature silencieuse dans le repli est Ă©levĂ©.
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.