Import DOCX : aller-retour des documents Word dans un éditeur IA
Il y a deux semaines, nous avons livrĂ© la réécriture de l'export DOCX/PDF : un document modifiĂ© dans AgentDoc (aussi : agent doc, agentdocs, docedit) quitte dĂ©sormais l'Ă©diteur sous la forme d'un fichier Word avec ses polices, ses couleurs, sa mise en page et ses en-tĂȘtes/pieds de page intacts. Cela rĂ©solvait exactement la moitiĂ© du flux de travail vraiment utile. L'autre moitiĂ© â faire entrer un document Word dans l'Ă©diteur sans perdre sa structure â est ce qui a abouti cette semaine.
Cet article est le pendant de la réécriture de l'export : mĂȘme modĂšle de donnĂ©es, sens opposĂ©, mĂȘme exigence de ne rien perdre de ce que l'utilisateur peut voir Ă l'Ă©cran.
Le cahier des charges
Un utilisateur téléverse un contract_v3.docx qu'il modifiait dans Word. AprÚs l'import,
l'ouverture du mĂȘme document dans l'Ă©diteur devrait afficher :
- Les titres, paragraphes, listes, tableaux â au bon niveau, dans le bon ordre.
- La mise en forme en ligne â gras, italique, soulignĂ©, barrĂ©, indice/exposant.
- Les couleurs et surlignages â les couleurs rĂ©elles de la palette de l'Ă©diteur, et non celles que Word a substituĂ©es par dĂ©faut.
- Les polices et les tailles â associĂ©es aux douze jetons de l'Ă©diteur (Inter, Playfair Display, Roboto Mono, etc.).
- L'alignement, le retrait, l'interligne.
- Les hyperliens â cliquables, avec leurs URL d'origine.
- Les sauts de page lĂ oĂč l'auteur les a placĂ©s.
- L'en-tĂȘte et le pied de page, avec leur propre mise en forme prĂ©servĂ©e.
RĂ©exportĂ© en DOCX, le mĂȘme fichier devrait ĂȘtre comparable en structure (pas identique au bit prĂšs â Word Ă©crit beaucoup de XML accessoire â mais visuellement indiscernable pour un lecteur).
Pourquoi analyser du DOCX n'est pas aussi accommodant qu'analyser du HTML
Un DOCX est un ZIP contenant du XML. Le schéma est l'OOXML (ECMA-376) et python-docx l'enveloppe dans un joli modÚle objet paragraphe/run. L'ennui, c'est que l'essentiel de ce qui rend un vrai document Word intéressant vit en dehors de ce modÚle objet accommodant :
- Les sauts de page sont des éléments
<w:br w:type="page"/>intĂ©grĂ©s Ă l'intĂ©rieur des runs. Ils apparaissent bien dansparagraph.runs, mais, par effet de bord, un\nse glisse dans le texte du run â comptez-le une fois et vous vous retrouvez avec un saut de ligne parasite. - Les hyperliens vivent dans des Ă©lĂ©ments
<w:hyperlink>qui sont des frĂšres des Ă©lĂ©ments<w:r>Ă l'intĂ©rieur du paragraphe.paragraph.runsles ignore entiĂšrement â n'itĂ©rez que sur les runs et le texte du lien disparaĂźt (ou subsiste, mais l'URL est perdue). - Les propriĂ©tĂ©s de section (taille de page, en-tĂȘtes, pieds de page) pendent Ă
sections[*].header / .footeravec des indicateurs d'héritage en cascade (is_linked_to_previous) que l'API accommodante résout silencieusement. - L'interligne est un multiplicateur flottant sur
paragraph_format.line_spacing; le réassocier aux jetons discrets de l'éditeur (tight / normal / relaxed / loose / double) exige une passe d'alignement sur la valeur la plus proche.
Si votre import DOCX n'utilise que paragraph.runs, votre document perd discrĂštement
chaque hyperlien et chaque saut de page dĂšs qu'il touche votre pipeline. Les deux font l'aller-retour
sous forme de texte brut ou disparaissent entiÚrement. Nous avons rencontré ces deux bugs dÚs la premiÚre exécution d'intégration.
La structure : import_docx_bytes renvoie six clés
Avant ce travail, notre chemin d'import renvoyait un 2-tuple (markdown_body,
formatting_array) â le contenu du corps plus les dĂ©calages de mise en forme stand-off. C'Ă©tait
correct pour les documents limitĂ©s au corps, mais on perdait tout en-tĂȘte / pied de page créé par l'auteur.
Nous avons changé le type de retour pour un dictionnaire à six clés qui reflÚte le modÚle de stockage de l'éditeur :
{
"body_md": "# Heading\n\nFirst paragraph...",
"body_formatting": [{"start": 0, "end": 9, "classes": "font-playfair"}, âŠ],
"header_md": "Confidential â Q3 2026",
"header_formatting": [{"start": 0, "end": 11, "classes": "decoration-bold"}],
"footer_md": "Page",
"footer_formatting": [],
}
Chaque *_md utilise son propre espace d'index Ă base zĂ©ro ; les dĂ©calages d'en-tĂȘte / de pied de page ne
sont pas partagĂ©s avec le corps. Les trois parcours partagent un mĂȘme utilitaire (walk_container) qui
réinitialise md_parts, formatting et le curseur à l'entrée, afin qu'ils
ne déteignent pas leurs décalages les uns sur les autres.
Nous ne prenons que sections[0] (l'Ă©diteur impose un en-tĂȘte / un pied de page par
document) et nous respectons is_linked_to_previous â lorsqu'une section hĂ©rite d'une
prĂ©cĂ©dente (le dĂ©faut pour sections[0]), elle n'a pas d'en-tĂȘte / de pied de page créé par l'auteur et nous
contribuons des chaĂźnes vides. Tout le bloc en-tĂȘte / pied de page est enveloppĂ© dans un try / except, de sorte qu'un
sectPr mal formĂ© se dĂ©grade en « pas d'en-tĂȘte / de pied de page » au lieu de faire Ă©chouer l'import entier.
Sauts de page : parcourir le XML brut
python-docx expose le texte d'un run sous la forme run.text â une concatĂ©nation de ses
enfants <w:t> avec les <w:br> réduits à \n.
Cela nous donne le texte mais perd la distinction entre un saut souple et un saut de page dur.
Correctif : parcourir paragraph._element.iter(qn("w:br")) sur chaque paragraphe et vérifier
l'attribut w:type de chaque saut. Lorsque w:type == "page", émettre notre
marqueur [PAGE BREAK]\n\n avant le contenu du paragraphe, afin qu'un
saut-de-page-avant-titre dans Word survive sous forme de [PAGE BREAK]\n\n# Heading dans notre
markdown. Si le paragraphe ne contient rien d'autre que le saut, ignorer le bloc final vide pour
éviter les doubles sauts de ligne parasites, et retirer le \n induit par le saut de page du
texte du run afin que le marqueur ne soit pas compté en double.
Hyperliens : itérer directement sur les enfants XML du paragraphe
Pour les hyperliens, l'astuce consiste à renoncer entiÚrement à paragraph.runs et à itérer
sur les enfants XML du paragraphe, en répartissant selon la balise :
for child in paragraph._element:
tag = etree.QName(child).localname
if tag == "r":
emit_run(child)
elif tag == "hyperlink":
emit_hyperlink(child)
emit_hyperlink résout r:id par rapport à la table de relations du paragraphe
pour obtenir l'URL externe, avec un repli sur #anchor pour les hyperliens internes
(entrĂ©es de table des matiĂšres pointant vers des signets de titre) afin que la structure survive mĂȘme lorsque
nous ne pouvons pas rĂ©soudre le signet. Nous Ă©mettons du markdown natif [text](url) â le
chemin de lien markdown existant du moteur de rendu produit un véritable <a href> sans
passe supplémentaire aprÚs coup.
Le style interne à l'intérieur de l'hyperlien (texte de lien en gras, texte de lien coloré) passe par le
mĂȘme pipeline emit_run que les runs simples, de sorte qu'un lien bleu en gras reste un lien bleu
en gras dans l'éditeur.
Interligne : alignement sur la valeur la plus proche, dans les deux sens
L'Ă©diteur n'accepte pas des flottants d'interligne arbitraires â il dispose de cinq jetons
(tight / normal / relaxed / loose / double) qui correspondent respectivement Ă 1.2 / 1.6 / 2.0
/ 2.5 / 3.0. De l'éditeur vers le DOCX, la correspondance est directe.
Le retour est plus flou : un document Word créé avec un interligne de 1,5x (typique pour le
corps de texte) devrait faire l'aller-retour vers linespacing-normal, sans ĂȘtre rejetĂ©.
L'implémentation est l'inverse du _nearest_size_token cÎté export : une
passe d'alignement sur la valeur la plus proche contre les cinq mĂȘmes valeurs de rĂ©fĂ©rence. Le rĂ©sultat est Ă©mis sous la forme d'un
attribut de bloc au niveau du paragraphe {: .linespacing-X } aux cÎtés de l'alignement et du retrait.
Le test d'aller-retour
Les conversions bidirectionnelles sont faciles à casser et difficiles à repérer. Nous avons ajouté
backend/tests/manual_docx_roundtrip.py â un script d'audit manuel (dans l'esprit
des autres audits manual_*.py) qui fait :
doc = build_test_document()
docx_1 = generate_docx_bytes(doc) # Editor -> Word
parsed = import_docx_bytes(docx_1) # Word -> Editor
docx_2 = generate_docx_bytes(parsed) # Editor -> Word again
assert paragraph_count(docx_1) == paragraph_count(docx_2)
assert heading_levels(docx_1) == heading_levels(docx_2)
assert run_properties(docx_1) == run_properties(docx_2)
Ce n'est pas un test pytest â il s'exĂ©cute contre le conteneur backend en direct et inspecte le XML OOXML rĂ©el des deux fichiers docx gĂ©nĂ©rĂ©s. La sortie est constituĂ©e de diffs lisibles par un humain de ce qui a changĂ© d'une passe Ă l'autre. Nous ajoutons une nouvelle assertion chaque fois qu'une rĂ©gression est dĂ©couverte, de sorte que la prochaine fois que le mĂȘme cas limite apparaĂźt, l'audit Ă©choue bruyamment au lieu de se perdre silencieusement dans un document complexe.
Ce qui reste imparfait
- Sections multiples. Un document avec des sauts de section (des en-tĂȘtes diffĂ©rents au chapitre 1 et au chapitre 2) est aplati au chrome de la premiĂšre section. Le modĂšle de donnĂ©es de l'Ă©diteur ne prend en charge qu'un en-tĂȘte / un pied de page par document, et changer cela est une opĂ©ration bien plus lourde que le seul chemin d'import.
- Commentaires et suivi des modifications. Les deux sont présents dans l'OOXML mais nous les laissons tomber aujourd'hui. L'éditeur n'a d'interface pour ni l'un ni l'autre, donc les importer reviendrait de toute façon à les écarter à la prochaine sauvegarde.
- Images. Nous extrayons bien les références d'images, mais seulement sous forme de substituts reliés. Extraire les octets réels de l'image du dossier média du DOCX, les persister, et réécrire la référence de l'image dans le markdown est la prochaine passe.
- Styles personnalisés. Un document qui utilise un style Word personnalisé (« Citation en retrait du corps ») qui ne fait pas partie des styles connus de l'éditeur se voit attribuer la correspondance la plus proche de notre table de styles. L'aller-retour fidÚle de styles personnalisés arbitraires exigerait de transporter leurs définitions à travers le modÚle de données de l'éditeur, ce que nous ne faisons pas.
Ce que cela change pour les utilisateurs
Le flux de travail vedette est désormais symétrique. Vous pouvez :
- Prendre un document Word existant â une lettre en brouillon, un article de recherche, un contrat.
- Le téléverser dans AgentDoc (un simple clic sur le bouton « Importer .docx » de la barre latérale, ou
un simple
POST /api/docs/import/docxpour les agents autonomes â voir la documentation des agents). - Le modifier Ă la voix ou par chat, l'agent effectuant des modifications structurĂ©es tandis que la mise en page reste exactement telle que vous l'avez créée.
- Le rĂ©exporter vers Word, et votre collaborateur ouvre le fichier dans le mĂȘme Word avec lequel il a commencĂ©, avec les mĂȘmes polices, les mĂȘmes couleurs, la mĂȘme gĂ©omĂ©trie de page.
Pour le flux de travail cĂŽtĂ© agent en particulier, cela boucle aussi le cycle oĂč un client
MCP autonome ne pouvait construire des documents qu'à partir de zéro. Avec import_docx_bytes
branchĂ©, un agent peut ingĂ©rer un DOCX gabarit (par ex. un en-tĂȘte d'entreprise avec des
champs prĂ©-remplis), piloter les modifications via la surface d'outils MCP, et exporter le rĂ©sultat â
exactement le genre de cas d'usage « remplissez ce formulaire » oĂč retaper depuis zĂ©ro est le
goulot d'étranglement.
à lire en complément
- Reconstruire l'export PDF + DOCX â le miroir de cet article sur le chemin de sortie de l'Ă©diteur.
- La granularitĂ© des outils dans les agents LLM â le cadre de conception d'outils qui pilote la surface MCP que les agents autonomes utilisent face aux documents importĂ©s.