Skip to content
Merged
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
110 changes: 110 additions & 0 deletions packages/layout-engine/pm-adapter/src/converters/table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1763,3 +1763,113 @@ describe('table converter', () => {
});
});
});

// ──────────────────────────────────────────────────────────────────────────────
// Theme-based cell background resolution
// ──────────────────────────────────────────────────────────────────────────────

describe('parseTableCell - theme shading resolution', () => {
const mockBlockIdGenerator: BlockIdGenerator = vi.fn((kind: string) => `test-${kind}`);
const mockPositionMap: PositionMap = new Map();
const mockParagraphConverter = vi.fn((params: { para: PMNode }) => {
return [
{
kind: 'paragraph',
id: 'p1',
runs: [{ text: params.para.content?.[0]?.text || 'text', fontFamily: 'Arial', fontSize: 12 }],
} as ParagraphBlock,
];
});

const themePalette: ThemeColorPalette = {
accent1: '#4F81BD',
dk1: '#000000',
};

const makeTableWithShading = (
shadingProps: Record<string, unknown>,
themeColors?: ThemeColorPalette,
tableStyleId?: string,
) => {
const styles = tableStyleId
? {
...DEFAULT_CONVERTER_CONTEXT.translatedLinkedStyles!,
styles: {
[tableStyleId]: {
type: 'table',
tableProperties: {},
tableStyleProperties: {
wholeTable: {
tableCellProperties: { shading: shadingProps },
},
},
},
},
}
: DEFAULT_CONVERTER_CONTEXT.translatedLinkedStyles!;

const node: PMNode = {
type: 'table',
attrs: tableStyleId
? {
tableStyleId,
tableProperties: { tableStyleId, tblLook: { noHBand: true, noVBand: true } },
}
: undefined,
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Cell' }] }],
},
],
},
],
};

return tableNodeToBlock(
node,
mockBlockIdGenerator,
mockPositionMap,
'Arial',
16,
undefined,
undefined,
undefined,
themeColors,
mockParagraphConverter,
{
...DEFAULT_CONVERTER_CONTEXT,
translatedLinkedStyles: styles,
},
) as TableBlock;
};

it('resolves themeFill from theme palette when no literal fill is present', () => {
const result = makeTableWithShading({ themeFill: 'accent1' }, themePalette, 'ThemeTable');
expect(result.rows[0].cells[0].attrs?.background).toBe('#4F81BD');
});

it('applies themeFillTint to the resolved theme color', () => {
const result = makeTableWithShading({ themeFill: 'accent1', themeFillTint: '99' }, themePalette, 'ThemeTable');
// accent1 (#4F81BD) tinted by 0x99/255 ≈ 0.6 → lighter blue
expect(result.rows[0].cells[0].attrs?.background).toBe('#B9CDE5');
});

it('prefers literal fill over themeFill', () => {
const result = makeTableWithShading({ fill: 'FF0000', themeFill: 'accent1' }, themePalette, 'ThemeTable');
expect(result.rows[0].cells[0].attrs?.background).toBe('#FF0000');
});

it('uses themeFill when fill is auto', () => {
const result = makeTableWithShading({ fill: 'auto', themeFill: 'accent1' }, themePalette, 'ThemeTable');
expect(result.rows[0].cells[0].attrs?.background).toBe('#4F81BD');
});

it('returns no background when themeFill key is not in palette', () => {
const result = makeTableWithShading({ themeFill: 'missing' }, themePalette, 'ThemeTable');
expect(result.rows[0].cells[0].attrs?.background).toBeUndefined();
});
});
38 changes: 29 additions & 9 deletions packages/layout-engine/pm-adapter/src/converters/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type {
NestedConverters,
TableNodeToBlockParams,
} from '../types.js';
import { extractTableBorders, extractCellPadding, convertBorderSpec } from '../attributes/index.js';
import { extractTableBorders, extractCellPadding, convertBorderSpec, normalizeShadingColor } from '../attributes/index.js';
import { pickNumber, twipsToPx } from '../utilities.js';
import { hydrateTableStyleAttrs } from './table-styles.js';
import { collectTrackedChangeFromMarks } from '../marks/index.js';
Expand All @@ -48,7 +48,9 @@ import {
TableProperties,
resolveTableCellProperties,
resolveExistingTableEffectiveStyleId,
type TableInfo,
} from '@superdoc/style-engine/ooxml';
import { resolveThemeColorValue } from '../marks/theme-color.js';

