Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c82a60a
fix(columns): resolveColumnLayout selects usable width/gap records, n…
caio-pizzol Jun 5, 2026
2c2153a
feat(columns): step-4 flip: explicit widths unscaled, per-column gaps…
caio-pizzol Jun 4, 2026
d5ce253
test(columns): flip downstream geometry test to no-scale; fix stale s…
caio-pizzol Jun 4, 2026
c4af500
feat(columns): position-hit determineColumn uses resolved geometry (S…
caio-pizzol Jun 4, 2026
ae5e74b
feat(columns): footnote column assignment + X read resolved geometry …
caio-pizzol Jun 4, 2026
d1d60ed
feat(columns): balancing column x from resolved geometry, not uniform…
caio-pizzol Jun 4, 2026
b9d6937
feat(columns): floating anchors read resolved geometry (SD-2629 4c)
caio-pizzol Jun 4, 2026
d5929a8
test(columns): pin per-column gap placement at the engine (SD-2629 4d)
caio-pizzol Jun 4, 2026
9ee7ce5
fix(columns): getColumnGeometry honors count when widths is absent (S…
caio-pizzol Jun 4, 2026
bbb3393
test(columns): resolveAnchoredGraphicX honors per-column geometry (SD…
caio-pizzol Jun 5, 2026
3e5700d
fix(columns): determineColumn maps clicks over the content box, not t…
caio-pizzol Jun 5, 2026
67e2a4e
fix(columns): anchor available width stays scalar to match object mea…
caio-pizzol Jun 5, 2026
4bc1c89
refactor(columns): balancing reads resolved widths/gaps via one build…
caio-pizzol Jun 5, 2026
ed56edb
test(columns): pin no-scale-down for overfull explicit widths (SD-262…
caio-pizzol Jun 5, 2026
fe3f294
fix(columns): hit-testing resolves the fragment's mid-page column reg…
caio-pizzol Jun 5, 2026
50d5235
fix(columns): table hit metadata uses its region's column count, not …
caio-pizzol Jun 5, 2026
ec23716
docs(columns): correct resolveColumnsForHit fallback comment (SD-2629)
caio-pizzol Jun 5, 2026
ceab584
Merge branch 'main' into caio-pizzol/sd-2629-step4-flip
harbournick Jun 5, 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
63 changes: 55 additions & 8 deletions packages/layout-engine/contracts/src/column-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,24 @@ describe('normalizeColumnLayout', () => {
});
});

it('scales explicit widths to the available width', () => {
it('does not scale explicit widths; authored widths are preserved (SD-2629 step 4)', () => {
// Word renders authored column widths as-is and leaves trailing space when they underfill, so
// [100, 200] in a 600px content area stays [100, 200] rather than stretching to [200, 400].
expect(normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: false }, 624)).toEqual({
count: 2,
gap: 24,
widths: [100, 200],
equalWidth: false,
width: 200,
});
});

it('does not scale DOWN overfull explicit widths either; authored widths overflow (SD-2629, Word-verified)', () => {
// Word keeps authored explicit widths even when they EXCEED the content area: a Word probe of two
// 360pt columns + 36pt gap in a 468pt content box renders both at 360pt, with column 2 overflowing
// off the page edge (Word re-saves the w:cols unchanged). So normalize must not scale down either -
// [200, 400] in a 300px content box stays [200, 400] (overfull), matching Word's overflow.
expect(normalizeColumnLayout({ count: 2, gap: 24, widths: [200, 400], equalWidth: false }, 300)).toEqual({
count: 2,
gap: 24,
widths: [200, 400],
Expand Down Expand Up @@ -151,7 +167,7 @@ describe('normalizeColumnLayout', () => {
});
});

describe('getColumnGeometry + geometry helpers (SD-2629, behavior-preserving)', () => {
describe('getColumnGeometry + geometry helpers (SD-2629)', () => {
it('mirrors equal-width normalized output (uniform gap, content-relative x)', () => {
const geom = getColumnGeometry(normalizeColumnLayout({ count: 2, gap: 24 }, 624));
expect(geom).toEqual([
Expand All @@ -160,13 +176,13 @@ describe('getColumnGeometry + geometry helpers (SD-2629, behavior-preserving)',
]);
});

it('mirrors explicit (scaled) widths', () => {
it('mirrors explicit widths without scaling (SD-2629 step 4)', () => {
const geom = getColumnGeometry(
normalizeColumnLayout({ count: 2, gap: 24, widths: [100, 200], equalWidth: false }, 624),
);
expect(geom).toEqual([
{ index: 0, x: 0, width: 200, gapAfter: 24 },
{ index: 1, x: 224, width: 400, gapAfter: 0 },
{ index: 0, x: 0, width: 100, gapAfter: 24 },
{ index: 1, x: 124, width: 200, gapAfter: 0 },
]);
});

Expand Down Expand Up @@ -195,10 +211,23 @@ describe('getColumnGeometry + geometry helpers (SD-2629, behavior-preserving)',
expect(getColumnAtX(geom, 96 + 100, 96)).toBe(0);
});

it('does NOT let per-column gaps drive geometry yet (step 1 is behavior-preserving)', () => {
// `gaps` is raw explicit-mode input; geometry still uses the scalar gap until the step-4 flip.
it('lets per-column gaps drive geometry (SD-2629 step 4)', () => {
// gaps[i] is the gap after column i; geometry uses it instead of the uniform scalar gap.
const geom = getColumnGeometry({ count: 2, gap: 24, widths: [300, 300], gaps: [999], width: 300 });
expect(geom[0].gapAfter).toBe(24);
expect(geom[0].gapAfter).toBe(999);
expect(geom[1].x).toBe(300 + 999);
});

it('expands an equal-mode layout with no widths array to `count` columns (SD-2629 regression)', () => {
// A hand-built equal-mode layout (column-balancing) carries only the scalar `width`, no widths
// array. Geometry must still yield `count` columns; collapsing to a single column mapped every
// index past 0 onto column 0's x, stacking balanced multi-column content on the left margin.
const geom = getColumnGeometry({ count: 2, gap: 48, width: 288 });
expect(geom).toEqual([
{ index: 0, x: 0, width: 288, gapAfter: 48 },
{ index: 1, x: 336, width: 288, gapAfter: 0 },
]);
expect(getColumnX(geom, 1, 96)).toBe(432);
});
});

Expand Down Expand Up @@ -300,6 +329,24 @@ describe('resolveColumnLayout (SD-2629)', () => {
// Omitted equalWidth is equal mode too.
expect(resolveColumnLayout({ count: 2, gap: 20, widths: [100, 200] })).toEqual({ count: 2, gap: 20 });
});

it('drops unusable widths by record, not by position, and stays idempotent (SD-2629)', () => {
// resolveColumnCount counts usable widths ([192, 384] -> 2). A positional slice would keep the
// leading 0 and drop the valid 384 ([0, 192]); that metadata re-resolves to count 1, so the
// fill (count 2) and the render metadata disagree. Record-filtering keeps [192, 384].
const resolved = resolveColumnLayout({ count: 3, gap: 20, widths: [0, 192, 384], equalWidth: false });
expect(resolved).toEqual({ count: 2, gap: 20, widths: [192, 384], equalWidth: false });
// Resolving the resolved metadata is a no-op (idempotent), which the positional slice was not.
expect(resolveColumnLayout(resolved)).toEqual(resolved);
});

it('keeps the gap following each surviving column when an unusable width is dropped (SD-2629)', () => {
// gaps[i] is the gap after column i. Dropping the leading 0-width column must keep the gap that
// sits between the surviving columns (after col 1 = 30), not the dropped column's gap (10).
expect(
resolveColumnLayout({ count: 3, gap: 20, widths: [0, 192, 384], gaps: [10, 30], equalWidth: false }),
).toEqual({ count: 2, gap: 20, widths: [192, 384], gaps: [30], equalWidth: false });
});
});

describe('columnRenderLayoutsEqual (SD-2629)', () => {
Expand Down
62 changes: 46 additions & 16 deletions packages/layout-engine/contracts/src/column-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,23 @@ export function resolveColumnLayout(input: ColumnLayout): ColumnLayout {
const resolved = cloneColumnLayout(input);
resolved.count = count;
if (resolveColumnMode(input) === 'explicit') {
if (Array.isArray(resolved.widths)) resolved.widths = resolved.widths.slice(0, count);
if (Array.isArray(resolved.gaps)) resolved.gaps = resolved.gaps.slice(0, Math.max(0, count - 1));
// Select widths the SAME way resolveColumnCount counts them: pair each width with the gap that
// follows it, keep only usable-width records (finite, > 0), then slice to the resolved count.
// A positional `widths.slice(0, count)` would keep an unusable leading entry and drop a usable
// later one (e.g. [0,192,384] -> count 2 -> [0,192]), producing metadata whose own usable-width
// count re-resolves smaller than the fill used (non-idempotent; fill and paint disagree).
if (Array.isArray(resolved.widths)) {
const rawGaps = Array.isArray(resolved.gaps) ? resolved.gaps : [];
const usable = resolved.widths
.map((width, i) => ({ width, gapAfter: rawGaps[i] }))
.filter((record) => typeof record.width === 'number' && Number.isFinite(record.width) && record.width > 0)
.slice(0, count);
resolved.widths = usable.map((record) => record.width);
// gaps[i] is the gap AFTER column i; the last surviving column has none, so keep count-1.
if (Array.isArray(resolved.gaps)) {
resolved.gaps = usable.slice(0, Math.max(0, count - 1)).map((record) => record.gapAfter ?? 0);
}
}
} else {
delete resolved.widths;
delete resolved.gaps;
Expand All @@ -97,19 +112,19 @@ export function resolveColumnLayout(input: ColumnLayout): ColumnLayout {
}

/**
* Build resolved per-column geometry from already-resolved widths and the uniform scalar gap.
* SD-2629 step 1 keeps this behavior-preserving: it mirrors today's normalized output (scaled
* widths, uniform gap). Per-column `gaps` do NOT drive geometry until the semantic flip (step 4).
* Build resolved per-column geometry from already-resolved widths. The gap after each column is its
* own `gaps[i]` when provided (SD-2629 step 4), falling back to the uniform scalar gap; the last
* column has no following gap. The separator sits at the midpoint of that column's own gap.
*/
function buildColumnGeometry(widths: number[], gap: number, withSeparator: boolean): ColumnGeometry[] {
function buildColumnGeometry(widths: number[], gap: number, withSeparator: boolean, gaps?: number[]): ColumnGeometry[] {
const geometry: ColumnGeometry[] = [];
let x = 0;
for (let i = 0; i < widths.length; i += 1) {
const width = widths[i];
const isLast = i === widths.length - 1;
const gapAfter = isLast ? 0 : gap;
const gapAfter = isLast ? 0 : (gaps?.[i] ?? gap);
const col: ColumnGeometry = { index: i, x, width, gapAfter };
if (withSeparator && !isLast) col.separatorX = x + width + gap / 2;
if (withSeparator && !isLast) col.separatorX = x + width + gapAfter / 2;
geometry.push(col);
x += width + gapAfter;
}
Expand Down Expand Up @@ -141,12 +156,18 @@ export function normalizeColumnLayout(
widths.push(...Array.from({ length: count - widths.length }, () => fallbackWidth));
}

const totalExplicitWidth = widths.reduce((sum, width) => sum + width, 0);
if (availableWidth > 0 && totalExplicitWidth > 0) {
const scale = availableWidth / totalExplicitWidth;
widths = widths.map((width) => Math.max(1, width * scale));
// Floor each column to >= 1px. Explicit widths are NOT scaled to fill the content area: Word
// renders authored widths as-is (a 2880tw column stays 2880tw, leaving trailing space when the
// columns underfill), so scaling them up would distort the document. Equal-mode widths already
// divide availableWidth evenly. (SD-2629 step 4)
if (availableWidth > 0) {
widths = widths.map((value) => Math.max(1, value));
}

// Per-column gaps drive geometry in explicit mode (step 4); equal mode uses the uniform gap.
const gaps =
explicitWidths.length > 0 && Array.isArray(input?.gaps) ? input.gaps.slice(0, Math.max(0, count - 1)) : undefined;

const width = widths.reduce((max, value) => Math.max(max, value), 0);

if (!Number.isFinite(width) || width <= epsilon) {
Expand All @@ -162,6 +183,7 @@ export function normalizeColumnLayout(
count,
gap,
...(widths.length > 0 ? { widths } : {}),
...(gaps && gaps.length > 0 ? { gaps } : {}),
...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}),
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
width,
Expand All @@ -171,13 +193,21 @@ export function normalizeColumnLayout(
/**
* Resolve per-column geometry for an already-normalized layout. This is the SD-2629 consumer API:
* fill/positioning/separators/hit-testing/footnotes/floating anchors/balancing should read this
* single source rather than re-deriving from `widths`/`gap`. Behavior-preserving in step 1: it
* mirrors today's normalized widths + scalar gap; per-column `gaps` drive it only after the flip.
* single source rather than re-deriving from `widths`/`gap`. Geometry uses the resolved (unscaled)
* widths and per-column `gaps`, falling back to the uniform gap when no per-column gaps exist.
*/
export function getColumnGeometry(normalized: NormalizedColumnLayout): ColumnGeometry[] {
// A geometry must have exactly `count` columns. normalizeColumnLayout always emits one width per
// column, but a hand-built equal-mode layout may carry only the scalar `width` with no widths array
// (e.g. column-balancing constructs its input directly). Expand that to `count` equal columns
// instead of collapsing to a single [width] column, which would map every column index past 0 onto
// column 0's x and stack later columns on the left margin. (SD-2629)
const count = Number.isFinite(normalized.count) ? Math.max(1, Math.floor(normalized.count)) : 1;
const widths =
Array.isArray(normalized.widths) && normalized.widths.length > 0 ? normalized.widths : [normalized.width];
return buildColumnGeometry(widths, normalized.gap, Boolean(normalized.withSeparator));
Array.isArray(normalized.widths) && normalized.widths.length > 0
? normalized.widths
: new Array(count).fill(normalized.width);
return buildColumnGeometry(widths, normalized.gap, Boolean(normalized.withSeparator), normalized.gaps);
}

// ---------------------------------------------------------------------------
Expand Down
24 changes: 24 additions & 0 deletions packages/layout-engine/contracts/src/graphic-placement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,28 @@ describe('resolveAnchoredGraphicX', () => {
it('defaults alignH to left and offsetH to zero', () => {
expect(resolveAnchoredGraphicX({}, 0, columns, objectWidth, margins, pageWidth)).toBe(margins.left);
});

describe('column-relative honors the authored per-column origin (SD-2629)', () => {
// Explicit unequal columns: col0 = 100px, gap-after-col0 = 40px, col1 = 300px. The column ORIGIN
// follows the resolved geometry (not a uniform columnIndex * (width + gap) stride); the available
// width stays the scalar (max) column width to match anchored-object measurement.
const unequal = { width: 300, gap: 20, count: 2, widths: [100, 300], gaps: [40] };

it('places a column-1 anchor at the authored column origin, not the uniform stride', () => {
// Geometry col1 x = 100 + 40 = 140; + left margin 72 = 212. The uniform stride would place it
// at 72 + (300 + 20) = 392; ignoring per-column gaps (scalar 20) would give 192.
expect(resolveAnchoredGraphicX({ alignH: 'left', offsetH: 0 }, 1, unequal, objectWidth, margins, pageWidth)).toBe(
212,
);
});

it('right-aligns within the scalar (max) column width to match object measurement', () => {
// Available width is the scalar max (columns.width = 300), matching the measurement clamp, so a
// max-sized object is not pushed into the margin/gap: col0 right edge = 72 + 300 - 80 = 292.
// (Per-column width 100 would give 92, but the object was measured against the max width.)
expect(
resolveAnchoredGraphicX({ alignH: 'right', offsetH: 0 }, 0, unequal, objectWidth, margins, pageWidth),
).toBe(292);
});
});
});
19 changes: 17 additions & 2 deletions packages/layout-engine/contracts/src/graphic-placement.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getColumnGeometry, getColumnX } from './column-layout.js';

type AnchorVRelative = 'paragraph' | 'page' | 'margin';
type AnchorHRelative = 'column' | 'page' | 'margin';
type AnchorAlignH = 'left' | 'center' | 'right';
Expand All @@ -7,6 +9,11 @@ export type ColumnLayoutForAnchor = {
width: number;
gap: number;
count: number;
// Per-column widths/gaps from the resolved (normalized) columns. When present, column-relative
// anchor x honors them via getColumnGeometry instead of a uniform columnIndex * (width + gap)
// stride; equal columns reduce to the old stride. (SD-2629)
widths?: number[];
gaps?: number[];
};

/**
Expand Down Expand Up @@ -125,7 +132,10 @@ export function resolveAnchoredGraphicX(
const contentWidth = pageWidth != null ? Math.max(1, pageWidth - (marginLeft + marginRight)) : columns.width;

const contentLeft = marginLeft;
const columnLeft = contentLeft + columnIndex * (columns.width + columns.gap);
// Column ORIGIN from the resolved geometry so column-relative anchors honor per-column widths and
// gaps (SD-2629) rather than a uniform columnIndex * (width + gap) stride. Equal columns reduce to
// the old stride. Page/margin semantics are unchanged. (Available width stays scalar; see below.)
const geometry = getColumnGeometry(columns);

const relativeFrom = anchor.hRelativeFrom ?? 'column';

Expand All @@ -138,7 +148,12 @@ export function resolveAnchoredGraphicX(
baseX = contentLeft;
availableWidth = contentWidth;
} else {
baseX = columnLeft;
baseX = getColumnX(geometry, columnIndex, contentLeft);
// Available width is the scalar (max) column width, matching anchored-object MEASUREMENT, which
// clamps width to columns.width (layout-image / layout-drawing), not the per-column width.
// Centering / right-aligning against a narrower per-column width while the object was sized to
// the max width would push it into the margin or gap. The column ORIGIN above is already
// per-column; revisit this once per-column object measurement exists. (SD-2629)
availableWidth = columns.width;
}

Expand Down
32 changes: 15 additions & 17 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
import {
cloneColumnLayout,
formatSectionPageNumberText,
getColumnGeometry,
getColumnX,
normalizeColumnLayout,
rescaleColumnWidths,
} from '@superdoc/contracts';
Expand Down Expand Up @@ -249,22 +251,17 @@ const assignFootnotesToColumns = (
if (fragment?.kind === 'table' && typeof fragment.columnIndex === 'number') {
columnIndex = Math.max(0, Math.min(columns.count - 1, fragment.columnIndex));
} else if (fragment && typeof fragment.x === 'number') {
const widths = Array.isArray(columns.widths) && columns.widths.length > 0 ? columns.widths : undefined;
if (widths) {
let cursorX = columns.left;
for (let index = 0; index < columns.count; index += 1) {
const columnWidth = widths[index] ?? columns.width;
if (fragment.x < cursorX + columnWidth + columns.gap / 2) {
columnIndex = index;
break;
}
cursorX += columnWidth + columns.gap;
columnIndex = Math.min(columns.count - 1, index + 1);
// Geometry-derived midpoint assignment: assign the ref to the column whose right edge plus
// half its own gap the fragment falls before. Per-column widths/gaps come from the resolved
// geometry, preserving the prior midpoint rule. The old uniform-stride branch was unreachable
// for count>1 (normalized columns always carry widths). (SD-2629 4c)
const geometry = getColumnGeometry(columns);
columnIndex = Math.max(0, geometry.length - 1);
for (const col of geometry) {
if (fragment.x < columns.left + col.x + col.width + col.gapAfter / 2) {
columnIndex = col.index;
break;
}
} else {
const columnStride = columns.width + columns.gap;
const rawIndex = columnStride > 0 ? Math.floor((fragment.x - columns.left) / columnStride) : 0;
columnIndex = Math.max(0, Math.min(columns.count - 1, rawIndex));
}
}
}
Expand Down Expand Up @@ -2031,8 +2028,9 @@ export async function incrementalLayout(
slicesByColumn.forEach((columnSlices, rawColumnIndex) => {
if (columnSlices.length === 0) return;
const columnIndex = Math.max(0, Math.min(columns.count - 1, rawColumnIndex));
const columnStride = columns.width + columns.gap;
const columnX = columns.left + columnIndex * columnStride;
const columnX = getColumnX(getColumnGeometry(columns), columnIndex, columns.left);
// Placement width stays uniform (= the measurement width); per-column footnote
// measurement is a deliberate follow-up, not this pass. (SD-2629 4c; do not narrow here)
const contentWidth = Math.min(columns.width, footnoteWidth);
if (!Number.isFinite(contentWidth) || contentWidth <= 0) return;

Expand Down
Loading
Loading