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