PDF- und DOCX-Export neu gebaut: WeasyPrint, Pandoc-Lua-Filter und Vertrauen ins Frontend
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:
- Die Paginierung war uneinig. Der Paginierer des Browsers maß jeden Block im echten DOM mit echten geladenen Schriften; der Server maß ein anderes DOM mit unauffällig anderen Schriften und anderen verfügbaren Breiten. Zehnseitige Dokumente kamen zuverlässig als neun- oder elfseitig heraus, und die Seite, auf der eine Überschrift erschien, verschob sich.
- CSS-Divergenz. Das Export-CSS war eine Kopie der
style.cssmit träge nachgezogenen Änderungen. Tabellen bekamen im Editor zentrierte Kopfzeilen und im PDF linksbündige;<blockquote>hatte auf dem Bildschirm einen linken Rand und im Export nicht. Wir debuggten einzelne Selektoren statt der Architektur. - DOCX war ein völlig anderes Tier. Der bisherige Schnell-und-Schmutzig-Pfad fügte Kopf-/Fußzeile als kursiven Text oben und unten im Body ein, ließ Farb- und Schriftklassen fallen und produzierte etwas, über das Word-Nutzer höflich waren, das sie aber nicht wirklich verwendeten.
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:
- Heading 1 / 2 / 3 / Title Playfair Display, fett, im text-primary-Marineblau unseres
Editors (
#111827) verwenden – genau dieselbe Farbe, die die Überschrift im Browser hat. - Normal / List Paragraph / Body Inter 11pt in Schiefergrau (
#374151) mit Zeilenhöhe 1.7 und 0.25" Listeneinrückung verwenden – abgestimmt auf den Editor. - Header- / Footer-Stile Inter 10pt mit dem dünnen unteren Rand sind, den der Editor über CSS rendert, sodass der visuelle Trenner den Formatwechsel überlebt.
- Die
fontTable.xmlum einen<w:font>-Eintrag pro Editor-Schriftart erweitert wird, jeder mit einem<w:altName>-Hinweis, der auf die nächstgelegene Word-Standardfamilie zeigt (Inter → Calibri, Playfair Display → Cambria, Roboto Mono → Consolas usw.). Word-Nutzer ohne unsere exakten Schriften installiert sehen die Alt-Substitution, statt auf Times New Roman zurückzufallen.
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:
- Farben: 17 benannte Editor-Farben, abgebildet auf den tatsächlichen Hex,
den CSS verwendet, nicht auf W3C-Schlüsselwort-Hex-Werte.
color-redist das weichere Alizarin des Editors#E74C3C, nicht#FF0000;color-blueist#3498DB, nicht#0000FF. Eine blaue Überschrift im Browser rendert jetzt als dasselbe Blau in Word. - Hervorhebungen: Words vollständiges Set benannter Hervorhebungen (gelb / grün / cyan / magenta / blau / rot plus dunkle/helle Varianten); alles außerhalb dieses Sets fällt auf Gelb zurück, statt stillschweigend wegzufallen.
- Schriften: 12 Einträge, die
<w:rFonts ascii="…" hAnsi="…" cs="…" eastAsia="…"/>für jede Editor-Schriftart ausgeben. - Größen: 7 Tokens (
xs..3xl), ausgegeben als<w:sz w:val="halfPt"/>-Werte, em-abgeleitet gegen die 13pt-Body-Baseline, die reference.docx verwendet. - Dekorationen:
decoration-bold/-italic/-strikethroughwerden vor Pandoc in Standard-Markdown übersetzt, sodass wir echte<w:b/>-,<w:i/>- usw. Runs erhalten statt rohem OOXML.
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:
agent/routers/chat.pydefiniertT_PLUS_EXCLUSIVE_TOOLS = {"trigger_docx_download"}; der Branch von T schließt dieses Set explizit aus, sodass ein neu registriertes Tool nicht versehentlich in T's eingefrorenem Schema landen kann.mcp_workflow_middleware.WORKFLOW_T_DENYlistet das Tool ebenfalls auf, sodass externe Agents (die per Design auf T's Oberfläche fixiert sind) es auch über den MCP-Endpunkt nicht sehen.
Was wir bewusst nicht getan haben
Zwei Dinge blieben absichtlich ungetan; beide sind Abwägungen, die es wert sind, explizit genannt zu werden:
- Schrift-Einbettung. Eine wirklich 1:1-Lösung würde die tatsächlichen
Inter- / Playfair-Display- / usw. TTFs in jede generierte DOCX einbetten. Sie würde auch
~400 KB pro Export hinzufügen, plus eine rechtliche Prüfung pro Schrift für die
Weiterverbreitung. Wir verlassen uns stattdessen auf Words Schrift-Substitution + die
altName-Hinweise in unserer erweiterten fontTable. Die meisten Schriftfamilien des Editors sind beliebte Google Fonts, die viele Office-365-Nutzer ohnehin schon haben. - Eine-Pipeline-für-alle. Wir führen jetzt zwei verschiedene PDF-Codepfade (html_map vs. Fallback) und einen separaten DOCX-Pfad. Eine weitere Verallgemeinerung würde es der Art von Fixed-Height-Clipping-Bug von vorhin erlauben, stillschweigend wiederzukehren. Zwei Codepfade plus Integrationstests schlagen einen cleveren.
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:
- Leite nicht neu her, was der Client bereits berechnet hat. Wenn der Browser das Dokument paginiert hat, schick die Paginierung herunter. Jede serverseitige Neuberechnung wird auf subtile Weise abweichen, und du wirst Wochen damit verbringen, den Abweichungen hinterherzujagen.
- Nutze den eigenen Vorlagenmechanismus des Formats, statt dagegen anzukämpfen. Pandocs
--reference-doc=…ist der richtige Ausweg für DOCX. WeasyPrints@page-Regeln sind der richtige Ausweg für PDF. Selbstgebaute HTML-zu-DOCX-Bibliotheken verlieren bei jeder nicht-trivialen Ausgabe immer gegen Pandoc + ein Referenzdokument. - Zwei Codepfade und ein Test, der den Unterschied erwischt, sind besser als ein Codepfad, dem du vertrauen musst. Die Kosten der parallelen Pfade sind klein; die Kosten eines stillschweigenden Truncation-Bugs im Fallback sind groß.
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.