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).
17 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 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 ath6, so 7–10 render as unknown elements thateditor.cssgivesdisplay: block. Levels 1–10 (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). 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 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, 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): 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. - Templates (
template.ts): a.ott/.dotxis the built document with one label changed — ODF's package media type (inmimetypeand in the manifest, the mimetype entry staying first and stored) and OOXML's content type for/word/document.xml.saveAsTemplateoffers both in one picker, so the bytes are only built once the extension is known. - 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. 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 thedocxpackage exposes asfeatures.trackRevisions(its schema quote names the element). DOCX needs no string pass:InsertedTextRun/DeletedTextRunfrom thedocxpackage emitw:ins/w:del+w:delText, withdocRevisionIdnumbering them per document as it does comments. - Bibliography (
replaceBibEntries/applyBibEntries,applyBibliographyDocx): aBIBsentinel 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 thedocxpackage knows nothing about, so the post-pack pass mints all four pieces itself:customXml/item1.xml(b:Sources), itsitemProps1.xml, both relationships and the properties part's content type. (Probed: LibreOffice's own DOCX export writes theCITATIONfield but no sources at all.) - Ruby (
replaceRuby/applyRuby,applyRubyDocx): anRBYsentinel becomes a<text:ruby>holding both halves plus one mintedstyle:family="ruby"automatic style (centred, above — probed: LibreOffice writes that style straight back out, adding only its ownloext:mirror of the position). Word's is a<w:ruby>run thedocxpackage cannot emit, so a post-pack pass swaps the sentinel run for it; thew:rubyPrsizes 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-cellin the same document orderapplyCellBlocksdoes and writestable:formula="ooow:…"+ the cachedoffice:value. Theooow:is a namespace prefix, soensureOoowNamespacedeclares 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 oneSimpleField— 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. Seedocs/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-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),
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).