Skip to content

Commit db6a2ff

Browse files
authored
feat(layout-engine): render table headers, tblLook support (#2256)
* feat(layout-engine): render table headers, tblLook support * chore: add doc-api-stories test
1 parent 8f3cbe4 commit db6a2ff

15 files changed

Lines changed: 1401 additions & 121 deletions

File tree

packages/layout-engine/pm-adapter/src/converters/table.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,3 +1763,113 @@ describe('table converter', () => {
17631763
});
17641764
});
17651765
});
1766+
1767+
// ──────────────────────────────────────────────────────────────────────────────
1768+
// Theme-based cell background resolution
1769+
// ──────────────────────────────────────────────────────────────────────────────
1770+
1771+
describe('parseTableCell - theme shading resolution', () => {
1772+
const mockBlockIdGenerator: BlockIdGenerator = vi.fn((kind: string) => `test-${kind}`);
1773+
const mockPositionMap: PositionMap = new Map();
1774+
const mockParagraphConverter = vi.fn((params: { para: PMNode }) => {
1775+
return [
1776+
{
1777+
kind: 'paragraph',
1778+
id: 'p1',
1779+
runs: [{ text: params.para.content?.[0]?.text || 'text', fontFamily: 'Arial', fontSize: 12 }],
1780+
} as ParagraphBlock,
1781+
];
1782+
});
1783+
1784+
const themePalette: ThemeColorPalette = {
1785+
accent1: '#4F81BD',
1786+
dk1: '#000000',
1787+
};
1788+
1789+
const makeTableWithShading = (
1790+
shadingProps: Record<string, unknown>,
1791+
themeColors?: ThemeColorPalette,
1792+
tableStyleId?: string,
1793+
) => {
1794+
const styles = tableStyleId
1795+
? {
1796+
...DEFAULT_CONVERTER_CONTEXT.translatedLinkedStyles!,
1797+
styles: {
1798+
[tableStyleId]: {
1799+
type: 'table',
1800+
tableProperties: {},
1801+
tableStyleProperties: {
1802+
wholeTable: {
1803+
tableCellProperties: { shading: shadingProps },
1804+
},
1805+
},
1806+
},
1807+
},
1808+
}
1809+
: DEFAULT_CONVERTER_CONTEXT.translatedLinkedStyles!;
1810+
1811+
const node: PMNode = {
1812+
type: 'table',
1813+
attrs: tableStyleId
1814+
? {
1815+
tableStyleId,
1816+
tableProperties: { tableStyleId, tblLook: { noHBand: true, noVBand: true } },
1817+
}
1818+
: undefined,
1819+
content: [
1820+
{
1821+
type: 'tableRow',
1822+
content: [
1823+
{
1824+
type: 'tableCell',
1825+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Cell' }] }],
1826+
},
1827+
],
1828+
},
1829+
],
1830+
};
1831+
1832+
return tableNodeToBlock(
1833+
node,
1834+
mockBlockIdGenerator,
1835+
mockPositionMap,
1836+
'Arial',
1837+
16,
1838+
undefined,
1839+
undefined,
1840+
undefined,
1841+
themeColors,
1842+
mockParagraphConverter,
1843+
{
1844+
...DEFAULT_CONVERTER_CONTEXT,
1845+
translatedLinkedStyles: styles,
1846+
},
1847+
) as TableBlock;
1848+
};
1849+
1850+
it('resolves themeFill from theme palette when no literal fill is present', () => {
1851+
const result = makeTableWithShading({ themeFill: 'accent1' }, themePalette, 'ThemeTable');
1852+
expect(result.rows[0].cells[0].attrs?.background).toBe('#4F81BD');
1853+
});
1854+
1855+
it('applies themeFillTint to the resolved theme color', () => {
1856+
const result = makeTableWithShading({ themeFill: 'accent1', themeFillTint: '99' }, themePalette, 'ThemeTable');
1857+
// accent1 (#4F81BD) tinted by 0x99/255 ≈ 0.6 → lighter blue
1858+
expect(result.rows[0].cells[0].attrs?.background).toBe('#B9CDE5');
1859+
});
1860+
1861+
it('prefers literal fill over themeFill', () => {
1862+
const result = makeTableWithShading({ fill: 'FF0000', themeFill: 'accent1' }, themePalette, 'ThemeTable');
1863+
expect(result.rows[0].cells[0].attrs?.background).toBe('#FF0000');
1864+
});
1865+
1866+
it('uses themeFill when fill is auto', () => {
1867+
const result = makeTableWithShading({ fill: 'auto', themeFill: 'accent1' }, themePalette, 'ThemeTable');
1868+
expect(result.rows[0].cells[0].attrs?.background).toBe('#4F81BD');
1869+
});
1870+
1871+
it('returns no background when themeFill key is not in palette', () => {
1872+
const result = makeTableWithShading({ themeFill: 'missing' }, themePalette, 'ThemeTable');
1873+
expect(result.rows[0].cells[0].attrs?.background).toBeUndefined();
1874+
});
1875+
});

