PDF- und DOCX-Export neu gebaut: WeasyPrint, Pandoc-Lua-Filter und Vertrauen ins Frontend

Engineering · 11 Min. Lesezeit

Für einen Dokumenteneditor darf "Export" nur eines bedeuten: Was du siehst, ist das, was aus der Datei herauskommt. Alles andere fühlt sich kaputt an, selbst wenn die Kaputtheit klein ist – eine Überschrift, die auf dem Bildschirm zentriert, im PDF aber linksbündig ist, ein Absatz, der anders umbricht, weil die Schriftmetriken des Servers denen des Browsers widersprechen, ein exportiertes DOCX, dessen blaue Überschrift schwarz geworden ist, weil Word kein CSS spricht.

In den letzten zwei Wochen haben wir die gesamte Export-Pipeline hinter AgentDoc neu geschrieben – dem KI-nativen Docs-Editor (auch: agent doc, agentdocs, docedit) – um WYSIWYG endlich wahr zu machen. Zwei Formate, zwei Engines, eine architektonische Erkenntnis: Das Frontend ist bereits die Quelle der Wahrheit für das Layout, also sollte der Server ihm folgen und es nicht neu herleiten.

Das alte Design und warum es immer wieder unauffällig falsch war

Vor der Neuschreibung funktionierten sowohl der PDF- als auch der DOCX-Export, indem das rohe Markdown des Dokuments an das Backend geschickt wurde, wo ein Renderer es (a) serverseitig neu paginierte, (b) CSS anwandte, das eine handgepflegte Teilmenge der style.css des Editors war, und (c) das Ergebnis an WeasyPrint oder einen schnellen docx-Writer übergab. Daraus folgten drei Fehlermodi:

Serverseitige Paginierung von Editor-Inhalten ist ein Caching-Problem, getarnt als Layout-Problem. Der Browser hat die Arbeit bereits erledigt; das Ergebnis herunterzuschicken ist günstiger, als es noch einmal zu machen.

Der architektonische Wandel: Das Frontend liefert eine html_map

Der Editor weiß bereits, wo jeder Seitenumbruch landet. Sein Paginierer führt das Layout aus, läuft durch das DOM und erzeugt ein Array aus HTML-Chunks – einer pro Seite – plus die aktiven Layout-Metadaten (page_size, margin_mm). Alles, was wir brauchten, war ein Weg, das an die Export-Endpunkte zu senden.

Der neue Export-Vertrag: Wenn der Nutzer auf "PDF exportieren" oder "DOCX exportieren" klickt, hängt das Frontend die Paginierungs-Map (html_map) an den Request-Body an. Der Server behandelt sie als das maßgebliche Layout und hört auf, sein eigenes zu berechnen.

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
}

Der PDF-Generator hüllt jeden Eintrag in einen .page-content-Block fester Größe, erzwingt einen harten Seitenumbruch zwischen ihnen über CSS break-after: page und überlässt WeasyPrint die eigentliche Rasterisierung. Kopf- und Fußzeile laufen durch WeasyPrints running()-Maschinerie, sodass sie auf jeder physischen Seite in den oben-zentrierten / unten-zentrierten Randboxen landen – einschließlich der seltenen zweiten physischen Seite, auf die ein überdimensionierter einzelner Map-Eintrag überlaufen könnte.

Zwei Pipelines, ein Fallback

Nicht jede Export-Anfrage kommt aus dem Browser. Externe Agents, die unseren MCP-Server aufrufen, können einen "lade dieses Dokument herunter"-Tool-Call absetzen, bevor ein Mensch das Dokument je im Editor geöffnet hat – was bedeutet, dass es noch keine html_map gibt, weil kein Browser die Layout-Arbeit erledigt hat.

Für diesen Fall greift der Server auf WeasyPrints native Paginierung über @page-Regeln zurück. Wir hatten vorher einen Fallback, aber er hatte den Bug, der die Neuschreibung überhaupt erst ausgelöst hat: Eine Regel zum Clipping fester Höhe, die für den html_map-Fall gedacht war, feuerte auch auf dem Fallback-Pfad, und jeglicher Inhalt nach der ersten Seite wurde stillschweigend abgeschnitten. Untersuchungen wie diese sind der Grund, warum wir jetzt zwei verschiedene Codepfade statt einer cleveren Verallgemeinerung führen.

DOCX: Pandoc + eine handgebaute reference.docx

DOCX verdient eine Sonderbehandlung, weil Word überhaupt kein CSS spricht. Der naive Ansatz – HTML → irgendeine Bibliothek → .docx – wirft jede Klasse an jedem Span weg und gibt dir ein Dokument, das den richtigen Text, aber keinerlei Styling hat.

Wir verwenden Pandoc für den Body, python-docx für die Word-spezifischen Teile, die Pandoc nicht erreichen kann (Seitenkopf, Seitenfuß), und eine handgebaute reference.docx als Style-Vorlage.

Der reference.docx-Trick

