Skip to content

Commit 9941829

Browse files
committed
fix(style-engine): resolve built-in Word heading styles when missing from styles.xml
Add fallback to Word's built-in heading defaults (Heading1–Heading6) in resolveStyleChain() when a referenced style is not explicitly defined in the document's styles.xml. Also apply fallback in the basedOn chain walk so custom styles inheriting from built-in headings resolve correctly. Documents created with python-docx's add_heading() reference built-in styles without adding definitions — this is valid OOXML but previously rendered as unstyled body text. Closes #2805
1 parent 4ba8992 commit 9941829

3 files changed

Lines changed: 265 additions & 3 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import type { StyleDefinition } from './styles-types.js';
2+
3+
/**
4+
* Built-in Word heading style defaults.
5+
*
6+
* Word defines ~260 built-in styles that implicitly exist even when not declared
7+
* in a document's styles.xml. When a paragraph references a built-in style
8+
* (e.g., `<w:pStyle w:val="Heading1"/>`) that has no explicit definition,
9+
* these defaults provide the expected visual properties.
10+
*
11+
* Values match Word's defaults per ECMA-376 §17.7.4.9. Font sizes are in
12+
* half-points (OOXML convention), spacing in twips (twentieths of a point).
13+
*
14+
* All headings inherit from 'Normal' via basedOn, so any document-level Normal
15+
* style properties (font family, line spacing, etc.) cascade correctly.
16+
*/
17+
export const BUILTIN_STYLE_DEFAULTS: Record<string, StyleDefinition> = {
18+
Heading1: {
19+
type: 'paragraph',
20+
styleId: 'Heading1',
21+
name: 'heading 1',
22+
basedOn: 'Normal',
23+
next: 'Normal',
24+
qFormat: true,
25+
uiPriority: 9,
26+
paragraphProperties: {
27+
keepNext: true,
28+
keepLines: true,
29+
spacing: { before: 360, after: 80 },
30+
outlineLvl: 0,
31+
},
32+
runProperties: {
33+
fontFamily: {
34+
asciiTheme: 'majorHAnsi',
35+
eastAsiaTheme: 'majorEastAsia',
36+
hAnsiTheme: 'majorHAnsi',
37+
cstheme: 'majorBidi',
38+
},
39+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
40+
fontSize: 40,
41+
fontSizeCs: 40,
42+
},
43+
},
44+
45+
Heading2: {
46+
type: 'paragraph',
47+
styleId: 'Heading2',
48+
name: 'heading 2',
49+
basedOn: 'Normal',
50+
next: 'Normal',
51+
qFormat: true,
52+
uiPriority: 9,
53+
paragraphProperties: {
54+
keepNext: true,
55+
keepLines: true,
56+
spacing: { before: 160, after: 80 },
57+
outlineLvl: 1,
58+
},
59+
runProperties: {
60+
fontFamily: {
61+
asciiTheme: 'majorHAnsi',
62+
eastAsiaTheme: 'majorEastAsia',
63+
hAnsiTheme: 'majorHAnsi',
64+
cstheme: 'majorBidi',
65+
},
66+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
67+
fontSize: 32,
68+
fontSizeCs: 32,
69+
},
70+
},
71+
72+
Heading3: {
73+
type: 'paragraph',
74+
styleId: 'Heading3',
75+
name: 'heading 3',
76+
basedOn: 'Normal',
77+
next: 'Normal',
78+
qFormat: true,
79+
uiPriority: 9,
80+
paragraphProperties: {
81+
keepNext: true,
82+
keepLines: true,
83+
spacing: { before: 160, after: 80 },
84+
outlineLvl: 2,
85+
},
86+
runProperties: {
87+
fontFamily: {
88+
eastAsiaTheme: 'majorEastAsia',
89+
cstheme: 'majorBidi',
90+
},
91+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
92+
fontSize: 28,
93+
fontSizeCs: 28,
94+
},
95+
},
96+
97+
Heading4: {
98+
type: 'paragraph',
99+
styleId: 'Heading4',
100+
name: 'heading 4',
101+
basedOn: 'Normal',
102+
next: 'Normal',
103+
qFormat: true,
104+
uiPriority: 9,
105+
paragraphProperties: {
106+
keepNext: true,
107+
keepLines: true,
108+
spacing: { before: 80, after: 40 },
109+
outlineLvl: 3,
110+
},
111+
runProperties: {
112+
fontFamily: {
113+
eastAsiaTheme: 'majorEastAsia',
114+
cstheme: 'majorBidi',
115+
},
116+
italic: true,
117+
iCs: true,
118+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
119+
fontSize: 24,
120+
fontSizeCs: 24,
121+
},
122+
},
123+
124+
Heading5: {
125+
type: 'paragraph',
126+
styleId: 'Heading5',
127+
name: 'heading 5',
128+
basedOn: 'Normal',
129+
next: 'Normal',
130+
qFormat: true,
131+
uiPriority: 9,
132+
paragraphProperties: {
133+
keepNext: true,
134+
keepLines: true,
135+
spacing: { before: 80, after: 40 },
136+
outlineLvl: 4,
137+
},
138+
runProperties: {
139+
fontFamily: {
140+
eastAsiaTheme: 'majorEastAsia',
141+
cstheme: 'majorBidi',
142+
},
143+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
144+
fontSize: 22,
145+
fontSizeCs: 22,
146+
},
147+
},
148+
149+
Heading6: {
150+
type: 'paragraph',
151+
styleId: 'Heading6',
152+
name: 'heading 6',
153+
basedOn: 'Normal',
154+
next: 'Normal',
155+
qFormat: true,
156+
uiPriority: 9,
157+
paragraphProperties: {
158+
keepNext: true,
159+
keepLines: true,
160+
spacing: { before: 80, after: 40 },
161+
outlineLvl: 5,
162+
},
163+
runProperties: {
164+
fontFamily: {
165+
eastAsiaTheme: 'majorEastAsia',
166+
cstheme: 'majorBidi',
167+
},
168+
italic: true,
169+
iCs: true,
170+
color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
171+
fontSize: 22,
172+
fontSizeCs: 22,
173+
},
174+
},
175+
};

