Files
edentext/src/lib/export/CLAUDE.md
T
Steffen Becker 0e04a41290 perf: stop deflating the ODT archive once per export pass
Each of the ~30 post-processing passes re-zipped every entry (images
included) at level 6 just for the next pass's unzipSync. Intermediate
archives are now stored; zipFinal compresses once at the end, keeping
mimetype first/stored and storing already-compressed picture formats.
Measured ~10x on an image-heavy document (853ms -> 87ms).
2026-08-15 22:03:12 +02:00

17 KiB
Raw Blame History

src/lib/export/

odt.ts (ODF), docx.ts (Word), pdf.ts (html2canvas + jsPDF), saveFile.ts. Frames, tables and pagination have their own deep-dives in docs/architecture/.

Heading defaults (HEADING_STYLE_OVERRIDES, export/odt.ts)

  • Headings follow LibreOffice too (HEADING_STYLE_OVERRIDES): 18 / 16 / 14 / 13 / 12 / 12pt (levels 4 and 6 italic) with the Heading style's 0.423cm/0.212cm margins on every level, bold, and sans (HEADING_FONT = Arial; on screen the bundled @font-face maps it to Liberation Sans, metric-identical, mirroring Liberation Serif → Times New Roman for the body). Levels 7–10 continue level 6 at 12pt (alternating italic): probed, LibreOffice writes those styles with no properties at all and resolves them from its own pool, so there is no file value to follow. HTML stops at h6, so 7–10 render as unknown elements that editor.css gives display: block. Levels 1–10 (MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length); both constants live in export/odt.ts and feed the importers, the TOC, the DOCX heading styles, utils/fontSize.ts, and editor.css (kept in sync by tests/unit/font-size-display.test.ts). The importers no longer override a file's own heading formatting: they only suppress values that equal these defaults, exactly as for body text.

ODF export (odt.ts)

