Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 255 additions & 0 deletions packages/layout-engine/style-engine/src/ooxml/builtin-styles.ts
Original file line number Diff line number Diff line change
@@ -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., `<w:pStyle w:val="Heading1"/>`) 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<string, StyleDefinition> = {
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',
Comment thread
gpardhivvarma marked this conversation as resolved.
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,
},
},
};
88 changes: 87 additions & 1 deletion packages/layout-engine/style-engine/src/ooxml/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/layout-engine/style-engine/src/ooxml/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -280,7 +281,7 @@ export function resolveStyleChain<T extends PropertyObject>(
): 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;
Expand All @@ -294,7 +295,7 @@ export function resolveStyleChain<T extends PropertyObject>(
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) {
Expand Down
Loading