packages/layout-engine/style-engine/src/ooxml/index.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,79 @@ describe('ooxml - resolveStyleChain', () => {
5555
expect(result).toEqual({ fontSize: 24, bold: true, italic: true });
5656
});
5757

58-
it('returns empty object when styleId is missing from definitions', () => {
58+
it('returns empty object when styleId is missing from definitions and not a built-in', () => {
5959
const params = buildParams();
6060
const result = resolveStyleChain('runProperties', params, 'MissingStyle');
6161
expect(result).toEqual({});
6262
});
63+
64+
it('resolves built-in Heading1 run properties when not defined in document', () => {
65+
const params = buildParams();
66+
const result = resolveStyleChain('runProperties', params, 'Heading1');
67+
expect(result.fontSize).toBe(40);
68+
expect(result.fontSizeCs).toBe(40);
69+
expect(result.color).toEqual({ val: '0F4761', themeColor: 'accent1', themeShade: 'BF' });
70+
});
71+
72+
it('resolves built-in Heading1 paragraph properties when not defined in document', () => {
73+
const params = buildParams();
74+
const result = resolveStyleChain('paragraphProperties', params, 'Heading1');
75+
expect(result.keepNext).toBe(true);
76+
expect(result.keepLines).toBe(true);
77+
expect(result.outlineLvl).toBe(0);
78+
expect(result.spacing).toEqual({ before: 360, after: 80 });
79+
});
80+
81+
it('explicit style definition takes precedence over built-in default', () => {
82+
const params = buildParams({
83+
translatedLinkedStyles: {
84+
...emptyStyles,
85+
styles: {
86+
Heading1: { runProperties: { fontSize: 48 } },
87+
},
88+
},
89+
});
90+
const result = resolveStyleChain('runProperties', params, 'Heading1');
91+
expect(result.fontSize).toBe(48);
92+
});
93+
94+
it('built-in heading inherits from document Normal style via basedOn', () => {
95+
const params = buildParams({
96+
translatedLinkedStyles: {
97+
...emptyStyles,
98+
styles: {
99+
Normal: { paragraphProperties: { widowControl: true } },
100+
},
101+
},
102+
});
103+
const result = resolveStyleChain('paragraphProperties', params, 'Heading1');
104+
expect(result.keepNext).toBe(true);
105+
expect(result.widowControl).toBe(true);
106+
});
107+
108+
it('resolves built-in Heading2 through Heading6 with correct outline levels', () => {
109+
const params = buildParams();
110+
for (let level = 2; level <= 6; level++) {
111+
const result = resolveStyleChain('paragraphProperties', params, `Heading${level}`);
112+
expect(result.outlineLvl).toBe(level - 1);
113+
}
114+
});
115+
116+
it('basedOn chain resolves built-in styles for intermediate parents', () => {
117+
const params = buildParams({
118+
translatedLinkedStyles: {
119+
...emptyStyles,
120+
styles: {
121+
CustomHeading: { basedOn: 'Heading1', paragraphProperties: { spacing: { before: 500 } } },
122+
},
123+
},
124+
});
125+
const result = resolveStyleChain('paragraphProperties', params, 'CustomHeading');
126+
// spacing.before overridden by CustomHeading, but keepNext inherited from built-in Heading1
127+
expect(result.spacing?.before).toBe(500);
128+
expect(result.keepNext).toBe(true);
129+
expect(result.outlineLvl).toBe(0);
130+
});
63131
});
64132

