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
137 changes: 137 additions & 0 deletions packages/core/src/prosemirror/conversion/toProseDoc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Integration tests for toProseDoc — theme color resolution in tables.
*
* Verifies that themed cell shading (w:shd with w:themeFill + w:themeFillTint/Shade)
* is correctly resolved to RGB values on ProseMirror tableCell node attrs.
*/

import { describe, test, expect } from 'bun:test';
import { toProseDoc } from './toProseDoc';
import type { Document, Table, TableRow, TableCell, Theme } from '../../types/document';

const OFFICE_THEME: Theme = {
colorScheme: {
dk1: '000000',
lt1: 'FFFFFF',
dk2: '44546A',
lt2: 'E7E6E6',
accent1: '4472C4',
accent2: 'ED7D31',
accent3: 'A5A5A5',
accent4: 'FFC000',
accent5: '5B9BD5',
accent6: '70AD47',
hlink: '0563C1',
folHlink: '954F72',
},
};

function makeCell(shading?: TableCell['formatting'] extends infer F ? F : never): TableCell {
return {
type: 'tableCell',
formatting: shading as TableCell['formatting'],
content: [
{
type: 'paragraph',
content: [],
},
],
};
}

function makeTable(cells: TableCell[]): Table {
const row: TableRow = { type: 'tableRow', cells };
return { type: 'table', rows: [row] };
}

function makeDocument(table: Table, theme?: Theme): Document {
return {
package: {
document: { content: [table] },
theme,
},
};
}

// Collect all tableCell PM nodes in document order.
function collectCellAttrs(pmDoc: ReturnType<typeof toProseDoc>): Array<Record<string, unknown>> {
const cells: Array<Record<string, unknown>> = [];
pmDoc.descendants((node) => {
if (node.type.name === 'tableCell') {
cells.push(node.attrs as Record<string, unknown>);
}
});
return cells;
}

