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:
1 parent
742b1cc2b0
commit
51f1ab372c
3 files changed
+31
-1
No files matched your search
@@ -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
@@ -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)] }
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in new issue
Block a user