diff --git a/packages/layout-engine/pm-adapter/src/converters/table.test.ts b/packages/layout-engine/pm-adapter/src/converters/table.test.ts index 6b8739d3c7..a01f5e6d30 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.test.ts @@ -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, + 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(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/converters/table.ts b/packages/layout-engine/pm-adapter/src/converters/table.ts index 9cd62da5a7..eefbadf98b 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table.ts @@ -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'; @@ -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. @@ -119,6 +121,7 @@ type ParseTableCellArgs = { context: TableParserDependencies; defaultCellPadding?: BoxSpacing; tableProperties?: TableProperties; + rowCnfStyle?: Record | null; }; type ParseTableRowArgs = { @@ -240,9 +243,15 @@ 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 | 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 | undefined; - const tableInfo = tableProperties ? { tableProperties, rowIndex, cellIndex, numCells, numRows } : undefined; const resolvedTcProps = resolveTableCellProperties( inlineTcProps as Parameters[0], tableInfo, @@ -250,7 +259,7 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { ); // 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') { @@ -264,10 +273,17 @@ 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; + } } } @@ -275,10 +291,10 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { // 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; @@ -585,6 +601,9 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { } const cells: TableCell[] = []; + const rowCnfStyle = (rowNode.attrs?.tableRowProperties as Record | undefined)?.cnfStyle as + | Record + | undefined; rowNode.content.forEach((cellNode, cellIndex) => { const parsedCell = parseTableCell({ cellNode, @@ -595,6 +614,7 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { tableProperties, numCells: rowNode?.content?.length || 1, numRows, + rowCnfStyle, }); if (parsedCell) { cells.push(parsedCell); diff --git a/packages/layout-engine/pm-adapter/src/marks/application.ts b/packages/layout-engine/pm-adapter/src/marks/application.ts index 3bc33f9557..eb2d43d2e3 100644 --- a/packages/layout-engine/pm-adapter/src/marks/application.ts +++ b/packages/layout-engine/pm-adapter/src/marks/application.ts @@ -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. @@ -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) { @@ -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. * @@ -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 = ( diff --git a/packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts b/packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts new file mode 100644 index 0000000000..acb74693e1 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { resolveThemeColorValue, applyThemeTint, applyThemeShade, parseThemePercentage } from './theme-color.js'; + +const palette = { + accent1: '#4F81BD', + dk1: '#000000', + lt1: '#FFFFFF', + hyperlink: '#0000FF', +}; + +describe('resolveThemeColorValue', () => { + it('resolves a known theme key to its base color', () => { + expect(resolveThemeColorValue('accent1', undefined, undefined, palette)).toBe('#4F81BD'); + }); + + it('returns undefined for an unknown theme key', () => { + expect(resolveThemeColorValue('missing', undefined, undefined, palette)).toBeUndefined(); + }); + + it('returns undefined for an empty theme key', () => { + expect(resolveThemeColorValue('', undefined, undefined, palette)).toBeUndefined(); + expect(resolveThemeColorValue(' ', undefined, undefined, palette)).toBeUndefined(); + }); + + it('applies tint to the base color', () => { + // accent1 (#4F81BD) with tint '99' (0x99/255 ≈ 0.6) + const result = resolveThemeColorValue('accent1', '99', undefined, palette); + expect(result).toBe('#B9CDE5'); + }); + + it('applies shade to the base color', () => { + // accent1 (#4F81BD) with shade '33' (0x33/255 ≈ 0.2) + const result = resolveThemeColorValue('accent1', undefined, '33', palette); + expect(result).toBe('#101A26'); + }); + + it('applies both tint and shade', () => { + const result = resolveThemeColorValue('accent1', '80', 'BF', palette); + expect(result).toBeDefined(); + // Tint applied first, then shade — result should be between base and modified + expect(result!.startsWith('#')).toBe(true); + expect(result!.length).toBe(7); + }); +}); + +describe('applyThemeTint', () => { + it('tints pure black toward white', () => { + // ratio 0.5: black (0,0,0) → (128,128,128) approx + expect(applyThemeTint('#000000', 0.5)).toBe('#808080'); + }); + + it('does nothing at ratio 0', () => { + expect(applyThemeTint('#4F81BD', 0)).toBe('#4F81BD'); + }); + + it('produces pure white at ratio 1', () => { + expect(applyThemeTint('#000000', 1)).toBe('#FFFFFF'); + }); + + it('handles already-white gracefully', () => { + expect(applyThemeTint('#FFFFFF', 0.5)).toBe('#FFFFFF'); + }); +}); + +describe('applyThemeShade', () => { + it('shades pure white toward black', () => { + // ratio 0.5: white (255,255,255) → (128,128,128) + expect(applyThemeShade('#FFFFFF', 0.5)).toBe('#808080'); + }); + + it('produces pure black at ratio 0', () => { + expect(applyThemeShade('#FFFFFF', 0)).toBe('#000000'); + }); + + it('does nothing at ratio 1', () => { + expect(applyThemeShade('#4F81BD', 1)).toBe('#4F81BD'); + }); +}); + +describe('parseThemePercentage', () => { + it('parses FF as 1', () => { + expect(parseThemePercentage('FF')).toBeCloseTo(1, 2); + }); + + it('parses 00 as 0', () => { + expect(parseThemePercentage('00')).toBe(0); + }); + + it('parses 80 as ~0.502', () => { + expect(parseThemePercentage('80')).toBeCloseTo(128 / 255, 3); + }); + + it('returns undefined for non-string', () => { + expect(parseThemePercentage(undefined)).toBeUndefined(); + expect(parseThemePercentage(null)).toBeUndefined(); + expect(parseThemePercentage(42)).toBeUndefined(); + }); + + it('returns undefined for empty string', () => { + expect(parseThemePercentage('')).toBeUndefined(); + expect(parseThemePercentage(' ')).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/pm-adapter/src/marks/theme-color.ts b/packages/layout-engine/pm-adapter/src/marks/theme-color.ts new file mode 100644 index 0000000000..9450ec9df2 --- /dev/null +++ b/packages/layout-engine/pm-adapter/src/marks/theme-color.ts @@ -0,0 +1,105 @@ +/** + * Theme Color Resolution + * + * Resolves OOXML theme color references (e.g., "accent1") to hex color strings, + * applying optional tint and shade modifiers via RGB channel blending. + */ + +import type { ThemeColorPalette } from '../types.js'; + +/** + * Parses a two-character hex string (0x00–0xFF) to a 0–1 ratio. + * OOXML encodes tint/shade as hex byte strings where 0xFF = full effect. + */ +export 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) { + return normalized + .split('') + .map((char) => char + char) + .join(''); + } + return normalized; +}; + +const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => { + const cleaned = expandHex(hex); + if (cleaned.length !== 6) return null; + const r = Number.parseInt(cleaned.slice(0, 2), 16); + const g = Number.parseInt(cleaned.slice(2, 4), 16); + const b = Number.parseInt(cleaned.slice(4, 6), 16); + if ([r, g, b].some((channel) => Number.isNaN(channel))) return 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)}`; +}; + +/** Blend each RGB channel toward white by `ratio` (0 = no change, 1 = pure white). */ +export 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); +}; + +/** Blend each RGB channel toward black by `ratio` (0 = pure black, 1 = no change). */ +export 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); +}; + +/** + * Resolve a theme color key to a hex color string, applying tint/shade modifiers. + * + * @param themeKey - Theme color key (e.g., "accent1", "dk1", "hyperlink") + * @param tint - Optional tint hex string (e.g., "99") + * @param shade - Optional shade hex string (e.g., "BF") + * @param themeColors - Palette mapping theme keys to base hex colors + * @returns Resolved hex color with `#` prefix, or undefined if the key is not found + */ +export const resolveThemeColorValue = ( + themeKey: string, + tint: string | undefined, + shade: string | undefined, + themeColors: ThemeColorPalette, +): string | undefined => { + const key = themeKey.trim(); + if (!key) return undefined; + const base = themeColors[key]; + if (!base) return undefined; + let computed = base; + const tintRatio = parseThemePercentage(tint); + const shadeRatio = parseThemePercentage(shade); + if (tintRatio != null) { + computed = applyThemeTint(computed, tintRatio); + } + if (shadeRatio != null) { + computed = applyThemeShade(computed, shadeRatio); + } + return computed; +}; diff --git a/packages/layout-engine/style-engine/src/ooxml/index.test.ts b/packages/layout-engine/style-engine/src/ooxml/index.test.ts index 63174a5d2d..b83b0fe5f3 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + DEFAULT_TBL_LOOK, resolveStyleChain, getNumberingProperties, resolveDocxFontFamily, @@ -728,3 +729,417 @@ describe('ooxml - resolveTableProperties', () => { expect(result.cellMargins?.marginEnd).toEqual({ value: 200, type: 'dxa' }); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// basedOn inheritance for tblStylePr (conditional table style properties) +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - resolveTableCellProperties basedOn tblStylePr inheritance', () => { + it('inherits firstRow shading from base style when child has no firstRow entry', () => { + const styles = { + ...emptyStyles, + styles: { + BaseTable: { + type: 'table', + tableProperties: { tableStyleRowBandSize: 1 }, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'AA0000' } }, + }, + }, + }, + ChildTable: { + type: 'table', + basedOn: 'BaseTable', + tableProperties: {}, + tableStyleProperties: { + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'EEEEEE' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'ChildTable', tblLook: { firstRow: true, noHBand: true, noVBand: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toEqual({ val: 'clear', fill: 'AA0000' }); + }); + + it('child tblStylePr overrides base tblStylePr for the same style type', () => { + const styles = { + ...emptyStyles, + styles: { + BaseTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + band1Horz: { + tableCellProperties: { shading: { val: 'clear', fill: 'CCCCCC' } }, + }, + }, + }, + ChildTable: { + type: 'table', + basedOn: 'BaseTable', + tableProperties: {}, + tableStyleProperties: { + band1Horz: { + tableCellProperties: { shading: { val: 'clear', fill: 'FF0000' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'ChildTable', tblLook: { noVBand: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toEqual({ val: 'clear', fill: 'FF0000' }); + }); + + it('follows a 3-level basedOn chain for tblStylePr', () => { + const styles = { + ...emptyStyles, + styles: { + Grandparent: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'AAAAAA' } }, + }, + }, + }, + Parent: { + type: 'table', + basedOn: 'Grandparent', + tableProperties: {}, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'BBBBBB' } }, + }, + }, + }, + Leaf: { + type: 'table', + basedOn: 'Parent', + tableProperties: {}, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'Leaf', tblLook: { firstRow: true, noHBand: true, noVBand: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 2, + numCells: 2, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // Parent overrides Grandparent; Leaf has no firstRow so Parent wins + expect(result.shading).toEqual({ val: 'clear', fill: 'BBBBBB' }); + }); + + it('inherits band sizes from base style', () => { + const styles = { + ...emptyStyles, + styles: { + BaseTable: { + type: 'table', + tableProperties: { tableStyleRowBandSize: 2 }, + tableStyleProperties: { + band1Horz: { tableCellProperties: { shading: { fill: 'AAA' } } }, + band2Horz: { tableCellProperties: { shading: { fill: 'BBB' } } }, + }, + }, + ChildTable: { + type: 'table', + basedOn: 'BaseTable', + tableProperties: {}, + }, + }, + }; + // With bandSize=2, rows 0-1 are band1, rows 2-3 are band2 + const tableInfoRow2 = { + tableProperties: { tableStyleId: 'ChildTable', tblLook: { noVBand: true } }, + rowIndex: 2, + cellIndex: 0, + numRows: 4, + numCells: 2, + }; + const result = resolveTableCellProperties(null, tableInfoRow2, styles); + expect(result.shading).toEqual({ fill: 'BBB' }); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// cnfStyle supplementing index-based conditional type detection +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - resolveCellStyles cnfStyle flags', () => { + it('includes firstRow properties when cellCnfStyle.firstRow is true at non-zero rowIndex', () => { + const styles = { + ...emptyStyles, + styles: { + TestTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + firstRow: { tableCellProperties: { shading: { fill: 'HEADER' } } }, + wholeTable: { tableCellProperties: { shading: { fill: 'DEFAULT' } } }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'TestTable', tblLook: { firstRow: true, noHBand: true, noVBand: true } }, + rowIndex: 2, // Not row 0, but cnfStyle says firstRow + cellIndex: 0, + numRows: 4, + numCells: 3, + cellCnfStyle: { firstRow: true }, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, styles); + // Should contain both wholeTable and firstRow (from cnfStyle) + expect(result).toEqual([{ shading: { fill: 'DEFAULT' } }, { shading: { fill: 'HEADER' } }]); + }); + + it('firstRow wins over cnfStyle-added band1Horz (ECMA-376 precedence)', () => { + // Regression: cnfStyle-added bands must not override row/corner types. + // ECMA-376 §17.7.6 precedence: wholeTable < bands < firstCol/lastCol < firstRow/lastRow < corners + const styles = { + ...emptyStyles, + styles: { + TestTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + band1Horz: { tableCellProperties: { shading: { fill: 'BAND' } } }, + firstRow: { tableCellProperties: { shading: { fill: 'HEADER' } } }, + wholeTable: { tableCellProperties: { shading: { fill: 'DEFAULT' } } }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { + tableStyleId: 'TestTable', + tblLook: { firstRow: true, noHBand: true, noVBand: true }, + }, + rowIndex: 0, // row 0 = firstRow + cellIndex: 0, + numRows: 4, + numCells: 3, + // cnfStyle adds band1Horz even though noHBand suppressed it from index logic + rowCnfStyle: { firstRow: true, oddHBand: true }, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, styles); + // Order must be: wholeTable → band1Horz → firstRow (last wins in combineProperties) + expect(result).toEqual([ + { shading: { fill: 'DEFAULT' } }, + { shading: { fill: 'BAND' } }, + { shading: { fill: 'HEADER' } }, + ]); + }); + + it('returns same result without cnfStyle (no regression)', () => { + const styles = { + ...emptyStyles, + styles: { + TestTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + wholeTable: { tableCellProperties: { shading: { fill: 'DEFAULT' } } }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'TestTable', tblLook: { noHBand: true, noVBand: true } }, + rowIndex: 1, + cellIndex: 0, + numRows: 3, + numCells: 3, + }; + const result = resolveCellStyles('tableCellProperties', tableInfo, styles); + expect(result).toEqual([{ shading: { fill: 'DEFAULT' } }]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// DEFAULT_TBL_LOOK fallback when tblLook is absent (SD-2086) +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - DEFAULT_TBL_LOOK fallback when tblLook is absent', () => { + it('applies firstRow shading when tblLook is absent (SD-2086)', () => { + const styles = { + ...emptyStyles, + styles: { + GridTable4: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'HEADER' } }, + }, + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'DEFAULT' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'GridTable4', tblLook: undefined }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // DEFAULT_TBL_LOOK has firstRow: true, so row 0 gets firstRow shading + expect(result.shading).toEqual({ val: 'clear', fill: 'HEADER' }); + }); + + it('explicit tblLook.firstRow: false still suppresses firstRow formatting', () => { + const styles = { + ...emptyStyles, + styles: { + GridTable4: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'HEADER' } }, + }, + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'DEFAULT' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { + tableStyleId: 'GridTable4', + tblLook: { firstRow: false, noHBand: true, noVBand: true }, + }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // Explicit tblLook overrides the default — firstRow is suppressed + expect(result.shading).toEqual({ val: 'clear', fill: 'DEFAULT' }); + }); + + it('applies firstRow through basedOn chain when tblLook is absent', () => { + const styles = { + ...emptyStyles, + styles: { + BaseTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + firstRow: { + tableCellProperties: { shading: { val: 'clear', fill: 'INHERITED_HEADER' } }, + }, + }, + }, + ChildTable: { + type: 'table', + basedOn: 'BaseTable', + tableProperties: {}, + tableStyleProperties: { + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'CHILD_DEFAULT' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'ChildTable', tblLook: undefined }, + rowIndex: 0, + cellIndex: 0, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // firstRow inherited from BaseTable, enabled by DEFAULT_TBL_LOOK + expect(result.shading).toEqual({ val: 'clear', fill: 'INHERITED_HEADER' }); + }); + + it('noVBand defaults to true — vertical banding is suppressed', () => { + const styles = { + ...emptyStyles, + styles: { + BandTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + band1Vert: { + tableCellProperties: { shading: { val: 'clear', fill: 'VBAND' } }, + }, + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'DEFAULT' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'BandTable', tblLook: undefined }, + rowIndex: 1, + cellIndex: 1, + numRows: 3, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // DEFAULT_TBL_LOOK has noVBand: true, so band1Vert should NOT appear + expect(result.shading).toEqual({ val: 'clear', fill: 'DEFAULT' }); + }); + + it('noHBand defaults to false — horizontal banding is enabled', () => { + const styles = { + ...emptyStyles, + styles: { + BandTable: { + type: 'table', + tableProperties: {}, + tableStyleProperties: { + band1Horz: { + tableCellProperties: { shading: { val: 'clear', fill: 'HBAND' } }, + }, + wholeTable: { + tableCellProperties: { shading: { val: 'clear', fill: 'DEFAULT' } }, + }, + }, + }, + }, + }; + const tableInfo = { + tableProperties: { tableStyleId: 'BandTable', tblLook: undefined }, + // Row 1 is the first data row (row 0 is firstRow with DEFAULT_TBL_LOOK). + // band1Horz applies to the first banding group after the header. + rowIndex: 1, + cellIndex: 0, + numRows: 4, + numCells: 4, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + // DEFAULT_TBL_LOOK has noHBand: false, so band1Horz IS applied + expect(result.shading).toEqual({ val: 'clear', fill: 'HBAND' }); + }); +}); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 53b1a46b41..f7a4e85ff8 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -7,9 +7,10 @@ import { combineIndentProperties, combineProperties, combineRunProperties } from '../cascade.js'; import type { PropertyObject } from '../cascade.js'; -import type { ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts'; +import type { ParagraphConditionalFormatting, ParagraphProperties, ParagraphTabStop, RunProperties } from './types.ts'; import type { NumberingProperties } from './numbering-types.ts'; import type { + StyleDefinition, StylesDocumentProperties, TableStyleType, TableProperties, @@ -46,8 +47,23 @@ export interface TableInfo { cellIndex: number; numCells: number; numRows: number; + rowCnfStyle?: ParagraphConditionalFormatting | null; + cellCnfStyle?: ParagraphConditionalFormatting | null; } +/** + * OOXML default tblLook value (0x04A0) per ECMA-376 §17.4.56. + * Word applies these flags when a table has no explicit w:tblLook element. + */ +export const DEFAULT_TBL_LOOK: TableLookProperties = { + firstRow: true, + lastRow: false, + firstColumn: true, + lastColumn: false, + noHBand: false, + noVBand: true, +}; + export function resolveRunProperties( params: OoxmlResolverParams, inlineRpr: RunProperties | null | undefined, @@ -414,6 +430,59 @@ export function resolveDocxFontFamily( return resolved; } +/** + * Resolve effective band sizes by walking the basedOn chain. + * Returns the first defined value for each band size, falling back to 1. + */ +function resolveEffectiveBandSizes( + styleId: string, + translatedLinkedStyles: StylesDocumentProperties, +): { rowBandSize: number; colBandSize: number } { + const seen = new Set(); + let currentId: string | undefined = styleId; + let rowBandSize: number | undefined; + let colBandSize: number | undefined; + while (currentId && !seen.has(currentId)) { + seen.add(currentId); + const def: StyleDefinition | undefined = translatedLinkedStyles.styles?.[currentId]; + const tblProps = def?.tableProperties; + if (rowBandSize == null && tblProps?.tableStyleRowBandSize != null) { + rowBandSize = tblProps.tableStyleRowBandSize; + } + if (colBandSize == null && tblProps?.tableStyleColBandSize != null) { + colBandSize = tblProps.tableStyleColBandSize; + } + if (rowBandSize != null && colBandSize != null) break; + currentId = def?.basedOn; + } + return { rowBandSize: rowBandSize ?? 1, colBandSize: colBandSize ?? 1 }; +} + +/** + * Resolve a single conditional table style property type across the basedOn chain. + * Collects entries from ancestors (deepest first) and merges them so the leaf wins. + */ +function resolveConditionalProps( + propertyType: 'paragraphProperties' | 'runProperties' | 'tableCellProperties', + styleType: TableStyleType, + styleId: string, + translatedLinkedStyles: StylesDocumentProperties, +): T | undefined { + const chain: T[] = []; + const seen = new Set(); + let currentId: string | undefined = styleId; + while (currentId && !seen.has(currentId)) { + seen.add(currentId); + const def: StyleDefinition | undefined = translatedLinkedStyles.styles?.[currentId]; + const props = def?.tableStyleProperties?.[styleType]?.[propertyType] as T | undefined; + if (props) chain.push(props); + currentId = def?.basedOn; + } + if (chain.length === 0) return undefined; + chain.reverse(); + return combineProperties(chain) as T; +} + export function resolveCellStyles( propertyType: 'paragraphProperties' | 'runProperties' | 'tableCellProperties', tableInfo: TableInfo | null | undefined, @@ -423,27 +492,25 @@ export function resolveCellStyles( return []; } const cellStyleProps: T[] = []; - if (tableInfo != null && tableInfo.tableProperties.tableStyleId) { - const tableStyleDef = translatedLinkedStyles.styles[tableInfo.tableProperties.tableStyleId]; - const tableStylePropsDef = tableStyleDef?.tableProperties; - const rowBandSize = tableStylePropsDef?.tableStyleRowBandSize ?? 1; - const colBandSize = tableStylePropsDef?.tableStyleColBandSize ?? 1; - const cellStyleTypes = determineCellStyleTypes( - tableInfo.tableProperties?.tblLook, - tableInfo.rowIndex, - tableInfo.cellIndex, - tableInfo.numRows, - tableInfo.numCells, - rowBandSize, - colBandSize, - ); - cellStyleTypes.forEach((styleType) => { - const typeProps = tableStyleDef?.tableStyleProperties?.[styleType]?.[propertyType] as T; - if (typeProps) { - cellStyleProps.push(typeProps); - } - }); - } + const tableStyleId = tableInfo.tableProperties.tableStyleId; + const { rowBandSize, colBandSize } = resolveEffectiveBandSizes(tableStyleId, translatedLinkedStyles); + const cellStyleTypes = determineCellStyleTypes( + tableInfo.tableProperties?.tblLook ?? DEFAULT_TBL_LOOK, + tableInfo.rowIndex, + tableInfo.cellIndex, + tableInfo.numRows, + tableInfo.numCells, + rowBandSize, + colBandSize, + tableInfo.rowCnfStyle, + tableInfo.cellCnfStyle, + ); + cellStyleTypes.forEach((styleType) => { + const typeProps = resolveConditionalProps(propertyType, styleType, tableStyleId, translatedLinkedStyles); + if (typeProps) { + cellStyleProps.push(typeProps); + } + }); return cellStyleProps; } @@ -482,6 +549,41 @@ export function resolveTableCellProperties( return combineProperties(chain, { fullOverrideProps: ['shading'] }); } +/** Maps cnfStyle boolean flags to their corresponding TableStyleType keys. */ +const CNF_STYLE_MAP: ReadonlyArray<[keyof ParagraphConditionalFormatting, TableStyleType]> = [ + ['oddHBand', 'band1Horz'], + ['evenHBand', 'band2Horz'], + ['oddVBand', 'band1Vert'], + ['evenVBand', 'band2Vert'], + ['firstRow', 'firstRow'], + ['firstColumn', 'firstCol'], + ['lastRow', 'lastRow'], + ['lastColumn', 'lastCol'], + ['firstRowFirstColumn', 'nwCell'], + ['firstRowLastColumn', 'neCell'], + ['lastRowFirstColumn', 'swCell'], + ['lastRowLastColumn', 'seCell'], +]; + +// ECMA-376 §17.7.6 precedence order (low → high). +// combineProperties treats later entries as higher priority, so this array +// must list types from lowest to highest override strength. +const TABLE_STYLE_PRECEDENCE: TableStyleType[] = [ + 'wholeTable', + 'band1Horz', + 'band2Horz', + 'band1Vert', + 'band2Vert', + 'firstCol', + 'lastCol', + 'firstRow', + 'lastRow', + 'nwCell', + 'neCell', + 'swCell', + 'seCell', +]; + function determineCellStyleTypes( tblLook: TableLookProperties | null | undefined, rowIndex: number, @@ -490,8 +592,10 @@ function determineCellStyleTypes( numCells?: number | null, rowBandSize = 1, colBandSize = 1, + rowCnfStyle?: ParagraphConditionalFormatting | null, + cellCnfStyle?: ParagraphConditionalFormatting | null, ): TableStyleType[] { - const styleTypes: TableStyleType[] = ['wholeTable']; + const applicable = new Set(['wholeTable']); const normalizedRowBandSize = rowBandSize > 0 ? rowBandSize : 1; const normalizedColBandSize = colBandSize > 0 ? colBandSize : 1; @@ -504,42 +608,34 @@ function determineCellStyleTypes( const colGroup = Math.floor(bandColIndex / normalizedColBandSize); if (!tblLook?.noHBand) { - if (rowGroup % 2 === 0) { - styleTypes.push('band1Horz'); - } else { - styleTypes.push('band2Horz'); - } + applicable.add(rowGroup % 2 === 0 ? 'band1Horz' : 'band2Horz'); } if (!tblLook?.noVBand) { - if (colGroup % 2 === 0) { - styleTypes.push('band1Vert'); - } else { - styleTypes.push('band2Vert'); - } + applicable.add(colGroup % 2 === 0 ? 'band1Vert' : 'band2Vert'); } if (tblLook?.firstRow && rowIndex === 0) { - styleTypes.push('firstRow'); + applicable.add('firstRow'); } if (tblLook?.firstColumn && cellIndex === 0) { - styleTypes.push('firstCol'); + applicable.add('firstCol'); } if (tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1) { - styleTypes.push('lastRow'); + applicable.add('lastRow'); } if (tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1) { - styleTypes.push('lastCol'); + applicable.add('lastCol'); } if (rowIndex === 0 && cellIndex === 0) { - styleTypes.push('nwCell'); + applicable.add('nwCell'); } if (rowIndex === 0 && numCells != null && numCells > 0 && cellIndex === numCells - 1) { - styleTypes.push('neCell'); + applicable.add('neCell'); } if (numRows != null && numRows > 0 && rowIndex === numRows - 1 && cellIndex === 0) { - styleTypes.push('swCell'); + applicable.add('swCell'); } if ( numRows != null && @@ -549,8 +645,22 @@ function determineCellStyleTypes( rowIndex === numRows - 1 && cellIndex === numCells - 1 ) { - styleTypes.push('seCell'); + applicable.add('seCell'); + } + + // Union in cnfStyle-derived types that index-based logic didn't already add. + // cnfStyle only adds types, never removes them. + if (rowCnfStyle || cellCnfStyle) { + for (const [flag, styleType] of CNF_STYLE_MAP) { + const rowFlag = rowCnfStyle?.[flag]; + const cellFlag = cellCnfStyle?.[flag]; + if (rowFlag === true || cellFlag === true) { + applicable.add(styleType); + } + } } - return styleTypes; + // Return types in ECMA-376 precedence order (low → high) so that + // combineProperties applies overrides correctly. + return TABLE_STYLE_PRECEDENCE.filter((t) => applicable.has(t)); } diff --git a/packages/super-editor/src/extensions/table/table.js b/packages/super-editor/src/extensions/table/table.js index 2d57154019..619bc181fb 100644 --- a/packages/super-editor/src/extensions/table/table.js +++ b/packages/super-editor/src/extensions/table/table.js @@ -177,6 +177,7 @@ import { createTable } from './tableHelpers/createTable.js'; import { createColGroup } from './tableHelpers/createColGroup.js'; import { deleteTableWhenSelected } from './tableHelpers/deleteTableWhenSelected.js'; import { normalizeNewTableAttrs } from './tableHelpers/normalizeNewTableAttrs.js'; +import { computeColumnWidths } from './tableHelpers/computeColumnWidths.js'; import { createTableBorders } from './tableHelpers/createTableBorders.js'; import { isLegacySchemaDefaultBorders, @@ -622,21 +623,7 @@ export const Table = Node.create({ insertTable: ({ rows = 3, cols = 3, withHeaderRow = false, columnWidths = null } = {}) => ({ tr, dispatch, editor }) => { - let widths = columnWidths; - - // If no widths provided, auto-calculate to fill available page width - if (!widths) { - const { pageSize = {}, pageMargins = {} } = editor.converter?.pageStyles ?? {}; - const { width: pageWidth } = pageSize; - const { left = 0, right = 0 } = pageMargins; - - if (pageWidth) { - // Page dimensions are in inches, convert to pixels (96 PPI) - const availableWidth = (pageWidth - left - right) * 96; - const columnWidth = Math.floor(availableWidth / cols); - widths = Array(cols).fill(columnWidth); - } - } + const widths = columnWidths ?? computeColumnWidths(editor, cols); const resolved = normalizeNewTableAttrs(editor); const tableAttrs = { @@ -693,11 +680,13 @@ export const Table = Node.create({ Array.from({ length: 8 }, () => Math.floor(Math.random() * 16).toString(16)) .join('') .toUpperCase(); + const widths = computeColumnWidths(editor, columns); const rowNodes = []; for (let r = 0; r < rows; r++) { const cellNodes = []; for (let c = 0; c < columns; c++) { - const cell = tableCellType.createAndFill({ paraId: genParaId() }); + const cellAttrs = { paraId: genParaId(), ...(widths ? { colwidth: [widths[c]] } : {}) }; + const cell = tableCellType.createAndFill(cellAttrs); if (!cell) return false; cellNodes.push(cell); } diff --git a/packages/super-editor/src/extensions/table/table.test.js b/packages/super-editor/src/extensions/table/table.test.js index 0a3d87123b..72bd73318a 100644 --- a/packages/super-editor/src/extensions/table/table.test.js +++ b/packages/super-editor/src/extensions/table/table.test.js @@ -3,6 +3,8 @@ import { EditorState, TextSelection } from 'prosemirror-state'; import { CellSelection, TableMap } from 'prosemirror-tables'; import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; import { createTable } from './tableHelpers/createTable.js'; +import { normalizeNewTableAttrs } from './tableHelpers/normalizeNewTableAttrs.js'; +import { DEFAULT_TBL_LOOK } from '@superdoc/style-engine/ooxml'; import { promises as fs } from 'fs'; // Cache DOCX data to avoid repeated file loading @@ -979,4 +981,143 @@ describe('Table commands', async () => { expect(tableNode?.attrs.tableProperties?.tblLook).toBeDefined(); }); }); + + describe('column width computation (SD-2086)', async () => { + it('insertTableAt cells have computed colwidth when pageStyles available', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + // Inject pageStyles so computeColumnWidths returns real widths + const originalConverter = editor.converter; + editor.converter = { + ...originalConverter, + pageStyles: { + ...originalConverter?.pageStyles, + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }; + + // Insert at end of doc + const pos = editor.state.doc.content.size; + const didInsert = editor.commands.insertTableAt({ pos, rows: 2, columns: 3 }); + expect(didInsert).toBe(true); + + // Find the inserted table + const tablePos = findTablePos(editor.state.doc); + expect(tablePos).not.toBeNull(); + const table = editor.state.doc.nodeAt(tablePos); + + // Each cell should have colwidth [208] (= Math.floor((8.5 - 1 - 1) * 96 / 3)) + table.forEach((row) => { + row.forEach((cell) => { + expect(cell.attrs.colwidth).toEqual([208]); + }); + }); + + editor.converter = originalConverter; + }); + + it('insertTable auto-calc still works after refactor', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + // Inject pageStyles + const originalConverter = editor.converter; + editor.converter = { + ...originalConverter, + pageStyles: { + ...originalConverter?.pageStyles, + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }; + + const didInsert = editor.commands.insertTable({ rows: 2, cols: 3 }); + expect(didInsert).toBe(true); + + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + // Verify cells have computed widths + table.forEach((row) => { + row.forEach((cell) => { + expect(cell.attrs.colwidth).toEqual([208]); + }); + }); + + editor.converter = originalConverter; + }); + + it('insertTable with explicit columnWidths bypasses auto-calc', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + // Inject pageStyles (should be ignored since explicit widths are provided) + const originalConverter = editor.converter; + editor.converter = { + ...originalConverter, + pageStyles: { + ...originalConverter?.pageStyles, + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }; + + const didInsert = editor.commands.insertTable({ rows: 2, cols: 3, columnWidths: [100, 200, 300] }); + expect(didInsert).toBe(true); + + const tablePos = findTablePos(editor.state.doc); + const table = editor.state.doc.nodeAt(tablePos); + + table.forEach((row) => { + expect(row.child(0).attrs.colwidth).toEqual([100]); + expect(row.child(1).attrs.colwidth).toEqual([200]); + expect(row.child(2).attrs.colwidth).toEqual([300]); + }); + + editor.converter = originalConverter; + }); + }); + + describe('normalizeNewTableAttrs tblLook (SD-2086)', async () => { + it('includes DEFAULT_TBL_LOOK in tableProperties when a style is resolved', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Inject a style catalog with TableGrid so the styled path is taken + const originalConverter = editor.converter; + editor.converter = { + ...originalConverter, + translatedLinkedStyles: { + styles: { TableGrid: { type: 'table' } }, + docDefaults: {}, + latentStyles: {}, + }, + }; + + const result = normalizeNewTableAttrs(editor); + expect(result.tableProperties?.tblLook).toEqual(DEFAULT_TBL_LOOK); + + editor.converter = originalConverter; + }); + + it('does not include tblLook when source is "none" (unstyled table)', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + + // Override converter to simulate no styles available + const originalConverter = editor.converter; + editor.converter = { translatedLinkedStyles: { styles: {} } }; + + const result = normalizeNewTableAttrs(editor); + expect(result.tableStyleId).toBeNull(); + expect(result.tableProperties?.tblLook).toBeUndefined(); + + editor.converter = originalConverter; + }); + }); }); diff --git a/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.js b/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.js new file mode 100644 index 0000000000..a9f7824aec --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.js @@ -0,0 +1,18 @@ +/** + * Compute equal column widths (px) that fill the available page content width. + * Returns null when page dimensions are unavailable. + * + * @param {import('@core/Editor').Editor} editor + * @param {number} columnCount + * @returns {number[] | null} + */ +export function computeColumnWidths(editor, columnCount) { + const { pageSize = {}, pageMargins = {} } = editor?.converter?.pageStyles ?? {}; + const { width: pageWidth } = pageSize; + const { left = 0, right = 0 } = pageMargins; + if (!pageWidth) return null; + + const availableWidth = (pageWidth - left - right) * 96; // inches → px at 96 PPI + const columnWidth = Math.floor(availableWidth / columnCount); + return Array(columnCount).fill(columnWidth); +} diff --git a/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.test.js b/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.test.js new file mode 100644 index 0000000000..4f63a911bf --- /dev/null +++ b/packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest'; +import { computeColumnWidths } from './computeColumnWidths.js'; + +describe('computeColumnWidths', () => { + it('returns null when editor is null', () => { + expect(computeColumnWidths(null, 3)).toBeNull(); + }); + + it('returns null when editor is undefined', () => { + expect(computeColumnWidths(undefined, 3)).toBeNull(); + }); + + it('returns null when converter has no pageStyles', () => { + const editor = { converter: {} }; + expect(computeColumnWidths(editor, 3)).toBeNull(); + }); + + it('returns null when pageSize has no width', () => { + const editor = { converter: { pageStyles: { pageSize: {}, pageMargins: {} } } }; + expect(computeColumnWidths(editor, 3)).toBeNull(); + }); + + it('computes equal widths for standard US Letter (8.5in, 1in margins, 3 cols)', () => { + const editor = { + converter: { + pageStyles: { + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }, + }; + // (8.5 - 1 - 1) * 96 = 624, 624 / 3 = 208 + const widths = computeColumnWidths(editor, 3); + expect(widths).toEqual([208, 208, 208]); + }); + + it('single column gets full content width', () => { + const editor = { + converter: { + pageStyles: { + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }, + }; + // (8.5 - 1 - 1) * 96 = 624 + const widths = computeColumnWidths(editor, 1); + expect(widths).toEqual([624]); + }); + + it('defaults margins to 0 when not provided', () => { + const editor = { + converter: { + pageStyles: { + pageSize: { width: 10 }, + }, + }, + }; + // (10 - 0 - 0) * 96 = 960, 960 / 2 = 480 + const widths = computeColumnWidths(editor, 2); + expect(widths).toEqual([480, 480]); + }); + + it('floors fractional pixel widths', () => { + const editor = { + converter: { + pageStyles: { + pageSize: { width: 8.5 }, + pageMargins: { left: 1, right: 1 }, + }, + }, + }; + // (8.5 - 1 - 1) * 96 = 624, 624 / 7 = 89.14... → 89 + const widths = computeColumnWidths(editor, 7); + expect(widths).toEqual(Array(7).fill(89)); + }); +}); diff --git a/packages/super-editor/src/extensions/table/tableHelpers/index.js b/packages/super-editor/src/extensions/table/tableHelpers/index.js index 124be6a824..008b6808c5 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/index.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/index.js @@ -4,3 +4,4 @@ export * from './createColGroup.js'; export * from './isCellSelection.js'; export * from './getColStyleDeclaration.js'; export * from './deleteTableWhenSelected.js'; +export * from './computeColumnWidths.js'; diff --git a/packages/super-editor/src/extensions/table/tableHelpers/normalizeNewTableAttrs.js b/packages/super-editor/src/extensions/table/tableHelpers/normalizeNewTableAttrs.js index 3b07e2571b..cce2fac854 100644 --- a/packages/super-editor/src/extensions/table/tableHelpers/normalizeNewTableAttrs.js +++ b/packages/super-editor/src/extensions/table/tableHelpers/normalizeNewTableAttrs.js @@ -1,5 +1,6 @@ // @ts-check import { + DEFAULT_TBL_LOOK, resolvePreferredNewTableStyleId, TABLE_FALLBACK_BORDERS, TABLE_FALLBACK_CELL_PADDING, @@ -71,7 +72,7 @@ export function normalizeNewTableAttrs(editor) { // Also include tableStyleId inside tableProperties so the exporter's // decodeProperties loop (which iterates Object.keys(tableProperties)) // finds it and writes into . - tableProperties: { tableStyleId: resolved.styleId }, + tableProperties: { tableStyleId: resolved.styleId, tblLook: { ...DEFAULT_TBL_LOOK } }, }; } diff --git a/tests/doc-api-stories/tests/tables/fixtures/sd-2086-header-row-shading-api.docx b/tests/doc-api-stories/tests/tables/fixtures/sd-2086-header-row-shading-api.docx new file mode 100644 index 0000000000..4b0647a4b5 Binary files /dev/null and b/tests/doc-api-stories/tests/tables/fixtures/sd-2086-header-row-shading-api.docx differ diff --git a/tests/doc-api-stories/tests/tables/header-row-shading-roundtrip.ts b/tests/doc-api-stories/tests/tables/header-row-shading-roundtrip.ts new file mode 100644 index 0000000000..45d56a9319 --- /dev/null +++ b/tests/doc-api-stories/tests/tables/header-row-shading-roundtrip.ts @@ -0,0 +1,236 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { describe, expect, it } from 'vitest'; +import { unwrap, useStoryHarness } from '../harness'; + +const execFileAsync = promisify(execFile); +const ZIP_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'sd-2086-header-row-shading-api.docx'); + +type TableSnapshot = { + explicitTableStyleId: string | null; + defaultTableStyleId: string | null; + resolvedTableStyleId: string | null; + styleFirstRowShadingFill: string | null; + tblLookFirstRow: boolean | null; + headerRowShadingFill: string | null; + gridWidths: number[]; + cellWidths: number[]; + rowCount: number; + cellCount: number; +}; + +function sid(label: string): string { + return `${label}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`; +} + +function escapeForRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function readDocxPart(docPath: string, partPath: string): Promise { + const { stdout } = await execFileAsync('unzip', ['-p', docPath, partPath], { + maxBuffer: ZIP_MAX_BUFFER_BYTES, + }); + return stdout; +} + +function extractDefaultTableStyleId(settingsXml: string): string | null { + const match = settingsXml.match(/]*\bw:val="([^"]+)"/); + return match?.[1] ?? null; +} + +function extractFirstTableXml(documentXml: string): string { + const match = documentXml.match(//); + if (!match) { + throw new Error('No table markup found in word/document.xml.'); + } + return match[0]; +} + +function extractExplicitTableStyleId(tableXml: string): string | null { + const match = tableXml.match(/]*\bw:val="([^"]+)"/); + return match?.[1] ?? null; +} + +function extractTblLookFirstRow(tableXml: string): boolean | null { + const tblLookMatch = tableXml.match(/]*)\/?\s*>/); + if (!tblLookMatch) return null; + + const attrs = tblLookMatch[1] ?? ''; + const firstRowMatch = attrs.match(/\bw:firstRow="([^"]+)"/); + if (!firstRowMatch) return null; + + const raw = firstRowMatch[1].toLowerCase(); + if (raw === '1' || raw === 'true' || raw === 'on') return true; + if (raw === '0' || raw === 'false' || raw === 'off') return false; + return null; +} + +function extractStyleFirstRowShadingFill(stylesXml: string, styleId: string | null): string | null { + if (!styleId) return null; + + const styleRegex = new RegExp( + `]*\\bw:type="table"[^>]*\\bw:styleId="${escapeForRegex(styleId)}"[^>]*>[\\s\\S]*?<\\/w:style>`, + ); + const styleMatch = stylesXml.match(styleRegex); + if (!styleMatch) return null; + + const firstRowMatch = styleMatch[0].match(/]*\bw:type="firstRow"[^>]*>([\s\S]*?)<\/w:tblStylePr>/); + if (!firstRowMatch) return null; + + const shadingMatch = firstRowMatch[1].match(/]*\bw:fill="([^"]+)"/); + return shadingMatch?.[1]?.toUpperCase() ?? null; +} + +function extractWidths(tableXml: string): { gridWidths: number[]; cellWidths: number[] } { + const gridWidths = Array.from(tableXml.matchAll(/]*\bw:w="(\d+)"/g), (match) => Number(match[1])); + const cellWidths = Array.from(tableXml.matchAll(/]*\bw:w="(\d+)"/g), (match) => Number(match[1])); + return { gridWidths, cellWidths }; +} + +function buildTableSnapshot(documentXml: string, stylesXml: string, settingsXml: string): TableSnapshot { + const tableXml = extractFirstTableXml(documentXml); + const explicitTableStyleId = extractExplicitTableStyleId(tableXml); + const defaultTableStyleId = extractDefaultTableStyleId(settingsXml); + const resolvedTableStyleId = explicitTableStyleId ?? defaultTableStyleId; + const tblLookFirstRow = extractTblLookFirstRow(tableXml); + const styleFirstRowShadingFill = extractStyleFirstRowShadingFill(stylesXml, resolvedTableStyleId); + const headerRowEnabled = tblLookFirstRow ?? true; + const headerRowShadingFill = headerRowEnabled ? styleFirstRowShadingFill : null; + const { gridWidths, cellWidths } = extractWidths(tableXml); + const rowCount = (tableXml.match(/ { + const [documentXml, stylesXml, settingsXml] = await Promise.all([ + readDocxPart(docPath, 'word/document.xml'), + readDocxPart(docPath, 'word/styles.xml'), + readDocxPart(docPath, 'word/settings.xml'), + ]); + + return buildTableSnapshot(documentXml, stylesXml, settingsXml); +} + +describe('document-api story: tables header row shading roundtrip', () => { + const { client, copyDoc, outPath } = useStoryHarness('tables/header-row-shading-roundtrip', { + preserveResults: true, + }); + + it('inserts a 2x2 table, preserves header-row shading semantics, and roundtrips widths', async () => { + const sourceDoc = await copyDoc(FIXTURE_DOC, 'source.docx'); + const insertSessionId = sid('tables-header-shading-insert'); + const reopenSessionId = sid('tables-header-shading-reopen'); + + await client.doc.open({ sessionId: insertSessionId, doc: sourceDoc }); + + const createResult = unwrap( + await client.doc.create.table({ + sessionId: insertSessionId, + rows: 2, + columns: 2, + }), + ); + expect(createResult?.success).toBe(true); + + const createdTableNodeId = createResult?.table?.nodeId; + expect(typeof createdTableNodeId).toBe('string'); + + const tableInfoBeforeExport = unwrap( + await client.doc.tables.get({ + sessionId: insertSessionId, + nodeId: createdTableNodeId, + }), + ); + expect(tableInfoBeforeExport?.rows).toBe(2); + expect(tableInfoBeforeExport?.columns).toBe(2); + + const insertedDocPath = outPath('header-row-shading-inserted.docx'); + await client.doc.save({ + sessionId: insertSessionId, + out: insertedDocPath, + force: true, + }); + + const before = await captureSnapshot(insertedDocPath); + + // Header-row shading should resolve from the document's default table style. + expect(before.defaultTableStyleId).toBe('CustomTableStyleA'); + expect(before.resolvedTableStyleId).toBe('CustomTableStyleA'); + expect(before.styleFirstRowShadingFill).toBe('F2F2F2'); + expect(before.headerRowShadingFill).toBe('F2F2F2'); + + // Table dimensions and width metadata should reflect a 2x2 insertion. + expect(before.rowCount).toBe(2); + expect(before.cellCount).toBe(4); + expect(before.gridWidths).toHaveLength(2); + expect(before.cellWidths).toHaveLength(4); + for (const width of before.gridWidths) { + expect(width).toBeGreaterThan(0); + } + for (const width of before.cellWidths) { + expect(width).toBeGreaterThan(0); + } + + await client.doc.open({ + sessionId: reopenSessionId, + doc: insertedDocPath, + }); + + const firstTableMatch = unwrap( + await client.doc.query.match({ + sessionId: reopenSessionId, + select: { type: 'node', nodeType: 'table' }, + require: 'first', + }), + ); + + const reopenedTableNodeId = firstTableMatch?.items?.[0]?.address?.nodeId; + expect(typeof reopenedTableNodeId).toBe('string'); + + const tableInfoAfterReimport = unwrap( + await client.doc.tables.get({ + sessionId: reopenSessionId, + nodeId: reopenedTableNodeId, + }), + ); + expect(tableInfoAfterReimport?.rows).toBe(2); + expect(tableInfoAfterReimport?.columns).toBe(2); + + const roundtripDocPath = outPath('header-row-shading-roundtrip.docx'); + await client.doc.save({ + sessionId: reopenSessionId, + out: roundtripDocPath, + force: true, + }); + + const after = await captureSnapshot(roundtripDocPath); + + expect(after.defaultTableStyleId).toBe(before.defaultTableStyleId); + expect(after.resolvedTableStyleId).toBe(before.resolvedTableStyleId); + expect(after.styleFirstRowShadingFill).toBe(before.styleFirstRowShadingFill); + expect(after.headerRowShadingFill).toBe(before.headerRowShadingFill); + + // Width values should survive export -> import -> export. + expect(after.gridWidths).toEqual(before.gridWidths); + expect(after.cellWidths).toEqual(before.cellWidths); + + expect(after.rowCount).toBe(2); + expect(after.cellCount).toBe(4); + }); +});