Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9a0a33f
fix(style-engine): surface table style base tcPr as the wholeTable layer
tupizz Jun 5, 2026
5a94ece
fix(painter): clamp double borders to CSS-visible width
tupizz Jun 5, 2026
0acd93b
fix(layout): double border band width and row reservation
tupizz Jun 5, 2026
26a163e
fix(measuring): content-size pure-auto tables like Word
tupizz Jun 5, 2026
32c0c9b
fix(painter): paint double borders as pixel-snapped strip overlays
tupizz Jun 5, 2026
f1bed4b
fix(painter): render double borders as Word's nested rectangles
tupizz Jun 5, 2026
960937b
feat(layout): render compound table border bands as nested rectangles
tupizz Jun 5, 2026
314a748
fix(measuring): apply word band rules to content-sized table columns
tupizz Jun 5, 2026
c4ed015
fix(editor): emit word tcw cell widths from inserttable
tupizz Jun 5, 2026
dcf99f4
fix(painter): straddle interior compound bands and join their middle …
tupizz Jun 5, 2026
45e5a2e
fix(measuring): keep vmerge-only table rows one line high
tupizz Jun 5, 2026
57ff800
fix(painter): narrow rectBorders for the strict references build
tupizz Jun 6, 2026
3b8d296
test(measuring): lock overhang merged-inset row geometry (sd-1513)
tupizz Jun 6, 2026
c69634b
fix(style-engine): resolve conditional regions by grid column (sd-302…
tupizz Jun 6, 2026
c4c415b
test(style-engine): lock tblPr shading off cells (sd-3028 g5 disproven)
tupizz Jun 6, 2026
bcbfb4e
fix(painter): paint row-boundary segments uncovered by narrower rows
tupizz Jun 6, 2026
536ae12
fix(painter): word separate-borders model for outset and inset tables
tupizz Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/layout-engine/contracts/src/border-band.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getBorderBandProfile>[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);
});
});
93 changes: 93 additions & 0 deletions packages/layout-engine/contracts/src/border-band.ts
Original file line number Diff line number Diff line change
@@ -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<string, (w: number) => 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;
}
18 changes: 17 additions & 1 deletion packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
});
});
Loading