From 0153245977e47c421f265669658f454f1798b1f2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 11:24:36 -0800 Subject: [PATCH 1/2] feat(layout-engine): render table headers, tblLook support --- .../pm-adapter/src/converters/table.test.ts | 110 +++++ .../pm-adapter/src/converters/table.ts | 38 +- .../pm-adapter/src/marks/application.ts | 60 +-- .../pm-adapter/src/marks/theme-color.test.ts | 103 +++++ .../pm-adapter/src/marks/theme-color.ts | 105 +++++ .../style-engine/src/ooxml/index.test.ts | 415 ++++++++++++++++++ .../style-engine/src/ooxml/index.ts | 194 ++++++-- .../src/extensions/table/table.js | 21 +- .../src/extensions/table/table.test.js | 141 ++++++ .../table/tableHelpers/computeColumnWidths.js | 18 + .../tableHelpers/computeColumnWidths.test.js | 77 ++++ .../extensions/table/tableHelpers/index.js | 1 + .../tableHelpers/normalizeNewTableAttrs.js | 3 +- 13 files changed, 1165 insertions(+), 121 deletions(-) create mode 100644 packages/layout-engine/pm-adapter/src/marks/theme-color.test.ts create mode 100644 packages/layout-engine/pm-adapter/src/marks/theme-color.ts create mode 100644 packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.js create mode 100644 packages/super-editor/src/extensions/table/tableHelpers/computeColumnWidths.test.js 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 } }, }; } From c595be4d5eacaa6ebd6679197365613d643ba48a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 11:49:37 -0800 Subject: [PATCH 2/2] chore: add doc-api-stories test --- .../sd-2086-header-row-shading-api.docx | Bin 0 -> 13814 bytes .../tables/header-row-shading-roundtrip.ts | 236 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 tests/doc-api-stories/tests/tables/fixtures/sd-2086-header-row-shading-api.docx create mode 100644 tests/doc-api-stories/tests/tables/header-row-shading-roundtrip.ts 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 0000000000000000000000000000000000000000..4b0647a4b59b99123af61d25019ba5feee560d7b GIT binary patch literal 13814 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 { + 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); + }); +});