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 new file mode 100644 index 0000000000..8e9b32deb3 --- /dev/null +++ b/packages/layout-engine/contracts/src/border-band.ts @@ -0,0 +1,93 @@ +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. + * + * 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. + * - 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 + * `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); + 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 afacffd35c..ea7da1a22e 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -53,6 +53,10 @@ 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, 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 { normalizeZIndex, @@ -704,13 +708,25 @@ export type BorderStyle = | 'single' | 'double' | 'dashed' + | 'dashSmallGap' | 'dotted' | 'thick' | 'triple' | 'dotDash' | 'dotDotDash' + | 'thinThickSmallGap' + | 'thickThinSmallGap' + | 'thinThickThinSmallGap' + | 'thinThickMediumGap' + | 'thickThinMediumGap' + | 'thinThickThinMediumGap' + | 'thinThickLargeGap' + | 'thickThinLargeGap' + | 'thinThickThinLargeGap' | 'wave' - | 'doubleWave'; + | 'doubleWave' + | 'outset' + | 'inset'; /** Border specification for table and cell borders. */ export type BorderSpec = { 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-columns.ts b/packages/layout-engine/measuring/dom/src/autofit-columns.ts index d2f1e59e46..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, }); @@ -254,7 +261,40 @@ 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; + // 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 + // 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); } @@ -334,6 +374,7 @@ function resolveAutoFitContext(input: AutoFitInput): AutoFitContext { preferredWidth: cell.preferredWidth, minContentWidth: cell.minContentWidth, maxContentWidth: cell.maxContentWidth, + horizontalInsets: cell.horizontalInsets, })), })); @@ -382,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; } @@ -429,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) => ({ @@ -449,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) { @@ -467,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.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); + }); +}); diff --git a/packages/layout-engine/measuring/dom/src/autofit-normalize.ts b/packages/layout-engine/measuring/dom/src/autofit-normalize.ts index 8b2bcb4c12..3231daa340 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 @@ -178,6 +192,7 @@ export function buildAutoFitWorkingGridInput( preferredColumnWidths, preferredTableWidth, gridColumnCount, + rows, }); const preserveExplicitAutoGrid = shouldPreserveExplicitAutoGrid({ layoutMode, @@ -186,14 +201,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 +232,8 @@ export function buildAutoFitWorkingGridInput( ...(preserveAutoGrid ? { preserveAutoGrid } : {}), ...(preserveExplicitAutoGrid ? { preserveExplicitAutoGrid } : {}), ...(autoGridWidthBudget != null ? { autoGridWidthBudget } : {}), + ...(contentSizeAutoTable ? { contentSizeAutoTable } : {}), + ...(columnBandAllowances ? { columnBandAllowances } : {}), preferredTableWidth, preferredColumnWidths, gridColumnCount, @@ -239,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; } @@ -264,6 +302,84 @@ 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. + * + * 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, + 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++) { + 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; +} + 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 f6407cb251..c99e996e57 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), @@ -4298,6 +4301,126 @@ 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 => + ({ + 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); + }); + + 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', () => { const makeCell = (id: string) => ({ id, @@ -4501,7 +4624,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', @@ -4541,8 +4664,139 @@ 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); + }); + + // 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 () => { @@ -5535,9 +5789,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/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index f7d97ac1aa..c28eede462 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). */ @@ -2979,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]; @@ -3107,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 @@ -3125,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; @@ -3143,6 +3169,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/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/border-utils.test.ts b/packages/layout-engine/painters/dom/src/table/border-utils.test.ts index ebadd10b2d..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', () => { @@ -39,10 +40,26 @@ 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: 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(/2px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); + 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('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(/12px double (#FF0000|rgb\(255,\s*0,\s*0\))/i); }); it('should apply border with dashed style', () => { @@ -57,10 +74,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', () => { @@ -561,4 +595,53 @@ 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); + }); +}); + +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 1285d0aae0..6cbc3e6832 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([ @@ -13,13 +14,25 @@ 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', + 'outset', + 'inset', ]); const borderStyleToCSS = (style?: BorderStyle): string => { @@ -31,18 +44,36 @@ 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', + // 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]; @@ -72,6 +103,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) { @@ -80,10 +112,15 @@ export const applyBorder = ( } const style = borderStyleToCSS(border.style); - 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; + // 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. `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}`; }; @@ -106,12 +143,57 @@ 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); +}; + +/** 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), + }; }; /** @@ -193,8 +275,20 @@ 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, + outset: 24, + inset: 25, }; // Number of drawn lines per style (single=1, double=2, triple=3, …). const BORDER_STYLE_LINES: Partial> = { @@ -206,8 +300,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/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..ebc66935e4 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'; @@ -554,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 */ @@ -703,6 +710,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen fromLine, toLine, resolvePhysical, + borderBandOverridesPx, } = deps; const attrs = cell?.attrs; @@ -714,9 +722,30 @@ 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) + // 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; + 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, borderBandOverridesPx?.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, borderBandOverridesPx?.right), + ); const paddingBottom = padding.bottom ?? 0; const cellEl = doc.createElement('div'); @@ -735,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..1640f14a62 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', @@ -2106,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 38989cf2b5..21dfe708f6 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -5,10 +5,11 @@ import type { ParagraphBlock, SdtMetadata, TableBlock, + TableBorders, TableFragment, TableMeasure, } from '@superdoc/contracts'; -import { getTableVisualDirection } 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'; @@ -22,8 +23,17 @@ import { type SdtAncestorOptions, type SdtBoundaryOptions, } from '../sdt/container.js'; -import { applyBorder, borderValueToSpec, hasExplicitCellBorders } from './border-utils.js'; +import { + bevelToneSpec, + 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; /** @@ -424,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 @@ -482,9 +505,8 @@ 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], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -634,10 +656,15 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement } // Render body rows (fromRow to toRow) + // 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, belowRowIndex: r }); + const isFirstRenderedBodyRow = r === fragment.fromRow; const isLastRenderedBodyRow = r === fragment.toRow - 1; @@ -656,9 +683,8 @@ 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], + separateBorders, totalRows: block.rows.length, tableBorders, columnWidths: effectiveColumnWidths, @@ -689,5 +715,191 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement y += actualRowHeight + cellSpacingPx; } + // 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], + ['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 }; + 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-compound-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}`; + } + } + + // 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 { y: gy } of interiorRowBoundaries) { + appendStrip( + 'superdoc-compound-border-midh', + ringLeftInset, + Math.round(gy + midOffsetOf(insideHMid)), + fragmentWidth - ringLeftInset - ringRightInset, + rule, + color, + ); + } + } + } + + // 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 && !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); + + 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 7062f0569b..c090141c68 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.test.ts @@ -285,11 +285,215 @@ describe('renderTableRow', () => { expect(secondCall.borders?.left).toBeDefined(); }); + // 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, + 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 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. + 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/); + // 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). + // 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: 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, + ); + + 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-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 - // 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, @@ -311,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, ); @@ -325,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' }, @@ -679,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({ @@ -705,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({ @@ -734,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: [ @@ -751,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' }); }); }); @@ -873,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 04cfcc4aa4..48184339a0 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 { getBorderBandProfile } from '@superdoc/contracts'; import type { ResolvePhysicalFamily } from '@superdoc/font-system'; import { renderTableCell } from './renderTableCell.js'; import { @@ -20,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,18 +45,12 @@ type CellBorderResolutionArgs = { /** 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) + * 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. */ - 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; + 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 @@ -85,22 +81,19 @@ const resolveRenderedCellBorders = ({ aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, + separateBorders, 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 @@ -108,7 +101,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 @@ -119,20 +112,35 @@ 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 { 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 @@ -241,8 +249,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. @@ -250,9 +256,8 @@ 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; + /** 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) */ @@ -363,6 +368,141 @@ type TableRowRenderDependencies = { * // Appends all cell elements to container * ``` */ +/** + * 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 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 appendCompoundBorderRects = ( + doc: Document, + container: HTMLElement, + cellElement: HTMLElement, + borders: CellBorders | undefined, + rect: { x: number; y: number; width: number; height: number }, + 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; + 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, outerRule, innerRule, midRule, midOffset, 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 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'; + cellElement.style[`border${cssSide}Color`] = 'transparent'; + } + + const [top, right, bottom, left] = sideInfo; + const rectEl = doc.createElement('div'); + 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. 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 + ? 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 + ? 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`; + st.height = `${y1 - y0 - topInset - bottomInset}px`; + 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 = `${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 + // 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 && !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'; + 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 => { const { doc, @@ -374,9 +514,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { prevRow, prevRowMeasure, nextRow, - nextRowMeasure, rowOccupiedRightCol, - nextRowOccupiedRightCol, + separateBorders, totalRows, tableBorders, columnWidths, @@ -539,32 +678,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]; @@ -600,13 +713,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. @@ -623,12 +729,23 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { aboveCellBorders, leftCellBorders, rightCellBorders, - nextRowLeavesRightGap, - deferTopToAboveCell, + 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 @@ -656,6 +773,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) ?? 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 = tonedBorders; + let borderBandOverridesPx: { left?: number; right?: number } | undefined; + if (leftStraddleProfile || rightStraddleProfile) { + paintBorders = { ...(tonedBorders ?? {}) }; + 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({ @@ -665,7 +835,8 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { rowHeight: cellHeight, cellMeasure, cell, - borders: finalBorders, + borders: paintBorders, + borderBandOverridesPx, useDefaultBorder: false, renderLine, captureLineSnapshot, @@ -687,5 +858,57 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { }); container.appendChild(cellElement); + + // 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 + ? effectiveTableBorders?.top + : effectiveTableBorders?.insideH, + ), + bottom: tableProvidesMid( + cellGridBounds.touchesBottomEdge || continuesOnNext === true + ? effectiveTableBorders?.bottom + : effectiveTableBorders?.insideH, + ), + left: tableProvidesMid( + visualTouchesLeft + ? isRtl + ? effectiveTableBorders?.right + : effectiveTableBorders?.left + : effectiveTableBorders?.insideV, + ), + right: tableProvidesMid( + visualTouchesRight + ? isRtl + ? effectiveTableBorders?.left + : effectiveTableBorders?.right + : effectiveTableBorders?.insideV, + ), + }; + + appendCompoundBorderRects( + doc, + container, + cellElement, + rectBorders, + { + x, + y, + width: computedCellWidth > 0 ? computedCellWidth : (cellMeasure.width ?? 0), + height: cellHeight, + }, + { + ownsBottomBand: cellGridBounds.touchesBottomEdge || continuesOnNext === true, + rightIsBoundary: visualTouchesRight, + leftIsBoundary: visualTouchesLeft, + suppressMid, + }, + ); } }; 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; +}; 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..4277f76c72 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 // ────────────────────────────────────────────────────────────────────────────── @@ -1330,3 +1416,143 @@ 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' }); + }); +}); + +/** + * 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' }); + }); +}); diff --git a/packages/layout-engine/style-engine/src/ooxml/index.ts b/packages/layout-engine/style-engine/src/ooxml/index.ts index 0791ee9472..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; } /** @@ -483,6 +494,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; @@ -511,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); @@ -601,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); @@ -625,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/attributes/borders.ts b/packages/super-editor/src/editors/v1/core/layout-adapter/attributes/borders.ts index 8f80e5bbe9..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 @@ -198,13 +198,25 @@ 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', + '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 b6d2d44e41..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 @@ -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'; @@ -159,10 +225,34 @@ 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': return 'doubleWave'; + case 'outset': + return 'outset'; + case 'inset': + return 'inset'; case 'single': default: return 'single'; @@ -284,7 +374,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) @@ -710,6 +815,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); @@ -941,7 +1048,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, @@ -949,6 +1064,8 @@ export function tableNodeToBlock( context: parserDeps, defaultCellPadding, tableProperties: tablePropertiesForCascade, + cellGridPlacements: placements, + numGridCols, }); if (parsedRow) { rows.push(parsedRow); 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) {