/**
* Normalizes tableCellSpacing from PM node to CellSpacing object format.
Expand Down Expand Up @@ -119,6 +121,7 @@ type ParseTableCellArgs = {
context: TableParserDependencies;
defaultCellPadding?: BoxSpacing;
tableProperties?: TableProperties;
rowCnfStyle?: Record<string, unknown> | null;
};

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

// Build tableInfo once with cnfStyle flags and reuse for both cascade and context.
const rowCnfStyle = args.rowCnfStyle ?? null;
const cellCnfStyle = (cellNode.attrs?.tableCellProperties as Record<string, unknown> | undefined)?.cnfStyle ?? null;
const tableInfo: TableInfo | undefined = tableProperties
? { tableProperties, rowIndex, cellIndex, numCells, numRows, rowCnfStyle, cellCnfStyle }
: undefined;

// Resolve table cell properties from the style cascade (wholeTable → bands → conditional → inline)
const inlineTcProps = cellNode.attrs?.tableCellProperties as Record<string, unknown> | undefined;
const tableInfo = tableProperties ? { tableProperties, rowIndex, cellIndex, numCells, numRows } : undefined;
const resolvedTcProps = resolveTableCellProperties(
inlineTcProps as Parameters<typeof resolveTableCellProperties>[0],
tableInfo,
context.converterContext?.translatedLinkedStyles,
);

// Extract cell background color for auto text color resolution
// Priority: inline background attr > resolved style shading
// Priority: inline background attr > literal fill > theme fill
const cellBackground = cellNode.attrs?.background as { color?: string } | undefined;
let cellBackgroundColor: string | undefined;
if (cellBackground && typeof cellBackground.color === 'string') {
Expand All @@ -264,21 +273,28 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => {
}
}
// Fall back to resolved style shading if no inline background
if (!cellBackgroundColor && resolvedTcProps?.shading?.fill) {
const fill = resolvedTcProps.shading.fill;
if (fill !== 'auto') {
cellBackgroundColor = fill.startsWith('#') ? fill : `#${fill}`;
if (!cellBackgroundColor && resolvedTcProps?.shading) {
const { fill, themeFill, themeFillTint, themeFillShade } = resolvedTcProps.shading;
const normalizedFill = normalizeShadingColor(fill);
if (normalizedFill) {
cellBackgroundColor = normalizedFill;
} else if (themeFill && context.themeColors) {
const resolved = resolveThemeColorValue(themeFill, themeFillTint, themeFillShade, context.themeColors);
const normalizedTheme = normalizeShadingColor(resolved);
if (normalizedTheme) {
cellBackgroundColor = normalizedTheme;
}
}
}

// Create enhanced converter context with table style paragraph props for the style cascade
// This allows paragraphs inside table cells to inherit table style's pPr
// Also includes backgroundColor for auto text color resolution
const cellConverterContext: ConverterContext =
tableProperties || cellBackgroundColor
tableInfo || cellBackgroundColor
? ({
...context.converterContext,
...(tableProperties && { tableInfo: { tableProperties, rowIndex, cellIndex, numCells, numRows } }),
...(tableInfo && { tableInfo }),
...(cellBackgroundColor && { backgroundColor: cellBackgroundColor }),
} as ConverterContext)
: context.converterContext;
Expand Down Expand Up @@ -585,6 +601,9 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => {
}

const cells: TableCell[] = [];
const rowCnfStyle = (rowNode.attrs?.tableRowProperties as Record<string, unknown> | undefined)?.cnfStyle as
| Record<string, unknown>
| undefined;
rowNode.content.forEach((cellNode, cellIndex) => {
const parsedCell = parseTableCell({
cellNode,
Expand All @@ -595,6 +614,7 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => {
tableProperties,
numCells: rowNode?.content?.length || 1,
numRows,
rowCnfStyle,
});
if (parsedCell) {
cells.push(parsedCell);
Expand Down
60 changes: 7 additions & 53 deletions packages/layout-engine/pm-adapter/src/marks/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { UnderlineStyle, PMMark, HyperlinkConfig, ThemeColorPalette } from
import { normalizeColor, isFiniteNumber, ptToPx } from '../utilities.js';
import { buildFlowRunLink, migrateLegacyLink } from './links.js';
import { sanitizeHref } from '@superdoc/url-validation';
import { resolveThemeColorValue } from './theme-color.js';

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

const parseThemePercentage = (value: unknown): number | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 16);
if (Number.isNaN(parsed)) return undefined;
return Math.max(0, Math.min(parsed / 255, 1));
};

const expandHex = (hex: string): string => {
const normalized = hex.replace('#', '');
if (normalized.length === 3) {
Expand All @@ -128,36 +120,6 @@ const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
return { r, g, b };
};

const rgbToHex = (value: { r: number; g: number; b: number }): string => {
const toHex = (channel: number) => {
const normalized = Math.max(0, Math.min(255, channel));
return normalized.toString(16).padStart(2, '0').toUpperCase();
};
return `#${toHex(value.r)}${toHex(value.g)}${toHex(value.b)}`;
};

const applyThemeTint = (baseHex: string, ratio: number): string => {
const rgb = hexToRgb(baseHex);
if (!rgb) return baseHex;
const tinted = {
r: Math.round(rgb.r + (255 - rgb.r) * ratio),
g: Math.round(rgb.g + (255 - rgb.g) * ratio),
b: Math.round(rgb.b + (255 - rgb.b) * ratio),
};
return rgbToHex(tinted);
};

const applyThemeShade = (baseHex: string, ratio: number): string => {
const rgb = hexToRgb(baseHex);
if (!rgb) return baseHex;
const shaded = {
r: Math.round(rgb.r * ratio),
g: Math.round(rgb.g * ratio),
b: Math.round(rgb.b * ratio),
};
return rgbToHex(shaded);
};

/**
* Calculates relative luminance of a hex color per WCAG 2.1 guidelines.
*
Expand Down Expand Up @@ -247,20 +209,12 @@ const resolveThemeColor = (
if (!attrs || !themeColors) return undefined;
const rawKey = attrs.themeColor;
if (typeof rawKey !== 'string') return undefined;
const key = rawKey.trim();
if (!key) return undefined;
const base = themeColors[key];
if (!base) return undefined;
const tint = parseThemePercentage(attrs.themeTint);
const shade = parseThemePercentage(attrs.themeShade);
let computed = base;
if (tint != null) {
computed = applyThemeTint(computed, tint);
}
if (shade != null) {
computed = applyThemeShade(computed, shade);
}
return computed;
return resolveThemeColorValue(
rawKey,
attrs.themeTint as string | undefined,
attrs.themeShade as string | undefined,
themeColors,
);
};

const resolveColorFromAttributes = (
Expand Down
Loading
Loading