fix: route Title/Heading1-6 through the docx factory's override slots

The docx package always emits its own Title and Heading1-6 styles; our
same-id copies in paragraphStyles made styles.xml define them twice, and
both Word and LibreOffice then dropped the basedOn chain — headings
rendered serif, regular, at the raw size. buildStyles now feeds those
seven through styles.default.title/headingN, leaving a single
definition with the chain intact. Regression test asserts unique
styleIds and Heading1's basedOn.
This commit is contained in:
Steffen Becker committed 2026-08-27 20:15:44 +00:00
1 parent 742b1cc2b0
commit 51f1ab372c
3 files changed
+31 -1

No files matched your search

+2
View File
@@ -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 `<undefined>` 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
+19 -1
View File
@@ -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<string, string> = {
Title: 'title', Heading1: 'heading1', Heading2: 'heading2', Heading3: 'heading3',
Heading4: 'heading4', Heading5: 'heading5', Heading6: 'heading6',
};
function buildStyles(sheet: StyleSheet, used: Set<string>, language?: { language: string; country: string } | null, usedTables: string[] = [], usedLists: string[] = []) {
const run: Writable<IRunStylePropertiesOptions> = { font: DOC_FONT, size: 24 };
if (language) run.language = { value: `${language.language}-${language.country}` };
const slotted: Record<string, Omit<IParagraphStyleOptions, 'id' | 'name'>> = {};
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)] }
+10
View File
@@ -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"><w:name w:val="Heading 1"\/><w:basedOn w:val="Heading"\/>/);
});
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)],