DOCX-Import: Word-Dokumente durch einen KI-Editor und zurĂŒck

Engineering · 9 Min. Lesezeit

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:

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:

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

Was sich dadurch fĂŒr Nutzerinnen und Nutzer Ă€ndert

Der zentrale Workflow ist jetzt symmetrisch. Du kannst:

  1. Ein bestehendes Word-Dokument nehmen — einen Briefentwurf, eine wissenschaftliche Arbeit, einen Vertrag.
  2. Es zu AgentDoc hochladen (ein einziger Klick auf den Button „.docx importieren“ in der Seitenleiste, oder ein einziges POST /api/docs/import/docx fĂŒr autonome Agents — siehe die Agent-Dokumentation).
  3. 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.
  4. 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 Alle BeitrĂ€ge →