describe('toProseDoc — table cell theme color resolution', () => {
test('cell with RGB fill sets backgroundColor directly', () => {
const cell = makeCell({ shading: { fill: { rgb: 'FF0000' } } });
const doc = makeDocument(makeTable([cell]), OFFICE_THEME);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
expect(cells[0].backgroundColor).toBe('FF0000');
});

test('cell with theme fill resolves to base theme color', () => {
// w:themeFill="accent1" with no tint/shade → base color
const cell = makeCell({ shading: { fill: { themeColor: 'accent1' } } });
const doc = makeDocument(makeTable([cell]), OFFICE_THEME);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
expect(cells[0].backgroundColor).toBe('4472C4');
});

test('cell with theme fill + tint resolves to lightened RGB', () => {
// accent1 (#4472C4) with themeFillTint="33" → near-white blue
// OOXML: t = 0x33/255 ≈ 0.2 → keep 20% color, 80% white
const cell = makeCell({
shading: { fill: { themeColor: 'accent1', themeTint: '33' } },
});
const doc = makeDocument(makeTable([cell]), OFFICE_THEME);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
expect(cells[0].backgroundColor).toBe('DAE3F3');
});

test('cell with theme fill + shade resolves to darkened RGB', () => {
// background1 (lt1 = FFFFFF) with themeFillShade="F2" → light gray
// OOXML: s = 0xF2/255 ≈ 0.949 → keep 95% of color
const cell = makeCell({
shading: { fill: { themeColor: 'background1', themeShade: 'F2' } },
});
const doc = makeDocument(makeTable([cell]), OFFICE_THEME);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
expect(cells[0].backgroundColor).toBe('F2F2F2');
});

test('cell with themed fill and no document theme leaves backgroundColor undefined', () => {
// Without a theme, theme color references can't be resolved.
// The rgb fallback is already overwritten by the parser when themeFill is present.
const cell = makeCell({
shading: { fill: { themeColor: 'accent1', themeTint: '33' } },
});
const doc = makeDocument(makeTable([cell]), undefined);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
expect(cells[0].backgroundColor).toBeFalsy();
});

test('multiple cells with different theme tints resolve independently', () => {
// Mimics the real-world scenario: title row with dark tint, section row with light tint.
const titleCell = makeCell({
shading: { fill: { themeColor: 'accent1', themeTint: '99' } },
});
const sectionCell = makeCell({
shading: { fill: { themeColor: 'accent1', themeTint: '33' } },
});
const doc = makeDocument(makeTable([titleCell, sectionCell]), OFFICE_THEME);
const pmDoc = toProseDoc(doc);
const cells = collectCellAttrs(pmDoc);
// tint=99 (0.6) → medium blue
expect(cells[0].backgroundColor).toBe('8FAADC');
// tint=33 (0.2) → near-white
expect(cells[1].backgroundColor).toBe('DAE3F3');
});
});
41 changes: 32 additions & 9 deletions packages/core/src/prosemirror/conversion/toProseDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import type {
import { emuToPixels } from '../../docx/imageParser';
import { createStyleResolver, type StyleResolver } from '../styles';
import type { TableAttrs, TableRowAttrs, TableCellAttrs } from '../schema/nodes';
import { resolveColor } from '../../utils/colorResolver';
import type { Theme } from '../../types/document';
import type { ColorValue } from '../../types/colors';

/**
* Options for document conversion
Expand All @@ -62,6 +65,7 @@ export interface ToProseDocOptions {
export function toProseDoc(document: Document, options?: ToProseDocOptions): PMNode {
const paragraphs = document.package.document.content;
const nodes: PMNode[] = [];
const theme = document.package.theme ?? null;

// Create style resolver if styles are provided
const styleResolver = options?.styles ? createStyleResolver(options.styles) : null;
Expand All @@ -75,7 +79,7 @@ export function toProseDoc(document: Document, options?: ToProseDocOptions): PMN
nodes.push(schema.node('pageBreak'));
}
} else if (block.type === 'table') {
const pmTable = convertTable(block, styleResolver);
const pmTable = convertTable(block, styleResolver, theme);
nodes.push(pmTable);
}
}
Expand Down Expand Up @@ -513,7 +517,11 @@ function calculateRowSpans(table: Table): Map<string, { rowSpan: number; skip: b
return result;
}

function convertTable(table: Table, styleResolver: StyleResolver | null): PMNode {
function convertTable(
table: Table,
styleResolver: StyleResolver | null,
theme?: Theme | null
): PMNode {
// Calculate rowSpan values from vMerge
const rowSpanMap = calculateRowSpans(table);

Expand Down Expand Up @@ -611,7 +619,8 @@ function convertTable(table: Table, styleResolver: StyleResolver | null): PMNode
totalRows,
totalColumns,
rowSpanMap,
cellMarginsAttr
cellMarginsAttr,
theme
);
});

Expand Down Expand Up @@ -650,7 +659,8 @@ function convertTableRow(
totalRows?: number,
totalColumns?: number,
rowSpanMap?: Map<string, { rowSpan: number; skip: boolean }>,
defaultCellMargins?: { top?: number; bottom?: number; left?: number; right?: number }
defaultCellMargins?: { top?: number; bottom?: number; left?: number; right?: number },
theme?: Theme | null
): PMNode {
const attrs: TableRowAttrs = {
height: row.formatting?.height?.value,
Expand Down Expand Up @@ -830,7 +840,8 @@ function convertTableRow(
isFirstCol,
isLastCol,
calculatedRowSpan,
defaultCellMargins
defaultCellMargins,
theme
)
);
}
Expand All @@ -853,7 +864,8 @@ function convertTableCell(
isFirstCol?: boolean,
isLastCol?: boolean,
calculatedRowSpan?: number,
defaultCellMargins?: { top?: number; bottom?: number; left?: number; right?: number }
defaultCellMargins?: { top?: number; bottom?: number; left?: number; right?: number },
theme?: Theme | null
): PMNode {
const formatting = cell.formatting;

Expand All @@ -870,9 +882,20 @@ function convertTableCell(
widthType = 'pct';
}

// Determine background color: prefer cell's own shading, fall back to conditional style
const backgroundColor =
formatting?.shading?.fill?.rgb ?? conditionalStyle?.tcPr?.shading?.fill?.rgb;
// Determine background color: prefer cell's own shading, fall back to conditional style.
// Resolve theme colors to RGB using the document theme.
const fillColor: ColorValue | undefined =
formatting?.shading?.fill ?? conditionalStyle?.tcPr?.shading?.fill;
let backgroundColor: string | undefined;
if (fillColor) {
if (fillColor.themeColor && theme) {
// Theme color takes precedence — resolve with tint/shade modifiers
const resolved = resolveColor(fillColor, theme);
backgroundColor = resolved.startsWith('#') ? resolved.slice(1) : resolved;
} else if (fillColor.rgb) {
backgroundColor = fillColor.rgb;
}
}

// Convert borders — preserve full BorderSpec per side
// Priority: cell borders > conditional style borders > table borders
Expand Down
84 changes: 82 additions & 2 deletions packages/core/src/utils/__tests__/colorResolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test';
import { generateThemeTintShadeMatrix, getThemeTintShadeHex } from '../colorResolver';
import type { ThemeColorScheme } from '../../types/document';
import { generateThemeTintShadeMatrix, getThemeTintShadeHex, resolveColor } from '../colorResolver';
import type { Theme, ThemeColorScheme } from '../../types/document';

const OFFICE_2016_DEFAULTS: ThemeColorScheme = {
dk1: '000000',
Expand Down Expand Up @@ -148,3 +148,83 @@ describe('getThemeTintShadeHex', () => {
expect(result).toBe('000000');
});
});

describe('resolveColor — OOXML theme color resolution', () => {
const theme: Theme = {
colorScheme: OFFICE_2016_DEFAULTS,
};

test('resolves plain RGB color', () => {
const result = resolveColor({ rgb: 'FF0000' }, theme);
expect(result).toBe('#FF0000');
});

test('resolves theme color without modifiers', () => {
const result = resolveColor({ themeColor: 'accent1' }, theme);
expect(result).toBe('#4472C4');
});

// Regression tests for the OOXML tint/shade fix.
// Per ECMA-376 §17.3.2.41, the tint/shade byte represents "how much of the
// original color to keep": 0xFF = no change, 0x00 = fully white/black.

test('themeTint "FF" (255) keeps original color', () => {
// tintByte/255 = 1.0 → no change
const result = resolveColor({ themeColor: 'accent1', themeTint: 'FF' }, theme);
expect(result).toBe('#4472C4');
});

test('themeTint "00" (0) produces white', () => {
const result = resolveColor({ themeColor: 'accent1', themeTint: '00' }, theme);
expect(result).toBe('#FFFFFF');
});

test('themeTint "33" (0x33 = 20%) produces near-white with slight color', () => {
// accent1 = #4472C4 → R=0x44(68), G=0x72(114), B=0xC4(196)
// t = 51/255 ≈ 0.2; new_r = 68*0.2 + 255*0.8 = 13.6 + 204 = 217.6 ≈ 0xDA
// new_g = 114*0.2 + 255*0.8 = 22.8 + 204 = 226.8 ≈ 0xE3
// new_b = 196*0.2 + 255*0.8 = 39.2 + 204 = 243.2 ≈ 0xF3
const result = resolveColor({ themeColor: 'accent1', themeTint: '33' }, theme);
expect(result).toBe('#DAE3F3');
});

test('themeTint "99" (0x99 = 60%) produces medium-light variant', () => {
// t = 153/255 ≈ 0.6; keep 60% color, add 40% white
// new_r = 68*0.6 + 255*0.4 = 40.8 + 102 = 142.8 ≈ 0x8F
// new_g = 114*0.6 + 255*0.4 = 68.4 + 102 = 170.4 ≈ 0xAA
// new_b = 196*0.6 + 255*0.4 = 117.6 + 102 = 219.6 ≈ 0xDC
const result = resolveColor({ themeColor: 'accent1', themeTint: '99' }, theme);
expect(result).toBe('#8FAADC');
});

test('themeShade "FF" (255) keeps original color', () => {
const result = resolveColor({ themeColor: 'accent1', themeShade: 'FF' }, theme);
expect(result).toBe('#4472C4');
});

test('themeShade "00" (0) produces black', () => {
const result = resolveColor({ themeColor: 'accent1', themeShade: '00' }, theme);
expect(result).toBe('#000000');
});

test('themeShade "F2" (0x F2 ≈ 95%) slightly darkens color', () => {
// accent1 = #4472C4; s = 242/255 ≈ 0.949
// new_r = 68*0.949 ≈ 65 = 0x41
// new_g = 114*0.949 ≈ 108 = 0x6C
// new_b = 196*0.949 ≈ 186 = 0xBA
const result = resolveColor({ themeColor: 'accent1', themeShade: 'F2' }, theme);
expect(result).toBe('#416CBA');
});

test('background1 with shade "F2" (light gray) — matches Word table row shading', () => {
// background1 (lt1) = FFFFFF
// s = 242/255 ≈ 0.949; new_r = 255*0.949 ≈ 242 = 0xF2
const result = resolveColor({ themeColor: 'background1', themeShade: 'F2' }, theme);
expect(result).toBe('#F2F2F2');
});

test('auto color returns default', () => {
const result = resolveColor({ auto: true }, theme);
expect(result).toBe('#000000');
});
});
Loading