packages/layout-engine/pm-adapter/src/converters/table.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type {
3434
NestedConverters,
3535
TableNodeToBlockParams,
3636
} from '../types.js';
37-
import { extractTableBorders, extractCellPadding, convertBorderSpec } from '../attributes/index.js';
37+
import { extractTableBorders, extractCellPadding, convertBorderSpec, normalizeShadingColor } from '../attributes/index.js';
3838
import { pickNumber, twipsToPx } from '../utilities.js';
3939
import { hydrateTableStyleAttrs } from './table-styles.js';
4040
import { collectTrackedChangeFromMarks } from '../marks/index.js';
@@ -48,7 +48,9 @@ import {
4848
TableProperties,
4949
resolveTableCellProperties,
5050
resolveExistingTableEffectiveStyleId,
51+
type TableInfo,
5152
} from '@superdoc/style-engine/ooxml';
53+
import { resolveThemeColorValue } from '../marks/theme-color.js';
5254

5355
/**
5456
* Normalizes tableCellSpacing from PM node to CellSpacing object format.
@@ -119,6 +121,7 @@ type ParseTableCellArgs = {
119121
context: TableParserDependencies;
120122
defaultCellPadding?: BoxSpacing;
121123
tableProperties?: TableProperties;
124+
rowCnfStyle?: Record<string, unknown> | null;
122125
};
123126

124127
type ParseTableRowArgs = {
@@ -240,17 +243,23 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
240243
// Table cells can contain paragraphs, images/drawings, structured content blocks, and nested tables.
241244
const blocks: (ParagraphBlock | ImageBlock | DrawingBlock | TableBlock)[] = [];
242245

246+
// Build tableInfo once with cnfStyle flags and reuse for both cascade and context.
247+
const rowCnfStyle = args.rowCnfStyle ?? null;
248+
const cellCnfStyle = (cellNode.attrs?.tableCellProperties as Record<string, unknown> | undefined)?.cnfStyle ?? null;
249+
const tableInfo: TableInfo | undefined = tableProperties
250+
? { tableProperties, rowIndex, cellIndex, numCells, numRows, rowCnfStyle, cellCnfStyle }
251+
: undefined;
252+
243253
// Resolve table cell properties from the style cascade (wholeTable → bands → conditional → inline)
244254
const inlineTcProps = cellNode.attrs?.tableCellProperties as Record<string, unknown> | undefined;
245-
const tableInfo = tableProperties ? { tableProperties, rowIndex, cellIndex, numCells, numRows } : undefined;
246255
const resolvedTcProps = resolveTableCellProperties(
247256
inlineTcProps as Parameters<typeof resolveTableCellProperties>[0],
248257
tableInfo,
249258
context.converterContext?.translatedLinkedStyles,
250259
);
251260

252261
// Extract cell background color for auto text color resolution
253-
// Priority: inline background attr > resolved style shading
262+
// Priority: inline background attr > literal fill > theme fill
254263
const cellBackground = cellNode.attrs?.background as { color?: string } | undefined;
255264
let cellBackgroundColor: string | undefined;
256265
if (cellBackground && typeof cellBackground.color === 'string') {
@@ -264,21 +273,28 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
264273
}
265274
}
266275
// Fall back to resolved style shading if no inline background
267-
if (!cellBackgroundColor && resolvedTcProps?.shading?.fill) {
268-
const fill = resolvedTcProps.shading.fill;
269-
if (fill !== 'auto') {
270-
cellBackgroundColor = fill.startsWith('#') ? fill : `#${fill}`;
276+
if (!cellBackgroundColor && resolvedTcProps?.shading) {
277+
const { fill, themeFill, themeFillTint, themeFillShade } = resolvedTcProps.shading;
278+
const normalizedFill = normalizeShadingColor(fill);
279+
if (normalizedFill) {
280+
cellBackgroundColor = normalizedFill;
281+
} else if (themeFill && context.themeColors) {
282+
const resolved = resolveThemeColorValue(themeFill, themeFillTint, themeFillShade, context.themeColors);
283+
const normalizedTheme = normalizeShadingColor(resolved);
284+
if (normalizedTheme) {
285+
cellBackgroundColor = normalizedTheme;
286+
}
271287
}
272288
}
273289

274290
// Create enhanced converter context with table style paragraph props for the style cascade
275291
// This allows paragraphs inside table cells to inherit table style's pPr
276292
// Also includes backgroundColor for auto text color resolution
277293
const cellConverterContext: ConverterContext =
278-
tableProperties || cellBackgroundColor
294+
tableInfo || cellBackgroundColor
279295
? ({
280296
...context.converterContext,
281-
...(tableProperties && { tableInfo: { tableProperties, rowIndex, cellIndex, numCells, numRows } }),
297+
...(tableInfo && { tableInfo }),
282298
...(cellBackgroundColor && { backgroundColor: cellBackgroundColor }),
283299
} as ConverterContext)
284300
: context.converterContext;
@@ -585,6 +601,9 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => {
585601
}
586602

587603
const cells: TableCell[] = [];
604+
const rowCnfStyle = (rowNode.attrs?.tableRowProperties as Record<string, unknown> | undefined)?.cnfStyle as
605+
| Record<string, unknown>
606+
| undefined;
588607
rowNode.content.forEach((cellNode, cellIndex) => {
589608
const parsedCell = parseTableCell({
590609
cellNode,
@@ -595,6 +614,7 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => {
595614
tableProperties,
596615
numCells: rowNode?.content?.length || 1,
597616
numRows,
617+
rowCnfStyle,
598618
});
599619
if (parsedCell) {
600620
cells.push(parsedCell);

packages/layout-engine/pm-adapter/src/marks/application.ts

Lines changed: 7 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { UnderlineStyle, PMMark, HyperlinkConfig, ThemeColorPalette } from
1313
import { normalizeColor, isFiniteNumber, ptToPx } from '../utilities.js';
1414
import { buildFlowRunLink, migrateLegacyLink } from './links.js';
1515
import { sanitizeHref } from '@superdoc/url-validation';
16+
import { resolveThemeColorValue } from './theme-color.js';
1617

1718
/**
1819
* Track change mark type constants from ProseMirror schema.
@@ -98,15 +99,6 @@ const validateDepth = (obj: unknown, currentDepth = 0): boolean => {
9899
return true;
99100
};
100101

101-
const parseThemePercentage = (value: unknown): number | undefined => {
102-
if (typeof value !== 'string') return undefined;
103-
const trimmed = value.trim();
104-
if (!trimmed) return undefined;
105-
const parsed = Number.parseInt(trimmed, 16);
106-
if (Number.isNaN(parsed)) return undefined;
107-
return Math.max(0, Math.min(parsed / 255, 1));
108-
};
109-
110102
const expandHex = (hex: string): string => {
111103
const normalized = hex.replace('#', '');
112104
if (normalized.length === 3) {
@@ -128,36 +120,6 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
128120
return { r, g, b };
129121
};
130122

131-
const rgbToHex = (value: { r: number; g: number; b: number }): string => {
132-
const toHex = (channel: number) => {
133-
const normalized = Math.max(0, Math.min(255, channel));
134-
return normalized.toString(16).padStart(2, '0').toUpperCase();
135-
};
136-
return `#${toHex(value.r)}${toHex(value.g)}${toHex(value.b)}`;
137-
};
138-
139-
const applyThemeTint = (baseHex: string, ratio: number): string => {
140-
const rgb = hexToRgb(baseHex);
141-
if (!rgb) return baseHex;
142-
const tinted = {
143-
r: Math.round(rgb.r + (255 - rgb.r) * ratio),
144-
g: Math.round(rgb.g + (255 - rgb.g) * ratio),
145-
b: Math.round(rgb.b + (255 - rgb.b) * ratio),
146-
};
147-
return rgbToHex(tinted);
148-
};
149-
150-
const applyThemeShade = (baseHex: string, ratio: number): string => {
151-
const rgb = hexToRgb(baseHex);
152-
if (!rgb) return baseHex;
153-
const shaded = {
154-
r: Math.round(rgb.r * ratio),
155-
g: Math.round(rgb.g * ratio),
156-
b: Math.round(rgb.b * ratio),
157-
};
158-
return rgbToHex(shaded);
159-
};
160-
161123
/**
162124
* Calculates relative luminance of a hex color per WCAG 2.1 guidelines.
163125
*
@@ -247,20 +209,12 @@ const resolveThemeColor = (
247209
if (!attrs || !themeColors) return undefined;
248210
const rawKey = attrs.themeColor;
249211
if (typeof rawKey !== 'string') return undefined;
250-
const key = rawKey.trim();
251-
if (!key) return undefined;
252-
const base = themeColors[key];
253-
if (!base) return undefined;
254-
const tint = parseThemePercentage(attrs.themeTint);
255-
const shade = parseThemePercentage(attrs.themeShade);
256-
let computed = base;
257-
if (tint != null) {
258-
computed = applyThemeTint(computed, tint);
259-
}
260-
if (shade != null) {
261-
computed = applyThemeShade(computed, shade);
262-
}
263-
return computed;
212+
return resolveThemeColorValue(
213+
rawKey,
214+
attrs.themeTint as string | undefined,
215+
attrs.themeShade as string | undefined,
216+
themeColors,
217+
);
264218
};
265219

266220
const resolveColorFromAttributes = (

0 commit comments

Comments
 (0)