From 0e04a41290dc30a8b5b392c8aefd5c0811acc4b3 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Sat, 15 Aug 2026 22:03:12 +0200 Subject: [PATCH] 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). --- src/lib/export/CLAUDE.md | 2 +- src/lib/export/odt.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 5512c0d..97d69f9 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -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 ``/`` in content.xml. diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index bb169ca..b6ad4d8 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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): Uint8Array { - const mimetype = files['mimetype']; + const out: Record = {}; + 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 = {}; + 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