Files
edentext/src/lib/export/CLAUDE.md
T
Steffen Becker d344494fcd feat: a text box keeps its lists and pictures on the way into Word
A list inside a box was flattened to literal bullet/number runs and a
picture was dropped: the box XML is a hand-built string, and neither had
the package parts it needs.

applyTextBoxesDocx already holds the whole unzipped package, so it mints
them — a media entry with its relationship, and an abstractNum/num pair
above the package's own ids, the paragraphs carrying a sentinel until
those ids exist. Verified through LibreOffice and back through our own
importer, nesting, marker symbols and start values intact.
2026-08-14 08:09:21 +02:00

13 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 1–6 (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, which preserves the mimetype-first uncompressed requirement). 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; 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.
  • 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. 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.
  • 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).