Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
95cf92d
fix(layout): place the first column on the right in RTL sections
Nathaniel-260 Sep 1, 2026
99cc234
fix(layout): order RTL balancing by document order, and cover the axi…
Nathaniel-260 Sep 1, 2026
5552e2c
fix(layout): gate the RTL column separator on the fragment edge that …
Nathaniel-260 Sep 1, 2026
b9e7e51
fix(painter): resolve a separator's neighbouring column by ownership,…
Nathaniel-260 Sep 1, 2026
2438bd9
fix(painter): attribute a fragment to its column by overlap, not by i…
Nathaniel-260 Sep 1, 2026
db5947e
fix(painter): bound column attribution by the page, and by both box e…
Nathaniel-260 Sep 2, 2026
f2a36d8
docs(painter): name the fragment kinds that actually record a column
Nathaniel-260 Sep 2, 2026
02fe513
style(painter): put the columnOwningSpan signature on one line
Nathaniel-260 Sep 2, 2026
3da48e3
fix(painter): trust a fragment's origin by its width, not by its righ…
Nathaniel-260 Sep 2, 2026
18c7b65
test(painter): guard the width gate with a shape that can reach it
Nathaniel-260 Sep 2, 2026
ad9bde6
fix(contracts): resolve an RTL column boundary the way geometry place…
Nathaniel-260 Sep 2, 2026
7948339
fix(contracts): compare the gutters that render, not the authored gaps
Nathaniel-260 Sep 2, 2026
71ff97a
fix(layout): order a balanced page by column, not by fragment x
Nathaniel-260 Sep 2, 2026
e8f70b9
fix(contracts): stop an unreachable scalar gap from splitting an expl…
Nathaniel-260 Sep 2, 2026
161f852
fix(layout): decide which column owns a fragment from more than its o…
Nathaniel-260 Sep 2, 2026
760df12
fix(layout): record the flow column on an anchored table fragment
Nathaniel-260 Sep 2, 2026
e7af399
docs(contracts): stop the gap comment contradicting the sub-pixel guard
Nathaniel-260 Sep 2, 2026
3c2b2ab
docs(layout): name the fragment kinds that actually record a column
Nathaniel-260 Sep 2, 2026
4b28634
fix(layout): trust a fragment's origin by its width, not by its edges
Nathaniel-260 Sep 2, 2026
d7c4964
fix(contracts): fall a hole in the per-column gaps back to the scalar
Nathaniel-260 Sep 2, 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
423 changes: 422 additions & 1 deletion packages/layout-engine/contracts/src/column-layout.test.ts

Large diffs are not rendered by default.

214 changes: 200 additions & 14 deletions packages/layout-engine/contracts/src/column-layout.ts

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions packages/layout-engine/contracts/src/graphic-placement.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getColumnGeometry, getColumnX } from './column-layout.js';
import type { BaseDirection } from './direction-context.js';

