From 1bd74eca78127ced89e476919f72b09420e737e0 Mon Sep 17 00:00:00 2001 From: sted Date: Thu, 16 Apr 2026 20:37:24 +0200 Subject: [PATCH 1/3] fix: use OOXML per-channel linear interpolation for tint/shade colors --- packages/core/src/utils/colorResolver.ts | 135 ++++++----------------- 1 file changed, 33 insertions(+), 102 deletions(-) diff --git a/packages/core/src/utils/colorResolver.ts b/packages/core/src/utils/colorResolver.ts index 750adb66..5372d51c 100644 --- a/packages/core/src/utils/colorResolver.ts +++ b/packages/core/src/utils/colorResolver.ts @@ -154,83 +154,6 @@ function rgbToHex(r: number, g: number, b: number): string { return `${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase(); } -/** - * Convert RGB to HSL - * - * @param r - Red 0-255 - * @param g - Green 0-255 - * @param b - Blue 0-255 - * @returns HSL object with h (0-360), s (0-1), l (0-1) - */ -function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { - r /= 255; - g /= 255; - b /= 255; - - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - - if (max === min) { - return { h: 0, s: 0, l }; - } - - const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - - let h: number; - switch (max) { - case r: - h = ((g - b) / d + (g < b ? 6 : 0)) / 6; - break; - case g: - h = ((b - r) / d + 2) / 6; - break; - case b: - h = ((r - g) / d + 4) / 6; - break; - default: - h = 0; - } - - return { h: h * 360, s, l }; -} - -/** - * Convert HSL to RGB - * - * @param h - Hue 0-360 - * @param s - Saturation 0-1 - * @param l - Lightness 0-1 - * @returns RGB object with r, g, b values 0-255 - */ -function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { - h = h / 360; - - if (s === 0) { - const gray = Math.round(l * 255); - return { r: gray, g: gray, b: gray }; - } - - const hue2rgb = (p: number, q: number, t: number) => { - if (t < 0) t += 1; - if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - - return { - r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255), - g: Math.round(hue2rgb(p, q, h) * 255), - b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255), - }; -} - /** * Apply tint to a color (make lighter by blending with white) * @@ -239,22 +162,22 @@ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: n * - Adjusts luminance: newLum = lum + (1 - lum) * tint * * @param hex - 6-character hex color (no #) - * @param tint - Tint value 0-1 (0 = no change, 1 = fully white) + * @param tint - Tint value 0-1: how much of the original color to keep. + * 1 = no change, 0 = fully white. Per ECMA-376 §17.3.2.41. * @returns Modified hex color */ function applyTint(hex: string, tint: number): string { - if (tint <= 0 || tint >= 1) { - return tint >= 1 ? 'FFFFFF' : hex; - } + if (tint >= 1) return hex; + if (tint <= 0) return 'FFFFFF'; + // OOXML per-channel linear interpolation toward white: + // new_channel = channel * t + 255 * (1 - t) const rgb = hexToRgb(hex); - const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); - - // Apply tint: increase luminance toward white - hsl.l = hsl.l + (1 - hsl.l) * tint; - - const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); - return rgbToHex(newRgb.r, newRgb.g, newRgb.b); + return rgbToHex( + Math.min(255, Math.max(0, Math.round(rgb.r * tint + 255 * (1 - tint)))), + Math.min(255, Math.max(0, Math.round(rgb.g * tint + 255 * (1 - tint)))), + Math.min(255, Math.max(0, Math.round(rgb.b * tint + 255 * (1 - tint)))) + ); } /** @@ -265,22 +188,22 @@ function applyTint(hex: string, tint: number): string { * - Adjusts luminance: newLum = lum * shade * * @param hex - 6-character hex color (no #) - * @param shade - Shade value 0-1 (0 = fully black, 1 = no change) + * @param shade - Shade value 0-1: how much of the original color to keep. + * 1 = no change, 0 = fully black. Per ECMA-376 §17.3.2.41. * @returns Modified hex color */ function applyShade(hex: string, shade: number): string { - if (shade <= 0 || shade >= 1) { - return shade <= 0 ? '000000' : hex; - } + if (shade >= 1) return hex; + if (shade <= 0) return '000000'; + // OOXML per-channel linear interpolation toward black: + // new_channel = channel * s const rgb = hexToRgb(hex); - const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); - - // Apply shade: decrease luminance toward black - hsl.l = hsl.l * shade; - - const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l); - return rgbToHex(newRgb.r, newRgb.g, newRgb.b); + return rgbToHex( + Math.min(255, Math.max(0, Math.round(rgb.r * shade))), + Math.min(255, Math.max(0, Math.round(rgb.g * shade))), + Math.min(255, Math.max(0, Math.round(rgb.b * shade))) + ); } /** @@ -604,6 +527,7 @@ export function darkenColor( ): string { const resolved = resolveColor(color, theme); const hex = resolved.replace(/^#/, ''); + // percent=80 means darken 80% → keep 20% of original const shade = 1 - percent / 100; return `#${applyShade(hex, shade)}`; } @@ -623,7 +547,8 @@ export function lightenColor( ): string { const resolved = resolveColor(color, theme); const hex = resolved.replace(/^#/, ''); - const tint = percent / 100; + // percent=80 means lighten 80% → keep 20% of original + const tint = 1 - percent / 100; return `#${applyTint(hex, tint)}`; } @@ -745,8 +670,11 @@ export function getThemeTintShadeHex( fraction: number ): string { if (type === 'tint') { - return applyTint(baseHex, fraction); + // fraction is "how much to lighten" (0 = no change, 1 = fully white) + // applyTint wants "how much to keep" (1 = no change, 0 = fully white) → invert + return applyTint(baseHex, 1 - fraction); } + // fraction is "how much to keep" (1 = no change, 0 = fully black) — matches applyShade return applyShade(baseHex, fraction); } @@ -775,8 +703,11 @@ export function generateThemeTintShadeMatrix( if (row.type === 'base') { hex = baseHex.toUpperCase(); } else if (row.type === 'tint') { - hex = applyTint(baseHex, row.value); + // row.value is "how much to lighten" (0.8 = 80% lighter) + // applyTint wants "how much to keep" → invert + hex = applyTint(baseHex, 1 - row.value); } else { + // row.value for shade is "how much to keep" (0.75 = keep 75% = darken 25%) hex = applyShade(baseHex, row.value); } From f1d49bd1a62467576bdb2b7f57426c777efd5d48 Mon Sep 17 00:00:00 2001 From: sted Date: Thu, 16 Apr 2026 20:37:55 +0200 Subject: [PATCH 2/3] fix: resolve theme colors in table cell backgrounds --- .../src/prosemirror/conversion/toProseDoc.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/core/src/prosemirror/conversion/toProseDoc.ts b/packages/core/src/prosemirror/conversion/toProseDoc.ts index 8bbfe7e2..ac2c3f3d 100644 --- a/packages/core/src/prosemirror/conversion/toProseDoc.ts +++ b/packages/core/src/prosemirror/conversion/toProseDoc.ts @@ -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 @@ -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; @@ -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); } } @@ -513,7 +517,11 @@ function calculateRowSpans(table: Table): Map, - 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, @@ -830,7 +840,8 @@ function convertTableRow( isFirstCol, isLastCol, calculatedRowSpan, - defaultCellMargins + defaultCellMargins, + theme ) ); } @@ -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; @@ -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 From 6793467428efa20d19aac736b0e90541f4ad8172 Mon Sep 17 00:00:00 2001 From: sted Date: Thu, 16 Apr 2026 21:29:07 +0200 Subject: [PATCH 3/3] test: add regression tests for OOXML theme color resolution Covers the tint/shade math fix and the theme color threading through toProseDoc to table cell backgroundColor attrs. Uses real-world tint values (33, 99, F2) from documents with themed table styles. --- .../prosemirror/conversion/toProseDoc.test.ts | 137 ++++++++++++++++++ .../src/utils/__tests__/colorResolver.test.ts | 84 ++++++++++- 2 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/prosemirror/conversion/toProseDoc.test.ts diff --git a/packages/core/src/prosemirror/conversion/toProseDoc.test.ts b/packages/core/src/prosemirror/conversion/toProseDoc.test.ts new file mode 100644 index 00000000..55518605 --- /dev/null +++ b/packages/core/src/prosemirror/conversion/toProseDoc.test.ts @@ -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): Array> { + const cells: Array> = []; + pmDoc.descendants((node) => { + if (node.type.name === 'tableCell') { + cells.push(node.attrs as Record); + } + }); + 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'); + }); +}); diff --git a/packages/core/src/utils/__tests__/colorResolver.test.ts b/packages/core/src/utils/__tests__/colorResolver.test.ts index 722aa7a4..827cf4fd 100644 --- a/packages/core/src/utils/__tests__/colorResolver.test.ts +++ b/packages/core/src/utils/__tests__/colorResolver.test.ts @@ -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', @@ -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'); + }); +});