Pandocs --reference-doc=…-Flag erlaubt es dir, ein Word-Dokument zu liefern, dessen Stile deine "Heading 1", "Normal", "Title" und so weiter definieren. Pandoc rendert das Markdown, wendet diese Stile an, und das Ergebnis erbt alles, was du in die Referenz gepackt hast – Schriften, Farben, Zeilenhöhe, Ränder, Listeneinrückung.

Wir generieren reference.docx aus einem kleinen einmaligen Build-Skript (templates/build_reference_docx.py), das Pandocs Standardreferenz ausgibt und sie dann mit python-docx patcht, sodass:

Inline-Klassen: der Lua-Filter

Pandocs docx-Writer lässt CSS-artige Attribute an Span-Elementen fallen. Das ist für reines Markdown in Ordnung, aber ein Problem für AgentDoc, wo jeder Inline-Stil als Stand-off-Klasse an einem Span kodiert ist: [text]{.color-red}, [text]{.highlight-yellow}, [text]{.font-lora .size-xl} und so weiter.

Die Lösung: ein kleiner Pandoc-Lua-Filter (templates/inline_styles.lua), der während des docx-Writer-Durchlaufs über den AST läuft und Klassen in rohe OOXML-Run-Eigenschaften übersetzt:

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

Die Abdeckung ist absichtlich vollständig:

Kombinationen stapeln sich. Ein Span wie [text]{.color-red .size-xl .font-lora} gibt einen einzigen Run mit allen drei Eigenschaften in einem einzigen <w:rPr> aus.

Kopf- und Fußzeile: der python-docx-Nachlauf

Pandocs docx-Writer produziert einen Body, füllt aber den Abschnitts-Header / -Footer nicht aus. Unser bisheriger Workaround – sie als kursive Einleitungs- / Schlusszeilen einzufügen – war ein ergonomisches Desaster: Jeder Nutzer, der einen echten Brief exportierte, sah seinen Kopfzeilentext über der Adresse des Empfängers schweben.

Nachdem Pandoc gelaufen ist, öffnet ein python-docx-Nachlauf die resultierende docx, läuft durch section.header / section.footer und schreibt das Kopf-/Fußzeilen-Markdown durch einen kleinen Inline-Parser. Der Parser behandelt Fettdruck (**…**), Kursiv (*…*) und dieselbe Stand-off-Span-Syntax, die der Body verwendet (color-X, highlight-X, decoration-*) – abgebildet jeweils auf Word-Run-Farben, -Hervorhebungen und Fett/Kursiv-<w:rPr>-Eigenschaften.

Das Ergebnis lebt im tatsächlichen Word-Abschnitts-Header, was bedeutet, dass es auf jeder Seite erscheint und korrekt druckt – einschließlich bei Neu-Paginierungen, die Word selbst vornimmt, wenn der Empfänger das Dokument öffnet.

Workflow-Gating: T+ bekommt den neuen Export, T bleibt bit-stabil

DOCX-Export ist bewusst eine Funktion, die nur auf Workflow T+ verfügbar ist. T bleibt bit-stabil, weil die Benchmarks zur Tool-Granularität gegen genau seine Tool-Oberfläche liefen, und wir wollen, dass diese CSV-Dateien reproduzierbar bleiben. T+ ist der mutable Produktions-Track nach dem Benchmark und bekommt die Ergänzungen.

Das wird an zwei Stellen durchgesetzt, um Lecks unmöglich zu machen:

Was wir bewusst nicht getan haben

Zwei Dinge blieben absichtlich ungetan; beide sind Abwägungen, die es wert sind, explizit genannt zu werden:

Die Integrationstests, die Regressionen wirklich erwischen

Das, was diese Neuschreibung haltbar macht, ist eine Reihe manueller Audit-Skripte, die die gerenderte Ausgabe introspizieren:

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

Das sind keine Unit-Tests; es sind Werkzeuge, die wir nach nicht-trivialen Änderungen ausführen, um zu bestätigen, dass die exportierte Datei tatsächlich die Farbe hat, die wir auf der Überschrift erwarten, die wir erwarten. Wir haben poppler-utils und pypdf zum Backend-Dockerfile hinzugefügt, damit sie auch auf dem Produktions-Image funktionieren, was bedeutet, dass wir sie gegen echte Nutzerdokumente laufen lassen können, wenn wir eine Beschwerde untersuchen.

Was wir gelernt haben und behalten sollten

Drei dauerhafte Lektionen aus dieser Neuschreibung:

Der nächste Beitrag in dieser Reihe handelt von der Voice-First-Architektur – einschließlich einiger neuerer Zuverlässigkeitsarbeit an der Wiederaufnahme von Gemini-Live-Sessions, die ebenfalls einen eigenen Beitrag wert ist. Wenn es ein bestimmtes Detail gibt, zu dem du mehr Tiefe möchtest, geht das Feedback-Widget unten rechts auf jeder Seite dieser Website direkt in unser Postfach.

← Patch Notes – April 2026 Tool-Granularität in LLM-Agents →