/** ECMA-376 Part 1 §20.4.3.4 (`ST_RelFromH`). */
export const ANCHOR_H_RELATIVE_VALUES = [
Expand Down Expand Up @@ -105,6 +106,12 @@ export type ColumnLayoutForAnchor = {
// stride; equal columns reduce to the old stride. (SD-2629)
widths?: number[];
gaps?: number[];
// Section page direction and the content width it was normalized against, both read by
// getColumnGeometry. Declared rather than left to structural pass-through: a column-relative
// anchor in an RTL section must resolve against the mirrored geometry, and silently dropping
// these would place it against the wrong margin with no type error to catch it.
direction?: BaseDirection;
contentWidth?: number;
};

/**
Expand Down
16 changes: 16 additions & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
} from './direction-context.js';
export { getParagraphInlineDirection, getTableVisualDirection } from './direction-context.js';
import type {
BaseDirection,
ParagraphDirectionContext,
RunBidiContext,
RunScriptContext,
Expand Down Expand Up @@ -162,6 +163,7 @@ export {
cloneColumnLayout,
columnLayoutsEqual,
columnRenderLayoutsEqual,
findColumnContaining,
getColumnAtX,
getColumnGapAfter,
getColumnGeometry,
Expand Down Expand Up @@ -2886,6 +2888,20 @@ export type ColumnLayout = {
* mode uses the scalar `gap`. When absent, consumers fall back to the uniform `gap`. (SD-2629)
*/
gaps?: number[];
/**
* Section page direction, from `w:sectPr/w:bidi`. Decides which side the FIRST column sits on:
* `'ltr'` (default) fills left to right, `'rtl'` fills right to left, matching Word.
*
* Per ECMA-376 §17.6.1 a section's `w:bidi` governs section-level chrome — page numbers, gutters
* and columns — and is independent of the paragraph inline direction (§17.3.1.6). It is carried
* here, on the column layout itself, because `getColumnGeometry` is the single source every
* column consumer reads for positioning (fill, hit testing, separators, balancing, floating
* anchors, footnotes); threading the axis alongside the widths keeps those consumers from having
* to re-derive it, and keeps them from disagreeing.
*
* Absent means `'ltr'`. Every existing producer therefore keeps its current geometry unchanged.
*/
direction?: BaseDirection;
};