buildOdt(json, margins, orientation) (the DOM-free pipeline returning the .odt bytes; App.svelte writes them via the File System Access API or a download — see export/saveFile.ts) calls tiptapToOdt(json, opts) from odf-kit, then runs several content.xml / styles.xml post-processing passes (unzip → string-replace → re-zip via rezipOdt). The ~30 intermediate archives are stored, not deflated — only the next pass's unzipSync ever reads them, and deflating every entry per pass made the pipeline ~10× slower (measured 853ms → 87ms on a 3-image document). zipFinal produces the returned .odt: mimetype first and stored (ODF requirement), already-compressed picture formats (png/jpg/gif/webp) stored, the rest deflated once. odf-kit alone can't express everything the editor supports, so:

  • Custom node types (injectCustomTypes): paragraphs/headings carrying lineHeight/textAlign/spacing attrs — or runs with a fontWeight textStyle attr, which odf-kit's native path drops — are renamed to __cust_p__/__cust_h__ and emitted via odf-kit's unknownNodeHandler (odf-kit ignores those node attrs otherwise). Tables are renamed to __cust_table__ and built by exportTable with an explicit cell border (odf-kit's native table path emits no borders → invisible in LibreOffice/Word).
  • Hard breaks & tabs (replaceHardBreaks/replaceTabs/applyInlineSentinels): hardBreak nodes become LBR-sentinel text and tab chars become TAB-sentinel text so both survive every odf-kit path (a literal \t would collapse to a space), then the sentinels are rewritten to <text:line-break/>/<text:tab/> in content.xml.
  • Images (replaceImages/applyImages): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. replaceImages (a pre-pass) swaps each image node for an IMG-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via round3); the sentinel rides every odf-kit path including table cells. applyImages (after applyCellBlocks) rewrites each sentinel to a <draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>, adds the binary Pictures/ entries to the zip, and appends a <manifest:file-entry> per picture to META-INF/manifest.xml. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a draw:transform="rotate(<rad>) translate(...)" (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A floating image (wrap !== 'inline') instead uses text:anchor-type="paragraph" + a minted <style:style style:family="graphic"> carrying style:wrap + style:horizontal-pos (image-left → wrap="right" horizontal-pos="left"; topBottom → wrap="none" horizontal-pos="center") injected into content.xml automatic-styles.
  • Tables: column widths come from the editor's per-column weights (tableColumnWidthsCm, summing exactly to the text width, with table:align="margins"). Table margins: a table's marginLeft/marginRight shrink that width and are written by applyTableMargins into the table's automatic style (style:width + fo:margin-left/-right; odf-kit names table styles Table1, Table2, … in document order, matching the descriptor list exportTable fills). odf-kit serializes each cell to a single <text:p> of runs; exportTable emits all of a cell's content into that paragraph separated by a SEG sentinel and records a CellBlock[] descriptor, then applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list> (incl. nested lists) with minted automatic paragraph styles. Merged cells: exportTable passes each cell's colspan/rowspan as odf-kit CellOptions.colSpan/rowSpan; odf-kit emits table:number-columns/rows-spanned and auto-generates the <table:covered-table-cell> placeholders (which never match the applyCellBlocks cell regex, so cellBlocks stays one-per-real-cell aligned). Import already reads spans/covered cells in convertTable. Cell shading: a cell's backgroundColor attr is passed as CellOptions.backgroundColor (normalized), which odf-kit mints into the cell style as fo:background-color. Cell borders: the per-side border attrs (tableCellBorders.ts) are passed as CellOptions.borderTop/Right/Bottom/Left ('none' or '<W>pt solid #RRGGBB') overriding the table-level default border per side.
  • Text boxes / shapes (replaceTextBoxes/applyTextBoxes): a top-level textBox is hoisted — replaceTextBoxes (after replacePageBreaks, before the inline passes) swaps it for TBX-sentinel marker paragraphs (S{i}…E{i}, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. applyTextBoxes (after applyImages) wraps each serialized S…E region into <draw:frame><draw:text-box fo:min-height> (plain box; svg:height also emitted for non-auto-grow consumers) or <draw:custom-shape> + preset <draw:enhanced-geometry> (utils/shapes.ts; see docs/architecture/frames.md) inside a fresh anchor paragraph, minting a TbxFr{n} graphic style (fill/stroke/padding + the image wrap props, plus <style:paragraph-properties style:writing-mode="tb-rl"/> for a box whose text runs top-to-bottom — probed: in the graphic properties LibreOffice drops it; draw:auto-grow-height="true" for boxes, both auto-grows explicitly false for shapes or LibreOffice's autofit shrinks them to their text).
  • A text box's own parts, DOCX (applyTextBoxesDocx): the docx package can't emit a DrawingML shape, so the box XML is a hand-built string — and its content needs parts the package never hears about. The pass holds the whole unzipped package, so it mints them itself: a picture becomes word/media/tbx{n}.{ext} plus a Relationship above the package's highest rId (Content_Types already declares the raster defaults), a list an <w:abstractNum>/<w:num> pair above its highest id, appended before the first <w:num> because every abstract definition has to precede them. The numbering ids only exist once the packed file is in hand, so the paragraphs carry a TXBX_NUM sentinel that the same pass substitutes.
  • Templates (template.ts): a .ott/.dotx is the built document with one label changed — ODF's package media type (in mimetype and in the manifest, the mimetype entry staying first and stored) and OOXML's content type for /word/document.xml. saveAsTemplate offers both in one picker, so the bytes are only built once the extension is known.
  • Formulas (replaceFormulas/applyFormulas): an MTH-sentinel run becomes an as-char <draw:frame><draw:object xlink:href="./Formula{n}"/> plus a Formula{n}/content.xml sub-document (MathML root) and its two manifest entries. No svg:width/svg:height — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits <m:oMath> (or <m:oMathPara> when displayed) from the same post-pack pass as the text boxes. See docs/architecture/formulas.md.
  • Right-to-left pages: ODF gets style:writing-mode="rl-tb" on the page layout (rewriteStylesXml), DOCX a <w:bidi/> in every w:sectPr from a post-pack pass (applyBidiDocx) — the package exposes only the paragraph-level flag. A paragraph's own direction (textDirection.ts) rides that same flag in DOCX and the ParaStyle/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks.
  • Mirror margins (applyMirrorMarginsDocx, export/docx.ts): the docx package writes only the per-section w:pgMar, so Word's document-level w:mirrorMargins goes into word/settings.xml in the same post-pack pass; ODF's flag rides rewriteStylesXml. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel.
  • Bookmarks & cross-references (replaceBookmarks/applyBookmarks): the BMS/BME/XRF sentinels are spliced into the run text rather than emitted by a run builder, so they ride every odf-kit path (cells, custom paragraphs, lists) with no per-emitter change; one post-pass rewrites them to <text:bookmark-start/>/<text:bookmark-end/>/<text:bookmark-ref>. DOCX needs no pass at all — the docx package has Bookmark, SimpleField (REF/PAGEREF) and InternalHyperlink. See docs/architecture/formatting.md.
  • Captions (replaceSequenceFields/applySequenceFields): a SEQ sentinel becomes a <text:sequence> carrying LibreOffice's own text:formula="ooow:<name>+1" (what makes its reader recount on load) plus a <text:sequence-decl> per counter used; DOCX gets a SimpleField SEQ Figure \* ARABIC. Both keep the editor's resolved rank as the field's cached result.
  • Tracked changes (replaceRevisions/applyRevisions): ODF splits the pair. An insertion keeps its text inline and is bracketed by TCI sentinels → <text:change-start/>/<text:change-end/>; a deletion's text is cut out of the run and left as one TCD → <text:change/>, the text itself going into the <text:tracked-changes> registry applyRevisions builds right after <office:text> (a <text:deletion> holds the deleted paragraphs, a <text:insertion> only its <office:change-info>). The registry's <dc:creator>/<dc:date> need ensureDcNamespace — odf-kit declares no dc: prefix in content.xml. The registry also carries whether recording goes on (text:track-changes), so it is written for that alone when a document records but holds no change yet — LibreOffice writes the same empty element (probed). Word keeps that flag in settings.xml instead, which the docx package exposes as features.trackRevisions (its schema quote names the element). DOCX needs no string pass: InsertedTextRun/DeletedTextRun from the docx package emit w:ins/w:del + w:delText, with docRevisionId numbering them per document as it does comments.
  • Bibliography (replaceBibEntries/applyBibEntries, applyBibliographyDocx): a BIB sentinel becomes a <text:bibliography-mark> whose attributes are the whole source record and which wraps the text the citation shows (unlike an index mark, which is a point). The <text:bibliography> index writes an entry template per type actually cited, spelling out the same "key: author, title, year" the node view prints — LibreOffice regenerates the rows from it, so a template that said anything else would rewrite them. DOCX cites by tag (CITATION "key") and keeps the sources in a custom-XML part the docx package knows nothing about, so the post-pack pass mints all four pieces itself: customXml/item1.xml (b:Sources), its itemProps1.xml, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes the CITATION field but no sources at all.)
  • Ruby (replaceRuby/applyRuby, applyRubyDocx): an RBY sentinel becomes a <text:ruby> holding both halves plus one minted style:family="ruby" automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its own loext: mirror of the position). Word's is a <w:ruby> run the docx package cannot emit, so a post-pack pass swaps the sentinel run for it; the w:rubyPr sizes are half-points of the 12pt body default, which is what LibreOffice's own export writes.
  • Cell formulas (applyCellFormulas, formulaCellContent): ODF puts the formula on the cell, so the pass walks <table:table-cell in the same document order applyCellBlocks does and writes table:formula="ooow:…" + the cached office:value. The ooow: is a namespace prefix, so ensureOoowNamespace declares it — undeclared, LibreOffice reads it as part of the formula. Word instead keeps a field inside the cell, so the DOCX side swaps the cell's first paragraph for one SimpleField — and the cell's number format rides that field's \# switch (\@ for a date), where ODF puts it on the cell style (a minted data style plus a copy of the shared cell style that references it). The data style names the document's language, or LibreOffice prints its own locale's separators and currency symbol. See docs/architecture/tables.md.
  • applyListItemStyles — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
  • applyListIndents — a top-level list's whole-list indent (cm) added to its L# list-style per-level fo:margin-left/text:list-tab-stop-position (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in convertList (level-1 margin minus the 1.27cm base).
  • applyNestedListTypes — odf-kit emits nested lists as bare <text:list> sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
  • applyListStartValues — odf-kit drops an ordered list's start attr; a Word list continued across an intervening paragraph (same numId, split into separate orderedList nodes with start > 1 by the importer) gets text:start-value on its first <text:list-item> so numbering keeps counting instead of restarting at 1.
  • applyTableRowHeights — dragged row heights → style:min-row-height automatic table-row styles.
  • collapseRunWhitespace — strips the bare \n separators odf-kit inserts between runs inside a paragraph (they'd otherwise collapse to spurious spaces mid-word).
  • rewriteStylesXml — style:page-usage="mirrored" on the page layout when the margins mirror (the left/right pair is already the inner/outer one), default font Liberation Serif → Times New Roman, heading sizes/margins → the editor's values, Standard's fo:margin-bottom → 0 (odf-kit emits 0.212cm; every paragraph and list item inherits it, and the editor has no paragraph spacing — see below).
  • normalizeColor — coerces colors to #RRGGBB (ODF requirement; rejects/normalizes rgb() and short hex).

The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to document.odt.

Header/footer export

  • Export: a synthetic __cust_hf__ node routes to unknownNodeHandler, which calls odf-kit's setHeader/setFooter (applyHfRuns); hardBreak→LBR, pageNumber→addPageNumber, pageCount→PGC sentinel. A zone image is an as-char <draw:frame>, or — floating — a page-anchored one plus a minted HfBg* graphic style (run-through behind the text, positioned from the page corner). applyHfPostProcess (on styles.xml) rewrites the sentinels to <text:line-break/>/<text:page-count>, applies the paragraph alignment to the Header/Footer styles, and converts geometry to Word's model: page margin = the zone's edge distance (headerDistanceCm/footerDistanceCm), header/footer min-height = body margin − distance, so the body still starts at the editor's margin.

Sentinel order matters — the passes assume it. Full list of sentinels in use: LBR/TAB (inline), IMG, SEG (cell blocks), TBX U+E008 (text boxes), U+E009 (columns), U+E00A (date/time fields), STY U+E00D (paragraph styles), CST U+E00E (character styles), TEF U+E00F (text effects), PBX (paragraph boxes), SEC U+E010 (section breaks), LEAD U+E011 (a tab stop's leader, inside style:type), MTH U+E012 (formulas), BMS/BME U+E013/U+E014 (a bookmark's range), XRF U+E015 (cross-references), CHP U+E016 (the chapter field), FNT U+E017 (footnotes/endnotes), CMS/CME U+E018/U+E019 (a comment's range), SEQ U+E01A (a caption's number), TCI U+E01B (a tracked insertion's range), TCD U+E01C (where a tracked deletion was), TXBX_NUM U+E01D (DOCX only: a numbering id a box's list has yet to be given), IXE U+E01E (an alphabetical-index entry), BIB U+E01F (a citation), NOHYP U+E020 (DOCX only: a paragraph whose w:pPr must gain w:suppressAutoHyphens), RBY U+E021 (a ruby annotation).