diff --git a/packages/layout-engine/style-engine/src/ooxml/builtin-styles.ts b/packages/layout-engine/style-engine/src/ooxml/builtin-styles.ts
new file mode 100644
index 0000000000..90d061d802
--- /dev/null
+++ b/packages/layout-engine/style-engine/src/ooxml/builtin-styles.ts
@@ -0,0 +1,255 @@
+import type { StyleDefinition } from './styles-types.js';
+
+/**
+ * Built-in Word heading style defaults.
+ *
+ * Word defines ~260 built-in styles that implicitly exist even when not declared
+ * in a document's styles.xml. When a paragraph references a built-in style
+ * (e.g., ``) that has no explicit definition,
+ * these defaults provide the expected visual properties.
+ *
+ * Values match Word's defaults per ECMA-376 §17.7.4.9. Font sizes are in
+ * half-points (OOXML convention), spacing in twips (twentieths of a point).
+ *
+ * Heading7–9 use a muted text color (595959) instead of the accent color
+ * used by Heading1–6, matching Word's visual hierarchy.
+ *
+ * All headings inherit from 'Normal' via basedOn, so any document-level Normal
+ * style properties (font family, line spacing, etc.) cascade correctly.
+ */
+export const BUILTIN_STYLE_DEFAULTS: Record = {
+ Heading1: {
+ type: 'paragraph',
+ styleId: 'Heading1',
+ name: 'heading 1',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 360, after: 80 },
+ outlineLvl: 0,
+ },
+ runProperties: {
+ fontFamily: {
+ asciiTheme: 'majorHAnsi',
+ eastAsiaTheme: 'majorEastAsia',
+ hAnsiTheme: 'majorHAnsi',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 40,
+ fontSizeCs: 40,
+ },
+ },
+
+ Heading2: {
+ type: 'paragraph',
+ styleId: 'Heading2',
+ name: 'heading 2',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 160, after: 80 },
+ outlineLvl: 1,
+ },
+ runProperties: {
+ fontFamily: {
+ asciiTheme: 'majorHAnsi',
+ eastAsiaTheme: 'majorEastAsia',
+ hAnsiTheme: 'majorHAnsi',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 32,
+ fontSizeCs: 32,
+ },
+ },
+
+ Heading3: {
+ type: 'paragraph',
+ styleId: 'Heading3',
+ name: 'heading 3',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 160, after: 80 },
+ outlineLvl: 2,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 28,
+ fontSizeCs: 28,
+ },
+ },
+
+ Heading4: {
+ type: 'paragraph',
+ styleId: 'Heading4',
+ name: 'heading 4',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 3,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ italic: true,
+ iCs: true,
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 24,
+ fontSizeCs: 24,
+ },
+ },
+
+ Heading5: {
+ type: 'paragraph',
+ styleId: 'Heading5',
+ name: 'heading 5',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 4,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 22,
+ fontSizeCs: 22,
+ },
+ },
+
+ Heading6: {
+ type: 'paragraph',
+ styleId: 'Heading6',
+ name: 'heading 6',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 5,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ italic: true,
+ iCs: true,
+ color: { val: '0F4761', themeColor: 'accent1', themeShade: 'BF' },
+ fontSize: 22,
+ fontSizeCs: 22,
+ },
+ },
+
+ Heading7: {
+ type: 'paragraph',
+ styleId: 'Heading7',
+ name: 'heading 7',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 6,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '595959', themeColor: 'text1', themeTint: 'A6' },
+ fontSize: 22,
+ fontSizeCs: 22,
+ },
+ },
+
+ Heading8: {
+ type: 'paragraph',
+ styleId: 'Heading8',
+ name: 'heading 8',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 7,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ italic: true,
+ iCs: true,
+ color: { val: '595959', themeColor: 'text1', themeTint: 'A6' },
+ fontSize: 22,
+ fontSizeCs: 22,
+ },
+ },
+
+ Heading9: {
+ type: 'paragraph',
+ styleId: 'Heading9',
+ name: 'heading 9',
+ basedOn: 'Normal',
+ next: 'Normal',
+ qFormat: true,
+ uiPriority: 9,
+ paragraphProperties: {
+ keepNext: true,
+ keepLines: true,
+ spacing: { before: 80, after: 40 },
+ outlineLvl: 8,
+ },
+ runProperties: {
+ fontFamily: {
+ eastAsiaTheme: 'majorEastAsia',
+ cstheme: 'majorBidi',
+ },
+ color: { val: '595959', themeColor: 'text1', themeTint: 'A6' },
+ fontSize: 21,
+ fontSizeCs: 21,
+ },
+ },
+};
diff --git a/packages/layout-engine/style-engine/src/ooxml/index.test.ts b/packages/layout-engine/style-engine/src/ooxml/index.test.ts
index 7cd3505210..14df6c3ef0 100644
--- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts
+++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts
@@ -55,11 +55,79 @@ describe('ooxml - resolveStyleChain', () => {
expect(result).toEqual({ fontSize: 24, bold: true, italic: true });
});
- it('returns empty object when styleId is missing from definitions', () => {
+ it('returns empty object when styleId is missing from definitions and not a built-in', () => {
const params = buildParams();
const result = resolveStyleChain('runProperties', params, 'MissingStyle');
expect(result).toEqual({});
});
+
+ it('resolves built-in Heading1 run properties when not defined in document', () => {
+ const params = buildParams();
+ const result = resolveStyleChain('runProperties', params, 'Heading1');
+ expect(result.fontSize).toBe(40);
+ expect(result.fontSizeCs).toBe(40);
+ expect(result.color).toEqual({ val: '0F4761', themeColor: 'accent1', themeShade: 'BF' });
+ });
+
+ it('resolves built-in Heading1 paragraph properties when not defined in document', () => {
+ const params = buildParams();
+ const result = resolveStyleChain('paragraphProperties', params, 'Heading1');
+ expect(result.keepNext).toBe(true);
+ expect(result.keepLines).toBe(true);
+ expect(result.outlineLvl).toBe(0);
+ expect(result.spacing).toEqual({ before: 360, after: 80 });
+ });
+
+ it('explicit style definition takes precedence over built-in default', () => {
+ const params = buildParams({
+ translatedLinkedStyles: {
+ ...emptyStyles,
+ styles: {
+ Heading1: { runProperties: { fontSize: 48 } },
+ },
+ },
+ });
+ const result = resolveStyleChain('runProperties', params, 'Heading1');
+ expect(result.fontSize).toBe(48);
+ });
+
+ it('built-in heading inherits from document Normal style via basedOn', () => {
+ const params = buildParams({
+ translatedLinkedStyles: {
+ ...emptyStyles,
+ styles: {
+ Normal: { paragraphProperties: { widowControl: true } },
+ },
+ },
+ });
+ const result = resolveStyleChain('paragraphProperties', params, 'Heading1');
+ expect(result.keepNext).toBe(true);
+ expect(result.widowControl).toBe(true);
+ });
+
+ it('resolves built-in Heading2 through Heading9 with correct outline levels', () => {
+ const params = buildParams();
+ for (let level = 2; level <= 9; level++) {
+ const result = resolveStyleChain('paragraphProperties', params, `Heading${level}`);
+ expect(result.outlineLvl).toBe(level - 1);
+ }
+ });
+
+ it('basedOn chain resolves built-in styles for intermediate parents', () => {
+ const params = buildParams({
+ translatedLinkedStyles: {
+ ...emptyStyles,
+ styles: {
+ CustomHeading: { basedOn: 'Heading1', paragraphProperties: { spacing: { before: 500 } } },
+ },
+ },
+ });
+ const result = resolveStyleChain('paragraphProperties', params, 'CustomHeading');
+ // spacing.before overridden by CustomHeading, but keepNext inherited from built-in Heading1
+ expect(result.spacing?.before).toBe(500);
+ expect(result.keepNext).toBe(true);
+ expect(result.outlineLvl).toBe(0);
+ });
});
describe('ooxml - getNumberingProperties', () => {
@@ -256,6 +324,14 @@ describe('ooxml - resolveRunProperties', () => {
const result = resolveRunProperties(params, { italic: true }, { runProperties: { bold: true } });
expect(result).toEqual({ italic: true });
});
+
+ it('resolves built-in heading run properties via paragraph styleId', () => {
+ const params = buildParams();
+ const result = resolveRunProperties(params, {}, { styleId: 'Heading1' });
+ expect(result.fontSize).toBe(40);
+ expect(result.fontSizeCs).toBe(40);
+ expect(result.color).toEqual({ val: '0F4761', themeColor: 'accent1', themeShade: 'BF' });
+ });
});
describe('ooxml - resolveParagraphProperties', () => {
@@ -389,6 +465,16 @@ describe('ooxml - resolveParagraphProperties', () => {
expect(result.spacing).toEqual({ before: 120, after: 240 });
expect(result.keepNext).toBe(true);
});
+
+ it('resolves paragraph with built-in Heading1 styleId when not defined in document', () => {
+ const params = buildParams();
+ const result = resolveParagraphProperties(params, { styleId: 'Heading1' });
+ expect(result.keepNext).toBe(true);
+ expect(result.keepLines).toBe(true);
+ expect(result.outlineLvl).toBe(0);
+ expect(result.spacing).toEqual({ before: 360, after: 80 });
+ expect(result.styleId).toBe('Heading1');
+ });
});
describe('ooxml - resolveCellStyles', () => {
diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts
index b486daed07..ebf64b0064 100644
--- a/packages/layout-engine/style-engine/src/ooxml/index.ts
+++ b/packages/layout-engine/style-engine/src/ooxml/index.ts
@@ -9,6 +9,7 @@ import { combineIndentProperties, combineProperties, combineRunProperties } from
import type { PropertyObject } from '../cascade.js';
import type { ParagraphConditionalFormatting, ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts';
import type { NumberingProperties } from './numbering-types.ts';
+import { BUILTIN_STYLE_DEFAULTS } from './builtin-styles.js';
import type {
StyleDefinition,
StylesDocumentProperties,
@@ -280,7 +281,7 @@ export function resolveStyleChain(
): T {
if (!styleId) return {} as T;
- const styleDef = params.translatedLinkedStyles?.styles?.[styleId];
+ const styleDef = params.translatedLinkedStyles?.styles?.[styleId] ?? BUILTIN_STYLE_DEFAULTS[styleId];
if (!styleDef) return {} as T;
const styleProps = (styleDef[propertyType as keyof typeof styleDef] ?? {}) as T;
@@ -294,7 +295,7 @@ export function resolveStyleChain(
break;
}
seenStyles.add(nextBasedOn as string);
- const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn];
+ const basedOnStyleDef = params.translatedLinkedStyles?.styles?.[nextBasedOn] ?? BUILTIN_STYLE_DEFAULTS[nextBasedOn];
const basedOnProps = basedOnStyleDef?.[propertyType as keyof typeof basedOnStyleDef] as T;
if (basedOnProps && Object.keys(basedOnProps).length) {