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) {