DOCX-Import: Word-Dokumente durch einen KI-Editor und zurĂŒck
Vor zwei Wochen haben wir die Neufassung des DOCX/PDF-Exports ausgeliefert: Ein in AgentDoc (auch: agent doc, agentdocs, docedit) bearbeitetes Dokument verlĂ€sst den Editor jetzt als Word-Datei mit intakten Schriftarten, Farben, Seitenlayout und Kopf-/FuĂzeilen. Damit war genau die HĂ€lfte des eigentlich nĂŒtzlichen Workflows gelöst. Die andere HĂ€lfte â ein Word-Dokument in den Editor zu bringen, ohne dabei Struktur zu verlieren â ist diese Woche gelandet.
Dieser Beitrag ist das Pendant zur Export-Neufassung: dasselbe Datenmodell, entgegengesetzte Richtung, dasselbe Beharren darauf, nichts zu verlieren, was die Nutzerin oder der Nutzer auf dem Bildschirm sehen kann.
Der Vertrag
Jemand lÀdt eine contract_v3.docx hoch, die in Word bearbeitet wurde. Nach dem
Import sollte das Ăffnen desselben Dokuments im Editor Folgendes zeigen:
- Ăberschriften, AbsĂ€tze, Listen, Tabellen â auf der richtigen Ebene, in der richtigen Reihenfolge.
- Inline-Formatierung â fett, kursiv, unterstrichen, durchgestrichen, tief-/hochgestellt.
- Farben und Hervorhebungen â die tatsĂ€chlichen Farben aus der Editor-Palette, nicht irgendein Word-Standard-Ersatz.
- Schriftarten und -gröĂen â abgebildet auf die zwölf Tokens des Editors (Inter, Playfair Display, Roboto Mono usw.).
- Ausrichtung, EinrĂŒckung, Zeilenabstand.
- Hyperlinks â anklickbar, mit ihren ursprĂŒnglichen URLs.
- SeitenumbrĂŒche dort, wo die verfassende Person sie gesetzt hat.
- Die Kopf- und FuĂzeile der Seite, mit ihrer jeweils eigenen Formatierung.
ZurĂŒck nach DOCX exportiert, sollte dieselbe Datei in ihrer Struktur byte-vergleichbar sein (nicht bit-identisch â Word schreibt jede Menge beilĂ€ufiges XML â aber fĂŒr eine Leserin oder einen Leser visuell ununterscheidbar).
Warum das Parsen von DOCX nicht so freundlich ist wie das Parsen von HTML
DOCX ist ein ZIP, das XML enthĂ€lt. Das Schema ist OOXML (ECMA-376), und python-docx packt es in ein hĂŒbsches Absatz-/Run-Objektmodell. Das Problem ist, dass das meiste von dem, was ein echtes Word-Dokument interessant macht, auĂerhalb dieses freundlichen Objektmodells lebt:
- SeitenumbrĂŒche sind
<w:br w:type="page"/>-Elemente, die in Runs eingebettet sind. Sie tauchen zwar inparagraph.runsauf, aber als Nebeneffekt schleicht sich ein\nin den Run-Text ein â zĂ€hle es einmal mit und du hast einen ĂŒberzĂ€hligen Zeilenumbruch. - Hyperlinks leben in
<w:hyperlink>-Elementen, die Geschwister von<w:r>-Elementen innerhalb des Absatzes sind.paragraph.runsĂŒberspringt sie komplett â iterierst du nur ĂŒber Runs, verschwindet der Linktext (oder bleibt, aber die URL geht verloren). - Abschnittseigenschaften (SeitengröĂe, Kopfzeilen, FuĂzeilen) hĂ€ngen an
sections[*].header / .footermit kaskadierenden Vererbungs-Flags (is_linked_to_previous), die die freundliche API stillschweigend auflöst. - Der Zeilenabstand ist ein Float-Multiplikator auf
paragraph_format.line_spacing; ihn wieder auf die diskreten Tokens des Editors (tight / normal / relaxed / loose / double) abzubilden, erfordert einen Snap-auf-den-nÀchsten-Wert-Durchlauf.
Wenn dein DOCX-Import nur paragraph.runs nutzt, verliert dein Dokument klammheimlich
jeden Hyperlink und jeden Seitenumbruch, sobald es deine Pipeline berĂŒhrt. Beide kommen als
Klartext durch den Round-Trip oder verschwinden ganz. Wir sind beim ersten Integrationslauf
in beide Bugs gelaufen.
Die Struktur: import_docx_bytes gibt sechs SchlĂŒssel zurĂŒck
Vor dieser Arbeit gab unser Import-Pfad ein 2-Tupel (markdown_body,
formatting_array) zurĂŒck â Body-Inhalt plus die Stand-off-Formatierungs-Offsets. Das war
in Ordnung fĂŒr Body-only-Dokumente, verlor aber jede verfasste Kopf-/FuĂzeile.
Wir haben den RĂŒckgabetyp in ein Dict mit sechs SchlĂŒsseln geĂ€ndert, das das Speichermodell des Editors widerspiegelt:
{
"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": [],
}
Jedes *_md verwendet seinen eigenen, bei null beginnenden Indexraum; Kopf-/FuĂzeilen-Offsets
teilen sich keinen mit dem Body. Die drei Walks teilen sich einen Helfer (walk_container), der
md_parts, formatting und den Cursor beim Eintritt zurĂŒcksetzt, damit sie
keine Offsets ineinander ĂŒberlaufen lassen.
Wir nehmen nur sections[0] (der Editor erzwingt eine Kopf-/eine FuĂzeile pro
Dokument) und respektieren is_linked_to_previous â wenn ein Abschnitt von einem
vorherigen erbt (der Standard fĂŒr sections[0]), hat er keine verfasste Kopf-/FuĂzeile und wir
steuern leere Strings bei. Der gesamte Kopf-/FuĂzeilen-Block ist in try / except gewickelt, sodass ein
fehlerhaftes sectPr zu âkeine Kopf-/FuĂzeileâ degradiert, statt den gesamten
Import scheitern zu lassen.
SeitenumbrĂŒche: durch das rohe XML laufen
python-docx stellt den Text eines Runs als run.text bereit â eine Verkettung seiner
<w:t>-Kinder, wobei <w:br> zu \n kollabiert wird.
Das liefert uns Text, verliert aber die Unterscheidung zwischen einem weichen Umbruch und einem harten Seitenumbruch.
Lösung: Lauf bei jedem Absatz ĂŒber paragraph._element.iter(qn("w:br")) und prĂŒfe das
w:type-Attribut jedes Umbruchs. Wenn w:type == "page", gib unseren
[PAGE BREAK]\n\n-Marker vor dem Absatzinhalt aus, sodass ein
Seitenumbruch-vor-Ăberschrift in Word als [PAGE BREAK]\n\n# Heading in unserem
Markdown ĂŒberlebt. EnthĂ€lt der Absatz nichts auĂer dem Umbruch, ĂŒberspringe den leeren abschlieĂenden Block, um
ĂŒberzĂ€hlige doppelte ZeilenumbrĂŒche zu vermeiden, und entferne das vom Seitenumbruch verursachte \n aus dem
Run-Text, damit der Marker nicht doppelt gezÀhlt wird.
Hyperlinks: ĂŒber die XML-Kinder des Absatzes direkt iterieren
Bei Hyperlinks besteht der Trick darin, paragraph.runs komplett aufzugeben und ĂŒber
die XML-Kinder des Absatzes zu iterieren, mit Dispatch nach Tag:
for child in paragraph._element:
tag = etree.QName(child).localname
if tag == "r":
emit_run(child)
elif tag == "hyperlink":
emit_hyperlink(child)
emit_hyperlink löst r:id gegen die Beziehungstabelle des Absatzes auf,
um die externe URL zu erhalten, mit einem Fallback auf #anchor fĂŒr interne
Hyperlinks (TOC-EintrĂ€ge, die auf Ăberschriften-Lesezeichen zeigen), sodass die Struktur selbst dann
ĂŒberlebt, wenn wir das Lesezeichen nicht auflösen können. Wir geben natives Markdown [text](url) aus â der
bestehende Markdown-Link-Pfad des Renderers erzeugt ein echtes <a href> ohne
einen separaten Nachlauf.
Internes Styling innerhalb des Hyperlinks (fetter Linktext, farbiger Linktext) durchlÀuft
dieselbe emit_run-Pipeline wie einfache Runs, sodass ein fetter blauer Link auch im
Editor ein fetter blauer Link bleibt.
Zeilenabstand: Snap auf den nÀchsten Wert, in beide Richtungen
Der Editor akzeptiert keine beliebigen Zeilenabstands-Floats â er hat fĂŒnf Tokens
(tight / normal / relaxed / loose / double), die auf 1.2 / 1.6 / 2.0
/ 2.5 / 3.0 abbilden. Vom Editor nach DOCX ist die Abbildung direkt.
Der RĂŒckweg ist unschĂ€rfer: Ein Word-Dokument, das mit 1,5-fachem Zeilenabstand verfasst wurde
(typisch fĂŒr FlieĂtext), sollte zu linespacing-normal zurĂŒckkommen, nicht
abgelehnt werden.
Die Implementierung ist die Umkehrung des export-seitigen _nearest_size_token: ein
Snap-auf-den-nĂ€chsten-Wert-Durchlauf gegen dieselben fĂŒnf Referenzwerte. Das Ergebnis wird als
absatz-ebenes {: .linespacing-X }-Block-Attribut neben Ausrichtung und
EinrĂŒckung ausgegeben.
Der Round-Trip-Test
Zwei-Wege-Konvertierungen lassen sich leicht kaputtmachen und schwer entdecken. Wir haben
backend/tests/manual_docx_roundtrip.py hinzugefĂŒgt â ein manuelles Audit-Skript (im Geiste
der anderen manual_*.py-Audits), das Folgendes tut:
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)
Es ist kein pytest-Test â es lĂ€uft gegen den Live-Backend-Container und inspiziert das tatsĂ€chliche OOXML-XML der beiden generierten docx-Dateien. Die Ausgabe sind menschenlesbare Diffs dessen, was sich ĂŒber die beiden DurchlĂ€ufe geĂ€ndert hat. Wir fĂŒgen jedes Mal eine neue Assertion hinzu, wenn eine Regression entdeckt wird, sodass beim nĂ€chsten Auftreten desselben Edge Case das Audit laut scheitert, statt in einem komplexen Dokument stillschweigend verloren zu gehen.
Was noch unvollkommen ist
- Mehrere Abschnitte. Ein Dokument mit AbschnittsumbrĂŒchen (unterschiedliche Kopfzeilen in Kapitel 1 vs. Kapitel 2) wird auf das Chrome des ersten Abschnitts plattgedrĂŒckt. Das Datenmodell des Editors unterstĂŒtzt nur eine Kopf-/eine FuĂzeile pro Dokument, und das zu Ă€ndern, ist eine viel gröĂere Operation als der Import-Pfad allein.
- Kommentare und Ănderungsverfolgung. Beide sind im OOXML vorhanden, aber wir lassen sie heute unter den Tisch fallen. Der Editor hat fĂŒr keines von beiden eine UI, also wĂŒrde sie zu importieren ohnehin nur bedeuten, sie beim nĂ€chsten Speichern zu verwerfen.
- Bilder. Wir extrahieren zwar Bildreferenzen, aber nur als neu verlinkte Platzhalter. Die tatsÀchlichen Bild-Bytes aus dem DOCX-Media-Ordner herauszuziehen, sie zu persistieren und die Bildreferenz im Markdown umzuschreiben, ist der nÀchste Durchlauf.
- Benutzerdefinierte Stile. Ein Dokument, das einen benutzerdefinierten Word-Stil (âBody Indented Quoteâ) verwendet, der nicht zu den bekannten Stilen des Editors gehört, bekommt die nĂ€chstbeste Entsprechung aus unserer Stiltabelle. Ein verbindlicher Round-Trip beliebiger benutzerdefinierter Stile wĂŒrde erfordern, ihre Definitionen durch das Datenmodell des Editors mitzufĂŒhren, was wir nicht tun.
Was sich dadurch fĂŒr Nutzerinnen und Nutzer Ă€ndert
Der zentrale Workflow ist jetzt symmetrisch. Du kannst:
- Ein bestehendes Word-Dokument nehmen â einen Briefentwurf, eine wissenschaftliche Arbeit, einen Vertrag.
- Es zu AgentDoc hochladen (ein einziger Klick auf den Button â.docx importierenâ in der Seitenleiste, oder
ein einziges
POST /api/docs/import/docxfĂŒr autonome Agents â siehe die Agent-Dokumentation). - Es per Sprache oder per Chat bearbeiten, wobei der Agent strukturierte Bearbeitungen vornimmt, wĂ€hrend das Seitenlayout genau so bleibt, wie du es verfasst hast.
- ZurĂŒck nach Word exportieren, und deine Mitarbeiterin oder dein Mitarbeiter öffnet die Datei in demselben Word, mit dem sie oder er begonnen hat â mit denselben Schriftarten, denselben Farben, derselben Seitengeometrie.
Speziell fĂŒr den Agent-seitigen Workflow schlieĂt das auch die LĂŒcke, in der ein autonomer
MCP-Client Dokumente nur von Grund auf neu bauen konnte. Mit verdrahtetem import_docx_bytes
kann ein Agent eine vorlagenbasierte DOCX aufnehmen (z. B. einen Firmenbriefkopf mit
vorausgefĂŒllten Feldern), Bearbeitungen ĂŒber die MCP-Tool-OberflĂ€che steuern und das Ergebnis exportieren â
genau die Art von âFĂŒlle dieses Formular ausâ-Anwendungsfall, bei dem das Neutippen von Grund auf der
Flaschenhals ist.
Passende LektĂŒre
- PDF- + DOCX-Export neu gebaut â das Spiegelbild dieses Beitrags auf dem Weg aus dem Editor heraus.
- Tool-GranularitĂ€t bei LLM-Agents â das Tool-Design-Framework, das die MCP-OberflĂ€che antreibt, die autonome Agents gegen importierte Dokumente nutzen.