diff --git a/src/lib/export/CLAUDE.md b/src/lib/export/CLAUDE.md index 2416f7b..24edfc4 100644 --- a/src/lib/export/CLAUDE.md +++ b/src/lib/export/CLAUDE.md @@ -54,6 +54,8 @@ fallback download shows a one-time hint (`edentext-download-hint`) that the brow - **`normalizeColor`** — coerces colors to `#RRGGBB` (ODF requirement; rejects/normalizes `rgb()` and short hex). - **Schema conformance** (guarded by `tests/schema-validation.test.ts`; LibreOffice forgives all of this, Word's strict reader does not): `applyOdfVersion` stamps every ODF part **1.3** — the version LibreOffice writes, and the first with `style:header-first` — over odf-kit's 1.2. `draw:image` carries the `xlink:type/show/actuate` trio (`xlink:type` is mandatory beside `xlink:href`); `text:time-value` is an xsd dateTime, never a `PT…S` duration (the ODT importer still reads the legacy duration); `index-entry-link-start/-end` only in TOC entry templates; `text:dont-balance-text-columns` on `style:section-properties`. DOCX: `orderDocxSettings` (last pass) re-sorts `w:settings` children into the fixed CT_Settings sequence the prepend-passes scramble; `w14:paraId` in comments.xml requires `mc:Ignorable="w14"` on the root; `styleXmlComponent` unwraps `ImportedXmlComponent.fromXmlString`'s nameless wrapper, which would serialize as a literal `` element. +- **Factory style slots** (`FACTORY_SLOTS`, `buildStyles`): the `docx` package always writes its own Title/Heading1–6 into styles.xml, so the registry's versions ride `styles.default.title/headingN` instead of `paragraphStyles` — a second definition under the same `w:styleId` makes Word **and** LibreOffice drop the `basedOn` chain (headings lose their sans/bold). + The filename is derived from the first non-empty heading (max 50 chars, sanitized), falling back to `document.odt`. ## Header/footer export diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index adcee5c..a5d897b 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -2412,15 +2412,33 @@ function numberingStyleXml(name: string): ImportedXmlComponent { ); } +// Styles the library always writes itself; ours must ride its override slots — a second +// definition under the same styleId makes Word and LibreOffice drop the basedOn chain. +const FACTORY_SLOTS: Record = { + Title: 'title', Heading1: 'heading1', Heading2: 'heading2', Heading3: 'heading3', + Heading4: 'heading4', Heading5: 'heading5', Heading6: 'heading6', +}; + function buildStyles(sheet: StyleSheet, used: Set, language?: { language: string; country: string } | null, usedTables: string[] = [], usedLists: string[] = []) { const run: Writable = { font: DOC_FONT, size: 24 }; if (language) run.language = { value: `${language.language}-${language.country}` }; + const slotted: Record> = {}; + const paragraphStyles = Object.values(sheet.paragraph) + .filter(st => used.has(st.name)).map(paragraphStyleOf) + .filter((st) => { + const slot = FACTORY_SLOTS[st.id]; + if (!slot) return true; + const { id: _id, name: _name, ...rest } = st; + slotted[slot] = rest; + return false; + }); return { default: { document: { run, paragraph: { spacing: { after: 0, line: 240, lineRule: LineRuleType.AUTO } } }, + ...slotted, }, // The document's named styles, chain intact — Word shows them in its style list. - paragraphStyles: Object.values(sheet.paragraph).filter(st => used.has(st.name)).map(paragraphStyleOf), + paragraphStyles, characterStyles: Object.values(sheet.character ?? {}).map(characterStyleOf), ...(usedTables.length || usedLists.length ? { importedStyles: [...usedTables.map(tableStyleXml), ...usedLists.map(numberingStyleXml)] } diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 2fba343..6bd0d10 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -160,6 +160,16 @@ describe('DOCX export → import round trip', () => { expect(table.content![0].content![0].attrs.backgroundColor).toBe('#4A7EBB'); }); + it('defines Title and Heading1-6 exactly once, chain intact', async () => { + const doc = { type: 'doc', content: [heading(1, 'Kapitel'), para('Text')] } as any; + const files = unzipSync(await buildDocx(doc)); + const stylesXml = strFromU8(files['word/styles.xml']); + const ids = [...stylesXml.matchAll(/w:styleId="([^"]+)"/g)].map((m) => m[1]); + // A second definition under the same id makes Word and LO drop the basedOn chain. + expect(ids.length).toBe(new Set(ids).size); + expect(stylesXml).toMatch(/w:styleId="Heading1">/); + }); + it('round-trips the table style options as w:tblLook', async () => { const cell = (t: string): N => ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [para(t)],