Import DOCX : aller-retour des documents Word dans un éditeur IA

Ingénierie · 9 min de lecture

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 :

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 :

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

Ce que cela change pour les utilisateurs

Le flux de travail vedette est désormais symétrique. Vous pouvez :

  1. Prendre un document Word existant — une lettre en brouillon, un article de recherche, un contrat.
  2. 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/docx pour les agents autonomes — voir la documentation des agents).
  3. 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.
  4. 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 Tous les articles →