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).
This commit is contained in:
Steffen Becker committed 2026-08-15 22:03:12 +02:00
1 parent 6b64b42c42
commit 0e04a41290
2 files changed
+16 -4

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
## 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:
`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.
+15 -3
View File
@@ -2609,13 +2609,25 @@ function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8
}
// Re-zip an unpacked ODF, preserving the mimetype-first uncompressed requirement.
// Intermediate archive between two passes: stored, the next unzipSync is the only
// reader — deflating every entry (images included) once per pass dominated buildOdt.
function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
const mimetype = files['mimetype'];
const out: Record<string, [Uint8Array, { level: 0 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 0 }];
return zipSync(out);
}
// The returned .odt: mimetype first and stored (ODF requirement), already-compressed
// picture formats stored (svg/bmp still deflate well), everything else deflated once.
function zipFinal(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
const mimetype = files['mimetype'];
if (mimetype) out['mimetype'] = [mimetype, { level: 0 }];
for (const [path, data] of Object.entries(files)) {
if (path === 'mimetype') continue;
out[path] = [data, { level: 6 }];
const stored = /^Pictures\/.*\.(png|jpg|gif|webp)$/.test(path);
out[path] = [data, { level: stored ? 0 : 6 }];
}
return zipSync(out);
}
@@ -4614,7 +4626,7 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// Sections past the first get their own master page, which is where ODF keeps a
// section's header/footer; the SEC-marked block points at it.
const withSections = applySectionMasterPages(withWatermark, hf?.sections ?? [], hf?.pageCount ?? 1, margins, pageFormat, orientation);
return applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props);
return zipFinal(applyDocProperties(applyPageNumberStart(applySpacingModel(withSections, spacingModel), pageNumbering.start), props));
}
// The document's first page number. ODF has no document-level start: LibreOffice puts