65133
describe('ooxml - getNumberingProperties', () => {
@@ -256,6 +324,14 @@ describe('ooxml - resolveRunProperties', () => {
256324
const result = resolveRunProperties(params, { italic: true }, { runProperties: { bold: true } });
257325
expect(result).toEqual({ italic: true });
258326
});
327+
328+
it('resolves built-in heading run properties via paragraph styleId', () => {
329+
const params = buildParams();
330+
const result = resolveRunProperties(params, {}, { styleId: 'Heading1' });
331+
expect(result.fontSize).toBe(40);
332+
expect(result.fontSizeCs).toBe(40);
333+
expect(result.color).toEqual({ val: '0F4761', themeColor: 'accent1', themeShade: 'BF' });
334+
});
259335
});
260336

261337
describe('ooxml - resolveParagraphProperties', () => {
@@ -389,6 +465,16 @@ describe('ooxml - resolveParagraphProperties', () => {
389465
expect(result.spacing).toEqual({ before: 120, after: 240 });
390466
expect(result.keepNext).toBe(true);
391467
});
468+
469+
it('resolves paragraph with built-in Heading1 styleId when not defined in document', () => {
470+
const params = buildParams();
471+
const result = resolveParagraphProperties(params, { styleId: 'Heading1' });
472+
expect(result.keepNext).toBe(true);
473+
expect(result.keepLines).toBe(true);
474+
expect(result.outlineLvl).toBe(0);
475+
expect(result.spacing).toEqual({ before: 360, after: 80 });
476+
expect(result.styleId).toBe('Heading1');
477+
});
392478
});
393479

394480
describe('ooxml - resolveCellStyles', () => {

packages/layout-engine/style-engine/src/ooxml/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { combineIndentProperties, combineProperties, combineRunProperties } from
99
import type { PropertyObject } from '../cascade.js';
1010
import type { ParagraphConditionalFormatting, ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts';
1111
import type { NumberingProperties } from './numbering-types.ts';
12+
import { BUILTIN_STYLE_DEFAULTS } from './builtin-styles.js';
1213
import type {
1314
StyleDefinition,
1415
StylesDocumentProperties,
@@ -280,7 +281,7 @@ export function resolveStyleChain<T extends PropertyObject>(
280281
): T {
281282
if (!styleId) return {} as T;
282283

283-
const styleDef = params.translatedLinkedStyles?.styles?.[styleId];
284+
const styleDef = params.translatedLinkedStyles?.styles?.[styleId] ?? BUILTIN_STYLE_DEFAULTS[styleId];
284285
if (!styleDef) return {} as T;
285286

286287
const styleProps = (styleDef[propertyType as keyof typeof styleDef] ?? {}) as T;
@@ -294,7 +295,7 @@ export function resolveStyleChain<T extends PropertyObject>(
294295
break;
295296
}
296297
seenStyles.add(nextBasedOn as string);
297-
const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn];
298+
const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn] ?? BUILTIN_STYLE_DEFAULTS[nextBasedOn];
298299
const basedOnProps = basedOnStyleDef?.[propertyType as keyof typeof basedOnStyleDef] as T;
299300

300301
if (basedOnProps && Object.keys(basedOnProps).length) {

0 commit comments

Comments
 (0)