/**
Expand Down
17 changes: 14 additions & 3 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1643,14 +1643,25 @@ 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') {
// 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-derived midpoint assignment: assign the ref to the column whose far edge plus
// half its own gap the fragment falls short of. 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)
//
// "Far edge" is direction-relative: in an RTL section column 0 sits on the right, so x
// DESCENDS with the index and the fragment must be compared against the column's LEFT edge
// minus half its gap instead. Walking the geometry with the LTR test in an RTL section
// matched column 0 for every fragment, which collapsed all of a page's footnotes into the
// first column's group — the left column's notes printed under the right column and its own
// note area stayed empty.
const geometry = getColumnGeometry(columns);
const mirrored = geometry.length > 1 && geometry[1].x < geometry[0].x;
columnIndex = Math.max(0, geometry.length - 1);
for (const col of geometry) {
if (fragment.x < columns.left + col.x + col.width + col.gapAfter / 2) {
const boundary = mirrored
? columns.left + col.x - col.gapAfter / 2
: columns.left + col.x + col.width + col.gapAfter / 2;
if (mirrored ? fragment.x >= boundary : fragment.x < boundary) {
columnIndex = col.index;
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,69 @@ describe('Footnotes in columns', () => {
expect(footnoteTwoFragment?.x).toBeCloseTo(columnTwoX, 2);
});

it('places footnotes in the mirrored column of their reference in an RTL section', async () => {
// Footnote refs are assigned to a column by comparing the reference fragment's x against each
// column's far edge plus half its gap. "Far edge" is direction-relative: in an RTL section
// column 0 sits on the right and x DESCENDS with the index, so the left-to-right test matches
// column 0 for every fragment and collapses the whole page's notes into the first column's
// group — the left column's notes print under the right column and its own note area is empty.
const paragraphOne = makeParagraph('para-1', 'Column 1 text', 0);
const columnBreak: FlowBlock = { kind: 'columnBreak', id: 'col-break-1' };
const paragraphTwo = makeParagraph('para-2', 'Column 2 text', 40);

const footnoteOne = makeParagraph('footnote-1-0-paragraph', 'Footnote one', 0);
const footnoteTwo = makeParagraph('footnote-2-0-paragraph', 'Footnote two', 0);

const measureBlock = vi.fn(async (block: FlowBlock) => {
if (block.kind === 'columnBreak') {
return { kind: 'columnBreak' } as Measure;
}
const textLength = block.kind === 'paragraph' ? (block.runs?.[0]?.text?.length ?? 1) : 1;
const lineHeight = block.id.startsWith('footnote-') ? 10 : 18;
return makeMeasure(lineHeight, textLength);
});

const columns = { count: 2, gap: 20, direction: 'rtl' as const };
const margins = { top: 60, right: 60, bottom: 60, left: 60 };
const pageSize = { w: 600, h: 800 };

const result = await incrementalLayout(
[],
null,
[paragraphOne, columnBreak, paragraphTwo],
{
pageSize,
margins,
columns,
footnotes: {
refs: [
{ id: '1', pos: 2 },
{ id: '2', pos: 42 },
],
blocksById: new Map([
['1', [footnoteOne]],
['2', [footnoteTwo]],
]),
},
},
measureBlock,
);

const page = result.layout.pages[0];
const columnWidth = (pageSize.w - margins.left - margins.right - columns.gap) / columns.count;
// Mirrored: fill column 0 is the RIGHT one, fill column 1 the left.
const firstColumnX = margins.left + columnWidth + columns.gap;
const secondColumnX = margins.left;

const footnoteOneFragment = page.fragments.find((fragment) => fragment.blockId === footnoteOne.id);
const footnoteTwoFragment = page.fragments.find((fragment) => fragment.blockId === footnoteTwo.id);

expect(footnoteOneFragment?.x).toBeCloseTo(firstColumnX, 2);
expect(footnoteTwoFragment?.x).toBeCloseTo(secondColumnX, 2);
// The two notes must land in DIFFERENT columns; collapsing them into one is the failure mode.
expect(footnoteOneFragment?.x).not.toBeCloseTo(footnoteTwoFragment?.x ?? 0, 2);
});

it('keeps footnotes in the owning column for wide overflow tables', async () => {
const paragraphOne = makeParagraph('para-1', 'Column 1 text', 0);
const columnBreak: FlowBlock = { kind: 'columnBreak', id: 'col-break-1' };
Expand Down
30 changes: 30 additions & 0 deletions packages/layout-engine/layout-bridge/test/position-hit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ describe('determineColumn (SD-2629: resolved per-column boundaries)', () => {
expect(determineColumn(layout, 540, page)).toBe(2);
});

it('resolves a click to the visually containing column in an RTL section', () => {
// In an RTL section column 0 sits against the RIGHT margin, so a click on the right half of the
// page selects the FIRST column. Resolving this with the left-to-right rule sends every click to
// the wrong column — the issue's "clicks will select the wrong column".
const columns = { count: 3, gap: 24, direction: 'rtl' as const };
const page = {
columns,
margins: { left: 96, right: 96 },
size: { w: 816, h: 1056 },
} as unknown as Page;
const layout = { pageSize: { w: 816, h: 1056 }, columns, pages: [page] } as unknown as Layout;

// Content width 624 -> 192px columns with a 24px gutter between them. Mirrored, column 0 spans
// 528..720, column 1 312..504, column 2 96..288 (absolute) -- each start is the previous
// column's start less width+gap, so the gutters are 504..528 and 288..312.
expect(determineColumn(layout, 700, page)).toBe(0);
expect(determineColumn(layout, 400, page)).toBe(1);
expect(determineColumn(layout, 150, page)).toBe(2);
// The outer margins stay with their own end columns.
expect(determineColumn(layout, 816, page)).toBe(0);
expect(determineColumn(layout, 0, page)).toBe(2);

// Same geometry without the direction keeps answering left to right.
const ltrColumns = { count: 3, gap: 24 };
const ltrPage = { ...page, columns: ltrColumns } as unknown as Page;
const ltrLayout = { pageSize: { w: 816, h: 1056 }, columns: ltrColumns, pages: [ltrPage] } as unknown as Layout;
expect(determineColumn(ltrLayout, 700, ltrPage)).toBe(2);
expect(determineColumn(ltrLayout, 150, ltrPage)).toBe(0);
});

it('maps a hit to its mid-page column region, not the page-start columns (SD-2629)', () => {
// A continuous section break splits the page: region 0 (y 96-300) is single-column; region 1
// (y 300-700) is two-column. page.columns is only the page-START config (single column), so a
Expand Down
Loading
Loading