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.
13 KiB
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-facemaps 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 inexport/odt.tsand feed the importers, the TOC, the DOCX heading styles,utils/fontSize.ts, andeditor.css(kept in sync bytests/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 carryinglineHeight/textAlign/spacing attrs — or runs with afontWeighttextStyle attr, which odf-kit's native path drops — are renamed to__cust_p__/__cust_h__and emitted via odf-kit'sunknownNodeHandler(odf-kit ignores those node attrs otherwise). Tables are renamed to__cust_table__and built byexportTablewith an explicit cell border (odf-kit's native table path emits no borders → invisible in LibreOffice/Word). - Hard breaks & tabs (
replaceHardBreaks/replaceTabs/applyInlineSentinels):hardBreaknodes becomeLBR-sentinel text and tab chars becomeTAB-sentinel text so both survive every odf-kit path (a literal\twould 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 eachimagenode for anIMG-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm viaround3); the sentinel rides every odf-kit path including table cells.applyImages(afterapplyCellBlocks) 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 binaryPictures/entries to the zip, and appends a<manifest:file-entry>per picture toMETA-INF/manifest.xml. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets adraw: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 usestext:anchor-type="paragraph"+ a minted<style:style style:family="graphic">carryingstyle: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, withtable:align="margins"). Table margins: a table'smarginLeft/marginRightshrink that width and are written byapplyTableMarginsinto the table's automatic style (style:width+fo:margin-left/-right; odf-kit names table stylesTable1,Table2, … in document order, matching the descriptor listexportTablefills). odf-kit serializes each cell to a single<text:p>of runs;exportTableemits all of a cell's content into that paragraph separated by aSEGsentinel and records aCellBlock[]descriptor, thenapplyCellBlockssplits onSEGand rebuilds real<text:h>/<text:p>/<text:list>(incl. nested lists) with minted automatic paragraph styles. Merged cells:exportTablepasses each cell'scolspan/rowspanas odf-kitCellOptions.colSpan/rowSpan; odf-kit emitstable:number-columns/rows-spannedand auto-generates the<table:covered-table-cell>placeholders (which never match theapplyCellBlockscell regex, socellBlocksstays one-per-real-cell aligned). Import already reads spans/covered cells inconvertTable. Cell shading: a cell'sbackgroundColorattr is passed asCellOptions.backgroundColor(normalized), which odf-kit mints into the cell style asfo:background-color. Cell borders: the per-side border attrs (tableCellBorders.ts) are passed asCellOptions.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-leveltextBoxis hoisted —replaceTextBoxes(afterreplacePageBreaks, before the inline passes) swaps it forTBX-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(afterapplyImages) wraps each serialized S…E region into<draw:frame><draw:text-box fo:min-height>(plain box;svg:heightalso emitted for non-auto-grow consumers) or<draw:custom-shape>+ preset<draw:enhanced-geometry>(utils/shapes.ts; seedocs/architecture/frames.md) inside a fresh anchor paragraph, minting aTbxFr{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): thedocxpackage 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 becomesword/media/tbx{n}.{ext}plus aRelationshipabove the package's highestrId(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 aTXBX_NUMsentinel that the same pass substitutes. - Formulas (
replaceFormulas/applyFormulas): anMTH-sentinel run becomes an as-char<draw:frame><draw:object xlink:href="./Formula{n}"/>plus aFormula{n}/content.xmlsub-document (MathML root) and its two manifest entries. Nosvg: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. Seedocs/architecture/formulas.md. - Right-to-left pages: ODF gets
style:writing-mode="rl-tb"on the page layout (rewriteStylesXml), DOCX a<w:bidi/>in everyw:sectPrfrom 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 theParaStyle/PBX-spec pipe in ODF, so it reaches cells and list items as well as top-level blocks. - Mirror margins (
applyMirrorMarginsDocx,export/docx.ts): thedocxpackage writes only the per-sectionw:pgMar, so Word's document-levelw:mirrorMarginsgoes intoword/settings.xmlin the same post-pack pass; ODF's flag ridesrewriteStylesXml. Both formats already hold left/right as the inner/outer pair, so only the flag has to travel. - Bookmarks & cross-references (
replaceBookmarks/applyBookmarks): theBMS/BME/XRFsentinels 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 — thedocxpackage hasBookmark,SimpleField(REF/PAGEREF) andInternalHyperlink. Seedocs/architecture/formatting.md. - Captions (
replaceSequenceFields/applySequenceFields): aSEQsentinel becomes a<text:sequence>carrying LibreOffice's owntext:formula="ooow:<name>+1"(what makes its reader recount on load) plus a<text:sequence-decl>per counter used; DOCX gets aSimpleFieldSEQ 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 byTCIsentinels →<text:change-start/>/<text:change-end/>; a deletion's text is cut out of the run and left as oneTCD→<text:change/>, the text itself going into the<text:tracked-changes>registryapplyRevisionsbuilds 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>needensureDcNamespace— odf-kit declares nodc:prefix in content.xml. DOCX needs no string pass:InsertedTextRun/DeletedTextRunfrom thedocxpackage emitw:ins/w:del+w:delText, withdocRevisionIdnumbering 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-listindent(cm) added to itsL#list-style per-levelfo: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 inconvertList(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'sstartattr; a Word list continued across an intervening paragraph (same numId, split into separateorderedListnodes withstart> 1 by the importer) getstext:start-valueon its first<text:list-item>so numbering keeps counting instead of restarting at 1.applyTableRowHeights— dragged row heights →style:min-row-heightautomatic table-row styles.collapseRunWhitespace— strips the bare\nseparators 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'sfo: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/normalizesrgb()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 tounknownNodeHandler, which calls odf-kit'ssetHeader/setFooter(applyHfRuns);hardBreak→LBR,pageNumber→addPageNumber,pageCount→PGCsentinel. A zone image is an as-char<draw:frame>, or — floating — a page-anchored one plus a mintedHfBg*graphic style (run-throughbehind 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 theHeader/Footerstyles, and converts geometry to Word's model: page margin = the zone's edge distance (headerDistanceCm/footerDistanceCm), header/footermin-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).