Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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;
}
14 changes: 14 additions & 0 deletions 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,11 +708,21 @@ export type BorderStyle =
| 'single'
| 'double'
| 'dashed'
| 'dashSmallGap'
| 'dotted'
| 'thick'
| 'triple'
| 'dotDash'
| 'dotDotDash'
| 'thinThickSmallGap'
| 'thickThinSmallGap'
| 'thinThickThinSmallGap'
| 'thinThickMediumGap'
| 'thickThinMediumGap'
| 'thinThickThinMediumGap'
| 'thinThickLargeGap'
| 'thickThinLargeGap'
| 'thinThickThinLargeGap'
| 'wave'
| 'doubleWave';

Expand Down
55 changes: 53 additions & 2 deletions packages/layout-engine/measuring/dom/src/autofit-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -166,6 +170,7 @@ type NormalizedCell = {
preferredWidth?: number;
minContentWidth: number;
maxContentWidth: number;
horizontalInsets: number;
};

type NormalizedRow = {
Expand Down Expand Up @@ -214,13 +219,15 @@ export function computeAutoFitColumnWidths(input: AutoFitInput): AutoFitResult {
const currentWidths = fixedLayout.columnWidths.slice(0, gridColumnCount);
const minBounds = new Array<number>(gridColumnCount).fill(0);
const maxBounds = new Array<number>(gridColumnCount).fill(0);
const textBounds = new Array<number>(gridColumnCount).fill(0);
const preferredOverrides = new Array<number | undefined>(gridColumnCount).fill(undefined);
const multiSpanCells: NormalizedCell[] = [];

accumulateBounds({
rows: normalizedRows,
minBounds,
maxBounds,
textBounds,
preferredOverrides,
multiSpanCells,
});
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -334,6 +374,7 @@ function resolveAutoFitContext(input: AutoFitInput): AutoFitContext {
preferredWidth: cell.preferredWidth,
minContentWidth: cell.minContentWidth,
maxContentWidth: cell.maxContentWidth,
horizontalInsets: cell.horizontalInsets,
})),
}));

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) => ({
Expand All @@ -449,10 +492,11 @@ function accumulateBounds(args: {
rows: NormalizedRow[];
minBounds: number[];
maxBounds: number[];
textBounds: number[];
preferredOverrides: Array<number | undefined>;
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) {
Expand All @@ -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;
}
Expand Down
Loading
Loading