From 9a0a33f7513260880d1b29b7c19b5d1d5d33ad3d Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 12:45:47 -0300 Subject: [PATCH 01/17] fix(style-engine): surface table style base tcPr as the wholeTable layer A table style's base-level (e.g. ) is the wholeTable conditional layer per ECMA-376 17.7.6: Word paints it on every cell of a table referencing the style. The translator stores it on the style definition's own tableCellProperties, a sibling of tableStyleProperties, but resolveConditionalProps only read tableStyleProperties[region], so style-only cell fills were dropped and such tables rendered with no background. Collect the base-level tableCellProperties into the wholeTable chain while walking the basedOn hierarchy, ordered so an explicit tableStyleProperties.wholeTable entry still wins within one definition, a leaf style's base props beat any ancestor's, and inline cell shading wins over everything. Verified against the SD-3035 mutation fixtures: tblStyle_applied, style_plus_direct_border_overrides, and style_plus_width_interactions now paint F2F2F2 on all cells, matching Word's render pixel values. Banding and conditional-region fixtures (first/last row/column, banded rows or columns) are byte-identical before and after, since bands and regions sit above wholeTable in the cascade. Layout corpus compare: 476 docs, zero changes. --- .../style-engine/src/ooxml/index.test.ts | 86 +++++++++++++++++++ .../style-engine/src/ooxml/index.ts | 9 ++ 2 files changed, 95 insertions(+) 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 7cd3505210..0286290c44 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -886,6 +886,92 @@ describe('ooxml - resolveTableCellProperties basedOn tblStylePr inheritance', () }); }); +// ────────────────────────────────────────────────────────────────────────────── +// Style base-level tcPr as the wholeTable layer (ECMA-376 17.7.6, SD-3035) +// A table style's base-level is stored on the style +// def's own tableCellProperties (sibling of tableStyleProperties) and IS the +// wholeTable conditional layer. Word paints it on every cell. +// ────────────────────────────────────────────────────────────────────────────── + +describe('ooxml - style base-level tcPr surfaces as wholeTable (SD-3035)', () => { + const interiorCell = (styleId: string) => ({ + tableProperties: { tableStyleId: styleId, tblLook: { noHBand: true, noVBand: true } }, + rowIndex: 1, + cellIndex: 1, + numRows: 3, + numCells: 3, + }); + + it('resolves a base-level shading with no explicit wholeTable region', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'F2F2F2' }); + }); + + it('leaf base-level shading beats an ancestor base-level shading via basedOn', () => { + const styles = { + ...emptyStyles, + styles: { + BaseStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'AAAAAA' } }, + }, + LeafStyle: { + type: 'table', + basedOn: 'BaseStyle', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('LeafStyle'), styles); + expect(result.shading).toEqual({ fill: 'F2F2F2' }); + }); + + it('an explicit tableStyleProperties.wholeTable entry beats the base-level tcPr', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'BASE99' } }, + tableStyleProperties: { + wholeTable: { tableCellProperties: { shading: { fill: 'EXPL77' } } }, + }, + }, + }, + }; + const result = resolveTableCellProperties(null, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ fill: 'EXPL77' }); + }); + + it('inline cell shading still wins over the base-level wholeTable fill', () => { + const styles = { + ...emptyStyles, + styles: { + CondStyle: { + type: 'table', + tableProperties: {}, + tableCellProperties: { shading: { fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties({ shading: { fill: '4472C4' } }, interiorCell('CondStyle'), styles); + expect(result.shading).toEqual({ fill: '4472C4' }); + }); +}); + // ────────────────────────────────────────────────────────────────────────────── // cnfStyle supplementing index-based conditional type detection // ────────────────────────────────────────────────────────────────────────────── diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0791ee9472..0f28d8cbd4 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -483,6 +483,15 @@ function resolveConditionalProps( const def: StyleDefinition | undefined = translatedLinkedStyles.styles?.[currentId]; const props = def?.tableStyleProperties?.[styleType]?.[propertyType] as T | undefined; if (props) chain.push(props); + // ECMA-376 17.7.6: a table style's BASE-LEVEL (stored on the def's own + // tableCellProperties, a sibling of tableStyleProperties) IS the wholeTable + // conditional layer; Word paints e.g. its w:shd on every cell. Pushed after the + // explicit wholeTable entry so, post-reverse, the explicit entry still wins within + // one def while a leaf's base props beat any ancestor's. (SD-3035) + if (styleType === 'wholeTable' && propertyType === 'tableCellProperties') { + const baseProps = def?.tableCellProperties as T | undefined; + if (baseProps) chain.push(baseProps); + } currentId = def?.basedOn; } if (chain.length === 0) return undefined; From 5a94ece6e48e29f5523bd78653cacd4d0aa5c354 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 11:50:00 -0300 Subject: [PATCH 02/17] fix(painter): clamp double borders to CSS-visible width CSS `double` only renders two distinct rules when the border is at least 3px wide (1px rule + 1px gap + 1px rule). The painter emitted the authored width verbatim, so a typical w:val="double" w:sz="12" border (~2px) was collapsed by the browser into a single solid-looking line, while Word always renders two parallel rules for double borders. Clamp the rendered width up to 3px when the resolved CSS style is double, keeping authored widths that are already 3px or wider. Every border paint path funnels through applyBorder, so cell borders, outer table edges, and continuation rows are all covered by the one change. The prior unit test asserted the collapsed 2px output; the expectation flip to 3px is deliberate. Verified against the SD-3308 mutation fixture (double_or_dotted_borders.docx): the double table now shows two rules on every outer and interior edge, dotted and dashed render unchanged. --- .../painters/dom/src/table/border-utils.test.ts | 13 +++++++++++-- .../painters/dom/src/table/border-utils.ts | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index ebadd10b2d..da9cdca1a8 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -39,10 +39,19 @@ describe('applyBorder', () => { expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); - it('should apply border with double style', () => { + // SD-3308: CSS `double` only renders two distinct rules at >= 3px (1px rule + 1px gap + + // 1px rule); below that it collapses to a single solid-looking line, while Word always + // shows two rules for w:val="double". The painter clamps the width up, never down. + it('clamps a double border below 3px up so both rules render', () => { const border: BorderSpec = { style: 'double', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/2px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/3px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('keeps an authored double width that is already >= 3px', () => { + const border: BorderSpec = { style: 'double', width: 4, color: '#FF0000' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/4px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); it('should apply border with dashed style', () => { diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index 1285d0aae0..ccf9803796 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -83,7 +83,11 @@ export const applyBorder = ( const width = border.width ?? 1; const color = border.color ?? '#000000'; const safeColor = isValidHexColor(color) ? color : '#000000'; - const actualWidth = border.style === 'thick' ? Math.max(width * 2, 3) : width; + // CSS `double` only renders two distinct rules at >= 3px (1px rule + 1px gap + 1px rule); + // below that it collapses to a single solid-looking line. Word always shows two rules for + // w:val="double", so clamp the rendered width up (never shrink an authored width). (SD-3308) + const minStyleWidth = style === 'double' ? 3 : 0; + const actualWidth = border.style === 'thick' ? Math.max(width * 2, 3) : Math.max(width, minStyleWidth); element.style[`border${side}`] = `${actualWidth}px ${style} ${safeColor}`; }; From 0acd93b8d1149542d9d2793d274ede4191cc8485 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 13:30:05 -0300 Subject: [PATCH 03/17] fix(layout): double border band width and row reservation Word renders w:val="double" at three times the authored w:sz: sz is the width of EACH rule and the painted band is rule + gap + rule. Measured against Word output, the dotted sz12 (2px band) and double sz12 (6px band) tables have identical content regions and their row pitch differs by exactly the band delta, so Word reserves the full band height in the table's vertical extent. SuperDoc painted double borders at the floor width and reserved no row height, leaving fat bands a single thin line and, when widened, overlapping row content. Three coordinated changes: - contracts: add getBorderBandWidthPx, the single source of truth for rendered band width (none 0, thick 2x min 3px, double 3x min 3px, else authored width), so paint and measurement can never disagree. - painter: applyBorder delegates its width math to the shared helper and now emits the full 3x double band. - measuring: replace the drifted local copy of the width logic with the shared helper, and reserve per-gridline band heights in collapsed mode. Each row reserves its top gridline, the last row also the bottom edge, with row-level tblPrEx overrides and cell tcBorders included (max across candidates approximates the 17.4.66 winner). Bands at or below 2px reserve nothing, keeping hairline-border geometry byte-stable across the corpus; wider bands reserve band minus 1px, the same slack every bordered table already absorbs. Painter cells are border-box, so the reserved height lets band and content coexist exactly like Word. Verification: the double fixture renders 6px bands on all 12 edges with zero clipped lines and rows growing exactly by the reservation; Word row pitch on sd-2343-table-border-widths band-8 rows is 43px at 100dpi, ours moved from 33px to 41px. Layout corpus: 8/476 docs changed, six of them border fixtures with pure height diffs in the fix direction (the other two carry unrelated font-substitution noise vs the npm reference); visual compare passed. Suites: painter-dom 1244, measuring-dom 351 plus one pre-existing text-width flake also failing on the clean tree. --- .../contracts/src/border-band.ts | 36 +++++++++++ packages/layout-engine/contracts/src/index.ts | 3 + .../measuring/dom/src/index.test.ts | 52 ++++++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 61 +++++++++++++++---- .../dom/src/table/border-utils.test.ts | 19 ++++-- .../painters/dom/src/table/border-utils.ts | 12 ++-- 6 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 packages/layout-engine/contracts/src/border-band.ts diff --git a/packages/layout-engine/contracts/src/border-band.ts b/packages/layout-engine/contracts/src/border-band.ts new file mode 100644 index 0000000000..e58b46adea --- /dev/null +++ b/packages/layout-engine/contracts/src/border-band.ts @@ -0,0 +1,36 @@ +import type { TableBorderValue } from './index.js'; + +/** + * Rendered border band width in pixels for a table or cell border value. + * + * This is the SINGLE source of truth for how wide a border paints, shared by the + * DOM painter (CSS border width) and the measuring engine (row-height reservation) + * so geometry and paint never disagree. + * + * Width semantics per ECMA-376 / Word rendering: + * - `none`/nil (or explicit `{none:true}`) paint nothing: band 0. + * - `thick` paints a heavier single rule: 2x the authored width, min 3px. + * - `double` w:sz is the width of EACH rule; Word paints rule + gap + rule at ~3x + * that width (measured against Word output: sz12 = 1.5pt rules, ~4.5pt band). + * CSS `double` divides the border-width into thirds, so the band is 3x the + * authored width, floored at 3px so both rules always render (CSS collapses + * `double` below 3px into a single solid-looking line). (SD-3308) + * - Every other style paints at the authored width. + * + * @param value - Border value from table attrs (`TableBorderValue`) or a cell-side + * `BorderSpec` (the `{none:true}` marker form is also accepted). + * @returns Band width in pixels (always >= 0). + */ +export function getBorderBandWidthPx(value: TableBorderValue | null | undefined): number { + if (value == null) return 0; + if (typeof value !== 'object') return 0; + if ('none' in value && value.none) return 0; + const raw = value as { style?: string; width?: number; size?: number }; + if (raw.style === 'none') return 0; + const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; + const width = Math.max(0, w); + if (width === 0) return 0; + if (raw.style === 'thick') return Math.max(width * 2, 3); + if (raw.style === 'double') return Math.max(width * 3, 3); + return width; +} diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index afacffd35c..60adf79df4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -53,6 +53,9 @@ export { rescaleColumnWidths } from './table-column-rescale.js'; // Cell spacing resolution (moved from measuring-dom for cross-stage use) export { getCellSpacingPx } from './cell-spacing.js'; +// Border band width (single source of truth for painter CSS width + measuring row reservation) +export { getBorderBandWidthPx } from './border-band.js'; + // OOXML z-index normalization (moved from pm-adapter for cross-stage use) export { normalizeZIndex, diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index f6407cb251..b72a13ee85 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -4298,6 +4298,58 @@ describe('measureBlock', () => { }); }); + describe('border band row-height reservation (SD-3308)', () => { + const makeTable = (borderStyle: string, width: number): FlowBlock => + ({ + kind: 'table', + id: 'table-band', + rows: [0, 1].map((r) => ({ + id: `row-${r}`, + cells: [ + { + id: `cell-${r}-0`, + blocks: [ + { + kind: 'paragraph', + id: `para-${r}`, + runs: [{ text: 'X', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + })), + attrs: { + borders: { + top: { style: borderStyle, width }, + bottom: { style: borderStyle, width }, + left: { style: borderStyle, width }, + right: { style: borderStyle, width }, + insideH: { style: borderStyle, width }, + insideV: { style: borderStyle, width }, + }, + }, + }) as unknown as FlowBlock; + + it('reserves the band excess for double borders and nothing for hairline singles', async () => { + const single = await measureBlock(makeTable('single', 1), { maxWidth: 600 }); + const double = await measureBlock(makeTable('double', 2), { maxWidth: 600 }); + if (single.kind !== 'table' || double.kind !== 'table') throw new Error('expected table measures'); + // double sz12: band = 3 * 2 = 6px -> reservation 5px per gridline. + // row 0 owns its top gridline; the last row owns its top gridline plus the bottom edge. + expect(double.rows[0].height).toBeCloseTo(single.rows[0].height + 5, 5); + expect(double.rows[1].height).toBeCloseTo(single.rows[1].height + 10, 5); + }); + + it('does not reserve anything in separate-borders mode', async () => { + const base = makeTable('double', 2) as { attrs: Record }; + base.attrs.cellSpacing = { type: 'dxa', value: 60 }; + const separate = await measureBlock(base as unknown as FlowBlock, { maxWidth: 600 }); + const collapsed = await measureBlock(makeTable('double', 2), { maxWidth: 600 }); + if (separate.kind !== 'table' || collapsed.kind !== 'table') throw new Error('expected table measures'); + expect(separate.rows[0].height).toBeLessThan(collapsed.rows[0].height); + }); + }); + describe('autofit tables with colspan should not truncate grid columns', () => { const makeCell = (id: string) => ({ id, diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index f7d97ac1aa..5f4f68405e 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -61,6 +61,7 @@ import { type CellSpacing, type TableBorders, type TableBorderValue, + type CellBorders, EMPTY_SDT_PLACEHOLDER_TEXT, effectiveTableCellSpacing, isEmptySdtPlaceholderRun, @@ -190,21 +191,16 @@ const pxToTwips = (px: number): number => Math.round(px * TWIPS_PER_PX); // Canonical implementation moved to @superdoc/contracts; re-imported for local use and re-exported. export { getCellSpacingPx } from '@superdoc/contracts'; -import { getCellSpacingPx } from '@superdoc/contracts'; +import { getCellSpacingPx, getBorderBandWidthPx } from '@superdoc/contracts'; /** - * Returns the border width in pixels for a table border value (matches painter border-utils logic). - * Used so total table dimensions include outer border sizes and there is enough space for last row/column spacing. + * Returns the border band width in pixels for a table border value. + * Delegates to the shared contracts helper so this always matches the painter's + * rendered width (thick = 2x min 3px, double = 3x per-rule width min 3px). Used for + * outer table dimensions and per-row band reservation. */ function getTableBorderWidthPx(value: TableBorderValue | null | undefined): number { - if (value == null) return 0; - if (typeof value === 'object' && 'none' in value && value.none) return 0; - const raw = value as { style?: string; width?: number; size?: number }; - const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; - const width = Math.max(0, w); - if (raw.style === 'none') return 0; - if (raw.style === 'thick') return Math.max(width * 2, 3); - return width; + return getBorderBandWidthPx(value); } /** Computes outer table border widths in px from table attrs (for total dimensions and content offset). */ @@ -3143,6 +3139,49 @@ async function measureTableBlock( } } + // Reserve row height for fat border bands (collapsed mode). Word adds the full border + // band to the table's vertical extent: measured against Word output, the dotted sz12 + // (2px band) and double sz12 (6px band) tables have IDENTICAL content regions and their + // row pitch differs by exactly the band delta. The legacy model absorbed hairline bands + // (<= 2px) in line-height slack, so to keep thin-border geometry byte-stable we only + // reserve bands above that hairline class, minus the same 1px nibble every bordered + // table already absorbs. Painter cells are border-box, so reserved height lets the band + // and the content coexist exactly like Word. Attribution follows the single-owner paint + // model: each row reserves its TOP gridline, the last row also reserves the bottom edge. + // (SD-3308) + const isCollapsedForBands = + (block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse')) !== 'separate'; + if (isCollapsedForBands && block.rows.length > 0) { + const tableBordersForBands = block.attrs?.borders as TableBorders | null | undefined; + const bandReservation = (band: number): number => (band > 2 ? band - 1 : 0); + const gridlineBand = (gridline: number): number => { + let band = 0; + const rowAbove = gridline > 0 ? block.rows[gridline - 1] : undefined; + const rowBelow = gridline < block.rows.length ? block.rows[gridline] : undefined; + for (const row of [rowAbove, rowBelow]) { + if (!row) continue; + // Row-level tblPrEx overrides merge per edge onto the table borders (§17.4.61). + const override = row.attrs?.borders as TableBorders | null | undefined; + const eff = override ? { ...(tableBordersForBands ?? {}), ...override } : tableBordersForBands; + const value = gridline === 0 ? eff?.top : gridline === block.rows.length ? eff?.bottom : eff?.insideH; + band = Math.max(band, getBorderBandWidthPx(value)); + } + // Cell-level tcBorders on either side of the gridline; the §17.4.66 winner is the + // heavier border, so the max band across candidates is the painted band width. + for (const cell of rowAbove?.cells ?? []) { + band = Math.max(band, getBorderBandWidthPx((cell.attrs?.borders as CellBorders | undefined)?.bottom)); + } + for (const cell of rowBelow?.cells ?? []) { + band = Math.max(band, getBorderBandWidthPx((cell.attrs?.borders as CellBorders | undefined)?.top)); + } + return band; + }; + for (let i = 0; i < block.rows.length; i++) { + rowHeights[i] += bandReservation(gridlineBand(i)); + } + rowHeights[block.rows.length - 1] += bandReservation(gridlineBand(block.rows.length)); + } + // Apply explicit row heights (exact / atLeast) from row attributes block.rows.forEach((row, index) => { const spec = row.attrs?.rowHeight as { value?: number; rule?: string } | undefined; diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index da9cdca1a8..c469d6fc33 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -39,19 +39,26 @@ describe('applyBorder', () => { expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); - // SD-3308: CSS `double` only renders two distinct rules at >= 3px (1px rule + 1px gap + - // 1px rule); below that it collapses to a single solid-looking line, while Word always - // shows two rules for w:val="double". The painter clamps the width up, never down. - it('clamps a double border below 3px up so both rules render', () => { + // SD-3308: OOXML w:sz on a double border is the width of EACH rule; Word renders + // rule + gap + rule at ~3x that width (measured: sz12 = 1.5pt rules, 6px band at + // 100dpi). The shared contracts band helper emits 3x the authored single-rule + // width, floored at 3px so CSS renders both rules. + it('renders a double border at three times the authored rule width', () => { const border: BorderSpec = { style: 'double', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/6px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('floors a hairline double border at 3px so both rules render', () => { + const border: BorderSpec = { style: 'double', width: 1, color: '#FF0000' }; + applyBorder(element, 'Top', border); expect(element.style.borderTop).toMatch(/3px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); - it('keeps an authored double width that is already >= 3px', () => { + it('scales a heavy double border by the same three-times rule', () => { const border: BorderSpec = { style: 'double', width: 4, color: '#FF0000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/4px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/12px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); it('should apply border with dashed style', () => { diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index ccf9803796..490f52b58c 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -6,6 +6,7 @@ import type { TableBorders, TableFragment, } from '@superdoc/contracts'; +import { getBorderBandWidthPx } from '@superdoc/contracts'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; const ALLOWED_BORDER_STYLES = new Set([ @@ -80,14 +81,13 @@ export const applyBorder = ( } const style = borderStyleToCSS(border.style); - const width = border.width ?? 1; const color = border.color ?? '#000000'; const safeColor = isValidHexColor(color) ? color : '#000000'; - // CSS `double` only renders two distinct rules at >= 3px (1px rule + 1px gap + 1px rule); - // below that it collapses to a single solid-looking line. Word always shows two rules for - // w:val="double", so clamp the rendered width up (never shrink an authored width). (SD-3308) - const minStyleWidth = style === 'double' ? 3 : 0; - const actualWidth = border.style === 'thick' ? Math.max(width * 2, 3) : Math.max(width, minStyleWidth); + // Band width comes from the shared contracts helper so the painted width and the + // measuring engine's row-height reservation can never disagree. Word semantics: + // thick = 2x (min 3px); double = 3x the per-rule w:sz (min 3px so CSS renders both + // rules); everything else = authored width. (SD-3308) + const actualWidth = getBorderBandWidthPx(border); element.style[`border${side}`] = `${actualWidth}px ${style} ${safeColor}`; }; From 26a163e2341b8ac70c33e6d44a55deb6f7cda430 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 14:20:23 -0300 Subject: [PATCH 04/17] fix(measuring): content-size pure-auto tables like Word A pure-auto table (autofit layout, auto/nil/absent tblW, and no cell anywhere carrying a concrete width preference) was pinned to its authored grid sum by the auto-grid width budget, stretching it to the grid width. Word recomputes layout for these tables on open and content-sizes them: each column takes its max-content width and the table ends at the content demand, capped by the available width. The stored w:tblGrid is only a Word layout cache for organically authored documents, which always carry tcW; a grid with no width preferences anywhere is not authoritative. Scope is deliberately narrow so every grid-trusting path keeps its proven behavior: tables claimed by preserveAutoGrid (non-uniform grids), preserveExplicitAutoGrid, explicit tblW, any concrete tcW, or grids wider than the available width (preserved overflow per SD-1239 and the overhang behavior) are untouched. The layout corpus confirms the blast radius: zero corpus documents change, since every real-world pure-auto table has a non-uniform cached grid. On the content-size path, columns also reserve their owned vertical border band (left gridline, last column also the right edge) since border-box cells subtract the band from the text box, and spanning cells top up their covered columns to their max-content demand so span text keeps Word's line breaks. Two legacy tests asserted the grid pin for pure-auto shapes and were updated to the Word behavior with their regression intent preserved (synthesized runtime columns, no upscaling beyond content). The SD-1239 wide-grid preservation test passes unchanged. Verified against fresh Word renders of the showcase fixture: all five sections now match, including the auto double-border table (Word ~165px, ours 157px) and the gridSpan/vMerge table (Word ~250px one line per cell, ours 252px one line per cell). Fixture gate auto_or_nil_widths content-sizes to 124px. Suites: measuring-dom 351 passing plus the pre-existing text-width flake, painter-dom 1244. --- .../measuring/dom/src/autofit-columns.ts | 28 ++++- .../measuring/dom/src/autofit-normalize.ts | 112 +++++++++++++++++- .../measuring/dom/src/index.test.ts | 16 ++- 3 files changed, 145 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/autofit-columns.ts b/packages/layout-engine/measuring/dom/src/autofit-columns.ts index d2f1e59e46..8c1afae197 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-columns.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-columns.ts @@ -254,7 +254,33 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { targetTableWidth = Math.min(targetTableWidth, maxResolvedTableWidth); } else { targetTableWidth = Math.min(targetTableWidth, maxResolvedTableWidth); - if (!shouldPreservePreferredGrid) { + if (workingInput.contentSizeAutoTable === true) { + // Pure-auto tables content-size like Word: each column takes its max-content + // width and the table ends at the content demand, capped by the available + // width (the shrink below redistributes overflow). The authored grid sum is + // deliberately ignored here; it is not a Word layout cache for this shape. + // (SD-3309) + const columnBandAllowances = workingInput.columnBandAllowances; + resolvedWidths = maxBounds.map( + (max, index) => Math.max(max, minBounds[index]) + (columnBandAllowances?.[index] ?? 0), + ); + // Spanning cells must keep their max-content demand: the proportional spread in + // applyMultiSpanMaximums can leave the covered columns collectively short (span + // padding is per cell, not per column), which wraps the span text where Word + // keeps one line. Top up the covered columns evenly. (SD-3309) + for (const spanCell of multiSpanCells) { + const covered = resolvedWidths.slice(spanCell.startColumn, spanCell.startColumn + spanCell.span); + const currentTotal = sumWidths(covered); + const demand = spanCell.preferredWidth ?? spanCell.maxContentWidth; + if (currentTotal < demand && covered.length > 0) { + const topUp = (demand - currentTotal) / covered.length; + for (let index = 0; index < covered.length; index++) { + resolvedWidths[spanCell.startColumn + index] += topUp; + } + } + } + targetTableWidth = Math.min(sumWidths(resolvedWidths), maxResolvedTableWidth); + } else if (!shouldPreservePreferredGrid) { resolvedWidths = redistributeTowardMaximumsWithinCurrentTable(resolvedWidths, minBounds, maxBounds); resolvedWidths = redistributeTowardContentWeightedShape(resolvedWidths, minBounds, maxBounds); } diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts index 8b2bcb4c12..75fe4275f5 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts @@ -1,5 +1,5 @@ -import type { TableBlock, TableRowProperties, TableWidthAttr } from '@superdoc/contracts'; -import { OOXML_PCT_DIVISOR, resolveTableWidthAttr } from '@superdoc/contracts'; +import type { TableBlock, TableBorders, TableRowProperties, TableWidthAttr } from '@superdoc/contracts'; +import { OOXML_PCT_DIVISOR, getBorderBandWidthPx, resolveTableWidthAttr } from '@superdoc/contracts'; import type { AutoFitCellInput, AutoFitLayoutMode, @@ -75,6 +75,20 @@ export type WorkingTableGridInput = { * force growth. */ preserveExplicitAutoGrid?: boolean; + /** + * Pure-auto tables (autofit layout, auto/nil/absent tblW, and no cell anywhere + * carrying a concrete width preference) content-size like Word: the stored grid + * is not a Word layout cache for these, so the solver targets content demand + * instead of the authored grid sum. (SD-3309) + */ + contentSizeAutoTable?: boolean; + /** + * Per-column vertical border band allowances for content-sized pure-auto tables: + * each column owns its LEFT gridline band, the last column also the right edge + * (single-owner model). Border-box cells subtract these from the text box, so + * content-sized columns must reserve them or text wraps earlier than Word. (SD-3309) + */ + columnBandAllowances?: number[]; /** * AutoFit tables with auto-width semantics and a complete authored grid that * fits the available width should use the grid sum as their outer width @@ -186,14 +200,29 @@ export function buildAutoFitWorkingGridInput( gridColumnCount, rows, }); - const autoGridWidthBudget = resolveAutoGridWidthBudget({ + const contentSizeAutoTable = resolveContentSizeAutoTable({ layoutMode, tableWidth, - preferredColumnWidths, preferredTableWidth, - gridColumnCount, + preferredColumnWidths, maxTableWidth, + rows, + preserveAutoGrid, + preserveExplicitAutoGrid, }); + const columnBandAllowances = contentSizeAutoTable + ? resolveColumnBandAllowances(block.attrs?.borders as TableBorders | null | undefined, gridColumnCount) + : undefined; + const autoGridWidthBudget = contentSizeAutoTable + ? undefined + : resolveAutoGridWidthBudget({ + layoutMode, + tableWidth, + preferredColumnWidths, + preferredTableWidth, + gridColumnCount, + maxTableWidth, + }); return { layoutMode, @@ -202,6 +231,8 @@ export function buildAutoFitWorkingGridInput( ...(preserveAutoGrid ? { preserveAutoGrid } : {}), ...(preserveExplicitAutoGrid ? { preserveExplicitAutoGrid } : {}), ...(autoGridWidthBudget != null ? { autoGridWidthBudget } : {}), + ...(contentSizeAutoTable ? { contentSizeAutoTable } : {}), + ...(columnBandAllowances ? { columnBandAllowances } : {}), preferredTableWidth, preferredColumnWidths, gridColumnCount, @@ -264,6 +295,77 @@ function shouldPreserveExplicitAutoGrid(args: { return approximatelyEqual(sumWidths(preferredColumnWidths), preferredTableWidth); } +/** + * A "pure auto" table: autofit layout, auto/nil/absent tblW, and no cell anywhere + * carrying a concrete width preference. For these the stored w:tblGrid is not a + * Word layout cache (Word recomputes and content-sizes such tables on open), so + * the solver should target content demand instead of the authored grid sum. + * Tables already claimed by a preserve policy keep that behavior. (SD-3309) + */ +function resolveContentSizeAutoTable(args: { + layoutMode: AutoFitLayoutMode; + tableWidth: TableWidthAttr | undefined; + preferredTableWidth: number | undefined; + preferredColumnWidths: number[]; + maxTableWidth: number; + rows: WorkingTableRowInput[]; + preserveAutoGrid: boolean; + preserveExplicitAutoGrid: boolean; +}): boolean { + const { + layoutMode, + tableWidth, + preferredTableWidth, + preferredColumnWidths, + maxTableWidth, + rows, + preserveAutoGrid, + preserveExplicitAutoGrid, + } = args; + if (layoutMode !== 'autofit') return false; + if (preferredTableWidth != null) return false; + if (preserveAutoGrid || preserveExplicitAutoGrid) return false; + if (!isAutoOrNilTableWidth(tableWidth)) return false; + if (hasConcreteCellWidthRequest(rows)) return false; + // An authored grid WIDER than the available width is preserved as an overflow + // (overhang) table; Word keeps those wide (SD-1239, SD-1513). Content sizing + // only applies when the grid fits the page. + if (sumWidths(preferredColumnWidths) > maxTableWidth + 0.5) return false; + return true; +} + +/** Auto-width semantics for content sizing: absent tblW, type=auto with no positive width, or type=nil. */ +function isAutoOrNilTableWidth(tableWidth: TableWidthAttr | undefined): boolean { + if (tableWidth == null) return true; + if (hasAutoTableWidthSemantics(tableWidth)) return true; + if (typeof tableWidth === 'object' && typeof tableWidth.type === 'string') { + return tableWidth.type.toLowerCase() === 'nil'; + } + return false; +} + +/** + * Vertical border band widths owed per column on the content-size path. Table-level + * borders only (left edge, insideV dividers, right edge); cell-level vertical + * variation is rare in pure-auto tables and at most under-reserves slightly. + */ +function resolveColumnBandAllowances( + borders: TableBorders | null | undefined, + gridColumnCount: number, +): number[] | undefined { + if (gridColumnCount <= 0) return undefined; + const left = getBorderBandWidthPx(borders?.left); + const insideV = getBorderBandWidthPx(borders?.insideV); + const right = getBorderBandWidthPx(borders?.right); + const allowances: number[] = []; + for (let i = 0; i < gridColumnCount; i++) { + let allowance = i === 0 ? left : insideV; + if (i === gridColumnCount - 1) allowance += right; + allowances.push(allowance); + } + return allowances.some((a) => a > 0) ? allowances : undefined; +} + function resolveAutoGridWidthBudget(args: { layoutMode: AutoFitLayoutMode; tableWidth: TableWidthAttr | undefined; diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index b72a13ee85..2241205a1c 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -3952,7 +3952,7 @@ describe('measureBlock', () => { expect(Math.abs(measure.columnWidths[0] - measure.columnWidths[1])).toBeLessThan(1); }); - it('preserves authored widths and synthesizes runtime widths for missing logical columns', async () => { + it('synthesizes runtime widths for missing logical columns and content-sizes pure-auto tables', async () => { const block: FlowBlock = { kind: 'table', id: 'table-3', @@ -4017,7 +4017,10 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); expect(measure.columnWidths).toHaveLength(3); expect(measure.columnWidths[0]).toBeGreaterThan(0); - expect(measure.columnWidths[1]).toBeGreaterThan(measure.columnWidths[0]); + // SD-3309: pure-auto tables (auto tblW, no tcW anywhere) content-size like Word; + // the partial authored grid is not a layout cache, so equal content gives equal columns. + expect(measure.columnWidths[1]).toBeGreaterThan(0); + expect(measure.totalWidth).toBeLessThan(250); expect(measure.columnWidths[2]).toBeGreaterThan(0); expect(measure.totalWidth).toBeCloseTo( measure.columnWidths.reduce((sum, width) => sum + width, 0), @@ -4553,7 +4556,7 @@ describe('measureBlock', () => { expect(measure.totalWidth).toBe(300); }); - it('does not scale when widths are within target', async () => { + it('does not upscale a pure-auto table beyond its content', async () => { const block: FlowBlock = { kind: 'table', id: 'scale-test-2', @@ -4593,8 +4596,11 @@ describe('measureBlock', () => { if (measure.kind !== 'table') throw new Error('expected table measure'); // Auto layout preserves explicit widths (no scale-up) - expect(measure.columnWidths).toEqual([50, 50]); - expect(measure.totalWidth).toBe(100); + // SD-3309: pure-auto tables content-size like Word; equal content gives equal + // columns and the table never upscales toward the authored grid sum. + expect(Math.abs(measure.columnWidths[0] - measure.columnWidths[1])).toBeLessThan(1); + expect(measure.totalWidth).toBeLessThanOrEqual(100); + expect(measure.totalWidth).toBeGreaterThan(0); }); it('produces exact sum after rounding adjustment', async () => { From 32c0c9b142f04e90555f853410a6be557afa8d61 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 14:30:48 -0300 Subject: [PATCH 05/17] fix(painter): paint double borders as pixel-snapped strip overlays CSS double borders miter diagonally where two sides of the same element meet and land on fractional device pixels (table row heights are fractional), so the two rules rendered with uneven weights and notched corner joins. Word draws both rules at even weight and crosses them squarely at junctions; verified against 300dpi Word probe renders (1x1, 1x2, 2x1 double-border tables), which also confirm the macro geometry is one rule-gap-rule band per edge with shared interior edges drawn once, exactly the existing single-owner model. Cells keep their CSS double border with a TRANSPARENT color so border-box layout (content inset and band reservation) is untouched; the visible rules are absolutely positioned strip overlays per owned edge, snapped to integer pixels, painted as two solid rules with the band gap between. Strips from adjacent owned edges overlap squarely at corners, matching Word's junctions. Verified at 3x zoom against the Word render: even rule weights, square crossings, identical band structure. painter-dom suite: 1245 passing. --- .../dom/src/table/renderTableRow.test.ts | 28 ++++++++ .../painters/dom/src/table/renderTableRow.ts | 67 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 7062f0569b..68a4a88ff0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -285,6 +285,34 @@ describe('renderTableRow', () => { expect(secondCall.borders?.left).toBeDefined(); }); + // SD-3308: double borders paint as pixel-snapped strip overlays (rule + gap + rule) + // with square junctions like Word; the cell keeps a transparent CSS double border so + // border-box layout is unchanged. + it('paints double borders as strip overlays and hides the cell CSS border paint', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'double', width: 2, color: '#000000' }, + bottom: { style: 'double', width: 2, color: '#000000' }, + left: { style: 'double', width: 2, color: '#000000' }, + right: { style: 'double', width: 2, color: '#000000' }, + }, + }) as never, + ); + + const strips = container.querySelectorAll('.superdoc-double-border-strip'); + expect(strips.length).toBe(4); + const top = [...strips].find((el) => (el as HTMLElement).style.borderTop !== ''); + expect(top).toBeDefined(); + expect((top as HTMLElement).style.height).toBe('6px'); + expect((top as HTMLElement).style.borderTop).toMatch(/2px solid/); + const cellArgs = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: { style?: string } } }; + expect(cellArgs.borders?.top?.style).toBe('double'); + }); + // SD-1797: a single row's measure only lists cells that START in it, so on a w:vMerge // (rowspan) continuation row the columns held by a cell spanning from above look empty. // `rowOccupiedRightCol` / `nextRowOccupiedRightCol` count that occupancy so the single-owner diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 04cfcc4aa4..77e84ecb94 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -9,6 +9,7 @@ import type { TableBorders, TableMeasure, } from '@superdoc/contracts'; +import { getBorderBandWidthPx } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { renderTableCell } from './renderTableCell.js'; import { @@ -363,6 +364,66 @@ type TableRowRenderDependencies = { * // Appends all cell elements to container * ``` */ +/** + * Paints a cell's double borders as pixel-snapped strip overlays (rule + gap + rule) + * instead of CSS borders. CSS double borders miter diagonally at element corners and + * land on fractional device pixels (row heights are fractional), which renders uneven + * rule weights and notched joins; Word draws both rules at even weight and crosses + * them squarely at junctions. The cell keeps its CSS double border with a TRANSPARENT + * color so border-box layout (content inset, band reservation) is unchanged, and the + * visible rules come from the strips. (SD-3308) + */ +const appendDoubleBorderStrips = ( + doc: Document, + container: HTMLElement, + cellElement: HTMLElement, + borders: CellBorders | undefined, + rect: { x: number; y: number; width: number; height: number }, +): void => { + if (!borders) return; + const sides: Array<['top' | 'right' | 'bottom' | 'left', 'Top' | 'Right' | 'Bottom' | 'Left']> = [ + ['top', 'Top'], + ['right', 'Right'], + ['bottom', 'Bottom'], + ['left', 'Left'], + ]; + const x0 = Math.round(rect.x); + const y0 = Math.round(rect.y); + const x1 = Math.round(rect.x + rect.width); + const y1 = Math.round(rect.y + rect.height); + for (const [side, cssSide] of sides) { + const spec = borders[side]; + if (!spec || spec.style !== 'double') continue; + const band = Math.max(3, Math.round(getBorderBandWidthPx(spec))); + const rule = Math.max(1, Math.round(band / 3)); + const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; + // Keep the layout border, hide its paint. + cellElement.style[`border${cssSide}Color`] = 'transparent'; + const strip = doc.createElement('div'); + strip.className = 'superdoc-double-border-strip'; + const st = strip.style; + st.position = 'absolute'; + st.boxSizing = 'border-box'; + st.pointerEvents = 'none'; + if (side === 'top' || side === 'bottom') { + st.left = `${x0}px`; + st.width = `${x1 - x0}px`; + st.height = `${band}px`; + st.top = side === 'top' ? `${y0}px` : `${y1 - band}px`; + st.borderTop = `${rule}px solid ${color}`; + st.borderBottom = `${rule}px solid ${color}`; + } else { + st.top = `${y0}px`; + st.height = `${y1 - y0}px`; + st.width = `${band}px`; + st.left = side === 'left' ? `${x0}px` : `${x1 - band}px`; + st.borderLeft = `${rule}px solid ${color}`; + st.borderRight = `${rule}px solid ${color}`; + } + container.appendChild(strip); + } +}; + export const renderTableRow = (deps: TableRowRenderDependencies): void => { const { doc, @@ -687,5 +748,11 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { }); container.appendChild(cellElement); + appendDoubleBorderStrips(doc, container, cellElement, finalBorders, { + x, + y, + width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), + height: cellHeight, + }); } }; From f1bed4bde3ade7eededbeb245268f69fdf9fb23a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 14:41:51 -0300 Subject: [PATCH 06/17] fix(painter): render double borders as Word's nested rectangles Word does not paint w:val="double" as a self-contained two-rule band per edge. Magnified 300dpi renders of the same file show it decomposes the double border into CLOSED RECTANGLES: one single-rule outline at the table boundary plus one complete single-rule inner rectangle per cell, the band's gap separating them, rules joined squarely at the corners. A per-edge band (CSS double or strip overlays) puts the rules in the right positions but the wrong connectivity: junctions render as crossings instead of nested boxes, which reads entirely differently when zoomed. Replace the strip overlays with the Word decomposition: each cell paints one inner rectangle with ALL FOUR sides sourced from the cell's EFFECTIVE borders (the single-owner-suppressed set only describes who paints a shared band, not which sides exist; every surrounding double edge contributes a side to the rectangle). Ownership picks the band face: rules sit inset band minus rule on sides whose band lives in this cell, and extend rule px past the box on interior sides whose band lives in the neighbor, so the two rules of a shared band land exactly on its faces with per-cell attribution. The table fragment paints the outline rule at the boundary for double outer borders, skipping broken edges on continuation fragments. Cells keep a transparent CSS double border so border-box layout, content insets, and the row band reservation are unchanged. Verified at 3x zoom against the 300dpi Word render of the same file: outer outline, four closed per-cell boxes, square joins, rules on the band faces. painter-dom suite: 1245 passing. --- .../dom/src/table/renderTableFragment.ts | 36 ++++- .../dom/src/table/renderTableRow.test.ts | 24 +-- .../painters/dom/src/table/renderTableRow.ts | 144 ++++++++++++------ 3 files changed, 144 insertions(+), 60 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 38989cf2b5..52d775c88d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -8,7 +8,7 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; -import { getTableVisualDirection } from '@superdoc/contracts'; +import { getTableVisualDirection, getBorderBandWidthPx } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; @@ -689,5 +689,39 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement y += actualRowHeight + cellSpacingPx; } + // Word paints a double table border as an outer OUTLINE rule at the table boundary + // plus each cell's inner rectangle (see appendDoubleBorderInnerRect). Paint the + // outline here for table-level double outer borders; continuation fragments skip the + // broken edge. (SD-3308) + { + const sides = [ + ['top', tableBorders?.top, fragment.continuesFromPrev !== true], + ['right', isRtl ? tableBorders?.left : tableBorders?.right, true], + ['bottom', tableBorders?.bottom, fragment.continuesOnNext !== true], + ['left', isRtl ? tableBorders?.right : tableBorders?.left, true], + ] as const; + let outlineEl: HTMLElement | null = null; + for (const [side, value, enabled] of sides) { + if (!enabled || value == null || typeof value !== 'object') continue; + const spec = value as { style?: string; color?: string }; + if (spec.style !== 'double') continue; + const band = Math.max(3, Math.round(getBorderBandWidthPx(value))); + const rule = Math.max(1, Math.round(band / 3)); + const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; + if (!outlineEl) { + outlineEl = doc.createElement('div'); + outlineEl.className = 'superdoc-double-border-outline'; + const st = outlineEl.style; + st.position = 'absolute'; + st.inset = '0'; + st.boxSizing = 'border-box'; + st.pointerEvents = 'none'; + container.appendChild(outlineEl); + } + const cssSide = side[0].toUpperCase() + side.slice(1); + outlineEl.style[`border${cssSide}` as 'borderTop'] = `${rule}px solid ${color}`; + } + } + return container; }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 68a4a88ff0..af28f19b3b 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -285,10 +285,10 @@ describe('renderTableRow', () => { expect(secondCall.borders?.left).toBeDefined(); }); - // SD-3308: double borders paint as pixel-snapped strip overlays (rule + gap + rule) - // with square junctions like Word; the cell keeps a transparent CSS double border so - // border-box layout is unchanged. - it('paints double borders as strip overlays and hides the cell CSS border paint', () => { + // SD-3308: double borders paint as a single-rule inner rectangle per cell (Word + // model: closed boxes with square L-joins, verified against 300dpi Word renders); + // the cell keeps a transparent CSS double border so border-box layout is unchanged. + it('paints double borders as a per-cell inner rectangle and hides the CSS border paint', () => { renderTableRow( createDeps({ rowIndex: 0, @@ -303,12 +303,16 @@ describe('renderTableRow', () => { }) as never, ); - const strips = container.querySelectorAll('.superdoc-double-border-strip'); - expect(strips.length).toBe(4); - const top = [...strips].find((el) => (el as HTMLElement).style.borderTop !== ''); - expect(top).toBeDefined(); - expect((top as HTMLElement).style.height).toBe('6px'); - expect((top as HTMLElement).style.borderTop).toMatch(/2px solid/); + const rects = container.querySelectorAll('.superdoc-double-border-rect'); + expect(rects.length).toBe(1); + const rect = rects[0] as HTMLElement; + // band 6, rule 2: the inner-face rule sits band - rule = 4px inside the owned edges. + expect(rect.style.left).toBe('4px'); + expect(rect.style.top).toBe('4px'); + expect(rect.style.borderTop).toMatch(/2px solid/); + expect(rect.style.borderBottom).toMatch(/2px solid/); + expect(rect.style.borderLeft).toMatch(/2px solid/); + expect(rect.style.borderRight).toMatch(/2px solid/); const cellArgs = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: { style?: string } } }; expect(cellArgs.borders?.top?.style).toBe('double'); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 77e84ecb94..52a7f8381a 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -365,63 +365,76 @@ type TableRowRenderDependencies = { * ``` */ /** - * Paints a cell's double borders as pixel-snapped strip overlays (rule + gap + rule) - * instead of CSS borders. CSS double borders miter diagonally at element corners and - * land on fractional device pixels (row heights are fractional), which renders uneven - * rule weights and notched joins; Word draws both rules at even weight and crosses - * them squarely at junctions. The cell keeps its CSS double border with a TRANSPARENT - * color so border-box layout (content inset, band reservation) is unchanged, and the - * visible rules come from the strips. (SD-3308) + * Paints a cell's double borders the way Word does: as a single-rule INNER RECTANGLE + * per cell, connected with square L-joins at the corners (verified against 300dpi Word + * renders of the same file). The band's two rules sit at its faces; the inner face rule + * belongs to this cell's rectangle and the outer face rule belongs to the table outline + * (outer edges) or to the neighboring cell's rectangle (interior edges). CSS double + * borders cannot do this: they miter diagonally and their band hugs the owning cell, so + * junctions render as crossings instead of closed boxes. + * + * The cell keeps its CSS double border with a TRANSPARENT color so border-box layout + * (content inset, band reservation) is unchanged. For each double side, the rectangle's + * rule sits at the inner face of that side's band: inset (band - rule) on sides whose + * band lives in this cell (top/left always, bottom/right at table boundaries), and + * extended rule px past the box on interior bottom/right sides whose band lives in the + * neighboring cell. The table outline rules are painted by renderTableFragment. (SD-3308) */ -const appendDoubleBorderStrips = ( +const appendDoubleBorderInnerRect = ( doc: Document, container: HTMLElement, cellElement: HTMLElement, borders: CellBorders | undefined, rect: { x: number; y: number; width: number; height: number }, + ownsBottomBand: boolean, + ownsRightBand: boolean, ): void => { if (!borders) return; - const sides: Array<['top' | 'right' | 'bottom' | 'left', 'Top' | 'Right' | 'Bottom' | 'Left']> = [ - ['top', 'Top'], - ['right', 'Right'], - ['bottom', 'Bottom'], - ['left', 'Left'], - ]; - const x0 = Math.round(rect.x); - const y0 = Math.round(rect.y); - const x1 = Math.round(rect.x + rect.width); - const y1 = Math.round(rect.y + rect.height); - for (const [side, cssSide] of sides) { + const sideInfo = (['top', 'right', 'bottom', 'left'] as const).map((side) => { const spec = borders[side]; - if (!spec || spec.style !== 'double') continue; + if (!spec || spec.style !== 'double') return null; const band = Math.max(3, Math.round(getBorderBandWidthPx(spec))); const rule = Math.max(1, Math.round(band / 3)); const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; - // Keep the layout border, hide its paint. + return { side, band, rule, color }; + }); + if (!sideInfo.some(Boolean)) return; + + const x0 = Math.round(rect.x); + const y0 = Math.round(rect.y); + const x1 = Math.round(rect.x + rect.width); + const y1 = Math.round(rect.y + rect.height); + + // Hide the CSS paint for double sides, keep the layout band. + for (const info of sideInfo) { + if (!info) continue; + const cssSide = (info.side[0].toUpperCase() + info.side.slice(1)) as 'Top' | 'Right' | 'Bottom' | 'Left'; cellElement.style[`border${cssSide}Color`] = 'transparent'; - const strip = doc.createElement('div'); - strip.className = 'superdoc-double-border-strip'; - const st = strip.style; - st.position = 'absolute'; - st.boxSizing = 'border-box'; - st.pointerEvents = 'none'; - if (side === 'top' || side === 'bottom') { - st.left = `${x0}px`; - st.width = `${x1 - x0}px`; - st.height = `${band}px`; - st.top = side === 'top' ? `${y0}px` : `${y1 - band}px`; - st.borderTop = `${rule}px solid ${color}`; - st.borderBottom = `${rule}px solid ${color}`; - } else { - st.top = `${y0}px`; - st.height = `${y1 - y0}px`; - st.width = `${band}px`; - st.left = side === 'left' ? `${x0}px` : `${x1 - band}px`; - st.borderLeft = `${rule}px solid ${color}`; - st.borderRight = `${rule}px solid ${color}`; - } - container.appendChild(strip); } + + const [top, right, bottom, left] = sideInfo; + const rectEl = doc.createElement('div'); + rectEl.className = 'superdoc-double-border-rect'; + const st = rectEl.style; + st.position = 'absolute'; + st.boxSizing = 'border-box'; + st.pointerEvents = 'none'; + // Inner-face placement per side. Owned band: rule sits band-rule inside the box. + // Neighbor-owned band (interior bottom/right): rule sits at the band's near face, + // which is rule px past this cell's box inside the neighbor. + const topInset = top ? top.band - top.rule : 0; + const leftInset = left ? left.band - left.rule : 0; + const bottomInset = bottom ? (ownsBottomBand ? bottom.band - bottom.rule : -bottom.rule) : 0; + const rightInset = right ? (ownsRightBand ? right.band - right.rule : -right.rule) : 0; + st.left = `${x0 + leftInset}px`; + st.top = `${y0 + topInset}px`; + st.width = `${x1 - x0 - leftInset - rightInset}px`; + st.height = `${y1 - y0 - topInset - bottomInset}px`; + if (top) st.borderTop = `${top.rule}px solid ${top.color}`; + if (bottom) st.borderBottom = `${bottom.rule}px solid ${bottom.color}`; + if (left) st.borderLeft = `${left.rule}px solid ${left.color}`; + if (right) st.borderRight = `${right.rule}px solid ${right.color}`; + container.appendChild(rectEl); }; export const renderTableRow = (deps: TableRowRenderDependencies): void => { @@ -748,11 +761,44 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { }); container.appendChild(cellElement); - appendDoubleBorderStrips(doc, container, cellElement, finalBorders, { - x, - y, - width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), - height: cellHeight, - }); + const cellGridBounds = getTableCellGridBounds(cellPosition); + // Word's double model needs the EFFECTIVE border of every side of this cell, + // not the single-owner-suppressed set: ownership picks which band face the rule + // sits on, but every surrounding double edge contributes a side to this cell's + // rectangle. (SD-3308) + const cb = (cellBordersAttr ?? {}) as CellBorders; + const effectiveSideSpecs: CellBorders = { + top: + cellGridBounds.touchesTopEdge || continuesFromPrev === true + ? resolveTableBorderValue(cb.top, effectiveTableBorders?.top) + : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? + borderValueToSpec(effectiveTableBorders?.insideH)), + bottom: + cellGridBounds.touchesBottomEdge || continuesOnNext === true + ? resolveTableBorderValue(cb.bottom, effectiveTableBorders?.bottom) + : (resolveBorderConflict(cb.bottom, undefined) ?? borderValueToSpec(effectiveTableBorders?.insideH)), + left: cellGridBounds.touchesLeftEdge + ? resolveTableBorderValue(cb.left, effectiveTableBorders?.left) + : (resolveBorderConflict(cb.left, leftCellBorders?.right) ?? borderValueToSpec(effectiveTableBorders?.insideV)), + right: cellGridBounds.touchesRightEdge + ? resolveTableBorderValue(cb.right, effectiveTableBorders?.right) + : (resolveBorderConflict(cb.right, rightCellBorders?.left) ?? + borderValueToSpec(effectiveTableBorders?.insideV)), + }; + const rectBorders = isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs; + appendDoubleBorderInnerRect( + doc, + container, + cellElement, + rectBorders, + { + x, + y, + width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), + height: cellHeight, + }, + cellGridBounds.touchesBottomEdge || continuesOnNext === true, + cellGridBounds.touchesRightEdge, + ); } }; From 960937b6646e42e019fa64317b288201b2ef0211 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 16:54:15 -0300 Subject: [PATCH 07/17] feat(layout): render compound table border bands as nested rectangles Word renders triple and the nine thinThick* ST_Border styles as multi-rule bands composed of nested rectangles, measured from 300dpi Word probes at sz 4/12/24: - triple: [w, w, w, w, w] - thinThickSmallGap: [w, 0.75pt, 0.75pt] (thickThin mirrors) - thinThickMediumGap: [w, w/2, w/2] (mirrors) - thinThickLargeGap: [1.5pt, w, 0.75pt] (gap scales; mirrors) - thinThickThin*: thin rules around a scaled center per family contracts getBorderBandProfile encodes the measured segments and becomes the band source of truth (getBorderBandWidthPx delegates, double unchanged). The painter generalizes the double-border nested-rectangle path: outline paints the outer-face rule, the per-cell inner rectangle paints the inner-face rule, and 3-rule bands add a middle rectangle inset by outer rule + gap so corners join cleanly. dashSmallGap routes to css dashed (same approximation class as dotDash). The layout-adapter whitelists and 17.4.66 weight tables cover the new styles. (SD-3308) --- .../contracts/src/border-band.test.ts | 135 ++++++++++++++++++ .../contracts/src/border-band.ts | 69 ++++++++- packages/layout-engine/contracts/src/index.ts | 13 +- .../dom/src/table/border-utils.test.ts | 37 ++++- .../painters/dom/src/table/border-utils.ts | 43 ++++++ .../dom/src/table/renderTableFragment.ts | 18 +-- .../dom/src/table/renderTableRow.test.ts | 76 +++++++++- .../painters/dom/src/table/renderTableRow.ts | 107 +++++++++----- .../core/layout-adapter/attributes/borders.ts | 10 ++ .../core/layout-adapter/converters/table.ts | 20 +++ 10 files changed, 476 insertions(+), 52 deletions(-) create mode 100644 packages/layout-engine/contracts/src/border-band.test.ts diff --git a/packages/layout-engine/contracts/src/border-band.test.ts b/packages/layout-engine/contracts/src/border-band.test.ts new file mode 100644 index 0000000000..7617982a8b --- /dev/null +++ b/packages/layout-engine/contracts/src/border-band.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { getBorderBandProfile, getBorderBandWidthPx } from './border-band.js'; + +/** + * Band compositions below are MEASURED from Word renders (300dpi PDF pixel-run + * profiling of single-cell probe tables, styles x sz {4,12,24}), recorded in the + * SD-3308 compound-borders plan. At CSS scale: w = authored width px, + * 0.75pt = 1px, 1.5pt = 2px. Segments alternate rule,gap,...,rule outer face first. + */ +describe('getBorderBandProfile', () => { + it('returns null for non-compound styles', () => { + expect(getBorderBandProfile({ style: 'single', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'thick', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'dotted', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'dashSmallGap', width: 2 })).toBeNull(); + expect(getBorderBandProfile({ style: 'none', width: 2 })).toBeNull(); + expect(getBorderBandProfile(undefined)).toBeNull(); + expect(getBorderBandProfile(null)).toBeNull(); + expect(getBorderBandProfile({ none: true })).toBeNull(); + }); + + it('double: rule + gap + rule, all at the authored width', () => { + expect(getBorderBandProfile({ style: 'double', width: 2 })).toEqual({ + segments: [2, 2, 2], + band: 6, + }); + }); + + it('triple: three rules and two gaps, all at the authored width (Word sz12 = r6+g6+r6+g6+r6 @300dpi)', () => { + expect(getBorderBandProfile({ style: 'triple', width: 2 })).toEqual({ + segments: [2, 2, 2, 2, 2], + band: 10, + }); + }); + + it('thinThickSmallGap: scaled outer rule, fixed 0.75pt gap and inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickSmallGap', width: 4 })).toEqual({ + segments: [4, 1, 1], + band: 6, + }); + }); + + it('thickThinSmallGap mirrors thinThickSmallGap', () => { + expect(getBorderBandProfile({ style: 'thickThinSmallGap', width: 4 })).toEqual({ + segments: [1, 1, 4], + band: 6, + }); + }); + + it('thinThickMediumGap: scaled outer rule, half-width gap and inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickMediumGap', width: 4 })).toEqual({ + segments: [4, 2, 2], + band: 8, + }); + }); + + it('thickThinMediumGap mirrors thinThickMediumGap', () => { + expect(getBorderBandProfile({ style: 'thickThinMediumGap', width: 4 })).toEqual({ + segments: [2, 2, 4], + band: 8, + }); + }); + + it('thinThickLargeGap: fixed 1.5pt outer rule, scaled gap, fixed 0.75pt inner rule', () => { + expect(getBorderBandProfile({ style: 'thinThickLargeGap', width: 4 })).toEqual({ + segments: [2, 4, 1], + band: 7, + }); + }); + + it('thickThinLargeGap mirrors thinThickLargeGap', () => { + expect(getBorderBandProfile({ style: 'thickThinLargeGap', width: 4 })).toEqual({ + segments: [1, 4, 2], + band: 7, + }); + }); + + it('thinThickThinSmallGap: fixed thin rules and gaps around a scaled center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinSmallGap', width: 4 })).toEqual({ + segments: [1, 1, 4, 1, 1], + band: 8, + }); + }); + + it('thinThickThinMediumGap: half-width thin rules and gaps around a scaled center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinMediumGap', width: 4 })).toEqual({ + segments: [2, 2, 4, 2, 2], + band: 12, + }); + }); + + it('thinThickThinLargeGap: fixed thin rules, scaled gaps, fixed 1.5pt center rule', () => { + expect(getBorderBandProfile({ style: 'thinThickThinLargeGap', width: 4 })).toEqual({ + segments: [1, 4, 2, 4, 1], + band: 12, + }); + }); + + it('clamps every rule and gap to at least 1px', () => { + // w/2 = 0.5 would vanish; Word still paints a visible hairline (measured r1 at sz4). + expect(getBorderBandProfile({ style: 'thinThickThinMediumGap', width: 1 })).toEqual({ + segments: [1, 1, 1, 1, 1], + band: 5, + }); + expect(getBorderBandProfile({ style: 'double', width: 0.5 })).toEqual({ + segments: [1, 1, 1], + band: 3, + }); + }); + + it('accepts the size alias used by raw table border values', () => { + const raw = { style: 'triple', size: 2 } as unknown as Parameters[0]; + expect(getBorderBandProfile(raw)?.band).toBe(10); + }); +}); + +describe('getBorderBandWidthPx with compound profiles', () => { + it('keeps the existing double behavior (band = 3x width, min 3)', () => { + expect(getBorderBandWidthPx({ style: 'double', width: 2 })).toBe(6); + expect(getBorderBandWidthPx({ style: 'double', width: 0.5 })).toBe(3); + }); + + it('keeps non-compound behavior unchanged', () => { + expect(getBorderBandWidthPx({ style: 'single', width: 2 })).toBe(2); + expect(getBorderBandWidthPx({ style: 'thick', width: 2 })).toBe(4); + expect(getBorderBandWidthPx({ style: 'none', width: 2 })).toBe(0); + expect(getBorderBandWidthPx(null)).toBe(0); + }); + + it('returns the profile band total for compound styles', () => { + expect(getBorderBandWidthPx({ style: 'triple', width: 2 })).toBe(10); + expect(getBorderBandWidthPx({ style: 'thinThickSmallGap', width: 4 })).toBe(6); + expect(getBorderBandWidthPx({ style: 'thinThickThinLargeGap', width: 4 })).toBe(12); + }); +}); diff --git a/packages/layout-engine/contracts/src/border-band.ts b/packages/layout-engine/contracts/src/border-band.ts index e58b46adea..8e9b32deb3 100644 --- a/packages/layout-engine/contracts/src/border-band.ts +++ b/packages/layout-engine/contracts/src/border-band.ts @@ -1,5 +1,61 @@ import type { TableBorderValue } from './index.js'; +/** + * Composition of a compound (multi-rule) border band. + * + * `segments` alternate rule, gap, rule, ... starting at the band's OUTER face + * (table boundary / neighbor-facing side) and ending at the inner face (cell + * content side). 3 segments = 2 rules, 5 segments = 3 rules. `band` is the sum. + */ +export type BorderBandProfile = { + segments: number[]; + band: number; +}; + +// Fixed rule/gap widths at CSS 96dpi: 0.75pt and 1.5pt. +const PT_075 = 1; +const PT_150 = 2; + +/** + * Per-style band composition as a function of the authored width `w` (px). + * Every formula is MEASURED from Word renders (300dpi probe tables at + * sz {4,12,24}); see the SD-3308 compound-borders plan for the raw data. + * "thinThick" carries the sz-scaled rule on the OUTER face, "thickThin" on the + * inner face; thinThickThin* scales the center rule except LargeGap, where the + * gaps scale and the center is fixed at 1.5pt. + */ +const COMPOUND_PROFILES: Record number[]> = { + double: (w) => [w, w, w], + triple: (w) => [w, w, w, w, w], + thinThickSmallGap: (w) => [w, PT_075, PT_075], + thickThinSmallGap: (w) => [PT_075, PT_075, w], + thinThickMediumGap: (w) => [w, w / 2, w / 2], + thickThinMediumGap: (w) => [w / 2, w / 2, w], + thinThickLargeGap: (w) => [PT_150, w, PT_075], + thickThinLargeGap: (w) => [PT_075, w, PT_150], + thinThickThinSmallGap: (w) => [PT_075, PT_075, w, PT_075, PT_075], + thinThickThinMediumGap: (w) => [w / 2, w / 2, w, w / 2, w / 2], + thinThickThinLargeGap: (w) => [PT_075, w, PT_150, w, PT_075], +}; + +/** + * Band composition for a compound border style, or null for single-rule styles + * (callers keep their existing single-rule path). Rules and gaps are clamped to + * >= 1px so hairline components stay visible, matching Word's measured minimums. + */ +export function getBorderBandProfile(value: TableBorderValue | null | undefined): BorderBandProfile | null { + if (value == null || typeof value !== 'object') return null; + if ('none' in value && value.none) return null; + const raw = value as { style?: string; width?: number; size?: number }; + if (!raw.style) return null; + const formula = COMPOUND_PROFILES[raw.style]; + if (!formula) return null; + const w = typeof raw.width === 'number' ? raw.width : typeof raw.size === 'number' ? raw.size : 1; + if (w <= 0) return null; + const segments = formula(w).map((s) => Math.max(1, s)); + return { segments, band: segments.reduce((sum, s) => sum + s, 0) }; +} + /** * Rendered border band width in pixels for a table or cell border value. * @@ -10,11 +66,11 @@ import type { TableBorderValue } from './index.js'; * Width semantics per ECMA-376 / Word rendering: * - `none`/nil (or explicit `{none:true}`) paint nothing: band 0. * - `thick` paints a heavier single rule: 2x the authored width, min 3px. - * - `double` w:sz is the width of EACH rule; Word paints rule + gap + rule at ~3x - * that width (measured against Word output: sz12 = 1.5pt rules, ~4.5pt band). - * CSS `double` divides the border-width into thirds, so the band is 3x the - * authored width, floored at 3px so both rules always render (CSS collapses - * `double` below 3px into a single solid-looking line). (SD-3308) + * - Compound styles (double, triple, thinThick*) paint a multi-rule band whose + * total width is the sum of the measured profile segments; see + * `getBorderBandProfile`. For `double` this preserves the original semantics: + * w:sz is the width of EACH rule, band = 3x the authored width, floored at 3px + * so both rules always render. (SD-3308) * - Every other style paints at the authored width. * * @param value - Border value from table attrs (`TableBorderValue`) or a cell-side @@ -31,6 +87,7 @@ export function getBorderBandWidthPx(value: TableBorderValue | null | undefined) const width = Math.max(0, w); if (width === 0) return 0; if (raw.style === 'thick') return Math.max(width * 2, 3); - if (raw.style === 'double') return Math.max(width * 3, 3); + const profile = getBorderBandProfile(value); + if (profile) return profile.band; return width; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 60adf79df4..fd23e7b7a7 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -54,7 +54,8 @@ export { rescaleColumnWidths } from './table-column-rescale.js'; export { getCellSpacingPx } from './cell-spacing.js'; // Border band width (single source of truth for painter CSS width + measuring row reservation) -export { getBorderBandWidthPx } from './border-band.js'; +export { getBorderBandWidthPx, getBorderBandProfile } from './border-band.js'; +export type { BorderBandProfile } from './border-band.js'; // OOXML z-index normalization (moved from pm-adapter for cross-stage use) export { @@ -707,11 +708,21 @@ export type BorderStyle = | 'single' | 'double' | 'dashed' + | 'dashSmallGap' | 'dotted' | 'thick' | 'triple' | 'dotDash' | 'dotDotDash' + | 'thinThickSmallGap' + | 'thickThinSmallGap' + | 'thinThickThinSmallGap' + | 'thinThickMediumGap' + | 'thickThinMediumGap' + | 'thinThickThinMediumGap' + | 'thinThickLargeGap' + | 'thickThinLargeGap' + | 'thinThickThinLargeGap' | 'wave' | 'doubleWave'; diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index c469d6fc33..c5e8245968 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -73,10 +73,27 @@ describe('applyBorder', () => { expect(element.style.borderTop).toMatch(/1px dotted (#0000FF|rgb\(0,\s*0,\s*255\))/i); }); - it('should convert triple to solid CSS', () => { + // SD-3308: compound styles carry their full measured band width so layout + // (content inset, row reservation) matches Word; the visible rules are painted + // by the compound nested-rectangle path, which makes this CSS border transparent. + it('renders a triple border at its full band width (5 segments of the authored width)', () => { const border: BorderSpec = { style: 'triple', width: 2, color: '#FF0000' }; applyBorder(element, 'Top', border); - expect(element.style.borderTop).toMatch(/2px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + expect(element.style.borderTop).toMatch(/10px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + it('renders thinThickSmallGap at its full band width (w + 0.75pt gap + 0.75pt rule)', () => { + const border: BorderSpec = { style: 'thinThickSmallGap', width: 4, color: '#FF0000' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/6px solid (#FF0000|rgb\(255,\s*0,\s*0\))/i); + }); + + // SD-3308: dashSmallGap is a dash variant; CSS dashed is the accepted + // approximation (same as dotDash/dotDotDash). + it('renders dashSmallGap as CSS dashed at the authored width', () => { + const border: BorderSpec = { style: 'dashSmallGap', width: 2, color: '#00FF00' }; + applyBorder(element, 'Top', border); + expect(element.style.borderTop).toMatch(/2px dashed (#00FF00|rgb\(0,\s*255,\s*0\))/i); }); it('should handle thick border with width multiplier', () => { @@ -577,4 +594,20 @@ describe('resolveBorderConflict (ECMA-376 §17.4.66)', () => { // brightness(R+B+2G): dark=0 < light=1020 → dark wins expect(resolveBorderConflict(light, dark)).toEqual(dark); }); + + // SD-3308: the §17.4.66 weight tables must cover the compound styles so they + // win/lose conflicts the way Word resolves them. + it('compound thinThick styles outweigh single rules', () => { + const single = { style: 'single' as const, width: 1, color: '#000000' }; + const compound = { style: 'thinThickSmallGap' as const, width: 1, color: '#000000' }; + // weight: single = 1×1 = 1, thinThickSmallGap = 2 lines × number 9 = 18 + expect(resolveBorderConflict(single, compound)).toEqual(compound); + expect(resolveBorderConflict(compound, single)).toEqual(compound); + }); + + it('dashSmallGap outweighs plain dashed (style number 20 vs 5)', () => { + const dashed = { style: 'dashed' as const, width: 1, color: '#000000' }; + const dashSmallGap = { style: 'dashSmallGap' as const, width: 1, color: '#000000' }; + expect(resolveBorderConflict(dashed, dashSmallGap)).toEqual(dashSmallGap); + }); }); diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index 490f52b58c..38fd635506 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -14,11 +14,21 @@ const ALLOWED_BORDER_STYLES = new Set([ 'single', 'double', 'dashed', + 'dashSmallGap', 'dotted', 'thick', 'triple', 'dotDash', 'dotDotDash', + 'thinThickSmallGap', + 'thickThinSmallGap', + 'thinThickThinSmallGap', + 'thinThickMediumGap', + 'thickThinMediumGap', + 'thinThickThinMediumGap', + 'thinThickLargeGap', + 'thickThinLargeGap', + 'thinThickThinLargeGap', 'wave', 'doubleWave', ]); @@ -32,16 +42,29 @@ const borderStyleToCSS = (style?: BorderStyle): string => { return 'solid'; } + // Compound styles (triple, thinThick*) map to 'solid' so the CSS border carries + // the full band width for layout; their visible rules are painted by the + // nested-rectangle compound path, which makes this CSS paint transparent. (SD-3308) const styleMap: Record = { none: 'none', single: 'solid', double: 'double', dashed: 'dashed', + dashSmallGap: 'dashed', dotted: 'dotted', thick: 'solid', triple: 'solid', dotDash: 'dashed', dotDotDash: 'dashed', + thinThickSmallGap: 'solid', + thickThinSmallGap: 'solid', + thinThickThinSmallGap: 'solid', + thinThickMediumGap: 'solid', + thickThinMediumGap: 'solid', + thinThickThinMediumGap: 'solid', + thinThickLargeGap: 'solid', + thickThinLargeGap: 'solid', + thinThickThinLargeGap: 'solid', wave: 'solid', doubleWave: 'solid', }; @@ -197,8 +220,18 @@ const BORDER_STYLE_NUMBER: Partial> = { dotDash: 6, dotDotDash: 7, triple: 8, + thinThickSmallGap: 9, + thickThinSmallGap: 10, + thinThickThinSmallGap: 11, + thinThickMediumGap: 12, + thickThinMediumGap: 13, + thinThickThinMediumGap: 14, + thinThickLargeGap: 15, + thickThinLargeGap: 16, + thinThickThinLargeGap: 17, wave: 18, doubleWave: 19, + dashSmallGap: 20, }; // Number of drawn lines per style (single=1, double=2, triple=3, …). const BORDER_STYLE_LINES: Partial> = { @@ -210,8 +243,18 @@ const BORDER_STYLE_LINES: Partial> = { dotDash: 1, dotDotDash: 1, triple: 3, + thinThickSmallGap: 2, + thickThinSmallGap: 2, + thinThickThinSmallGap: 3, + thinThickMediumGap: 2, + thickThinMediumGap: 2, + thinThickThinMediumGap: 3, + thinThickLargeGap: 2, + thickThinLargeGap: 2, + thinThickThinLargeGap: 3, wave: 1, doubleWave: 2, + dashSmallGap: 1, }; export const isPresentBorder = (b?: BorderSpec): b is BorderSpec => diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 52d775c88d..bb2d670ddc 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -8,7 +8,7 @@ import type { TableFragment, TableMeasure, } from '@superdoc/contracts'; -import { getTableVisualDirection, getBorderBandWidthPx } from '@superdoc/contracts'; +import { getTableVisualDirection, getBorderBandProfile } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { CLASS_NAMES, fragmentStyles } from '../styles.js'; import { DOM_CLASS_NAMES } from '../constants.js'; @@ -689,10 +689,10 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement y += actualRowHeight + cellSpacingPx; } - // Word paints a double table border as an outer OUTLINE rule at the table boundary - // plus each cell's inner rectangle (see appendDoubleBorderInnerRect). Paint the - // outline here for table-level double outer borders; continuation fragments skip the - // broken edge. (SD-3308) + // Word paints a compound table border (double, triple, thinThick*) as an outer + // OUTLINE rule at the table boundary plus each cell's inner rectangle (see + // appendCompoundBorderRects). The outline rule is the band's OUTER-face rule + // (profile segments[0]). Continuation fragments skip the broken edge. (SD-3308) { const sides = [ ['top', tableBorders?.top, fragment.continuesFromPrev !== true], @@ -704,13 +704,13 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement for (const [side, value, enabled] of sides) { if (!enabled || value == null || typeof value !== 'object') continue; const spec = value as { style?: string; color?: string }; - if (spec.style !== 'double') continue; - const band = Math.max(3, Math.round(getBorderBandWidthPx(value))); - const rule = Math.max(1, Math.round(band / 3)); + const profile = getBorderBandProfile(value); + if (!profile) continue; + const rule = Math.max(1, Math.round(profile.segments[0])); const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; if (!outlineEl) { outlineEl = doc.createElement('div'); - outlineEl.className = 'superdoc-double-border-outline'; + outlineEl.className = 'superdoc-compound-border-outline'; const st = outlineEl.style; st.position = 'absolute'; st.inset = '0'; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index af28f19b3b..e8f058abf8 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -303,7 +303,7 @@ describe('renderTableRow', () => { }) as never, ); - const rects = container.querySelectorAll('.superdoc-double-border-rect'); + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); expect(rects.length).toBe(1); const rect = rects[0] as HTMLElement; // band 6, rule 2: the inner-face rule sits band - rule = 4px inside the owned edges. @@ -313,10 +313,84 @@ describe('renderTableRow', () => { expect(rect.style.borderBottom).toMatch(/2px solid/); expect(rect.style.borderLeft).toMatch(/2px solid/); expect(rect.style.borderRight).toMatch(/2px solid/); + // double is symmetric: no middle strips + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); const cellArgs = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: { style?: string } } }; expect(cellArgs.borders?.top?.style).toBe('double'); }); + // SD-3308: asymmetric 2-rule bands. thinThickSmallGap = [w, 0.75pt, 0.75pt] outer + // to inner (measured from Word 300dpi probes): the inner rectangle paints the + // INNER-face rule (1px), the outline paints the outer-face rule. + it('paints thinThickSmallGap with the inner-face rule width on the inner rectangle', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + bottom: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + left: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + right: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + }, + }) as never, + ); + + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(1); + const rect = rects[0] as HTMLElement; + // band 6 (4+1+1), inner rule 1: rule sits band - rule = 5px inside the owned edges. + expect(rect.style.left).toBe('5px'); + expect(rect.style.top).toBe('5px'); + expect(rect.style.borderTop).toMatch(/1px solid/); + expect(rect.style.borderLeft).toMatch(/1px solid/); + // 2-rule band: no middle strips + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + }); + + // SD-3308: 3-rule bands (triple = [w, w, w, w, w]) add a middle RECTANGLE between + // the outline and the inner rectangle (Word's 300dpi corner crops show three clean + // nested boxes; full-edge strips would protrude across the outer and inner rings). + it('paints triple borders as inner rectangle plus a middle rectangle on owned edges', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }) as never, + ); + + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(1); + const rect = rects[0] as HTMLElement; + // band 10 (2+2+2+2+2), inner rule 2: rule sits band - rule = 8px inside. + expect(rect.style.left).toBe('8px'); + expect(rect.style.top).toBe('8px'); + expect(rect.style.borderTop).toMatch(/2px solid/); + + // The middle rule is ONE bordered rectangle inset by outer rule + gap = 4px, + // so its corners join cleanly instead of crossing the other rings. + const mids = container.querySelectorAll('.superdoc-compound-border-mid'); + expect(mids.length).toBe(1); + const mid = mids[0] as HTMLElement; + expect(mid.style.left).toBe('4px'); + expect(mid.style.top).toBe('4px'); + // 100x20 cell inset 4px on each side + expect(mid.style.width).toBe('92px'); + expect(mid.style.height).toBe('12px'); + expect(mid.style.borderTop).toMatch(/2px solid/); + expect(mid.style.borderBottom).toMatch(/2px solid/); + expect(mid.style.borderLeft).toMatch(/2px solid/); + expect(mid.style.borderRight).toMatch(/2px solid/); + }); + // SD-1797: a single row's measure only lists cells that START in it, so on a w:vMerge // (rowspan) continuation row the columns held by a cell spanning from above look empty. // `rowOccupiedRightCol` / `nextRowOccupiedRightCol` count that occupancy so the single-owner diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 52a7f8381a..ad3dc5a110 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -9,7 +9,7 @@ import type { TableBorders, TableMeasure, } from '@superdoc/contracts'; -import { getBorderBandWidthPx } from '@superdoc/contracts'; +import { getBorderBandProfile } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { renderTableCell } from './renderTableCell.js'; import { @@ -365,22 +365,26 @@ type TableRowRenderDependencies = { * ``` */ /** - * Paints a cell's double borders the way Word does: as a single-rule INNER RECTANGLE - * per cell, connected with square L-joins at the corners (verified against 300dpi Word - * renders of the same file). The band's two rules sit at its faces; the inner face rule - * belongs to this cell's rectangle and the outer face rule belongs to the table outline - * (outer edges) or to the neighboring cell's rectangle (interior edges). CSS double - * borders cannot do this: they miter diagonally and their band hugs the owning cell, so - * junctions render as crossings instead of closed boxes. + * Paints a cell's compound borders (double, triple, thinThick*) the way Word does: + * as a single-rule INNER RECTANGLE per cell, connected with square L-joins at the + * corners (verified against 300dpi Word renders). A band's rules sit at fixed + * positions measured from Word (see contracts getBorderBandProfile): the inner-face + * rule belongs to this cell's rectangle, the outer-face rule belongs to the table + * outline (outer edges) or to the neighboring cell's rectangle (interior edges), + * and a 3-rule band's middle rule is a centered strip per owned edge (strips span + * the full edge so they join squarely at corners, forming Word's middle rectangle). + * CSS compound borders cannot do this: they miter diagonally and their band hugs + * the owning cell, so junctions render as crossings instead of closed boxes. * - * The cell keeps its CSS double border with a TRANSPARENT color so border-box layout - * (content inset, band reservation) is unchanged. For each double side, the rectangle's - * rule sits at the inner face of that side's band: inset (band - rule) on sides whose - * band lives in this cell (top/left always, bottom/right at table boundaries), and - * extended rule px past the box on interior bottom/right sides whose band lives in the - * neighboring cell. The table outline rules are painted by renderTableFragment. (SD-3308) + * The cell keeps its CSS border with a TRANSPARENT color so border-box layout + * (content inset, band reservation) is unchanged. For each compound side, the + * rectangle's rule sits at the inner face of that side's band: inset (band - rule) + * on sides whose band lives in this cell (top/left always, bottom/right at table + * boundaries), and the OUTER-face rule extended past the box on interior + * bottom/right sides whose band lives in the neighboring cell. The table outline + * rules are painted by renderTableFragment. (SD-3308) */ -const appendDoubleBorderInnerRect = ( +const appendCompoundBorderRects = ( doc: Document, container: HTMLElement, cellElement: HTMLElement, @@ -392,11 +396,17 @@ const appendDoubleBorderInnerRect = ( if (!borders) return; const sideInfo = (['top', 'right', 'bottom', 'left'] as const).map((side) => { const spec = borders[side]; - if (!spec || spec.style !== 'double') return null; - const band = Math.max(3, Math.round(getBorderBandWidthPx(spec))); - const rule = Math.max(1, Math.round(band / 3)); + const profile = spec ? getBorderBandProfile(spec) : null; + if (!spec || !profile) return null; + const { segments } = profile; + const band = Math.max(1, Math.round(profile.band)); + const outerRule = Math.max(1, Math.round(segments[0])); + const innerRule = Math.max(1, Math.round(segments[segments.length - 1])); + // 5 segments = 3 rules: the middle rule sits outer rule + gap inside the band. + const midRule = segments.length === 5 ? Math.max(1, Math.round(segments[2])) : 0; + const midOffset = segments.length === 5 ? Math.round(segments[0] + segments[1]) : 0; const color = spec.color && /^#[0-9A-Fa-f]{6}$/.test(spec.color) ? spec.color : '#000000'; - return { side, band, rule, color }; + return { side, band, outerRule, innerRule, midRule, midOffset, color }; }); if (!sideInfo.some(Boolean)) return; @@ -405,7 +415,7 @@ const appendDoubleBorderInnerRect = ( const x1 = Math.round(rect.x + rect.width); const y1 = Math.round(rect.y + rect.height); - // Hide the CSS paint for double sides, keep the layout band. + // Hide the CSS paint for compound sides, keep the layout band. for (const info of sideInfo) { if (!info) continue; const cssSide = (info.side[0].toUpperCase() + info.side.slice(1)) as 'Top' | 'Right' | 'Bottom' | 'Left'; @@ -414,27 +424,58 @@ const appendDoubleBorderInnerRect = ( const [top, right, bottom, left] = sideInfo; const rectEl = doc.createElement('div'); - rectEl.className = 'superdoc-double-border-rect'; + rectEl.className = 'superdoc-compound-border-rect'; const st = rectEl.style; st.position = 'absolute'; st.boxSizing = 'border-box'; st.pointerEvents = 'none'; - // Inner-face placement per side. Owned band: rule sits band-rule inside the box. - // Neighbor-owned band (interior bottom/right): rule sits at the band's near face, - // which is rule px past this cell's box inside the neighbor. - const topInset = top ? top.band - top.rule : 0; - const leftInset = left ? left.band - left.rule : 0; - const bottomInset = bottom ? (ownsBottomBand ? bottom.band - bottom.rule : -bottom.rule) : 0; - const rightInset = right ? (ownsRightBand ? right.band - right.rule : -right.rule) : 0; + // Inner-face placement per side. Owned band: the inner rule sits band-rule inside + // the box. Neighbor-owned band (interior bottom/right): this cell contributes the + // band's OUTER-face rule, which sits just past this cell's box inside the neighbor. + const topInset = top ? top.band - top.innerRule : 0; + const leftInset = left ? left.band - left.innerRule : 0; + const bottomInset = bottom ? (ownsBottomBand ? bottom.band - bottom.innerRule : -bottom.outerRule) : 0; + const rightInset = right ? (ownsRightBand ? right.band - right.innerRule : -right.outerRule) : 0; st.left = `${x0 + leftInset}px`; st.top = `${y0 + topInset}px`; st.width = `${x1 - x0 - leftInset - rightInset}px`; st.height = `${y1 - y0 - topInset - bottomInset}px`; - if (top) st.borderTop = `${top.rule}px solid ${top.color}`; - if (bottom) st.borderBottom = `${bottom.rule}px solid ${bottom.color}`; - if (left) st.borderLeft = `${left.rule}px solid ${left.color}`; - if (right) st.borderRight = `${right.rule}px solid ${right.color}`; + if (top) st.borderTop = `${top.innerRule}px solid ${top.color}`; + if (bottom) st.borderBottom = `${ownsBottomBand ? bottom.innerRule : bottom.outerRule}px solid ${bottom.color}`; + if (left) st.borderLeft = `${left.innerRule}px solid ${left.color}`; + if (right) st.borderRight = `${ownsRightBand ? right.innerRule : right.outerRule}px solid ${right.color}`; container.appendChild(rectEl); + + // Middle rule of 3-rule bands: ONE bordered rectangle inset to the middle rule's + // position (outer rule + gap) on each OWNED 3-rule side. A box with borders joins + // cleanly at corners, matching Word's middle rectangle; full-edge strips would + // protrude across the outer and inner rings. Neighbor-owned interior sides are + // painted by the owning cell's own middle rectangle. + const midTop = top && top.midRule > 0 ? top : null; + const midLeft = left && left.midRule > 0 ? left : null; + const midBottom = bottom && bottom.midRule > 0 && ownsBottomBand ? bottom : null; + const midRight = right && right.midRule > 0 && ownsRightBand ? right : null; + if (midTop || midLeft || midBottom || midRight) { + const mid = doc.createElement('div'); + mid.className = 'superdoc-compound-border-mid'; + const ms = mid.style; + ms.position = 'absolute'; + ms.boxSizing = 'border-box'; + ms.pointerEvents = 'none'; + const tIn = midTop ? midTop.midOffset : 0; + const lIn = midLeft ? midLeft.midOffset : 0; + const bIn = midBottom ? midBottom.midOffset : 0; + const rIn = midRight ? midRight.midOffset : 0; + ms.left = `${x0 + lIn}px`; + ms.top = `${y0 + tIn}px`; + ms.width = `${x1 - x0 - lIn - rIn}px`; + ms.height = `${y1 - y0 - tIn - bIn}px`; + if (midTop) ms.borderTop = `${midTop.midRule}px solid ${midTop.color}`; + if (midBottom) ms.borderBottom = `${midBottom.midRule}px solid ${midBottom.color}`; + if (midLeft) ms.borderLeft = `${midLeft.midRule}px solid ${midLeft.color}`; + if (midRight) ms.borderRight = `${midRight.midRule}px solid ${midRight.color}`; + container.appendChild(mid); + } }; export const renderTableRow = (deps: TableRowRenderDependencies): void => { @@ -786,7 +827,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { borderValueToSpec(effectiveTableBorders?.insideV)), }; const rectBorders = isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs; - appendDoubleBorderInnerRect( + appendCompoundBorderRects( doc, container, cellElement, diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts index 8f80e5bbe9..536fe149a2 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts @@ -198,11 +198,21 @@ const BORDER_STYLES = new Set([ 'single', 'double', 'dashed', + 'dashSmallGap', 'dotted', 'thick', 'triple', 'dotDash', 'dotDotDash', + 'thinThickSmallGap', + 'thickThinSmallGap', + 'thinThickThinSmallGap', + 'thinThickMediumGap', + 'thickThinMediumGap', + 'thinThickThinMediumGap', + 'thinThickLargeGap', + 'thickThinLargeGap', + 'thinThickThinLargeGap', 'wave', 'doubleWave', ]); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index b6d2d44e41..6610cb89d6 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -159,6 +159,26 @@ function normalizeLegacyBorderStyle(value: string | undefined): string { return 'dotDash'; case 'dotdotdash': return 'dotDotDash'; + case 'dashsmallgap': + return 'dashSmallGap'; + case 'thinthicksmallgap': + return 'thinThickSmallGap'; + case 'thickthinsmallgap': + return 'thickThinSmallGap'; + case 'thinthickthinsmallgap': + return 'thinThickThinSmallGap'; + case 'thinthickmediumgap': + return 'thinThickMediumGap'; + case 'thickthinmediumgap': + return 'thickThinMediumGap'; + case 'thinthickthinmediumgap': + return 'thinThickThinMediumGap'; + case 'thinthicklargegap': + return 'thinThickLargeGap'; + case 'thickthinlargegap': + return 'thickThinLargeGap'; + case 'thinthickthinlargegap': + return 'thinThickThinLargeGap'; case 'wave': return 'wave'; case 'doublewave': From 314a74889313059d1d39145b24bc15b59b3996cb Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 16:54:35 -0300 Subject: [PATCH 08/17] fix(measuring): apply word band rules to content-sized table columns Word probes (single-cell auto tables, band styles x sz 4/12/24) pin three sizing rules SuperDoc was missing: - A single-column pure-auto grid is not a Word layout cache: Word content-sizes the table on open. preserveAutoGrid no longer claims single-column grids with no tcW anywhere (hasNonUniformGrid treats length <= 1 as non-uniform, which pinned the stale grid at 200px where Word renders ~70px). - A content-sized column grows by HALF the border band per adjacent gridline, not a full band per side: resolveColumnBandAllowances halves each edge contribution. The painted band then eats the other half back from the padding: renderTableCell compresses horizontal padding by band/2 on compound sides (floor 0), matching Word's measured leftover margin = padding - band/2. - Padding compresses to zero but text never clips: the solver floors the column at text + 2x band via text-only bounds threaded from table-autofit-metrics (horizontalInsets) into accumulateBounds. Net effect: column = text + max(padding + band, 2x band), verified against Word renders of all 18 probe tables; the residual width delta is a constant font-metrics difference. Corpus layout compare vs the previous commit: 476 docs, zero changes (no corpus doc carries these shapes). (SD-3308) --- .../measuring/dom/src/autofit-columns.ts | 33 +++- .../measuring/dom/src/autofit-normalize.ts | 22 ++- .../measuring/dom/src/index.test.ts | 155 +++++++++++++++++- .../dom/src/table-autofit-metrics.ts | 10 ++ .../dom/src/table/renderTableCell.test.ts | 37 +++++ .../painters/dom/src/table/renderTableCell.ts | 22 ++- 6 files changed, 265 insertions(+), 14 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/autofit-columns.ts b/packages/layout-engine/measuring/dom/src/autofit-columns.ts index 8c1afae197..32b35fcea0 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-columns.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-columns.ts @@ -45,6 +45,8 @@ export type AutoFitCellInput = { maxContentWidth?: number; /** Preferred width hint equivalent to `tcW`, in pixels. */ preferredWidth?: number; + /** Horizontal padding + cell-border insets baked into the content widths, in pixels. */ + horizontalInsets?: number; }; /** @@ -76,6 +78,8 @@ export type AutoFitContentMetricsCell = { minContentWidth: number; /** Maximum outer cell width, in pixels. */ maxContentWidth: number; + /** Horizontal padding + cell-border insets baked into the content widths, in pixels. */ + horizontalInsets?: number; }; /** @@ -166,6 +170,7 @@ type NormalizedCell = { preferredWidth?: number; minContentWidth: number; maxContentWidth: number; + horizontalInsets: number; }; type NormalizedRow = { @@ -214,6 +219,7 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { const currentWidths = fixedLayout.columnWidths.slice(0, gridColumnCount); const minBounds = new Array(gridColumnCount).fill(0); const maxBounds = new Array(gridColumnCount).fill(0); + const textBounds = new Array(gridColumnCount).fill(0); const preferredOverrides = new Array(gridColumnCount).fill(undefined); const multiSpanCells: NormalizedCell[] = []; @@ -221,6 +227,7 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { rows: normalizedRows, minBounds, maxBounds, + textBounds, preferredOverrides, multiSpanCells, }); @@ -261,9 +268,16 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult { // deliberately ignored here; it is not a Word layout cache for this shape. // (SD-3309) const columnBandAllowances = workingInput.columnBandAllowances; - resolvedWidths = maxBounds.map( - (max, index) => Math.max(max, minBounds[index]) + (columnBandAllowances?.[index] ?? 0), - ); + // Word band rule (SD-3308 probes): the column grows by half a band per edge + // (the allowance); the painted band then consumes the other half from the + // padding. Padding compresses but TEXT never clips, so the column is floored + // at text + the full bands (2x the allowance). + resolvedWidths = maxBounds.map((max, index) => { + const allowance = columnBandAllowances?.[index] ?? 0; + const withAllowance = Math.max(max, minBounds[index]) + allowance; + const textFloor = textBounds[index] + allowance * 2; + return Math.max(withAllowance, textFloor); + }); // Spanning cells must keep their max-content demand: the proportional spread in // applyMultiSpanMaximums can leave the covered columns collectively short (span // padding is per cell, not per column), which wraps the span text where Word @@ -360,6 +374,7 @@ function resolveAutoFitContext(input: AutoFitInput): AutoFitContext { preferredWidth: cell.preferredWidth, minContentWidth: cell.minContentWidth, maxContentWidth: cell.maxContentWidth, + horizontalInsets: cell.horizontalInsets, })), })); @@ -408,6 +423,7 @@ function normalizeLegacyRows(rows: AutoFitRowInput[]): NormalizedRow[] { preferredWidth: sanitizeOptionalWidth(cell.preferredWidth), minContentWidth: Math.max(0, cell.minContentWidth ?? 0), maxContentWidth: Math.max(0, cell.maxContentWidth ?? cell.minContentWidth ?? 0), + horizontalInsets: Math.max(0, cell.horizontalInsets ?? 0), }); columnIndex += span; } @@ -455,6 +471,7 @@ function buildNormalizedRows( preferredWidth: sanitizeOptionalWidth(metrics?.preferredWidth ?? placedCell.preferredWidth), minContentWidth: Math.max(0, metrics?.minContentWidth ?? 0), maxContentWidth: Math.max(0, metrics?.maxContentWidth ?? metrics?.minContentWidth ?? 0), + horizontalInsets: Math.max(0, metrics?.horizontalInsets ?? 0), }; }), skippedColumns: (workingRow.skippedColumns ?? []).map((skipped) => ({ @@ -475,10 +492,11 @@ function accumulateBounds(args: { rows: NormalizedRow[]; minBounds: number[]; maxBounds: number[]; + textBounds: number[]; preferredOverrides: Array; multiSpanCells: NormalizedCell[]; }): void { - const { rows, minBounds, maxBounds, preferredOverrides, multiSpanCells } = args; + const { rows, minBounds, maxBounds, textBounds, preferredOverrides, multiSpanCells } = args; for (const row of rows) { for (const skipped of row.skippedColumns) { @@ -493,6 +511,13 @@ function accumulateBounds(args: { if (cell.span === 1) { minBounds[cell.startColumn] = Math.max(minBounds[cell.startColumn], cell.minContentWidth); maxBounds[cell.startColumn] = Math.max(maxBounds[cell.startColumn], cell.maxContentWidth); + // Text-only demand (content width minus padding/cell-border insets), used by + // the content-size band floor: padding may compress under a fat border band + // but the text itself never loses space. (SD-3308) + textBounds[cell.startColumn] = Math.max( + textBounds[cell.startColumn], + Math.max(0, cell.maxContentWidth - cell.horizontalInsets), + ); if (preferredOverrides[cell.startColumn] == null && cell.preferredWidth != null) { preferredOverrides[cell.startColumn] = cell.preferredWidth; } diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts index 75fe4275f5..3231daa340 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts @@ -192,6 +192,7 @@ export function buildAutoFitWorkingGridInput( preferredColumnWidths, preferredTableWidth, gridColumnCount, + rows, }); const preserveExplicitAutoGrid = shouldPreserveExplicitAutoGrid({ layoutMode, @@ -270,11 +271,17 @@ function shouldPreserveAutoGrid(args: { preferredColumnWidths: number[]; preferredTableWidth: number | undefined; gridColumnCount: number; + rows: WorkingTableRowInput[]; }): boolean { - const { layoutMode, preferredColumnWidths, preferredTableWidth, gridColumnCount } = args; + const { layoutMode, preferredColumnWidths, preferredTableWidth, gridColumnCount, rows } = args; if (layoutMode !== 'autofit') return false; if (preferredTableWidth != null) return false; if (preferredColumnWidths.length === 0 || preferredColumnWidths.length !== gridColumnCount) return false; + // A single-column grid with no concrete cell width anywhere is not a Word layout + // cache: Word recomputes and content-sizes the table on open (verified against + // Word renders of single-cell auto tables with a stale w:tblGrid, SD-3308). Let + // the content-size path claim it. With a tcW present the grid IS a cache: keep it. + if (preferredColumnWidths.length === 1 && !hasConcreteCellWidthRequest(rows)) return false; if (!hasNonUniformGrid(preferredColumnWidths)) return false; return true; } @@ -348,6 +355,13 @@ function isAutoOrNilTableWidth(tableWidth: TableWidthAttr | undefined): boolean * Vertical border band widths owed per column on the content-size path. Table-level * borders only (left edge, insideV dividers, right edge); cell-level vertical * variation is rare in pure-auto tables and at most under-reserves slightly. + * + * Word-measured rule (band-scaling probes, SD-3308): each vertical gridline grants + * HALF its band width to each adjacent column. The painted band then sits fully + * inside the cell box, eating the other half back from the content area, so the + * content span shrinks by exactly the band delta while the column grows by half a + * band per edge. A single-column table therefore widens by ONE band (half left + + * half right), matching Word's measured column = text + margins + band. */ function resolveColumnBandAllowances( borders: TableBorders | null | undefined, @@ -359,9 +373,9 @@ function resolveColumnBandAllowances( const right = getBorderBandWidthPx(borders?.right); const allowances: number[] = []; for (let i = 0; i < gridColumnCount; i++) { - let allowance = i === 0 ? left : insideV; - if (i === gridColumnCount - 1) allowance += right; - allowances.push(allowance); + const edgeLeft = i === 0 ? left : insideV; + const edgeRight = i === gridColumnCount - 1 ? right : insideV; + allowances.push((edgeLeft + edgeRight) / 2); } return allowances.some((a) => a > 0) ? allowances : undefined; } diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 2241205a1c..194800b5d0 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -4351,6 +4351,23 @@ describe('measureBlock', () => { if (separate.kind !== 'table' || collapsed.kind !== 'table') throw new Error('expected table measures'); expect(separate.rows[0].height).toBeLessThan(collapsed.rows[0].height); }); + + it('reserves the band excess for compound thinThick* and triple borders', async () => { + // The shared contracts band profile drives the reservation, so compound + // styles reserve exactly like double does. (SD-3308) + const single = await measureBlock(makeTable('single', 1), { maxWidth: 600 }); + const thinThick = await measureBlock(makeTable('thinThickSmallGap', 4), { maxWidth: 600 }); + const triple = await measureBlock(makeTable('triple', 2), { maxWidth: 600 }); + if (single.kind !== 'table' || thinThick.kind !== 'table' || triple.kind !== 'table') { + throw new Error('expected table measures'); + } + // thinThickSmallGap w4: band = 4 + 1 + 1 = 6px -> reservation 5px per gridline. + expect(thinThick.rows[0].height).toBeCloseTo(single.rows[0].height + 5, 5); + expect(thinThick.rows[1].height).toBeCloseTo(single.rows[1].height + 10, 5); + // triple w2: band = 5 * 2 = 10px -> reservation 9px per gridline. + expect(triple.rows[0].height).toBeCloseTo(single.rows[0].height + 9, 5); + expect(triple.rows[1].height).toBeCloseTo(single.rows[1].height + 18, 5); + }); }); describe('autofit tables with colspan should not truncate grid columns', () => { @@ -4603,6 +4620,134 @@ describe('measureBlock', () => { expect(measure.totalWidth).toBeGreaterThan(0); }); + // SD-3308: a SINGLE-column pure-auto grid is not a Word layout cache either. + // Word content-sizes such tables to the text (band-scaling probe: gridCol 3000 + // renders ~70px wide around "XXXX", not 200px). The single-column arm of + // hasNonUniformGrid must not let preserveAutoGrid pin the stale grid. + it('content-sizes a single-column pure-auto table instead of preserving its grid', async () => { + const block: FlowBlock = { + kind: 'table', + id: 'single-col-pure-auto', + attrs: { tableWidth: { width: 0, type: 'auto' } }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + // Content demand for "XXXX" plus margins is far below the stale 200px grid. + expect(measure.totalWidth).toBeLessThan(120); + expect(measure.totalWidth).toBeGreaterThan(0); + }); + + // SD-3308 Word-measured band rule for content-sized columns (band-scaling probes): + // each vertical gridline grants HALF its band to each adjacent column, then the + // painted band sits fully inside the cell box and eats the other half back from + // the PADDING. Padding may compress to zero but text never clips, so: + // column = text + max(padding + band, 2 x band) + // Probe evidence: Word's thinThickSmallGap content span shrinks by exactly the + // band delta as sz grows (padding absorbing), while triple sz24's content span + // bottoms out at the text width (floor active, margins fully consumed). + it('content-sized columns add half a band per edge, padding absorbing until the text floor', async () => { + const makeBordered = (style: string, width: number): FlowBlock => + ({ + kind: 'table', + id: `band-allowance-${style}-${width}`, + attrs: { + tableWidth: { width: 0, type: 'auto' }, + borders: { + top: { style, width }, + bottom: { style, width }, + left: { style, width }, + right: { style, width }, + }, + }, + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }) as unknown as FlowBlock; + + const bare = await measureBlock(makeBordered('none', 0), { maxWidth: 624 }); + const single = await measureBlock(makeBordered('single', 2), { maxWidth: 624 }); + const triple = await measureBlock(makeBordered('triple', 2), { maxWidth: 624 }); + if (bare.kind !== 'table' || single.kind !== 'table' || triple.kind !== 'table') { + throw new Error('expected table measures'); + } + // bare = text + default padding 8. single band 2: 2x2 < 8+2 -> padding absorbs, + // column = bare + band. + expect(single.totalWidth - bare.totalWidth).toBeCloseTo(2, 5); + // triple band 10: 2x10 > 8+10 -> text floor wins, column = text + 2x10 = + // (bare - 8) + 20 = bare + 12. The content area between the painted bands is + // exactly the text width: no clipping. + expect(triple.totalWidth - bare.totalWidth).toBeCloseTo(12, 5); + }); + + it('still preserves a single-column auto grid when the cell carries a concrete width', async () => { + // With tcW present the authored grid IS a valid Word layout cache: keep it. + const block: FlowBlock = { + kind: 'table', + id: 'single-col-cached-grid', + rows: [ + { + id: 'row-0', + cells: [ + { + id: 'cell-0-0', + attrs: { tableCellProperties: { cellWidth: { value: 3000, type: 'dxa' } } }, + blocks: [ + { + kind: 'paragraph', + id: 'para-0', + runs: [{ text: 'XXXX', fontFamily: 'Arial', fontSize: 12 }], + }, + ], + }, + ], + }, + ], + columnWidths: [200], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + expect(measure.totalWidth).toBeCloseTo(200, 0); + }); + it('produces exact sum after rounding adjustment', async () => { const block: FlowBlock = { kind: 'table', @@ -5593,9 +5738,13 @@ describe('measureBlock', () => { expect(measure.kind).toBe('table'); if (measure.kind !== 'table') throw new Error('expected table measure'); - // No tableWidth - auto layout preserves column widths - expect(measure.totalWidth).toBe(140); - expect(measure.columnWidths[0]).toBe(140); + // SD-1239 invariant: a missing tableWidth must not be misread as a percentage + // or crash; the table still measures sanely. SD-3308: a single-column + // pure-auto table content-sizes to its text like Word (the stale 140px grid + // is not a layout cache), so the width tracks content, not the grid. + expect(measure.totalWidth).toBeGreaterThan(0); + expect(measure.totalWidth).toBeLessThan(140); + expect(measure.columnWidths[0]).toBe(measure.totalWidth); }); it('does NOT scale up column widths for fixed layout tables with explicit width', async () => { diff --git a/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts b/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts index 9b621bb1b1..84b95ba3c9 100644 --- a/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts +++ b/packages/layout-engine/measuring/dom/src/table-autofit-metrics.ts @@ -69,6 +69,12 @@ export type TableCellContentMetrics = { * This is the no-wrap authored line width, plus horizontal cell chrome. */ maxWidthPx: number; + /** + * Horizontal cell chrome (padding + cell-border widths) baked into the outer + * widths, in pixels. Lets the AutoFit solver recover the text-only demand for + * the content-size band floor. (SD-3308) + */ + horizontalInsetsPx?: number; }; /** @@ -376,6 +382,7 @@ export async function measureTableCellContentMetrics( const emptyMetrics = { minWidthPx: horizontalInsets, maxWidthPx: horizontalInsets, + horizontalInsetsPx: horizontalInsets, }; tableCellMetricsCache.set(cacheKey, emptyMetrics); return emptyMetrics; @@ -393,6 +400,7 @@ export async function measureTableCellContentMetrics( const result = { minWidthPx: minContentWidthPx + horizontalInsets, maxWidthPx: maxContentWidthPx + horizontalInsets, + horizontalInsetsPx: horizontalInsets, }; tableCellMetricsCache.set(cacheKey, result); @@ -461,6 +469,7 @@ export async function measureTableAutoFitContentMetrics( preferredWidth: normalizedCell?.preferredWidth, minContentWidth: metrics.minWidthPx, maxContentWidth: metrics.maxWidthPx, + horizontalInsets: metrics.horizontalInsetsPx, }; }), ); @@ -483,6 +492,7 @@ export async function measureTableAutoFitContentMetrics( preferredWidth: cellMetrics.preferredWidth, minContentWidth: cellMetrics.minContentWidth, maxContentWidth: cellMetrics.maxContentWidth, + horizontalInsets: cellMetrics.horizontalInsets, })), skippedAfter: normalizedRow.skippedAfter ?? [], }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index cd22ae83dd..ec84630ec2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -101,6 +101,43 @@ describe('renderTableCell', () => { }, }); + // SD-3308 Word-measured padding rule for compound border bands: the painted band + // eats HALF its width back from the cell padding per side (probe evidence: Word's + // thinThickSmallGap sz24 leftover margin = padding - band/2). Padding floors at 0; + // non-compound borders keep the full padding (sub-pixel difference, deliberately + // out of scope to avoid corpus churn). + it('compresses horizontal padding by half the band on compound border sides', () => { + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: baseCellMeasure, + cell: baseCell, + borders: { + // thinThickSmallGap w4: band 6 -> padding 4 - 3 = 1px + left: { style: 'thinThickSmallGap', width: 4, color: '#000000' }, + // triple w2: band 10 -> padding 4 - 5 -> floors at 0 + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }); + + expect(cellElement.style.paddingLeft).toBe('1px'); + expect(cellElement.style.paddingRight).toBe('0px'); + }); + + it('keeps full padding for non-compound border sides', () => { + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure: baseCellMeasure, + cell: baseCell, + borders: { + left: { style: 'single', width: 2, color: '#000000' }, + right: { style: 'thick', width: 2, color: '#000000' }, + }, + }); + + expect(cellElement.style.paddingLeft).toBe('4px'); + expect(cellElement.style.paddingRight).toBe('4px'); + }); + it('uses an end-of-cell mark for the final paragraph in a table cell', () => { const secondParagraphBlock: ParagraphBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index c59888ebb6..b265c6599e 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -1,4 +1,5 @@ import type { + BorderSpec, CellBorders, DrawingBlock, ImageDrawing, @@ -18,7 +19,7 @@ import type { WrapExclusion, WrapTextMode, } from '@superdoc/contracts'; -import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx } from '@superdoc/contracts'; +import { rescaleColumnWidths, normalizeZIndex, getCellSpacingPx, getBorderBandProfile } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import type { MinimalWordLayout } from '@superdoc/common/list-marker-utils'; import type { FragmentRenderContext, RenderedLineInfo } from '../renderer.js'; @@ -714,9 +715,24 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen ): HTMLElement => buildImageHyperlinkAnchor(doc, imageEl, hyperlink, display); // RTL: swap left↔right cell margins (ECMA-376 Part 4 §14.3.3–14.3.4, §14.3.7–14.3.8) - const paddingLeft = isRtl ? (padding.right ?? 4) : (padding.left ?? 4); + // Word eats half of a border band back from the cell padding on that side + // (band-scaling probe measurements: leftover margin = padding - band/2, floored + // at 0). Scoped to compound bands (double, triple, thinThick*): for single-rule + // borders the difference is sub-pixel and not worth disturbing existing layouts. + // The matching column growth lives in measuring resolveColumnBandAllowances. (SD-3308) + const compoundBandEats = (border: BorderSpec | undefined): number => { + const profile = border ? getBorderBandProfile(border) : null; + return profile ? profile.band / 2 : 0; + }; + const paddingLeft = Math.max( + 0, + (isRtl ? (padding.right ?? 4) : (padding.left ?? 4)) - compoundBandEats(borders?.left), + ); const paddingTop = padding.top ?? 0; - const paddingRight = isRtl ? (padding.left ?? 4) : (padding.right ?? 4); + const paddingRight = Math.max( + 0, + (isRtl ? (padding.left ?? 4) : (padding.right ?? 4)) - compoundBandEats(borders?.right), + ); const paddingBottom = padding.bottom ?? 0; const cellEl = doc.createElement('div'); From c4ed0156be5b6276e30600e3f48793fe4ea7e18b Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 18:58:26 -0300 Subject: [PATCH 09/17] fix(editor): emit word tcw cell widths from inserttable Word writes w:tcW on every cell it inserts; the concrete cell width marks the stored grid as a real layout cache. SuperDoc's createTable only set the PM colwidth attr, which feeds the grid but not the cell width preference, so the SD-3309 pure-auto classifier content-sized freshly inserted tables to their text instead of keeping the requested full-width columns. Sets tableCellProperties.cellWidth (dxa twips) alongside colwidth, matching Word's emission and the existing import shape, so inserted tables keep their geometry and export real tcW values. Fixes the behavior CI failure in drag-selection-into-table-feedback (SD-2676 spec): the drag endpoint at the last cell's line midpoint landed mid-text once the inserted table content-sized, so the selection stopped at "R4C1 " instead of covering the cell. Verified failing without this change and passing with it on chromium; all 83 table behavior specs pass. --- .../editors/v1/extensions/table/table.test.js | 23 +++++++++++++++++++ .../table/tableHelpers/createTable.js | 16 ++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/extensions/table/table.test.js b/packages/super-editor/src/editors/v1/extensions/table/table.test.js index 4f01cf3f06..7fb9ca5739 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/table.test.js +++ b/packages/super-editor/src/editors/v1/extensions/table/table.test.js @@ -1142,6 +1142,29 @@ describe('Table commands', async () => { editor.converter = originalConverter; }); + + // SD-3308: Word writes w:tcW on every cell it inserts, which marks the grid + // as a real layout cache. Without it the measuring pass classifies the table + // as pure-auto and content-sizes it (shrinking a freshly inserted table to + // its empty-cell width instead of keeping the requested column widths). + it('insertTable cells carry a concrete cellWidth like Word tcW', async () => { + const { docx, media, mediaFiles, fonts } = cachedBlankDoc; + ({ editor } = initTestEditor({ content: docx, media, mediaFiles, fonts })); + ({ schema } = editor); + + 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) => { + // twips = px * 15 at 96dpi + expect(row.child(0).attrs.tableCellProperties?.cellWidth).toEqual({ value: 1500, type: 'dxa' }); + expect(row.child(1).attrs.tableCellProperties?.cellWidth).toEqual({ value: 3000, type: 'dxa' }); + expect(row.child(2).attrs.tableCellProperties?.cellWidth).toEqual({ value: 4500, type: 'dxa' }); + }); + }); }); describe('insertTableAt trailing separator paragraph', () => { diff --git a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js index 082ec7a0eb..595ca06c08 100644 --- a/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js +++ b/packages/super-editor/src/editors/v1/extensions/table/tableHelpers/createTable.js @@ -41,8 +41,22 @@ export const createTable = ( const headerCells = []; const cells = []; + // Twips per CSS pixel at 96 DPI (1440 twips/inch / 96 px/inch). + const TWIPS_PER_PX = 15; + for (let index = 0; index < colsCount; index++) { - const cellAttrs = columnWidths ? { colwidth: [columnWidths[index]] } : null; + // Word writes w:tcW on every cell it inserts; the concrete cell width marks + // the grid as a real layout cache so the measuring pass preserves the + // requested column widths instead of content-sizing the table as pure-auto. + // (SD-3308) + const cellAttrs = columnWidths + ? { + colwidth: [columnWidths[index]], + tableCellProperties: { + cellWidth: { value: columnWidths[index] * TWIPS_PER_PX, type: 'dxa' }, + }, + } + : null; const cell = createCell(types.tableCell, cellContent, cellAttrs); if (cell) cells.push(cell); if (withHeaderRow) { From dcf99f49bbb07398a7756afcd4b639b2e186dcc3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 19:49:25 -0300 Subject: [PATCH 10/17] fix(painter): straddle interior compound bands and join their middle grid Two Word behaviors measured from 300dpi probes of the triple fixtures: - An interior compound band is CENTERED on the gridline (spans gridline -band/2 .. +band/2), so both adjacent cells keep equal content widths. SuperDoc placed the whole band inside the owner cell, making the left cell a full band wider than the right. Each adjacent cell now carries HALF the band as its transparent CSS border (borderBandOverridesPx), the inner rectangles place their divider-facing rules at the straddled band's faces, and the padding compression skips straddled edges (the half-band the column was granted covers them). - The MIDDLE rule of 3-rule bands (triple, thinThickThin*) is a continuous grid: it runs unbroken through perpendicular band crossings and meets the boundary band's middle ring squarely. Per-cell middle rectangles break at every intersection, so table-level 3-rule borders now paint a fragment-level ring plus full-length center strips, and the per-cell middle rectangle is suppressed for those sides (kept for cell-level-only compound borders). Verified in-browser against the Word probes: divider rules symmetric around the gridline, equal cell boxes, continuous middle grid. Painter suite 1256 tests. --- .../painters/dom/src/table/border-utils.ts | 17 +- .../painters/dom/src/table/renderTableCell.ts | 23 ++- .../dom/src/table/renderTableFragment.test.ts | 106 ++++++++++++ .../dom/src/table/renderTableFragment.ts | 110 ++++++++++++ .../dom/src/table/renderTableRow.test.ts | 109 +++++++++++- .../painters/dom/src/table/renderTableRow.ts | 162 ++++++++++++++---- 6 files changed, 475 insertions(+), 52 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index 38fd635506..c36e2e2b55 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -96,6 +96,7 @@ export const applyBorder = ( element: HTMLElement, side: 'Top' | 'Right' | 'Bottom' | 'Left', border?: BorderSpec, + widthOverridePx?: number, ): void => { if (!border) return; if (border.style === 'none' || border.width === 0) { @@ -109,8 +110,10 @@ export const applyBorder = ( // Band width comes from the shared contracts helper so the painted width and the // measuring engine's row-height reservation can never disagree. Word semantics: // thick = 2x (min 3px); double = 3x the per-rule w:sz (min 3px so CSS renders both - // rules); everything else = authored width. (SD-3308) - const actualWidth = getBorderBandWidthPx(border); + // rules); everything else = authored width. `widthOverridePx` carries the + // straddled half-band for interior compound edges (Word centers those bands on + // the gridline, half in each adjacent cell). (SD-3308) + const actualWidth = widthOverridePx ?? getBorderBandWidthPx(border); element.style[`border${side}`] = `${actualWidth}px ${style} ${safeColor}`; }; @@ -133,12 +136,16 @@ export const applyBorder = ( * }); * ``` */ -export const applyCellBorders = (element: HTMLElement, borders?: CellBorders): void => { +export const applyCellBorders = ( + element: HTMLElement, + borders?: CellBorders, + widthOverridesPx?: { left?: number; right?: number }, +): void => { if (!borders) return; applyBorder(element, 'Top', borders.top); - applyBorder(element, 'Right', borders.right); + applyBorder(element, 'Right', borders.right, widthOverridesPx?.right); applyBorder(element, 'Bottom', borders.bottom); - applyBorder(element, 'Left', borders.left); + applyBorder(element, 'Left', borders.left, widthOverridesPx?.left); }; /** diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index b265c6599e..ebc66935e4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -555,6 +555,12 @@ type TableCellRenderDependencies = { cell?: TableBlock['rows'][number]['cells'][number]; /** Resolved borders for this cell */ borders?: CellBorders; + /** + * Per-side CSS band width overrides in px. Interior compound bands straddle the + * gridline (Word model, SD-3308): each adjacent cell carries HALF the band as its + * transparent border instead of the owner carrying it all. + */ + borderBandOverridesPx?: { left?: number; right?: number }; /** Whether to apply default border if no borders specified */ useDefaultBorder?: boolean; /** Function to render a line of paragraph content */ @@ -704,6 +710,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen fromLine, toLine, resolvePhysical, + borderBandOverridesPx, } = deps; const attrs = cell?.attrs; @@ -720,18 +727,24 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen // at 0). Scoped to compound bands (double, triple, thinThick*): for single-rule // borders the difference is sub-pixel and not worth disturbing existing layouts. // The matching column growth lives in measuring resolveColumnBandAllowances. (SD-3308) - const compoundBandEats = (border: BorderSpec | undefined): number => { + // The eaten amount is the painted band in THIS cell minus the half-band the + // column was granted: a boundary band (fully inside) eats band/2; a straddled + // interior band (half inside, see borderBandOverridesPx) eats nothing. + const compoundBandEats = (border: BorderSpec | undefined, bandInCellPx?: number): number => { const profile = border ? getBorderBandProfile(border) : null; - return profile ? profile.band / 2 : 0; + if (!profile) return 0; + const bandInCell = bandInCellPx ?? profile.band; + return Math.max(0, bandInCell - profile.band / 2); }; const paddingLeft = Math.max( 0, - (isRtl ? (padding.right ?? 4) : (padding.left ?? 4)) - compoundBandEats(borders?.left), + (isRtl ? (padding.right ?? 4) : (padding.left ?? 4)) - compoundBandEats(borders?.left, borderBandOverridesPx?.left), ); const paddingTop = padding.top ?? 0; const paddingRight = Math.max( 0, - (isRtl ? (padding.left ?? 4) : (padding.right ?? 4)) - compoundBandEats(borders?.right), + (isRtl ? (padding.left ?? 4) : (padding.right ?? 4)) - + compoundBandEats(borders?.right, borderBandOverridesPx?.right), ); const paddingBottom = padding.bottom ?? 0; @@ -751,7 +764,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen cellEl.style.paddingBottom = `${paddingBottom}px`; if (borders) { - applyCellBorders(cellEl, borders); + applyCellBorders(cellEl, borders, borderBandOverridesPx); } else if (useDefaultBorder) { cellEl.style.border = '1px solid rgba(0,0,0,0.6)'; } diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index 79d5d1d529..c8b71db135 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -170,6 +170,112 @@ describe('renderTableFragment', () => { expect(element.style.borderRightWidth).toBe('3px'); }); + // SD-3308: Word paints the MIDDLE rule of table-level 3-rule bands as a continuous + // grid (measured: the divider's middle rule runs unbroken through the row band and + // meets the boundary band's middle ring). The fragment paints one ring inset by + // outer rule + gap plus full-length center strips per interior gridline. + it('paints a continuous middle grid for table-level triple borders', () => { + const para = (id: string): ParagraphBlock => ({ kind: 'paragraph', id: id as BlockId, runs: [] }); + const block: TableBlock = { + kind: 'table', + id: 'triple-grid' as BlockId, + attrs: { + borders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + insideH: { style: 'triple', width: 2, color: '#000000' }, + insideV: { style: 'triple', width: 2, color: '#000000' }, + }, + }, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { id: 'c00' as BlockId, blocks: [para('p00')] }, + { id: 'c01' as BlockId, blocks: [para('p01')] }, + ], + }, + { + id: 'r1' as BlockId, + cells: [ + { id: 'c10' as BlockId, blocks: [para('p10')] }, + { id: 'c11' as BlockId, blocks: [para('p11')] }, + ], + }, + ], + }; + const cellMeasure = { + blocks: [{ kind: 'paragraph' as const, lines: [], totalHeight: 20 }], + width: 100, + height: 20, + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [cellMeasure, cellMeasure], height: 20 }, + { cells: [cellMeasure, cellMeasure], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const fragment: TableFragment = { + kind: 'table', + blockId: 'triple-grid' as BlockId, + fromRow: 0, + toRow: 2, + x: 0, + y: 0, + width: 200, + height: 40, + }; + + const element = renderTableFragment({ + doc, + fragment, + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + + // Per-cell mids are suppressed (table-level provides the grid). + expect(element.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + + // Ring inset by outer rule + gap = 4, borders 2px. + const ring = element.querySelector('.superdoc-compound-border-midring') as HTMLElement; + expect(ring).toBeTruthy(); + expect(ring.style.left).toBe('4px'); + expect(ring.style.top).toBe('4px'); + expect(ring.style.borderTop).toMatch(/2px solid/); + expect(ring.style.borderLeft).toMatch(/2px solid/); + + // Interior vertical center strip: centered on the gridline (x=100), spanning + // between the ring's middle rules (continuous through the row band). + const verticals = [...element.querySelectorAll('.superdoc-compound-border-midv')] as HTMLElement[]; + expect(verticals.length).toBe(1); + expect(verticals[0].style.left).toBe('99px'); + expect(verticals[0].style.width).toBe('2px'); + expect(verticals[0].style.top).toBe('4px'); + expect(verticals[0].style.height).toBe(`${40 - 8}px`); + + // Interior horizontal center strip: at row boundary + midOffset, full width + // between the ring's middle rules. + const horizontals = [...element.querySelectorAll('.superdoc-compound-border-midh')] as HTMLElement[]; + expect(horizontals.length).toBe(1); + expect(horizontals[0].style.top).toBe('24px'); + expect(horizontals[0].style.height).toBe('2px'); + expect(horizontals[0].style.left).toBe('4px'); + expect(horizontals[0].style.width).toBe(`${200 - 8}px`); + }); + it('suppresses child chrome when table containerSdt shares id-less metadata', () => { const sharedSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index bb2d670ddc..4de7ea6b25 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -634,10 +634,14 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } // Render body rows (fromRow to toRow) + // Interior row boundary Ys, collected for the fragment-level compound middle grid. + const interiorRowBoundaries: number[] = []; for (let r = fragment.fromRow; r < fragment.toRow; r += 1) { const rowMeasure = measure.rows[r]; if (!rowMeasure) break; + if (r > fragment.fromRow) interiorRowBoundaries.push(y); + const isFirstRenderedBodyRow = r === fragment.fromRow; const isLastRenderedBodyRow = r === fragment.toRow - 1; @@ -723,5 +727,111 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } + // Middle layer of table-level 3-rule bands (triple, thinThickThin*): Word paints + // it as a CONTINUOUS grid, measured from 300dpi probes: a ring inset by + // outer rule + gap from the table boundary, plus full-length center strips per + // interior gridline that run unbroken through perpendicular band crossings and + // meet the ring squarely. Per-cell middle rectangles are suppressed for these + // sides (see renderTableRow). Interior vertical strips sit centered on the + // gridline (the band straddles it); interior horizontal strips sit at the + // band's middle inside the lower row. (SD-3308) + { + const midProfileOf = (value: unknown) => { + if (value == null || typeof value !== 'object') return null; + const profile = getBorderBandProfile(value as never); + return profile && profile.segments.length === 5 ? profile : null; + }; + const colorOf = (value: unknown): string => { + const c = (value as { color?: string } | null)?.color; + return c && /^#[0-9A-Fa-f]{6}$/.test(c) ? c : '#000000'; + }; + const midOffsetOf = (profile: { segments: number[] }): number => + Math.round(profile.segments[0] + profile.segments[1]); + const midRuleOf = (profile: { segments: number[] }): number => Math.max(1, Math.round(profile.segments[2])); + + const topBorder = tableBorders?.top; + const bottomBorder = tableBorders?.bottom; + const leftBorder = isRtl ? tableBorders?.right : tableBorders?.left; + const rightBorder = isRtl ? tableBorders?.left : tableBorders?.right; + const topMid = fragment.continuesFromPrev !== true ? midProfileOf(topBorder) : null; + const bottomMid = fragment.continuesOnNext !== true ? midProfileOf(bottomBorder) : null; + const leftMid = midProfileOf(leftBorder); + const rightMid = midProfileOf(rightBorder); + const insideHMid = midProfileOf(tableBorders?.insideH); + const insideVMid = midProfileOf(tableBorders?.insideV); + + const fragmentWidth = fragment.width; + const fragmentHeight = fragment.height; + const ringTopInset = topMid ? midOffsetOf(topMid) : 0; + const ringBottomInset = bottomMid ? midOffsetOf(bottomMid) : 0; + const ringLeftInset = leftMid ? midOffsetOf(leftMid) : 0; + const ringRightInset = rightMid ? midOffsetOf(rightMid) : 0; + + if (topMid || bottomMid || leftMid || rightMid) { + const ring = doc.createElement('div'); + ring.className = 'superdoc-compound-border-midring'; + const rs = ring.style; + rs.position = 'absolute'; + rs.boxSizing = 'border-box'; + rs.pointerEvents = 'none'; + rs.left = `${ringLeftInset}px`; + rs.top = `${ringTopInset}px`; + rs.width = `${fragmentWidth - ringLeftInset - ringRightInset}px`; + rs.height = `${fragmentHeight - ringTopInset - ringBottomInset}px`; + if (topMid) rs.borderTop = `${midRuleOf(topMid)}px solid ${colorOf(topBorder)}`; + if (bottomMid) rs.borderBottom = `${midRuleOf(bottomMid)}px solid ${colorOf(bottomBorder)}`; + if (leftMid) rs.borderLeft = `${midRuleOf(leftMid)}px solid ${colorOf(leftBorder)}`; + if (rightMid) rs.borderRight = `${midRuleOf(rightMid)}px solid ${colorOf(rightBorder)}`; + container.appendChild(ring); + } + + const appendStrip = (className: string, l: number, t: number, w: number, h: number, color: string): void => { + const strip = doc.createElement('div'); + strip.className = className; + const ss = strip.style; + ss.position = 'absolute'; + ss.pointerEvents = 'none'; + ss.left = `${l}px`; + ss.top = `${t}px`; + ss.width = `${w}px`; + ss.height = `${h}px`; + ss.background = color; + container.appendChild(strip); + }; + + if (insideVMid && effectiveColumnWidths.length > 1) { + const rule = midRuleOf(insideVMid); + const color = colorOf(tableBorders?.insideV); + let cum = 0; + for (let i = 0; i < effectiveColumnWidths.length - 1; i += 1) { + cum += effectiveColumnWidths[i]; + const gx = isRtl ? fragmentWidth - cum : cum; + appendStrip( + 'superdoc-compound-border-midv', + Math.round(gx - rule / 2), + ringTopInset, + rule, + fragmentHeight - ringTopInset - ringBottomInset, + color, + ); + } + } + + if (insideHMid && interiorRowBoundaries.length > 0) { + const rule = midRuleOf(insideHMid); + const color = colorOf(tableBorders?.insideH); + for (const gy of interiorRowBoundaries) { + appendStrip( + 'superdoc-compound-border-midh', + ringLeftInset, + Math.round(gy + midOffsetOf(insideHMid)), + fragmentWidth - ringLeftInset - ringRightInset, + rule, + color, + ); + } + } + } + return container; }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index e8f058abf8..90c4a5584f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -352,17 +352,31 @@ describe('renderTableRow', () => { // SD-3308: 3-rule bands (triple = [w, w, w, w, w]) add a middle RECTANGLE between // the outline and the inner rectangle (Word's 300dpi corner crops show three clean // nested boxes; full-edge strips would protrude across the outer and inner rings). + // Cell-level borders here: table-level 3-rule borders paint their middle layer as + // a continuous fragment-level grid instead (see renderTableFragment). it('paints triple borders as inner rectangle plus a middle rectangle on owned edges', () => { renderTableRow( createDeps({ rowIndex: 0, totalRows: 1, cellSpacingPx: 0, - tableBorders: { - top: { style: 'triple', width: 2, color: '#000000' }, - bottom: { style: 'triple', width: 2, color: '#000000' }, - left: { style: 'triple', width: 2, color: '#000000' }, - right: { style: 'triple', width: 2, color: '#000000' }, + tableBorders: undefined, + row: { + id: 'row-1', + cells: [ + { + id: 'cell-1', + attrs: { + borders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }, + blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }], + }, + ], }, }) as never, ); @@ -391,6 +405,91 @@ describe('renderTableRow', () => { expect(mid.style.borderRight).toMatch(/2px solid/); }); + // SD-3308: table-level 3-rule borders paint their middle layer at the FRAGMENT + // level (continuous grid through band intersections, measured from Word), so the + // per-cell middle rectangle must not double-paint it. + it('suppresses the per-cell middle rectangle when table-level borders provide the grid', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + tableBorders: { + top: { style: 'triple', width: 2, color: '#000000' }, + bottom: { style: 'triple', width: 2, color: '#000000' }, + left: { style: 'triple', width: 2, color: '#000000' }, + right: { style: 'triple', width: 2, color: '#000000' }, + }, + }) as never, + ); + + expect(container.querySelectorAll('.superdoc-compound-border-rect').length).toBe(1); + expect(container.querySelectorAll('.superdoc-compound-border-mid').length).toBe(0); + }); + + // SD-3308: Word centers an interior compound band ON the gridline (measured from + // the triple probe: the divider spans gridline -band/2 .. +band/2 and both cells + // keep equal content widths). Each adjacent cell carries HALF the band as its + // transparent CSS border, and the inner rectangles place their divider-facing + // rules at the straddled band's faces. + it('straddles an interior vertical compound band across the gridline', () => { + renderTableRow( + createDeps({ + rowIndex: 0, + totalRows: 1, + cellSpacingPx: 0, + columnWidths: [100, 100], + rowMeasure: { + height: 20, + cells: [ + { width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }, + { width: 100, height: 20, gridColumnStart: 1, colSpan: 1, rowSpan: 1 }, + ], + }, + row: { + id: 'row-1', + cells: [ + { id: 'cell-1', blocks: [{ kind: 'paragraph', id: 'p1', runs: [] }] }, + { id: 'cell-2', blocks: [{ kind: 'paragraph', id: 'p2', runs: [] }] }, + ], + }, + tableBorders: { + top: { style: 'double', width: 2, color: '#000000' }, + bottom: { style: 'double', width: 2, color: '#000000' }, + left: { style: 'double', width: 2, color: '#000000' }, + right: { style: 'double', width: 2, color: '#000000' }, + insideV: { style: 'double', width: 2, color: '#000000' }, + }, + }) as never, + ); + + // Both cells carry half the divider band (6/2 = 3px) as their CSS border. + const callA = renderTableCellMock.mock.calls[0][0] as { + borders?: { right?: unknown }; + borderBandOverridesPx?: { left?: number; right?: number }; + }; + const callB = renderTableCellMock.mock.calls[1][0] as { + borders?: { left?: unknown }; + borderBandOverridesPx?: { left?: number; right?: number }; + }; + expect(callA.borders?.right).toBeDefined(); + expect(callA.borderBandOverridesPx?.right).toBe(3); + expect(callB.borders?.left).toBeDefined(); + expect(callB.borderBandOverridesPx?.left).toBe(3); + + // Inner rectangles: divider-facing rules sit at the straddled band's faces. + const rects = container.querySelectorAll('.superdoc-compound-border-rect'); + expect(rects.length).toBe(2); + const rectA = rects[0] as HTMLElement; + const rectB = rects[1] as HTMLElement; + // A: left boundary inset band - rule = 4; right inset band/2 - outerRule = 1. + expect(rectA.style.left).toBe('4px'); + expect(rectA.style.width).toBe('95px'); + // B starts at gridline 100 + (band/2 - innerRule) = 101; right boundary inset 4. + expect(rectB.style.left).toBe('101px'); + expect(rectB.style.width).toBe('95px'); + }); + // SD-1797: a single row's measure only lists cells that START in it, so on a w:vMerge // (rowspan) continuation row the columns held by a cell spanning from above look empty. // `rowOccupiedRightCol` / `nextRowOccupiedRightCol` count that occupancy so the single-owner diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index ad3dc5a110..714c361e8f 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -390,10 +390,18 @@ const appendCompoundBorderRects = ( cellElement: HTMLElement, borders: CellBorders | undefined, rect: { x: number; y: number; width: number; height: number }, - ownsBottomBand: boolean, - ownsRightBand: boolean, + edges: { + ownsBottomBand: boolean; + /** Visual right side is the table boundary (band fully inside this cell). */ + rightIsBoundary: boolean; + /** Visual left side is the table boundary (band fully inside this cell). */ + leftIsBoundary: boolean; + /** Sides whose 3-rule middle layer is painted by the fragment grid instead. */ + suppressMid?: { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean }; + }, ): void => { if (!borders) return; + const { ownsBottomBand, rightIsBoundary, leftIsBoundary, suppressMid } = edges; const sideInfo = (['top', 'right', 'bottom', 'left'] as const).map((side) => { const spec = borders[side]; const profile = spec ? getBorderBandProfile(spec) : null; @@ -429,13 +437,26 @@ const appendCompoundBorderRects = ( st.position = 'absolute'; st.boxSizing = 'border-box'; st.pointerEvents = 'none'; - // Inner-face placement per side. Owned band: the inner rule sits band-rule inside - // the box. Neighbor-owned band (interior bottom/right): this cell contributes the - // band's OUTER-face rule, which sits just past this cell's box inside the neighbor. + // Inner-face placement per side. Boundary band (fully inside the cell): the inner + // rule sits band - rule inside the box. Interior VERTICAL bands straddle the + // gridline (half in each cell, Word model): this cell's divider-facing rule sits + // at the straddled band's near face, band/2 - rule from the gridline (negative + // when the rule is wider than the half-band, extending past the gridline). + // Interior horizontal bands keep the owner-cell placement: the band lives in the + // lower cell's top (the row reservation already centers it visually), and the + // upper cell contributes the band's outer-face rule just past its box. const topInset = top ? top.band - top.innerRule : 0; - const leftInset = left ? left.band - left.innerRule : 0; + const leftInset = left + ? leftIsBoundary + ? left.band - left.innerRule + : Math.round(left.band / 2) - left.innerRule + : 0; const bottomInset = bottom ? (ownsBottomBand ? bottom.band - bottom.innerRule : -bottom.outerRule) : 0; - const rightInset = right ? (ownsRightBand ? right.band - right.innerRule : -right.outerRule) : 0; + const rightInset = right + ? rightIsBoundary + ? right.band - right.innerRule + : Math.round(right.band / 2) - right.outerRule + : 0; st.left = `${x0 + leftInset}px`; st.top = `${y0 + topInset}px`; st.width = `${x1 - x0 - leftInset - rightInset}px`; @@ -443,7 +464,7 @@ const appendCompoundBorderRects = ( if (top) st.borderTop = `${top.innerRule}px solid ${top.color}`; if (bottom) st.borderBottom = `${ownsBottomBand ? bottom.innerRule : bottom.outerRule}px solid ${bottom.color}`; if (left) st.borderLeft = `${left.innerRule}px solid ${left.color}`; - if (right) st.borderRight = `${ownsRightBand ? right.innerRule : right.outerRule}px solid ${right.color}`; + if (right) st.borderRight = `${rightIsBoundary ? right.innerRule : right.outerRule}px solid ${right.color}`; container.appendChild(rectEl); // Middle rule of 3-rule bands: ONE bordered rectangle inset to the middle rule's @@ -451,10 +472,10 @@ const appendCompoundBorderRects = ( // cleanly at corners, matching Word's middle rectangle; full-edge strips would // protrude across the outer and inner rings. Neighbor-owned interior sides are // painted by the owning cell's own middle rectangle. - const midTop = top && top.midRule > 0 ? top : null; - const midLeft = left && left.midRule > 0 ? left : null; - const midBottom = bottom && bottom.midRule > 0 && ownsBottomBand ? bottom : null; - const midRight = right && right.midRule > 0 && ownsRightBand ? right : null; + const midTop = top && top.midRule > 0 && !suppressMid?.top ? top : null; + const midLeft = left && left.midRule > 0 && !suppressMid?.left ? left : null; + const midBottom = bottom && bottom.midRule > 0 && ownsBottomBand && !suppressMid?.bottom ? bottom : null; + const midRight = right && right.midRule > 0 && rightIsBoundary && !suppressMid?.right ? right : null; if (midTop || midLeft || midBottom || midRight) { const mid = doc.createElement('div'); mid.className = 'superdoc-compound-border-mid'; @@ -771,6 +792,59 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { x = tableContentWidth - x - computedCellWidth; } + const cellGridBounds = getTableCellGridBounds(cellPosition); + // Word's double model needs the EFFECTIVE border of every side of this cell, + // not the single-owner-suppressed set: ownership picks which band face the rule + // sits on, but every surrounding double edge contributes a side to this cell's + // rectangle. (SD-3308) + const cb = (cellBordersAttr ?? {}) as CellBorders; + const effectiveSideSpecs: CellBorders = { + top: + cellGridBounds.touchesTopEdge || continuesFromPrev === true + ? resolveTableBorderValue(cb.top, effectiveTableBorders?.top) + : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? + borderValueToSpec(effectiveTableBorders?.insideH)), + bottom: + cellGridBounds.touchesBottomEdge || continuesOnNext === true + ? resolveTableBorderValue(cb.bottom, effectiveTableBorders?.bottom) + : (resolveBorderConflict(cb.bottom, undefined) ?? borderValueToSpec(effectiveTableBorders?.insideH)), + left: cellGridBounds.touchesLeftEdge + ? resolveTableBorderValue(cb.left, effectiveTableBorders?.left) + : (resolveBorderConflict(cb.left, leftCellBorders?.right) ?? borderValueToSpec(effectiveTableBorders?.insideV)), + right: cellGridBounds.touchesRightEdge + ? resolveTableBorderValue(cb.right, effectiveTableBorders?.right) + : (resolveBorderConflict(cb.right, rightCellBorders?.left) ?? + borderValueToSpec(effectiveTableBorders?.insideV)), + }; + const rectBorders = isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs; + + // Visual (post-RTL-swap) boundary flags matching rectBorders sides. + const visualTouchesLeft = isRtl ? cellGridBounds.touchesRightEdge : cellGridBounds.touchesLeftEdge; + const visualTouchesRight = isRtl ? cellGridBounds.touchesLeftEdge : cellGridBounds.touchesRightEdge; + + // Interior vertical compound bands straddle the gridline (Word model, measured + // from the triple probes: the divider spans gridline -band/2 .. +band/2 and both + // cells keep equal content widths). Each adjacent cell carries HALF the band as + // its transparent CSS border, so the painted geometry and the column's half-band + // allowance agree. Boundary bands stay fully inside the cell. (SD-3308) + const leftStraddleProfile = !visualTouchesLeft && rectBorders.left ? getBorderBandProfile(rectBorders.left) : null; + const rightStraddleProfile = + !visualTouchesRight && rectBorders.right ? getBorderBandProfile(rectBorders.right) : null; + let paintBorders = finalBorders; + let borderBandOverridesPx: { left?: number; right?: number } | undefined; + if (leftStraddleProfile || rightStraddleProfile) { + paintBorders = { ...(finalBorders ?? {}) }; + borderBandOverridesPx = {}; + if (leftStraddleProfile) { + paintBorders.left = rectBorders.left; + borderBandOverridesPx.left = leftStraddleProfile.band / 2; + } + if (rightStraddleProfile) { + paintBorders.right = rectBorders.right; + borderBandOverridesPx.right = rightStraddleProfile.band / 2; + } + } + // Never use default borders - cells are either explicitly styled or borderless // This prevents gray borders on cells with borders={} (intentionally borderless) const { cellElement } = renderTableCell({ @@ -780,7 +854,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { rowHeight: cellHeight, cellMeasure, cell, - borders: finalBorders, + borders: paintBorders, + borderBandOverridesPx, useDefaultBorder: false, renderLine, captureLineSnapshot, @@ -802,31 +877,40 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { }); container.appendChild(cellElement); - const cellGridBounds = getTableCellGridBounds(cellPosition); - // Word's double model needs the EFFECTIVE border of every side of this cell, - // not the single-owner-suppressed set: ownership picks which band face the rule - // sits on, but every surrounding double edge contributes a side to this cell's - // rectangle. (SD-3308) - const cb = (cellBordersAttr ?? {}) as CellBorders; - const effectiveSideSpecs: CellBorders = { - top: + + // Table-level 3-rule bands paint their middle layer as a continuous fragment + // grid (see renderTableFragment); suppress the per-cell middle rectangle there. + const tableProvidesMid = (value: unknown): boolean => { + const profile = value != null && typeof value === 'object' ? getBorderBandProfile(value as never) : null; + return profile != null && profile.segments.length === 5; + }; + const suppressMid = { + top: tableProvidesMid( cellGridBounds.touchesTopEdge || continuesFromPrev === true - ? resolveTableBorderValue(cb.top, effectiveTableBorders?.top) - : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? - borderValueToSpec(effectiveTableBorders?.insideH)), - bottom: + ? effectiveTableBorders?.top + : effectiveTableBorders?.insideH, + ), + bottom: tableProvidesMid( cellGridBounds.touchesBottomEdge || continuesOnNext === true - ? resolveTableBorderValue(cb.bottom, effectiveTableBorders?.bottom) - : (resolveBorderConflict(cb.bottom, undefined) ?? borderValueToSpec(effectiveTableBorders?.insideH)), - left: cellGridBounds.touchesLeftEdge - ? resolveTableBorderValue(cb.left, effectiveTableBorders?.left) - : (resolveBorderConflict(cb.left, leftCellBorders?.right) ?? borderValueToSpec(effectiveTableBorders?.insideV)), - right: cellGridBounds.touchesRightEdge - ? resolveTableBorderValue(cb.right, effectiveTableBorders?.right) - : (resolveBorderConflict(cb.right, rightCellBorders?.left) ?? - borderValueToSpec(effectiveTableBorders?.insideV)), + ? effectiveTableBorders?.bottom + : effectiveTableBorders?.insideH, + ), + left: tableProvidesMid( + visualTouchesLeft + ? isRtl + ? effectiveTableBorders?.right + : effectiveTableBorders?.left + : effectiveTableBorders?.insideV, + ), + right: tableProvidesMid( + visualTouchesRight + ? isRtl + ? effectiveTableBorders?.left + : effectiveTableBorders?.right + : effectiveTableBorders?.insideV, + ), }; - const rectBorders = isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs; + appendCompoundBorderRects( doc, container, @@ -838,8 +922,12 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), height: cellHeight, }, - cellGridBounds.touchesBottomEdge || continuesOnNext === true, - cellGridBounds.touchesRightEdge, + { + ownsBottomBand: cellGridBounds.touchesBottomEdge || continuesOnNext === true, + rightIsBoundary: visualTouchesRight, + leftIsBoundary: visualTouchesLeft, + suppressMid, + }, ); } }; From 45e5a2ea2d4c743e5e176ed48b96198891577fbf Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Fri, 5 Jun 2026 19:49:28 -0300 Subject: [PATCH 11/17] fix(measuring): keep vmerge-only table rows one line high A row whose cells are ALL row-spanning (vMerge start or continuation) measured 0 high: row base heights only count rowspan=1 cells, and the span constraints were already satisfied by the neighboring rows, so nothing raised it. The row collapsed and the next row painted over it (showcase: head/only-real-cell/new rendered as two rows where Word renders three). In OOXML the vMerge continuation cells are real w:tc elements holding an empty paragraph and Word sizes the row from them, but the import merges those cells away. The measurer now grants every spanned row with no height of its own the spanning cell's first-line height (non-text spans such as logo images fall back to an even share so a spanning picture never doubles a header). Corpus layout compare vs the previous commit: 1 changed doc (sd-1797-autofit-tables, an invisible boundary redistribution inside spanned cells; the visible region matches a fresh Word render). --- .../measuring/dom/src/index.test.ts | 51 +++++++++++++++++++ .../layout-engine/measuring/dom/src/index.ts | 34 ++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.test.ts b/packages/layout-engine/measuring/dom/src/index.test.ts index 194800b5d0..c99e996e57 100644 --- a/packages/layout-engine/measuring/dom/src/index.test.ts +++ b/packages/layout-engine/measuring/dom/src/index.test.ts @@ -4301,6 +4301,57 @@ describe('measureBlock', () => { }); }); + // SD-3308: a row whose cells are ALL row-spanning (vMerge start or continuation) + // must not collapse to zero height. In OOXML the continuation cells are real + // elements holding an empty paragraph, and Word sizes the row from them + // (one line high). The import merges those cells away, so the measurer grants + // every spanned row a minimum of the spanning cell's one-line height. + describe('rowspan-only rows (SD-3028 vMerge continuation)', () => { + it('keeps a row alive when all of its cells are row-spanning', async () => { + const para = (id: string, text: string) => ({ + kind: 'paragraph' as const, + id, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], + }); + const block: FlowBlock = { + kind: 'table', + id: 'vmerge-rows', + rows: [ + { + id: 'r0', + cells: [ + { id: 'c00', blocks: [para('p00', 'head')] }, + { id: 'c01', rowSpan: 2, colSpan: 2, blocks: [para('p01', 'span-down')] }, + { id: 'c02', rowSpan: 2, blocks: [para('p02', 'right')] }, + ], + }, + { + id: 'r1', + cells: [{ id: 'c10', rowSpan: 2, blocks: [para('p10', 'only-real-cell')] }], + }, + { + id: 'r2', + cells: [ + { id: 'c20', colSpan: 2, blocks: [para('p20', 'new')] }, + { id: 'c21', blocks: [para('p21', 'new2')] }, + ], + }, + ], + columnWidths: [120, 60, 60, 60], + }; + + const measure = await measureBlock(block, { maxWidth: 624 }); + expect(measure.kind).toBe('table'); + if (measure.kind !== 'table') throw new Error('expected table measure'); + expect(measure.rows).toHaveLength(3); + // Row 1 holds only the start of a 2-row span (its other columns are vMerge + // continuations merged away at import): one line high like Word, not zero. + expect(measure.rows[1].height).toBeGreaterThan(0); + expect(measure.rows[1].height).toBeCloseTo(measure.rows[0].height, 0); + expect(measure.rows[2].height).toBeCloseTo(measure.rows[0].height, 0); + }); + }); + describe('border band row-height reservation (SD-3308)', () => { const makeTable = (borderStyle: string, width: number): FlowBlock => ({ diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 5f4f68405e..c28eede462 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -2975,7 +2975,8 @@ async function measureTableBlock( // Measure each cell paragraph with appropriate column width based on colspan const rows: TableRowMeasure[] = []; const rowBaseHeights: number[] = new Array(block.rows.length).fill(0); - const spanConstraints: Array<{ startRow: number; rowSpan: number; requiredHeight: number }> = []; + const spanConstraints: Array<{ startRow: number; rowSpan: number; requiredHeight: number; minRowHeight: number }> = + []; for (let rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { const row = block.rows[rowIndex]; const normalizedRow = workingInput.rows[rowIndex]; @@ -3103,7 +3104,23 @@ async function measureTableBlock( if (rowspan === 1) { rowBaseHeights[rowIndex] = Math.max(rowBaseHeights[rowIndex], totalCellHeight); } else { - spanConstraints.push({ startRow: rowIndex, rowSpan: rowspan, requiredHeight: totalCellHeight }); + // A row whose cells are ALL row-spanning would otherwise measure 0: the + // OOXML vMerge continuation cells are real elements holding an empty + // paragraph and Word sizes rows from them, but the import merges those + // cells away. Approximate the lost empty-cell height with the spanning + // cell's first text line; non-text spans (e.g. a logo image) fall back to + // an even share so a spanning picture never doubles. Applied only to rows + // with no height of their own (see pass 1 below). (SD-3028) + const firstBlockMeasure = blockMeasures[0]; + const firstLineHeight = + firstBlockMeasure?.kind === 'paragraph' && firstBlockMeasure.lines.length > 0 + ? firstBlockMeasure.lines[0].lineHeight + : undefined; + const minRowHeight = Math.min( + totalCellHeight, + firstLineHeight != null ? firstLineHeight + paddingTop + paddingBottom : totalCellHeight / rowspan, + ); + spanConstraints.push({ startRow: rowIndex, rowSpan: rowspan, requiredHeight: totalCellHeight, minRowHeight }); } // Advance grid column position by colspan @@ -3121,6 +3138,19 @@ async function measureTableBlock( } const rowHeights = [...rowBaseHeights]; + // Pass 1: a spanned row with NO height of its own (all of its cells are vMerge + // starts/continuations) gets the spanning cell's one-line minimum instead of + // collapsing to zero. Rows that already have height from their own cells are + // left alone; Word sizes those from their own content. + for (const constraint of spanConstraints) { + const spanLength = Math.min(constraint.rowSpan, rowHeights.length - constraint.startRow); + for (let i = 0; i < spanLength; i++) { + if (rowBaseHeights[constraint.startRow + i] === 0) { + rowHeights[constraint.startRow + i] = Math.max(rowHeights[constraint.startRow + i], constraint.minRowHeight); + } + } + } + // Pass 2: the spanned rows together must fit the spanning cell's full content. for (const constraint of spanConstraints) { const { startRow, rowSpan, requiredHeight } = constraint; if (rowSpan <= 0) continue; From 57ff8008212b2518733ce3ac2e85e2b30a422bb3 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 08:39:40 -0300 Subject: [PATCH 12/17] fix(painter): narrow rectBorders for the strict references build swapCellBordersLR returns CellBorders | undefined; the straddle code read rectBorders.left/right without narrowing, failing tsc -b under the references config (TS18048). Fall back to the unswapped specs, which the function only skips when given undefined input. --- packages/layout-engine/painters/dom/src/table/renderTableRow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 714c361e8f..751669d106 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -816,7 +816,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { : (resolveBorderConflict(cb.right, rightCellBorders?.left) ?? borderValueToSpec(effectiveTableBorders?.insideV)), }; - const rectBorders = isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs; + const rectBorders = (isRtl ? swapCellBordersLR(effectiveSideSpecs) : effectiveSideSpecs) ?? effectiveSideSpecs; // Visual (post-RTL-swap) boundary flags matching rectBorders sides. const visualTouchesLeft = isRtl ? cellGridBounds.touchesRightEdge : cellGridBounds.touchesLeftEdge; From 3b8d296abfb7a3777eb75e10a5100f7b3bae1f84 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 12:00:40 -0300 Subject: [PATCH 13/17] test(measuring): lock overhang merged-inset row geometry (sd-1513) The SD-1513 merged-inset narrowing does not reproduce at current code: both repro fixtures measure within 1px of fresh Word renders (ticket repro 629.3px vs Word 630.4; canonical 601.1 vs 602.2), and live solver instrumentation shows preserveAuthoredGrid serving both shapes, so the shrink path never runs. The originally reported ~250 dxa narrowing was back-computed from wrap points; the actual cell box was correct. The remaining one-word-early wrap is the cross-cutting font-metrics text width delta, tracked separately. Locks the contract so the narrowing cannot appear for real: - autofit-normalize.test.ts pins both repro geometries end to end (normalize + fixed solve): merged span = grid_sum - wBefore - wAfter and preserveAuthoredGrid true. Mutation-validated: forcing the preserve flag off turns both red. - resolve-table-frame.test.ts adds the full-window pct + negative tblInd guard (left overhang only, grid ends short of the right margin), the one overhang frame shape that had no pin. --- .../src/resolve-table-frame.test.ts | 15 +++ .../dom/src/autofit-normalize.test.ts | 110 ++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts b/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts index ef5282795c..8593042d08 100644 --- a/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts +++ b/packages/layout-engine/layout-engine/src/resolve-table-frame.test.ts @@ -154,5 +154,20 @@ describe('resolveTableFrame', () => { expect(result.width).toBe(750); expect(result.x).toBe(-125); }); + + // SD-1513 overhang guard: a full-window (100% pct) table shifted left by a + // negative tblInd keeps its computed width, so it overhangs the LEFT margin + // only and ends short of the right margin (verified against Word; the old + // benchmark prediction of a right overhang was wrong). + it('shifts a full-window table left with negative indent, ending short of the right margin', () => { + const result = resolveTableFrame(0, 500, 480, { + tableWidth: { value: 5000, type: 'pct' }, + tableIndent: { width: -24 }, + } as TableAttrs); + expect(result.x).toBe(-24); + expect(result.width).toBe(524); + // right edge = x + width = 500, the column edge; the painted grid itself + // spans 500px starting at -24, so it ends 24px short of the right margin. + }); }); }); diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts index 821baf9b52..b0e4f34378 100644 --- a/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts +++ b/packages/layout-engine/measuring/dom/src/autofit-normalize.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { TableBlock } from '@superdoc/contracts'; import { buildAutoFitWorkingGridInput } from './autofit-normalize.js'; +import { computeFixedTableColumnWidths } from './fixed-table-columns.js'; /** * Build a minimal runtime table block for normalization tests. @@ -738,3 +739,112 @@ describe('buildAutoFitWorkingGridInput', () => { expect(result.gridColumnCount).toBe(2); }); }); + +/** + * SD-1513 regression locks: overhanging fixed-layout tables with a merged, + * inset row (gridBefore/gridAfter + wBefore/wAfter + gridSpan). + * + * Contract, verified against Word renders and the live production pipeline: + * the authored grid is preserved verbatim (preserveAuthoredGrid), so the + * merged span resolves to exactly grid_sum - wBefore - wAfter. Word wraps the + * cell text at that width; any narrowing here reflows lines one word early. + * + * Block shapes below mirror the v1 layout-adapter output captured from the + * running pipeline: tableWidth pre-converted to px, cell widths as raw dxa + * measurements, row skips on attrs.tableRowProperties. + */ +describe('overhanging fixed tables with a merged inset row (SD-1513)', () => { + const TWIPS_PER_PX = 15; + + /** Table block mirroring the adapter output for a fixed table with one merged inset row. */ + function createOverhangBlock(args: { + gridDxa: number[]; + headerCells: Array<{ span?: number; widthDxa: number }>; + insetRow: { wBeforeDxa: number; wAfterDxa: number; span: number; widthDxa: number }; + }): TableBlock { + const gridSumDxa = args.gridDxa.reduce((sum, w) => sum + w, 0); + return createTableBlock({ + attrs: { + tableLayout: 'fixed', + tableWidth: { width: gridSumDxa / TWIPS_PER_PX, type: 'dxa' }, + }, + columnWidths: args.gridDxa.map((w) => w / TWIPS_PER_PX), + rows: [ + { + id: 'header-row', + cells: args.headerCells.map((cell, index) => ({ + id: `header-${index}`, + colSpan: cell.span ?? 1, + attrs: { tableCellProperties: { cellWidth: { value: cell.widthDxa, type: 'dxa' } } }, + })), + }, + { + id: 'inset-row', + attrs: { + tableRowProperties: { + gridBefore: 1, + gridAfter: 1, + wBefore: { value: args.insetRow.wBeforeDxa, type: 'dxa' }, + wAfter: { value: args.insetRow.wAfterDxa, type: 'dxa' }, + }, + }, + cells: [ + { + id: 'merged-cell', + colSpan: args.insetRow.span, + attrs: { tableCellProperties: { cellWidth: { value: args.insetRow.widthDxa, type: 'dxa' } } }, + }, + ], + }, + ], + } as Partial); + } + + /** Resolve the merged span's final width: the solved columns it covers. */ + function solveMergedSpanWidth(block: TableBlock, maxWidth: number, span: number): number { + const working = buildAutoFitWorkingGridInput(block, { maxWidth }); + // The overhang shape must take the authored-grid early return; the shrink + // path is what could narrow the merged span. + expect(working.preserveAuthoredGrid).toBe(true); + const solved = computeFixedTableColumnWidths(working); + return solved.columnWidths.slice(1, 1 + span).reduce((sum, w) => sum + w, 0); + } + + it('keeps the ticket repro merged span at grid_sum - wBefore - wAfter (9920 grid)', () => { + // overhang__first row overhangs margins: grid 9920 dxa > 9360 text column. + const block = createOverhangBlock({ + gridDxa: [280, 1900, 1900, 1900, 1900, 1840, 200], + headerCells: [ + { span: 2, widthDxa: 2180 }, + { widthDxa: 1900 }, + { widthDxa: 1900 }, + { widthDxa: 1900 }, + { span: 2, widthDxa: 2040 }, + ], + insetRow: { wBeforeDxa: 280, wAfterDxa: 200, span: 5, widthDxa: 9440 }, + }); + + const mergedWidth = solveMergedSpanWidth(block, 624, 5); + expect(mergedWidth).toBeCloseTo((9920 - 280 - 200) / TWIPS_PER_PX, 1); + }); + + it('keeps the canonical repro merged span at grid_sum - wBefore - wAfter (9360 grid)', () => { + // sd1513-paragraph-allignment: grid exactly the text column width. + const block = createOverhangBlock({ + gridDxa: [185, 51, 1451, 1503, 1503, 1503, 1503, 1503, 158], + headerCells: [ + { span: 2, widthDxa: 236 }, + { widthDxa: 1451 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { widthDxa: 1503 }, + { span: 2, widthDxa: 1661 }, + ], + insetRow: { wBeforeDxa: 185, wAfterDxa: 158, span: 7, widthDxa: 9017 }, + }); + + const mergedWidth = solveMergedSpanWidth(block, 624, 7); + expect(mergedWidth).toBeCloseTo((9360 - 185 - 158) / TWIPS_PER_PX, 1); + }); +}); From c69634b82a6038c3ec31d48a5ae6bb9d6f9e782a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 12:37:43 -0300 Subject: [PATCH 14/17] fix(style-engine): resolve conditional regions by grid column (sd-3028 g7) Word's firstCol/lastCol and vertical banding regions follow GRID columns, but determineCellStyleTypes keyed off display-cell indices. gridSpan, vMerge continuations (merged into rowspans at import), and gridBefore placeholders all shift display indices off the grid, landing edge styling one column early: in merged_cells_with_styles.docx the middle cell B3 painted the lastCol green because the trailing C3 is a vMerge continuation, where Word keeps B3 unshaded and runs the green down grid column 3. The v1 adapter now places each display cell on the grid (column cursor skipping rowspan-occupied columns, the measuring normalizer idiom) and threads gridColumnStart/gridColumnSpan/numGridCols through TableInfo. determineCellStyleTypes uses grid positions when present: firstCol = grid start 0, lastCol = span reaches the last grid column, vertical band index from the grid start. Display indices remain the fallback for callers without grid data. Verified against the Word render: B3 unshaded, firstCol yellow and lastCol green follow the grid through the spans. Style-engine 150 tests, super-editor suite 16137 tests. --- .../style-engine/src/ooxml/index.test.ts | 92 ++++++++++++++++++ .../style-engine/src/ooxml/index.ts | 30 +++++- .../core/layout-adapter/converters/table.ts | 95 ++++++++++++++++++- 3 files changed, 213 insertions(+), 4 deletions(-) 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 0286290c44..f31c2c4268 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -1416,3 +1416,95 @@ describe('ooxml - corner cell gating matches Word behavior', () => { expect(fills).toContain('NE'); }); }); + +/** + * SD-3028 G7: conditional firstCol/lastCol regions are GRID positions in Word, + * not display-cell indices. gridSpan, vMerge continuations (merged away at + * import), and gridBefore placeholders all shift display indices off the grid, + * landing edge styling one column early. TableInfo carries optional grid + * positions; display indices remain the fallback for legacy callers. + * + * Fixture evidence: merged_cells_with_styles.docx, Word render 2026-06-06 + * (B3 stays unshaded; the lastCol green follows grid column 3 through the + * vMerge; the gridSpan firstRow cell keeps firstCol at grid start). + */ +describe('grid-position conditional regions (SD-3028 G7)', () => { + const condGridStyles = { + ...emptyStyles, + styles: { + CondGrid: { + type: 'table', + tableStyleProperties: { + firstCol: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'FFFF00' } } }, + lastCol: { tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: '92D050' } } }, + }, + }, + }, + }; + + const tableInfoBase = { + tableProperties: { + tableStyleId: 'CondGrid', + tblLook: { firstRow: false, lastRow: false, firstColumn: true, lastColumn: true, noHBand: true, noVBand: true }, + }, + numRows: 3, + }; + + it('does not mark a middle cell lastCol when a vMerge hides the trailing display cell', () => { + // Row 3 of the fixture: display cells [A3, B3] because C3 is a vMerge + // continuation. B3 is display-last but sits at grid column 1 of 3. + const tableInfo = { + ...tableInfoBase, + rowIndex: 2, + cellIndex: 1, + numCells: 2, + gridColumnStart: 1, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toBeUndefined(); + }); + + it('marks lastCol when the cell grid span reaches the last grid column', () => { + // The vMerge restart cell in column C: display index 2, grid columns 2..3. + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 2, + numCells: 3, + gridColumnStart: 2, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: '92D050' }); + }); + + it('keeps firstCol by grid start when a placeholder shifts the display index', () => { + // A gridBefore placeholder makes the first REAL cell display index 1, but + // it still starts at grid column 0. + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 1, + numCells: 3, + gridColumnStart: 0, + gridColumnSpan: 1, + numGridCols: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'FFFF00' }); + }); + + it('falls back to display indices when grid positions are absent', () => { + const tableInfo = { + ...tableInfoBase, + rowIndex: 1, + cellIndex: 2, + numCells: 3, + }; + const result = resolveTableCellProperties(null, tableInfo, condGridStyles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: '92D050' }); + }); +}); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0f28d8cbd4..91ab4149b3 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.ts @@ -49,6 +49,17 @@ export interface TableInfo { numRows: number; rowCnfStyle?: ParagraphConditionalFormatting | null; cellCnfStyle?: ParagraphConditionalFormatting | null; + /** + * Grid position of the cell (SD-3028 G7). Word's firstCol/lastCol/banding + * regions are GRID columns, not display-cell indices: gridSpan, vMerge + * continuations (merged away at import), and gridBefore placeholders all + * shift display indices off the grid. When absent, display indices are used. + */ + gridColumnStart?: number | null; + /** Grid columns covered by the cell. Defaults to 1. */ + gridColumnSpan?: number | null; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number | null; } /** @@ -520,6 +531,9 @@ export function resolveCellStyles( colBandSize, tableInfo.rowCnfStyle, tableInfo.cellCnfStyle, + tableInfo.gridColumnStart, + tableInfo.gridColumnSpan, + tableInfo.numGridCols, ); cellStyleTypes.forEach((styleType) => { const typeProps = resolveConditionalProps(propertyType, styleType, tableStyleId, translatedLinkedStyles); @@ -610,16 +624,26 @@ function determineCellStyleTypes( colBandSize = 1, rowCnfStyle?: ParagraphConditionalFormatting | null, cellCnfStyle?: ParagraphConditionalFormatting | null, + gridColumnStart?: number | null, + gridColumnSpan?: number | null, + numGridCols?: number | null, ): TableStyleType[] { const applicable = new Set(['wholeTable']); const normalizedRowBandSize = rowBandSize > 0 ? rowBandSize : 1; const normalizedColBandSize = colBandSize > 0 ? colBandSize : 1; + // Column position on the GRID when the caller provides it (SD-3028 G7); + // display indices otherwise. firstCol/lastCol and vertical banding follow + // grid columns in Word, so spans and merges must not shift them. + const columnStart = gridColumnStart ?? cellIndex; + const columnEnd = columnStart + (gridColumnSpan ?? 1); + const columnCount = numGridCols ?? numCells; + // Per ECMA-376, banding excludes header/footer rows and first/last columns. // Offset the index so the first data row/column starts at band1. const bandRowIndex = Math.max(0, rowIndex - (tblLook?.firstRow ? 1 : 0)); - const bandColIndex = Math.max(0, cellIndex - (tblLook?.firstColumn ? 1 : 0)); + const bandColIndex = Math.max(0, columnStart - (tblLook?.firstColumn ? 1 : 0)); const rowGroup = Math.floor(bandRowIndex / normalizedRowBandSize); const colGroup = Math.floor(bandColIndex / normalizedColBandSize); @@ -634,8 +658,8 @@ function determineCellStyleTypes( // Row/column edge flags — reused for both row/col styles and corner gating. const isFirstRow = !!tblLook?.firstRow && rowIndex === 0; const isLastRow = !!tblLook?.lastRow && numRows != null && numRows > 0 && rowIndex === numRows - 1; - const isFirstCol = !!tblLook?.firstColumn && cellIndex === 0; - const isLastCol = !!tblLook?.lastColumn && numCells != null && numCells > 0 && cellIndex === numCells - 1; + const isFirstCol = !!tblLook?.firstColumn && columnStart === 0; + const isLastCol = !!tblLook?.lastColumn && columnCount != null && columnCount > 0 && columnEnd >= columnCount; if (isFirstRow) applicable.add('firstRow'); if (isFirstCol) applicable.add('firstCol'); diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index 6610cb89d6..b7aaa15e5c 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -110,6 +110,10 @@ type ParseTableCellArgs = { defaultCellPadding?: BoxSpacing; tableProperties?: TableProperties; rowCnfStyle?: Record | null; + /** Grid placement for conditional style regions (SD-3028 G7). */ + gridPlacement?: GridCellPlacement | null; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number; }; type ParseTableRowArgs = { @@ -120,6 +124,68 @@ type ParseTableRowArgs = { defaultCellPadding?: BoxSpacing; /** Table style to pass to paragraph converter for style cascade */ tableProperties?: TableProperties; + /** Per-content-index grid placements for this row (SD-3028 G7). */ + cellGridPlacements?: Array; + /** Total grid columns in the table (w:tblGrid length). */ + numGridCols?: number; +}; + +/** Grid column placement of one display cell (SD-3028 G7). */ +type GridCellPlacement = { + gridColumnStart: number; + gridColumnSpan: number; +}; + +/** + * Place a row's cells on the table grid (SD-3028 G7). + * + * Word's firstCol/lastCol/banding conditional regions follow GRID columns, but + * the PM document only exposes display cells: vMerge continuations are merged + * into rowspans on earlier rows and gridBefore/gridAfter become placeholder + * cells. This walks the row's content with a column cursor, skipping columns + * occupied by rowspans from above, so every display cell knows its grid start. + * + * Mirrors the measuring normalizer's activeRowSpans idiom: `activeRowSpans[col]` + * counts how many upcoming rows column `col` is still covered by. + * + * @param rowNode - The PM table row node. + * @param activeRowSpans - Occupied-column counters carried from previous rows. + * @returns Placements aligned to `rowNode.content` indices plus the counters + * for the next row. + */ +const placeRowCellsOnGrid = ( + rowNode: PMNode, + activeRowSpans: number[], +): { placements: Array; nextActiveRowSpans: number[] } => { + const placements: Array = []; + const nextActiveRowSpans = activeRowSpans.map((count) => Math.max(0, count - 1)); + let column = 0; + + const cellSpan = (cellNode: PMNode): number => { + const colspan = cellNode.attrs?.colspan; + if (typeof colspan === 'number' && colspan > 0) return colspan; + const colwidth = cellNode.attrs?.colwidth; + return Array.isArray(colwidth) && colwidth.length > 0 ? colwidth.length : 1; + }; + + for (const cellNode of Array.isArray(rowNode.content) ? rowNode.content : []) { + if (!isTableCellNode(cellNode)) { + placements.push(null); + continue; + } + while ((activeRowSpans[column] ?? 0) > 0) column += 1; + const span = cellSpan(cellNode); + placements.push({ gridColumnStart: column, gridColumnSpan: span }); + const rowspan = typeof cellNode.attrs?.rowspan === 'number' ? cellNode.attrs.rowspan : 1; + if (rowspan > 1) { + for (let covered = column; covered < column + span; covered += 1) { + nextActiveRowSpans[covered] = Math.max(nextActiveRowSpans[covered] ?? 0, rowspan - 1); + } + } + column += span; + } + + return { placements, nextActiveRowSpans }; }; const isTableRowNode = (node: PMNode): boolean => node.type === 'tableRow' || node.type === 'table_row'; @@ -304,7 +370,22 @@ const parseTableCell = (args: ParseTableCellArgs): TableCell | null => { 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 } + ? { + tableProperties, + rowIndex, + cellIndex, + numCells, + numRows, + rowCnfStyle, + cellCnfStyle, + ...(args.gridPlacement != null && args.numGridCols != null + ? { + gridColumnStart: args.gridPlacement.gridColumnStart, + gridColumnSpan: args.gridPlacement.gridColumnSpan, + numGridCols: args.numGridCols, + } + : {}), + } : undefined; // Resolve table cell properties from the style cascade (wholeTable → bands → conditional → inline) @@ -730,6 +811,8 @@ const parseTableRow = (args: ParseTableRowArgs): TableRow | null => { numCells: rowNode?.content?.length || 1, numRows, rowCnfStyle, + gridPlacement: args.cellGridPlacements?.[cellIndex] ?? null, + numGridCols: args.numGridCols, }); if (parsedCell) { cells.push(parsedCell); @@ -961,7 +1044,15 @@ export function tableNodeToBlock( : undefined; const rows: TableRow[] = []; + // Grid placements for conditional style regions (SD-3028 G7): Word's + // firstCol/lastCol/banding follow grid columns, so each display cell needs + // its grid start across rowspans, spans, and placeholder columns. + const grid = node.attrs?.grid; + const numGridCols = Array.isArray(grid) && grid.length > 0 ? grid.length : undefined; + let activeRowSpans: number[] = []; node.content.forEach((rowNode, rowIndex) => { + const { placements, nextActiveRowSpans } = placeRowCellsOnGrid(rowNode, activeRowSpans); + activeRowSpans = nextActiveRowSpans; const parsedRow = parseTableRow({ rowNode, rowIndex, @@ -969,6 +1060,8 @@ export function tableNodeToBlock( context: parserDeps, defaultCellPadding, tableProperties: tablePropertiesForCascade, + cellGridPlacements: placements, + numGridCols, }); if (parsedRow) { rows.push(parsedRow); From c4c415bb9496ceb0d0cb3c0b6b15befc87f9900f Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 12:40:58 -0300 Subject: [PATCH 15/17] test(style-engine): lock tblPr shading off cells (sd-3028 g5 disproven) The remaining G5 hypothesis claimed a table style's table-level shading (w:tblPr w:shd) should fill every cell. A fresh Word render of nested_tables_with_styles.docx (NestedSage style, tblPr shd C6E0B4, no tcPr shading) disproves it: the inner cells render pure white (zero C6E0B4 pixels); only the style's borders and run formatting apply. Locks the verified behavior: tblPr shading stays off cells, base tcPr shading still fills (the wholeTable layer shipped as 9a0a33f75). --- .../style-engine/src/ooxml/index.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 f31c2c4268..4277f76c72 100644 --- a/packages/layout-engine/style-engine/src/ooxml/index.test.ts +++ b/packages/layout-engine/style-engine/src/ooxml/index.test.ts @@ -1508,3 +1508,51 @@ describe('grid-position conditional regions (SD-3028 G7)', () => { expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: '92D050' }); }); }); + +/** + * SD-3028 G5 remainder, DISPROVEN and locked: a table STYLE's table-level + * shading (w:tblPr > w:shd) does NOT fill cells in Word. Measured from the + * nested_tables_with_styles.docx Word render (NestedSage style carries + * and no tcPr shading): the inner cells + * render pure white (zero C6E0B4 pixels); only the style's borders and run + * formatting apply. Cell fills come from the style's base tcPr (the + * wholeTable layer), conditional regions, or inline cell shading. + */ +describe('table style tblPr shading stays off cells (SD-3028 G5, Word-verified)', () => { + const tableInfo = { + tableProperties: { tableStyleId: 'NestedSage', tblLook: { noHBand: true, noVBand: true } }, + rowIndex: 0, + cellIndex: 0, + numRows: 2, + numCells: 2, + }; + + it('does not paint the style table-level shading onto cells', () => { + const styles = { + ...emptyStyles, + styles: { + NestedSage: { + type: 'table', + tableProperties: { shading: { val: 'clear', color: 'auto', fill: 'C6E0B4' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toBeUndefined(); + }); + + it('still fills cells from the style base tcPr when both shadings exist', () => { + const styles = { + ...emptyStyles, + styles: { + NestedSage: { + type: 'table', + tableProperties: { shading: { val: 'clear', color: 'auto', fill: 'C6E0B4' } }, + tableCellProperties: { shading: { val: 'clear', color: 'auto', fill: 'F2F2F2' } }, + }, + }, + }; + const result = resolveTableCellProperties(null, tableInfo, styles); + expect(result.shading).toEqual({ val: 'clear', color: 'auto', fill: 'F2F2F2' }); + }); +}); From bcbfb4e770e1c1a3335b72499587121a70399f68 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 15:14:29 -0300 Subject: [PATCH 16/17] fix(painter): paint row-boundary segments uncovered by narrower rows Word paints the horizontal boundary between two rows as one continuous line across the union of both rows' cell extents (300dpi probes: when a row is narrower via gridBefore/gridAfter, the uncovered slivers still render, with the table insideH border). The single-owner model gave the edge to the row below, so slivers with no cell below were painted by nobody, and the SD-3345 per-cell ownership flips doubled the covered span when the row above had multiple cells. Replace the flips (nextRowLeavesRightGap / deferTopToAboveCell) with fragment-level gap strips: cells below keep painting their own top, and boundary segments covered above but not below get a positioned strip resolved as the above cell bottom against the effective insideH. One paint per segment by construction: no doubling, no dropped corners. --- .../dom/src/table/renderTableFragment.test.ts | 229 ++++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 70 +++++- .../dom/src/table/renderTableRow.test.ts | 62 ++--- .../painters/dom/src/table/renderTableRow.ts | 89 ++----- .../dom/src/table/row-boundary-gaps.test.ts | 132 ++++++++++ .../dom/src/table/row-boundary-gaps.ts | 93 +++++++ 6 files changed, 551 insertions(+), 124 deletions(-) create mode 100644 packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts create mode 100644 packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index c8b71db135..1640f14a62 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -2212,4 +2212,233 @@ describe('renderTableFragment', () => { expect(positions).toEqual([0, 100]); }); }); + + describe('interior row boundary gap strips (SD-3028)', () => { + // Word paints the boundary between two rows as ONE continuous line across the UNION of + // both rows' extents (300dpi probes: gridBefore/gridAfter slivers render with insideH). + // Cells below own their own span; segments with a cell above but none below get a strip. + const para = (id: string) => ({ kind: 'paragraph' as const, id: id as BlockId, runs: [] }); + const measuredCell = (gridColumnStart: number, colSpan: number, width: number, rowSpan = 1) => ({ + paragraph: { kind: 'paragraph' as const, lines: [], totalHeight: 20 }, + width, + height: 20, + gridColumnStart, + colSpan, + rowSpan, + }); + const fragmentFor = (block: TableBlock, width: number, height: number, toRow: number): TableFragment => ({ + kind: 'table', + blockId: block.id, + fromRow: 0, + toRow, + x: 0, + y: 0, + width, + height, + }); + const render = (block: TableBlock, measure: TableMeasure, fragment: TableFragment) => + renderTableFragment({ + doc, + fragment, + context, + block, + measure, + cellSpacingPx: 0, + effectiveColumnWidths: measure.columnWidths, + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: () => {}, + }); + const gapStrips = (el: HTMLElement): HTMLElement[] => + Array.from(el.querySelectorAll('.superdoc-row-boundary-gap')) as HTMLElement[]; + + it('paints insideH strips over gridBefore/gridAfter slivers of a narrower row (SD-1513)', () => { + // Grid [20, 100, 15]: row 0 spans all three columns; row 1 covers only col 1. + const block: TableBlock = { + kind: 'table', + id: 'gap-table' as BlockId, + attrs: { + borders: { + top: { style: 'single', width: 1, color: '#000000' }, + bottom: { style: 'single', width: 1, color: '#000000' }, + left: { style: 'single', width: 1, color: '#000000' }, + right: { style: 'single', width: 1, color: '#000000' }, + insideH: { style: 'single', width: 1, color: '#FF0000' }, + insideV: { style: 'single', width: 1, color: '#FF00FF' }, + }, + }, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 3, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 3, 135)], height: 20 }, + { cells: [measuredCell(1, 1, 100)], height: 20 }, + ], + columnWidths: [20, 100, 15], + totalWidth: 135, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 135, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(2); + const lefts = strips.map((s) => parseFloat(s.style.left)).sort((a, b) => a - b); + const widths = strips.map((s) => parseFloat(s.style.width)).sort((a, b) => a - b); + expect(lefts).toEqual([0, 120]); + expect(widths).toEqual([15, 20]); + for (const strip of strips) { + expect(strip.style.top).toBe('20px'); + expect(strip.style.borderTopStyle).toBe('solid'); + expect(strip.style.borderTopColor.toLowerCase()).toBe('#ff0000'); + } + // The wide cell above must NOT paint its own interior bottom (the strip + the cell + // below own the boundary); painting it too would double the line. + const aboveCell = el.children[0] as HTMLElement; + expect(aboveCell.style.borderBottomWidth).toBe(''); + }); + + it('paints the uncovered span with the above cell own bottom border when there are no table borders (SD-3345 callout)', () => { + // The 23_notification shape: a full-width callout cell with its own borders above a + // narrower row (gridAfter). The strip closes the bottom-right corner in the callout color. + const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; + const block: TableBlock = { + kind: 'table', + id: 'callout-table' as BlockId, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { + id: 'callout' as BlockId, + colSpan: 2, + attrs: { borders: { top: blue, left: blue, right: blue, bottom: blue } }, + paragraph: para('p0'), + }, + ], + }, + { id: 'r1' as BlockId, cells: [{ id: 'opt' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 200)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(1); + expect(strips[0].style.left).toBe('100px'); + expect(strips[0].style.width).toBe('100px'); + expect(strips[0].style.top).toBe('20px'); + expect(strips[0].style.borderTopColor.toLowerCase()).toBe('#342d8c'); + // The callout does not also paint its interior bottom (single line, no doubling). + const callout = el.children[0] as HTMLElement; + expect(callout.style.borderBottomWidth).toBe(''); + }); + + it('does not paint a strip inside a rowspan crossing the boundary', () => { + const block: TableBlock = { + kind: 'table', + id: 'span-table' as BlockId, + attrs: { + borders: { + insideH: { style: 'single', width: 1, color: '#FF0000' }, + }, + }, + rows: [ + { + id: 'r0' as BlockId, + cells: [ + { id: 'tall' as BlockId, rowSpan: 2, paragraph: para('p0') }, + { id: 'top' as BlockId, paragraph: para('p1') }, + ], + }, + { id: 'r1' as BlockId, cells: [{ id: 'under' as BlockId, paragraph: para('p2') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 1, 100, 2), measuredCell(1, 1, 100)], height: 20 }, + { cells: [measuredCell(1, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + // Col 0 is a vMerge (no edge), col 1 is covered below (the cell paints its own top). + expect(gapStrips(el).length).toBe(0); + }); + + it('mirrors strip positions for RTL tables', () => { + const block: TableBlock = { + kind: 'table', + id: 'rtl-gap-table' as BlockId, + attrs: { + tableProperties: { rightToLeft: true }, + borders: { + insideH: { style: 'single', width: 1, color: '#FF0000' }, + }, + }, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 2, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 150)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 50], + totalWidth: 150, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 150, 40, 2)); + + const strips = gapStrips(el); + expect(strips.length).toBe(1); + // Logical gap is cols [1,2) = x 100..150; mirrored: left = 150 - 100 - 50 = 0. + expect(strips[0].style.left).toBe('0px'); + expect(strips[0].style.width).toBe('50px'); + }); + + it('paints no strip when neither the above cell nor the table defines a border for the edge', () => { + const block: TableBlock = { + kind: 'table', + id: 'borderless-gap-table' as BlockId, + rows: [ + { id: 'r0' as BlockId, cells: [{ id: 'c0' as BlockId, colSpan: 2, paragraph: para('p0') }] }, + { id: 'r1' as BlockId, cells: [{ id: 'c1' as BlockId, paragraph: para('p1') }] }, + ], + }; + const measure: TableMeasure = { + kind: 'table', + rows: [ + { cells: [measuredCell(0, 2, 200)], height: 20 }, + { cells: [measuredCell(0, 1, 100)], height: 20 }, + ], + columnWidths: [100, 100], + totalWidth: 200, + totalHeight: 40, + }; + const el = render(block, measure, fragmentFor(block, 200, 40, 2)); + + expect(gapStrips(el).length).toBe(0); + }); + }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 4de7ea6b25..50c18b62f5 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -5,6 +5,7 @@ import type { ParagraphBlock, SdtMetadata, TableBlock, + TableBorders, TableFragment, TableMeasure, } from '@superdoc/contracts'; @@ -22,8 +23,16 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; -import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; +import { + applyBorder, + borderValueToSpec, + hasExplicitCellBorders, + isExplicitNoneBorder, + isPresentBorder, + resolveTableBorderValue, +} from './border-utils.js'; import { getTableCellGridBounds } from './grid-geometry.js'; +import { buildColumnOccupancy, computeBoundaryGapSegments } from './row-boundary-gaps.js'; type ApplyStylesFn = (el: HTMLElement, styles: Partial) => void; /** @@ -482,9 +491,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRow: r > 0 ? block.rows[r - 1] : undefined, prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, - nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], - nextRowOccupiedRightCol: rowOccupiedRightCols[r + 1], totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -634,13 +641,14 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } // Render body rows (fromRow to toRow) - // Interior row boundary Ys, collected for the fragment-level compound middle grid. - const interiorRowBoundaries: number[] = []; + // Interior row boundary Ys, collected for the fragment-level compound middle grid and + // the row-boundary gap strips. + const interiorRowBoundaries: Array<{ y: number; belowRowIndex: number }> = []; for (let r = fragment.fromRow; r < fragment.toRow; r += 1) { const rowMeasure = measure.rows[r]; if (!rowMeasure) break; - if (r > fragment.fromRow) interiorRowBoundaries.push(y); + if (r > fragment.fromRow) interiorRowBoundaries.push({ y, belowRowIndex: r }); const isFirstRenderedBodyRow = r === fragment.fromRow; const isLastRenderedBodyRow = r === fragment.toRow - 1; @@ -660,9 +668,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRow: r > 0 ? block.rows[r - 1] : undefined, prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, - nextRowMeasure: r < block.rows.length - 1 ? measure.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], - nextRowOccupiedRightCol: rowOccupiedRightCols[r + 1], totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -820,7 +826,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement if (insideHMid && interiorRowBoundaries.length > 0) { const rule = midRuleOf(insideHMid); const color = colorOf(tableBorders?.insideH); - for (const gy of interiorRowBoundaries) { + for (const { y: gy } of interiorRowBoundaries) { appendStrip( 'superdoc-compound-border-midh', ringLeftInset, @@ -833,5 +839,51 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } } + // Word paints an interior row boundary as ONE continuous line across the UNION of the two + // adjacent rows' extents (300dpi probes: gridBefore/gridAfter slivers render with insideH). + // Cells in the row below own and paint their top across their own span; segments with a + // cell above but none below are closed here as positioned strips, so the line never doubles + // and never stops short of a wider row's edge. (SD-3028 / SD-1513) + if (cellSpacingPx === 0 && interiorRowBoundaries.length > 0 && block.rows?.length) { + const occupancy = buildColumnOccupancy(measure.rows, effectiveColumnWidths.length); + const columnX: number[] = [0]; + for (const width of effectiveColumnWidths) columnX.push(columnX[columnX.length - 1] + width); + + for (const { y: boundaryY, belowRowIndex } of interiorRowBoundaries) { + for (const segment of computeBoundaryGapSegments(occupancy, belowRowIndex)) { + // A rowspan cell that started before this fragment is rendered as a ghost cell, + // which already paints its own bottom edge. + if (segment.aboveCell.rowIndex < fragment.fromRow) continue; + + const aboveCell = block.rows[segment.aboveCell.rowIndex]?.cells?.[segment.aboveCell.cellIndex]; + const boundaryRowBorders = block.rows[belowRowIndex - 1]?.attrs?.borders; + const effectiveInsideH = boundaryRowBorders + ? ({ ...(tableBorders ?? {}), ...boundaryRowBorders } as TableBorders).insideH + : tableBorders?.insideH; + const cellBottom = aboveCell?.attrs?.borders?.bottom; + const spec = isExplicitNoneBorder(cellBottom) + ? undefined + : resolveTableBorderValue(cellBottom, effectiveInsideH); + if (!isPresentBorder(spec)) continue; + + const x = columnX[segment.startCol]; + const width = columnX[segment.endColExclusive] - x; + if (width <= 0) continue; + + const strip = doc.createElement('div'); + strip.className = 'superdoc-row-boundary-gap'; + const ss = strip.style; + ss.position = 'absolute'; + ss.pointerEvents = 'none'; + ss.left = `${isRtl ? fragment.width - x - width : x}px`; + ss.top = `${boundaryY}px`; + ss.width = `${width}px`; + ss.height = '0'; + applyBorder(strip, 'Top', spec); + container.appendChild(strip); + } + } + } + return container; }; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index 90c4a5584f..cb7b319fd5 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -492,9 +492,8 @@ describe('renderTableRow', () => { // SD-1797: a single row's measure only lists cells that START in it, so on a w:vMerge // (rowspan) continuation row the columns held by a cell spanning from above look empty. - // `rowOccupiedRightCol` / `nextRowOccupiedRightCol` count that occupancy so the single-owner - // edge ownership doesn't misfire (a leftmost cell drawing a right border, or a covered column - // mistaken for a gridAfter gap) and double the shared edge. + // `rowOccupiedRightCol` counts that occupancy so the single-owner edge ownership doesn't + // misfire (a leftmost cell drawing a right border) and double the shared edge. const sparseRow = (overrides: Record = {}) => createDeps({ rowIndex: 2, @@ -516,13 +515,14 @@ describe('renderTableRow', () => { expect(call.borders?.left).toBeDefined(); }); - it('does not treat a rowspan-covered column below a spanning cell as a gridAfter gap', () => { - // A cell spanning all 4 columns; the row below is fully covered (occupancy 4) -> no gap, - // so the spanning cell does NOT draw its own bottom (the cell below owns the shared edge). + it('never paints an interior bottom on a spanning cell, even over a gridAfter gap below', () => { + // Interior bottoms are always owned by the row below; boundary segments the row below + // leaves uncovered (gridBefore/gridAfter slivers) are closed by fragment-level gap strips + // (row-boundary-gaps.ts), never by this cell painting its full-width bottom — that would + // double the covered part of the edge (this painter has no border-collapse). (SD-3028) renderTableRow( sparseRow({ rowMeasure: { height: 20, cells: [{ width: 400, height: 20, gridColumnStart: 0, colSpan: 4, rowSpan: 1 }] }, - nextRowOccupiedRightCol: 4, }) as never, ); @@ -530,20 +530,6 @@ describe('renderTableRow', () => { expect(call.borders?.bottom).toBeUndefined(); }); - it('still treats a genuine gridAfter gap as a bottom boundary (SD-3345 preserved)', () => { - // The cell spans all 4 columns but the row below only reaches column 2 (real gridAfter gap), - // so the spanning cell must draw its own bottom across the uncovered span. - renderTableRow( - sparseRow({ - rowMeasure: { height: 20, cells: [{ width: 400, height: 20, gridColumnStart: 0, colSpan: 4, rowSpan: 1 }] }, - nextRowOccupiedRightCol: 2, - }) as never, - ); - - const call = getRenderedCellCall(); - expect(call.borders?.bottom).toBeDefined(); - }); - it('does not paint interior bottom border for explicit cell borders in collapsed mode on non-final row', () => { const explicit = { top: { style: 'single' as const, width: 2, color: '#123456' }, @@ -884,12 +870,12 @@ describe('renderTableRow', () => { expect(cell.borders?.top).toMatchObject({ style: 'single', color: '#000000' }); }); - it('draws its own bottom border when the next row leaves a gridAfter gap under it (SD-3345 callout corner)', () => { - // SD-3345 23_notification: the callout cell spans the full grid (gridSpan), but the - // row below has a gridAfter so its real cells do not reach the callout's rightmost - // column. Single-owner would defer the callout's bottom to the row below, which then - // stops short of the right edge → a gap at the bottom-right corner. The callout must - // draw its own bottom across the uncovered span instead. + it('keeps the interior bottom on the spanning callout suppressed even over a gridAfter gap below (SD-3345)', () => { + // SD-3345 23_notification: the callout cell spans the full grid, the row below has a + // gridAfter. The covered span of the shared edge is painted by the row below (its top + // resolves to the §17.4.66 winner, the callout blue), and the uncovered sliver is + // closed by a fragment-level gap strip (row-boundary-gaps.ts) — never by the callout + // painting its full-width bottom, which would double the covered part. (SD-3028) const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; renderTableRow( createDeps({ @@ -910,21 +896,17 @@ describe('renderTableRow', () => { }, ], }, - // next row covers only col0 (col1 is a gridAfter spacer → not a real cell) - nextRowMeasure: { - height: 20, - cells: [{ width: 100, height: 20, gridColumnStart: 0, colSpan: 1, rowSpan: 1 }], - }, }) as never, ); const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { bottom?: unknown } }; - expect(cell.borders?.bottom).toMatchObject({ style: 'single', color: '#342D8C' }); + expect(cell.borders?.bottom).toBeUndefined(); }); - it('suppresses this row top border when the cell above spans past it (gridAfter) so the edge is drawn once', () => { - // The companion to the case above: when the spanning cell owns the shared bottom edge, - // the narrower row below must NOT also draw its top, or the two adjacent cell divs stack - // into a doubled line (this painter has no border-collapse). (SD-3345 callout) + it('paints this row top as the conflict winner when the cell above spans past it (single line, below owns)', () => { + // The narrower row below a spanning bordered cell owns the covered span of the shared + // edge: its top resolves to the §17.4.66 winner of (own top, callout bottom) — the + // callout blue. The uncovered gridAfter sliver is closed by a fragment-level gap strip. + // Exactly one paint per segment: no doubling, no dropped corner. (SD-3028) const blue = { style: 'single' as const, width: 1, color: '#342D8C' }; renderTableRow( createDeps({ @@ -939,7 +921,7 @@ describe('renderTableRow', () => { id: 'r1', cells: [{ id: 'opt', attrs: {}, blocks: [{ kind: 'paragraph', id: 'po', runs: [] }] }], }, - // the cell above spans BOTH columns and has a bottom border (it owns the shared edge) + // the cell above spans BOTH columns and has a bottom border prevRow: { id: 'r0', cells: [ @@ -956,8 +938,8 @@ describe('renderTableRow', () => { }, }) as never, ); - const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: unknown } } | undefined; - expect(cell?.borders?.top).toBeUndefined(); + const cell = renderTableCellMock.mock.calls[0][0] as { borders?: { top?: unknown } }; + expect(cell.borders?.top).toMatchObject({ style: 'single', color: '#342D8C' }); }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 751669d106..be7f49f3f7 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -43,19 +43,6 @@ type CellBorderResolutionArgs = { leftCellBorders?: CellBorders; /** Borders of the cell directly to the right (same row, next grid column), for asymmetric-edge ownership. */ rightCellBorders?: CellBorders; - /** - * True when the next row's real cells do not reach this cell's right edge (e.g. the next - * row has a `w:gridAfter` spacer while this cell spans into it). The cell below then can't - * own the shared bottom edge across the uncovered span, so this cell must draw its own - * bottom border or the line stops short at the bottom-right corner. (SD-3345) - */ - nextRowLeavesRightGap?: boolean; - /** - * True when the cell ABOVE spans past this cell's row right edge (this row has a gridAfter - * relative to it). The spanning cell owns the shared bottom edge and draws it, so this cell - * must suppress its top border to avoid a doubled line. (SD-3345) - */ - deferTopToAboveCell?: boolean; /** * True when the row BELOW has a tblPrEx border override that suppresses its shared horizontal * edge (insideH none/nil). The lower cell owns that edge but won't draw it, so a present @@ -86,22 +73,18 @@ const resolveRenderedCellBorders = ({ aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, nextRowSuppressesSharedTop, }: CellBorderResolutionArgs): CellBorders | undefined => { const hasExplicitBorders = hasExplicitCellBorders(cellBorders); const cellBounds = getTableCellGridBounds(cellPosition); const touchesTopBoundary = cellBounds.touchesTopEdge || continuesFromPrev; - // The bottom is a real boundary either when this is the last row / a fragment break, OR - // when the next row's real cells don't reach this cell's right edge (a gridAfter spacer - // under a spanning cell): the row below can't own the shared edge across the uncovered - // span, so this (spanning) cell owns and draws its full-width bottom. The row below then - // suppresses its top there (see `deferTopToAboveCell`) so the edge is drawn exactly once — - // this painter has no border-collapse, so two cells drawing it would stack into a doubled - // line, not overlap. (SD-3345) - const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext || nextRowLeavesRightGap === true; + // Interior bottoms are always owned by the row below: each cell there paints its own top, + // and boundary segments the row below leaves uncovered (gridBefore/gridAfter slivers) are + // closed by fragment-level gap strips (see row-boundary-gaps.ts), never by this cell + // painting its full-width bottom — this painter has no border-collapse, so two cells + // drawing one edge stack into a doubled line. (SD-3345, SD-3028) + const touchesBottomBoundary = cellBounds.touchesBottomEdge || continuesOnNext; // A shared interior edge in the collapsed model is owned by the lower/right cell, so a // border defined ONLY by the neighbor above/left must still be painted here — even when @@ -109,7 +92,7 @@ const resolveRenderedCellBorders = ({ // suppressed its own edge under single-owner). (SD-2969: a bordered clause-header row // above a fully borderless spacer row.) const hasInteriorNeighborBorder = - (!touchesTopBoundary && !deferTopToAboveCell && isPresentBorder(aboveCellBorders?.bottom)) || + (!touchesTopBoundary && isPresentBorder(aboveCellBorders?.bottom)) || (!cellBounds.touchesLeftEdge && isPresentBorder(leftCellBorders?.right)); // Collapsed model (zero cell spacing): single-owner positioning, where the value at a @@ -125,15 +108,13 @@ const resolveRenderedCellBorders = ({ return { top: touchesTopBoundary ? resolveTableBorderValue(cb.top, tableBorders?.top) - : deferTopToAboveCell - ? undefined - : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? - // Both sides not present: an explicit nil on BOTH adjacent cells suppresses the - // shared horizontal edge (§17.4.66); only inherit the table insideH when at least - // one side is merely unset. (SD-3028) - (isExplicitNoneBorder(cb.top) && isExplicitNoneBorder(aboveCellBorders?.bottom) - ? undefined - : borderValueToSpec(tableBorders?.insideH))), + : (resolveBorderConflict(cb.top, aboveCellBorders?.bottom) ?? + // Both sides not present: an explicit nil on BOTH adjacent cells suppresses the + // shared horizontal edge (§17.4.66); only inherit the table insideH when at least + // one side is merely unset. (SD-3028) + (isExplicitNoneBorder(cb.top) && isExplicitNoneBorder(aboveCellBorders?.bottom) + ? undefined + : borderValueToSpec(tableBorders?.insideH))), // Vertical interior edges: when BOTH adjacent cells declare a border, the right cell // owns it (draws its left as the §17.4.66 winner) so the edge is painted once (no // doubling). When only ONE side declares a border (asymmetric, no doubling risk) that @@ -242,8 +223,6 @@ type TableRowRenderDependencies = { /** Next (below) row data, to detect a row-level border override that suppresses the shared * horizontal edge so the current row closes the grid itself (§17.4.61/§17.4.66). */ nextRow?: TableRow; - /** Next (below) row measure, to detect a gridAfter gap under a spanning cell (SD-3345). */ - nextRowMeasure?: TableRowMeasure; /** * Rightmost occupied grid column (exclusive) for THIS row, counting cells that span into it * via w:vMerge (rowspan) from an earlier row. Falls back to this row's own cells when absent. @@ -251,9 +230,6 @@ type TableRowRenderDependencies = { * column. (SD-1797) */ rowOccupiedRightCol?: number; - /** Same as {@link rowOccupiedRightCol} for the NEXT row, so a rowspan continuation below is - * not mistaken for a gridAfter gap (which would double the shared bottom edge). (SD-1797) */ - nextRowOccupiedRightCol?: number; /** Total number of rows in the table (for border resolution) */ totalRows: number; /** Table-level borders (for resolving cell borders) */ @@ -510,9 +486,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { prevRow, prevRowMeasure, nextRow, - nextRowMeasure, rowOccupiedRightCol, - nextRowOccupiedRightCol, totalRows, tableBorders, columnWidths, @@ -675,32 +649,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { return undefined; }; - // Right edge (exclusive grid column) of the cell occupying `gridCol` in `measureCells`. - const findCellRightEdgeAtColumn = ( - measureCells: TableRowMeasure['cells'] | undefined, - gridCol: number, - ): number | undefined => { - if (!measureCells) return undefined; - for (let i = 0; i < measureCells.length; i++) { - const start = measureCells[i].gridColumnStart ?? i; - const span = measureCells[i].colSpan ?? 1; - if (gridCol >= start && gridCol < start + span) return start + span; - } - return undefined; - }; - - // Rightmost grid column (exclusive) covered by the next row's REAL cells. When a spanning - // cell's right edge exceeds this, the next row has a gridAfter spacer beneath it and can't - // own the shared bottom edge across the uncovered span. (SD-3345) - // Rowspan-aware occupied width of the next row (counts cells spanning into it); fall back to - // the next row's own cells. A covered column must not look like a gridAfter gap. (SD-1797) - const nextRowMaxCol = - nextRowOccupiedRightCol != null && nextRowOccupiedRightCol > 0 - ? nextRowOccupiedRightCol - : nextRowMeasure?.cells?.length - ? Math.max(...nextRowMeasure.cells.map((c) => (c.gridColumnStart ?? 0) + (c.colSpan ?? 1))) - : Infinity; - for (let cellIndex = 0; cellIndex < rowMeasure.cells.length; cellIndex += 1) { const cellMeasure = rowMeasure.cells[cellIndex]; const cell = row?.cells?.[cellIndex]; @@ -736,13 +684,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { // The cell to the right (same row, the column just past this cell's span) — used to keep // an asymmetric vertical edge on the owning cell instead of moving it to the neighbor. const rightCellBorders = findCellBordersAtColumn(row?.cells, rowMeasure.cells, gridColumnStart + colSpan); - // This cell spans past the next row's real cells (gridAfter spacer beneath its right edge). - const nextRowLeavesRightGap = gridColumnStart + colSpan > nextRowMaxCol; - // Conversely, the cell ABOVE spans past THIS row's right edge (this row has a gridAfter - // relative to it). The spanning cell then owns the full shared edge and draws its own - // bottom, so this cell must NOT also draw its top, or the edge doubles. (SD-3345) - const aboveCellRightEdge = findCellRightEdgeAtColumn(prevRowMeasure?.cells, gridColumnStart); - const deferTopToAboveCell = aboveCellRightEdge !== undefined && aboveCellRightEdge > rowRightEdgeCol; // Resolve borders using logical positions, then swap output for RTL. // The resolver uses touchesLeftEdge/touchesRightEdge which are LOGICAL edges. @@ -759,8 +700,6 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, nextRowSuppressesSharedTop, }); // RTL: swap resolved left↔right so CSS properties match visual edges diff --git a/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts new file mode 100644 index 0000000000..d074cc9b32 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { buildColumnOccupancy, computeBoundaryGapSegments } from './row-boundary-gaps.js'; + +describe('buildColumnOccupancy', () => { + it('maps plain cells to their grid columns', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [{ gridColumnStart: 0, colSpan: 2 }] }, + ], + 2, + ); + expect(occupancy[0][0]).toEqual({ rowIndex: 0, cellIndex: 0 }); + expect(occupancy[0][1]).toEqual({ rowIndex: 0, cellIndex: 1 }); + expect(occupancy[1][0]).toEqual({ rowIndex: 1, cellIndex: 0 }); + expect(occupancy[1][1]).toBe(occupancy[1][0]); + }); + + it('leaves gridBefore/gridAfter columns unoccupied', () => { + // Row 1 skips col 0 (gridBefore) and col 3 (gridAfter): one merged cell over cols 1-2. + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 0, colSpan: 4 }] }, { cells: [{ gridColumnStart: 1, colSpan: 2 }] }], + 4, + ); + expect(occupancy[1][0]).toBeNull(); + expect(occupancy[1][1]).toEqual({ rowIndex: 1, cellIndex: 0 }); + expect(occupancy[1][2]).toBe(occupancy[1][1]); + expect(occupancy[1][3]).toBeNull(); + }); + + it('marks rowspan coverage on continuation rows with the same ref', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1, rowSpan: 2 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 1 }] }, + ], + 2, + ); + expect(occupancy[1][0]).toBe(occupancy[0][0]); + expect(occupancy[1][1]).toEqual({ rowIndex: 1, cellIndex: 0 }); + }); +}); + +describe('computeBoundaryGapSegments', () => { + it('returns no segments when the row below fully covers the row above', () => { + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 0, colSpan: 2 }] }, { cells: [{ gridColumnStart: 0, colSpan: 2 }] }], + 2, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([]); + }); + + it('finds the gridBefore and gridAfter slivers under a wider row (SD-1513 shape)', () => { + // Above: five cells over the full 7-col grid. Below: gridBefore=1, merged span 5, gridAfter=1. + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 2 }, + { gridColumnStart: 2, colSpan: 1 }, + { gridColumnStart: 3, colSpan: 1 }, + { gridColumnStart: 4, colSpan: 1 }, + { gridColumnStart: 5, colSpan: 2 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 5 }] }, + ], + 7, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 0, endColExclusive: 1, aboveCell: { rowIndex: 0, cellIndex: 0 } }, + { startCol: 6, endColExclusive: 7, aboveCell: { rowIndex: 0, cellIndex: 4 } }, + ]); + }); + + it('returns no segment where neither row has a cell (gridBefore in both rows)', () => { + const occupancy = buildColumnOccupancy( + [{ cells: [{ gridColumnStart: 1, colSpan: 2 }] }, { cells: [{ gridColumnStart: 1, colSpan: 2 }] }], + 3, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([]); + }); + + it('does not produce a segment inside a rowspan crossing the boundary', () => { + // Col 0 is a vMerge crossing the boundary: same cell above and below -> no edge, no strip. + // Col 2 of the above row has nothing below (gridAfter) -> strip. + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1, rowSpan: 2 }, + { gridColumnStart: 1, colSpan: 2 }, + ], + }, + { cells: [{ gridColumnStart: 1, colSpan: 1 }] }, + ], + 3, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 2, endColExclusive: 3, aboveCell: { rowIndex: 0, cellIndex: 1 } }, + ]); + }); + + it('splits adjacent gap columns owned by different above cells into separate segments', () => { + const occupancy = buildColumnOccupancy( + [ + { + cells: [ + { gridColumnStart: 0, colSpan: 1 }, + { gridColumnStart: 1, colSpan: 1 }, + ], + }, + { cells: [] }, + ], + 2, + ); + expect(computeBoundaryGapSegments(occupancy, 1)).toEqual([ + { startCol: 0, endColExclusive: 1, aboveCell: { rowIndex: 0, cellIndex: 0 } }, + { startCol: 1, endColExclusive: 2, aboveCell: { rowIndex: 0, cellIndex: 1 } }, + ]); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts new file mode 100644 index 0000000000..69ceae254d --- /dev/null +++ b/packages/layout-engine/painters/dom/src/table/row-boundary-gaps.ts @@ -0,0 +1,93 @@ +/** + * Interior row boundary coverage for the single-owner border model. + * + * Word paints the horizontal boundary between two rows as ONE continuous line across the + * UNION of both rows' cell extents (verified with 300dpi probes: when one row is narrower, + * e.g. via `w:gridBefore`/`w:gridAfter`, the uncovered slivers still render, and with the + * table's insideH border). In this painter each cell in the row BELOW owns and paints its + * top across its own span, so boundary segments that have a cell ABOVE but none BELOW are + * painted by nobody. These helpers identify exactly those segments so the fragment renderer + * can close them with positioned strips, without ever doubling a line that a cell below + * already paints. (SD-3028 / SD-1513) + */ + +/** Identifies a measured cell by the row it STARTS in and its index within that row. */ +export interface BoundaryCellRef { + rowIndex: number; + cellIndex: number; +} + +interface MeasuredCellLike { + gridColumnStart?: number; + colSpan?: number; + rowSpan?: number; +} + +interface MeasuredRowLike { + cells?: readonly MeasuredCellLike[] | null; +} + +/** + * Builds the per-row grid column occupancy map, including columns covered by cells that + * span into a row via rowspan (`w:vMerge`). `occupancy[r][c]` is the cell covering grid + * column `c` on row `r`, or null when no cell covers it (a gridBefore/gridAfter region). + */ +export const buildColumnOccupancy = ( + rows: ReadonlyArray, + numCols: number, +): (BoundaryCellRef | null)[][] => { + const occupancy: (BoundaryCellRef | null)[][] = rows.map(() => new Array(numCols).fill(null)); + rows.forEach((row, rowIndex) => { + row?.cells?.forEach((cell, cellIndex) => { + const startCol = cell.gridColumnStart ?? 0; + const endCol = Math.min(numCols, startCol + (cell.colSpan ?? 1)); + const endRow = Math.min(rows.length, rowIndex + (cell.rowSpan ?? 1)); + const ref: BoundaryCellRef = { rowIndex, cellIndex }; + for (let r = rowIndex; r < endRow; r += 1) { + for (let c = startCol; c < endCol; c += 1) { + occupancy[r][c] = ref; + } + } + }); + }); + return occupancy; +}; + +/** A run of grid columns on a row boundary covered above but not below. */ +export interface BoundaryGapSegment { + startCol: number; + endColExclusive: number; + /** The cell whose bottom edge forms this segment (its borders resolve the strip). */ + aboveCell: BoundaryCellRef; +} + +/** + * Segments of the boundary ABOVE `belowRowIndex` where a cell ends from above but no cell + * exists below. A rowspan cell crossing the boundary occupies both sides with the same ref, + * so it never produces a segment (there is no edge inside a vertical merge). Contiguous + * columns sharing the same above cell merge into one segment. + */ +export const computeBoundaryGapSegments = ( + occupancy: ReadonlyArray>, + belowRowIndex: number, +): BoundaryGapSegment[] => { + const above = occupancy[belowRowIndex - 1]; + const below = occupancy[belowRowIndex]; + if (!above || !below) return []; + + const segments: BoundaryGapSegment[] = []; + let current: BoundaryGapSegment | null = null; + for (let c = 0; c < above.length; c += 1) { + const aboveCell = above[c]; + const isGap = aboveCell !== null && below[c] === null; + if (isGap && current && current.aboveCell === aboveCell) { + current.endColExclusive = c + 1; + } else if (isGap) { + current = { startCol: c, endColExclusive: c + 1, aboveCell: aboveCell as BoundaryCellRef }; + segments.push(current); + } else { + current = null; + } + } + return segments; +}; From 536ae1216b8ac4e478cbedccb50e7a8945f2ad73 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Sat, 6 Jun 2026 18:01:59 -0300 Subject: [PATCH 17/17] fix(painter): word separate-borders model for outset and inset tables A table that authors w:tblCellSpacing (even 0) renders with Word's separate-borders model, measured from 300dpi probes (SD-3028): - every cell paints all four edges (own border, else the table border for its position) and adjacent edges stack into a double-width line - outset renders the legacy HTML bevel: raised table frame (visual top/left light, bottom/right dark) with sunken cells (the inverse); inset mirrors both - tones derive from the authored color: auto/black uses #F0F0F0 and #A0A0A0, an explicit color uses the color and its half intensity - in the collapsed model outset/inset stay plain solid lines at the authored width and color, also probe-verified outset/inset now flow through the contracts BorderStyle union, the adapter style sets, and the painter style maps with their ECMA-376 17.4.66 precedence numbers (24/25). --- packages/layout-engine/contracts/src/index.ts | 4 +- .../dom/src/table/border-utils.test.ts | 34 ++++++++++++ .../painters/dom/src/table/border-utils.ts | 50 +++++++++++++++++ .../dom/src/table/renderTableFragment.ts | 26 +++++++-- .../dom/src/table/renderTableRow.test.ts | 54 +++++++++++++++++++ .../painters/dom/src/table/renderTableRow.ts | 46 +++++++++++++++- .../core/layout-adapter/attributes/borders.ts | 2 + .../core/layout-adapter/converters/table.ts | 4 ++ 8 files changed, 212 insertions(+), 8 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index fd23e7b7a7..ea7da1a22e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -724,7 +724,9 @@ export type BorderStyle = | 'thickThinLargeGap' | 'thinThickThinLargeGap' | 'wave' - | 'doubleWave'; + | 'doubleWave' + | 'outset' + | 'inset'; /** Border specification for table and cell borders. */ export type BorderSpec = { diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index c5e8245968..6cbc85aa22 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.test.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts @@ -23,6 +23,7 @@ import { swapTableBordersLR, swapCellBordersLR, resolveBorderConflict, + bevelToneSpec, } from './border-utils.js'; describe('applyBorder', () => { @@ -611,3 +612,36 @@ describe('resolveBorderConflict (ECMA-376 §17.4.66)', () => { expect(resolveBorderConflict(dashed, dashSmallGap)).toEqual(dashSmallGap); }); }); + +describe('bevelToneSpec (separate-borders outset/inset, SD-3028)', () => { + const outset = (color: string) => ({ style: 'outset' as const, width: 1, color }); + const inset = (color: string) => ({ style: 'inset' as const, width: 1, color }); + + it('raises the table frame for outset: top/left light, bottom/right dark', () => { + expect(bevelToneSpec(outset('#000000'), 'top', 'table')).toMatchObject({ style: 'single', color: '#F0F0F0' }); + expect(bevelToneSpec(outset('#000000'), 'left', 'table')).toMatchObject({ color: '#F0F0F0' }); + expect(bevelToneSpec(outset('#000000'), 'bottom', 'table')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(outset('#000000'), 'right', 'table')).toMatchObject({ color: '#A0A0A0' }); + }); + + it('sinks the cells for outset: top/left dark, bottom/right light (legacy HTML look)', () => { + expect(bevelToneSpec(outset('#000000'), 'top', 'cell')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(outset('#000000'), 'bottom', 'cell')).toMatchObject({ color: '#F0F0F0' }); + }); + + it('inset mirrors both owners', () => { + expect(bevelToneSpec(inset('#000000'), 'top', 'table')).toMatchObject({ color: '#A0A0A0' }); + expect(bevelToneSpec(inset('#000000'), 'top', 'cell')).toMatchObject({ color: '#F0F0F0' }); + }); + + it('derives tones from an explicit color: light = the color, dark = half intensity', () => { + expect(bevelToneSpec(outset('#FF0000'), 'top', 'table')).toMatchObject({ color: '#FF0000' }); + expect(bevelToneSpec(outset('#FF0000'), 'bottom', 'table')).toMatchObject({ color: '#7f0000' }); + }); + + it('passes other styles through unchanged', () => { + const single = { style: 'single' as const, width: 1, color: '#123456' }; + expect(bevelToneSpec(single, 'top', 'table')).toBe(single); + expect(bevelToneSpec(undefined, 'top', 'cell')).toBeUndefined(); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/table/border-utils.ts b/packages/layout-engine/painters/dom/src/table/border-utils.ts index c36e2e2b55..6cbc3e6832 100644 --- a/packages/layout-engine/painters/dom/src/table/border-utils.ts +++ b/packages/layout-engine/painters/dom/src/table/border-utils.ts @@ -31,6 +31,8 @@ const ALLOWED_BORDER_STYLES = new Set([ 'thinThickThinLargeGap', 'wave', 'doubleWave', + 'outset', + 'inset', ]); const borderStyleToCSS = (style?: BorderStyle): string => { @@ -67,6 +69,11 @@ const borderStyleToCSS = (style?: BorderStyle): string => { thinThickThinLargeGap: 'solid', wave: 'solid', doubleWave: 'solid', + // In the collapsed model Word paints outset/inset as plain solid lines at the + // authored width and color (300dpi probes, SD-3028); the bevel only exists in + // separate-borders mode where bevelToneSpec retones the sides. + outset: 'solid', + inset: 'solid', }; return styleMap[style]; @@ -148,6 +155,47 @@ export const applyCellBorders = ( applyBorder(element, 'Left', borders.left, widthOverridesPx?.left); }; +/** The two tones Word uses for outset/inset bevel sides (300dpi probes, SD-3028). */ +const BEVEL_LIGHT_AUTO = '#F0F0F0'; +const BEVEL_DARK_AUTO = '#A0A0A0'; + +const bevelDarkColor = (color?: string): string => { + if (!color || !/^#[0-9A-Fa-f]{6}$/.test(color) || color.toLowerCase() === '#000000') return BEVEL_DARK_AUTO; + const half = (i: number) => Math.floor(parseInt(color.slice(i, i + 2), 16) / 2); + return `#${[1, 3, 5].map((i) => half(i).toString(16).padStart(2, '0')).join('')}`; +}; + +const bevelLightColor = (color?: string): string => { + if (!color || !/^#[0-9A-Fa-f]{6}$/.test(color) || color.toLowerCase() === '#000000') return BEVEL_LIGHT_AUTO; + return color; +}; + +/** + * Word's separate-borders bevel model for `outset`/`inset` (measured from 300dpi + * probes, SD-3028): the legacy HTML table look. With `outset` the TABLE frame is + * raised (visual top/left light, bottom/right dark) and each CELL is sunken (the + * inverse); `inset` mirrors both. Tones derive from the authored color: auto/black + * uses #F0F0F0 / #A0A0A0, an explicit color uses the color itself (light) and the + * color at half intensity (dark). All other styles pass through unchanged. Only + * separate-borders mode calls this; in the collapsed model Word paints these + * styles as plain solid lines. + */ +export const bevelToneSpec = ( + spec: BorderSpec | undefined, + visualSide: 'top' | 'right' | 'bottom' | 'left', + owner: 'table' | 'cell', +): BorderSpec | undefined => { + if (!spec || (spec.style !== 'outset' && spec.style !== 'inset')) return spec; + const raisedSide = visualSide === 'top' || visualSide === 'left'; + const raisedOwner = (spec.style === 'outset') === (owner === 'table'); + const light = raisedOwner === raisedSide; + return { + ...spec, + style: 'single', + color: light ? bevelLightColor(spec.color) : bevelDarkColor(spec.color), + }; +}; + /** * Converts a TableBorderValue to a BorderSpec for rendering. * @@ -239,6 +287,8 @@ const BORDER_STYLE_NUMBER: Partial> = { wave: 18, doubleWave: 19, dashSmallGap: 20, + outset: 24, + inset: 25, }; // Number of drawn lines per style (single=1, double=2, triple=3, …). const BORDER_STYLE_LINES: Partial> = { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index 50c18b62f5..21dfe708f6 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -24,6 +24,7 @@ import { type SdtBoundaryOptions, } from '../sdt/container.js'; import { + bevelToneSpec, applyBorder, borderValueToSpec, hasExplicitCellBorders, @@ -433,11 +434,24 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } const borderCollapse = block.attrs?.borderCollapse ?? (block.attrs?.cellSpacing != null ? 'separate' : 'collapse'); + // Word's separate-borders model also applies at spacing 0: edges stack, cells paint all four + // sides, and outset/inset render as the legacy HTML bevel (SD-3028, 300dpi probes). + const separateBorders = borderCollapse === 'separate'; if (borderCollapse === 'separate' && tableBorders) { - applyBorder(container, 'Top', borderValueToSpec(tableBorders.top)); - applyBorder(container, 'Right', borderValueToSpec(isRtl ? tableBorders.left : tableBorders.right)); - applyBorder(container, 'Bottom', borderValueToSpec(tableBorders.bottom)); - applyBorder(container, 'Left', borderValueToSpec(isRtl ? tableBorders.right : tableBorders.left)); + // The table frame renders raised for outset (visual top/left light, bottom/right dark), + // the inverse of its cells; inset mirrors. Other styles pass through unchanged. (SD-3028) + applyBorder(container, 'Top', bevelToneSpec(borderValueToSpec(tableBorders.top), 'top', 'table')); + applyBorder( + container, + 'Right', + bevelToneSpec(borderValueToSpec(isRtl ? tableBorders.left : tableBorders.right), 'right', 'table'), + ); + applyBorder(container, 'Bottom', bevelToneSpec(borderValueToSpec(tableBorders.bottom), 'bottom', 'table')); + applyBorder( + container, + 'Left', + bevelToneSpec(borderValueToSpec(isRtl ? tableBorders.right : tableBorders.left), 'left', 'table'), + ); } // Pre-calculate all row heights for rowspan calculations @@ -492,6 +506,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -669,6 +684,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement prevRowMeasure: r > 0 ? measure.rows[r - 1] : undefined, nextRow: r < block.rows.length - 1 ? block.rows[r + 1] : undefined, rowOccupiedRightCol: rowOccupiedRightCols[r], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -844,7 +860,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Cells in the row below own and paint their top across their own span; segments with a // cell above but none below are closed here as positioned strips, so the line never doubles // and never stops short of a wider row's edge. (SD-3028 / SD-1513) - if (cellSpacingPx === 0 && interiorRowBoundaries.length > 0 && block.rows?.length) { + if (cellSpacingPx === 0 && !separateBorders && interiorRowBoundaries.length > 0 && block.rows?.length) { const occupancy = buildColumnOccupancy(measure.rows, effectiveColumnWidths.length); const columnX: number[] = [0]; for (const width of effectiveColumnWidths) columnX.push(columnX[columnX.length - 1] + width); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts index cb7b319fd5..c090141c68 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -1060,4 +1060,58 @@ describe('renderTableRow', () => { expect(calls[1].x).toBe(4); }); }); + describe('separate-borders mode (authored tblCellSpacing, even 0) (SD-3028)', () => { + // Word probes (300dpi): with w:tblCellSpacing present every cell paints all four edges + // (own border, else the table border for its position) and adjacent edges STACK; outset + // cells render sunken: visual top/left dark #A0A0A0, bottom/right light #F0F0F0. + it('paints all four edges on an interior cell so adjacent edges stack like Word', () => { + renderTableRow( + createDeps({ + rowIndex: 3, + totalRows: 10, + cellSpacingPx: 0, + separateBorders: true, + }) as never, + ); + + const call = getRenderedCellCall(); + expect(call.borders?.top).toBeDefined(); + expect(call.borders?.bottom).toBeDefined(); + expect(call.borders?.left).toBeDefined(); + expect(call.borders?.right).toBeDefined(); + }); + + it('tones outset cell edges sunken: top dark, bottom light', () => { + renderTableRow( + createDeps({ + rowIndex: 3, + totalRows: 10, + cellSpacingPx: 0, + separateBorders: true, + tableBorders: { + top: { style: 'outset', width: 1, color: '#000000' }, + bottom: { style: 'outset', width: 1, color: '#000000' }, + left: { style: 'outset', width: 1, color: '#000000' }, + right: { style: 'outset', width: 1, color: '#000000' }, + insideH: { style: 'outset', width: 1, color: '#000000' }, + insideV: { style: 'outset', width: 1, color: '#000000' }, + }, + }) as never, + ); + + const call = getRenderedCellCall(); + expect(call.borders?.top).toMatchObject({ style: 'single', color: '#A0A0A0' }); + expect(call.borders?.bottom).toMatchObject({ style: 'single', color: '#F0F0F0' }); + expect(call.borders?.left).toMatchObject({ color: '#A0A0A0' }); + expect(call.borders?.right).toMatchObject({ color: '#F0F0F0' }); + }); + + it('keeps collapsed single-owner behavior when no cell spacing is authored', () => { + renderTableRow(createDeps({ rowIndex: 3, totalRows: 10, cellSpacingPx: 0 }) as never); + + const call = getRenderedCellCall(); + // Interior bottom owned by the row below in the collapsed model. + expect(call.borders?.bottom).toBeUndefined(); + }); + }); }); diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index be7f49f3f7..48184339a0 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -21,6 +21,7 @@ import { isPresentBorder, isExplicitNoneBorder, swapCellBordersLR, + bevelToneSpec, } from './border-utils.js'; import { getTableCellGridBounds, type TableCellGridPosition } from './grid-geometry.js'; import type { FragmentRenderContext } from '../renderer.js'; @@ -43,6 +44,13 @@ type CellBorderResolutionArgs = { leftCellBorders?: CellBorders; /** Borders of the cell directly to the right (same row, next grid column), for asymmetric-edge ownership. */ rightCellBorders?: CellBorders; + /** + * True when the table authored `w:tblCellSpacing` (even 0), which switches Word to the + * separate-borders model: every cell paints all four edges from its own/table borders and + * adjacent edges STACK (300dpi probes render single sz=6 boundaries 2x wide, SD-3028). + * Single-owner suppression does not apply. + */ + separateBorders?: boolean; /** * True when the row BELOW has a tblPrEx border override that suppresses its shared horizontal * edge (insideH none/nil). The lower cell owns that edge but won't draw it, so a present @@ -73,6 +81,7 @@ const resolveRenderedCellBorders = ({ aboveCellBorders, leftCellBorders, rightCellBorders, + separateBorders, nextRowSuppressesSharedTop, }: CellBorderResolutionArgs): CellBorders | undefined => { const hasExplicitBorders = hasExplicitCellBorders(cellBorders); @@ -103,6 +112,23 @@ const resolveRenderedCellBorders = ({ // (undefined, x) === x). Interior right/bottom are owned by the neighbor to the right/below; // outer edges use the cell border (which beats the table border), falling back to the table // border. Works whether or not table-level borders exist. (SD-3345, SD-2969) + // Authored `w:tblCellSpacing` (even 0) = Word's separate-borders model: each cell paints + // all four edges (own border, else the table outer/inside border for its position) and + // adjacent cell edges stack into a double-width line exactly like Word renders them. + // Spacing > 0 keeps the legacy branches below (visible gaps, probe-verified earlier). + if (separateBorders && cellSpacingPx === 0) { + const cb = (cellBorders ?? {}) as CellBorders; + return { + top: resolveTableBorderValue(cb.top, touchesTopBoundary ? tableBorders?.top : tableBorders?.insideH), + right: resolveTableBorderValue( + cb.right, + cellBounds.touchesRightEdge ? tableBorders?.right : tableBorders?.insideV, + ), + bottom: resolveTableBorderValue(cb.bottom, touchesBottomBoundary ? tableBorders?.bottom : tableBorders?.insideH), + left: resolveTableBorderValue(cb.left, cellBounds.touchesLeftEdge ? tableBorders?.left : tableBorders?.insideV), + }; + } + if (cellSpacingPx === 0 && (hasExplicitBorders || hasInteriorNeighborBorder)) { const cb = (cellBorders ?? {}) as CellBorders; return { @@ -230,6 +256,8 @@ type TableRowRenderDependencies = { * column. (SD-1797) */ rowOccupiedRightCol?: number; + /** Authored `w:tblCellSpacing` present (even 0): Word separate-borders model (SD-3028). */ + separateBorders?: boolean; /** Total number of rows in the table (for border resolution) */ totalRows: number; /** Table-level borders (for resolving cell borders) */ @@ -487,6 +515,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { prevRowMeasure, nextRow, rowOccupiedRightCol, + separateBorders, totalRows, tableBorders, columnWidths, @@ -700,10 +729,23 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { aboveCellBorders, leftCellBorders, rightCellBorders, + separateBorders, nextRowSuppressesSharedTop, }); // RTL: swap resolved left↔right so CSS properties match visual edges const finalBorders = isRtl && resolvedBorders ? swapCellBordersLR(resolvedBorders) : resolvedBorders; + // Separate-borders mode: outset/inset cells render sunken (the legacy HTML table look) — + // visual top/left dark, bottom/right light; inset mirrors. Toned after the RTL swap so the + // lighting follows VISUAL sides. Other styles pass through unchanged. (SD-3028, 300dpi probes) + const tonedBorders = + separateBorders && finalBorders + ? { + top: bevelToneSpec(finalBorders.top, 'top', 'cell'), + right: bevelToneSpec(finalBorders.right, 'right', 'cell'), + bottom: bevelToneSpec(finalBorders.bottom, 'bottom', 'cell'), + left: bevelToneSpec(finalBorders.left, 'left', 'cell'), + } + : finalBorders; // Calculate cell height - use rowspan height if cell spans multiple rows // For partial rows, use the partial height instead @@ -769,10 +811,10 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { const leftStraddleProfile = !visualTouchesLeft && rectBorders.left ? getBorderBandProfile(rectBorders.left) : null; const rightStraddleProfile = !visualTouchesRight && rectBorders.right ? getBorderBandProfile(rectBorders.right) : null; - let paintBorders = finalBorders; + let paintBorders = tonedBorders; let borderBandOverridesPx: { left?: number; right?: number } | undefined; if (leftStraddleProfile || rightStraddleProfile) { - paintBorders = { ...(finalBorders ?? {}) }; + paintBorders = { ...(tonedBorders ?? {}) }; borderBandOverridesPx = {}; if (leftStraddleProfile) { paintBorders.left = rectBorders.left; diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts index 536fe149a2..9551ca674c 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts @@ -215,6 +215,8 @@ const BORDER_STYLES = new Set([ 'thinThickThinLargeGap', 'wave', 'doubleWave', + 'outset', + 'inset', ]); function isBorderStyle(value: unknown): value is BorderStyle { diff --git a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts index b7aaa15e5c..8e037f3503 100644 --- a/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts +++ b/packages/super-editor/src/editors/v1/core/layout-adapter/converters/table.ts @@ -249,6 +249,10 @@ function normalizeLegacyBorderStyle(value: string | undefined): string { return 'wave'; case 'doublewave': return 'doubleWave'; + case 'outset': + return 'outset'; + case 'inset': + return 'inset'; case 'single': default: return 'single';