Skip to content
Closed
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
293 changes: 293 additions & 0 deletions packages/layout-engine/contracts/src/column-layout.test.ts

Large diffs are not rendered by default.

137 changes: 129 additions & 8 deletions packages/layout-engine/contracts/src/column-layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColumnLayout } from './index.js';
import type { BaseDirection, ColumnLayout } from './index.js';

/**
* Resolved geometry for a single column. `x` and `separatorX` are CONTENT-RELATIVE (measured from
Expand All @@ -15,7 +15,24 @@ export type ColumnGeometry = {
separatorX?: number;
};

export type NormalizedColumnLayout = ColumnLayout & { width: number };
export type NormalizedColumnLayout = ColumnLayout & {
width: number;
/**
* The content-area width the layout was normalized against, in px.
*
* Only RTL geometry reads it, and it exists because `width` above is the WIDEST column, not the
* strip: explicit widths are deliberately not scaled to fill the content area (Word renders an
* authored 2880tw column as 2880tw and leaves the slack), so a strip of explicit columns can be
* narrower — or wider — than the area it sits in. Mirroring such a strip about its own span would
* keep it pinned to the LEFT margin and only swap the columns inside it, which is not what Word
* does: the first column belongs against the RIGHT margin and the slack falls on the left.
*
* Optional because `getColumnGeometry` also accepts hand-built layouts (column balancing assembles
* one directly). When absent, RTL mirrors about the strip's own span, which is exact whenever the
* columns fill the area — always true in equal mode.
*/
contentWidth?: number;
};

export function widthsEqual(a?: number[], b?: number[]): boolean {
if (!a && !b) return true;
Expand Down Expand Up @@ -69,6 +86,7 @@ export function cloneColumnLayout(columns?: ColumnLayout): ColumnLayout {
...(Array.isArray(columns.gaps) ? { gaps: [...columns.gaps] } : {}),
...(columns.equalWidth !== undefined ? { equalWidth: columns.equalWidth } : {}),
...(columns.withSeparator !== undefined ? { withSeparator: columns.withSeparator } : {}),
...(columns.direction !== undefined ? { direction: columns.direction } : {}),
}
: { count: 1, gap: 0 };
}
Expand Down Expand Up @@ -116,7 +134,14 @@ export function resolveColumnLayout(input: ColumnLayout): ColumnLayout {
* 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, gaps?: number[]): ColumnGeometry[] {
function buildColumnGeometry(
widths: number[],
gap: number,
withSeparator: boolean,
gaps?: number[],
direction?: BaseDirection,
contentWidth?: number,
): ColumnGeometry[] {
const geometry: ColumnGeometry[] = [];
let x = 0;
for (let i = 0; i < widths.length; i += 1) {
Expand All @@ -128,7 +153,29 @@ function buildColumnGeometry(widths: number[], gap: number, withSeparator: boole
geometry.push(col);
x += width + gapAfter;
}
return geometry;
if (direction !== 'rtl') return geometry;

// RTL: the FIRST column belongs on the right (ECMA-376 §17.6.1). A single column is mirrored too:
// it is a no-op when the column fills the content area, but an explicit column that underfills it
// still belongs against the RIGHT margin, by the same axis rule as a multi-column strip.
//
// Mirror rather than reverse the array: `index` stays the FILL order, so every consumer that
// walks columns 0..n-1 keeps filling in document order and only the painted x changes. `x` stays
// the LEFT edge of the column, which is what the whole geometry API and its callers mean by `x`.
// `gapAfter` is likewise untouched — it is the gap after this column in fill order, and in RTL
// that gap lies to its left, exactly where the mirrored x places it.
//
// The mirror axis is the CONTENT AREA, not the strip: explicit widths are not scaled to fill it
// (see normalizeColumnLayout), so a strip that underfills must end up against the RIGHT margin
// with the slack on the left — mirroring about the strip's own span would leave it pinned left
// and merely swap the columns inside it. Falls back to the span when the area is unknown, which
// is exact whenever the columns fill it (always so in equal mode).
const span = contentWidth ?? x;
return geometry.map((col) => ({
...col,
x: span - (col.x + col.width),
...(col.separatorX === undefined ? {} : { separatorX: span - col.separatorX }),
}));
}

export function normalizeColumnLayout(
Expand Down Expand Up @@ -165,8 +212,17 @@ export function normalizeColumnLayout(
}

// Per-column gaps drive geometry in explicit mode (step 4); equal mode uses the uniform gap.
//
// Clamped to >= 0 like the scalar `gap` above. OOXML cannot express a negative gutter — `w:space`
// is ST_TwipsMeasure, unsigned — and letting one through breaks the invariant the geometry API
// relies on: that in an LTR layout `x` rises with the column index. Direction-aware consumers read
// that monotonicity to tell a mirrored strip from an upright one, so a negative gap wide enough to
// pull a column back behind its predecessor would make an LTR layout answer hit tests as if it
// were RTL.
const gaps =
explicitWidths.length > 0 && Array.isArray(input?.gaps) ? input.gaps.slice(0, Math.max(0, count - 1)) : undefined;
explicitWidths.length > 0 && Array.isArray(input?.gaps)
? input.gaps.slice(0, Math.max(0, count - 1)).map((value) => Math.max(0, value))
: undefined;

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

Expand All @@ -176,6 +232,8 @@ export function normalizeColumnLayout(
gap: 0,
width: Math.max(0, contentWidth),
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
...(input?.direction !== undefined ? { direction: input.direction } : {}),
contentWidth: Math.max(0, contentWidth),
};
}

Expand All @@ -186,7 +244,9 @@ export function normalizeColumnLayout(
...(gaps && gaps.length > 0 ? { gaps } : {}),
...(input?.equalWidth !== undefined ? { equalWidth: input.equalWidth } : {}),
...(input?.withSeparator !== undefined ? { withSeparator: input.withSeparator } : {}),
...(input?.direction !== undefined ? { direction: input.direction } : {}),
width,
contentWidth: Math.max(0, contentWidth),
};
}

Expand All @@ -207,7 +267,14 @@ export function getColumnGeometry(normalized: NormalizedColumnLayout): ColumnGeo
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);
return buildColumnGeometry(
widths,
normalized.gap,
Boolean(normalized.withSeparator),
normalized.gaps,
normalized.direction,
normalized.contentWidth,
);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -242,13 +309,63 @@ export function getColumnSeparatorPositions(geometry: ColumnGeometry[], originX
.map((col) => originX + (col.separatorX as number));
}

/** Index of the column containing absolute `x` (clicks in a gap map to the preceding column). */
/**
* Index of the column whose OWN span contains absolute `x`, or `null` when `x` lies in no column at
* all — a gutter, the page margins, or something that is not column flow in the first place.
*
* This is the strict counterpart to `getColumnAtX` below, and the two exist because paint-time and
* hit-testing want opposite answers. A click has to select something, so `getColumnAtX` clamps and
* hands a gap to its neighbouring column. Asking "is there content in a later column" must not
* clamp: `page.items` carries page-anchored objects, and a full-width watermark belongs to no
* column, so answering with one makes it evidence for chrome Word does not draw.
*
* Direction-agnostic by construction. It tests containment in each column's own span instead of
* comparing against a boundary, so it does not care whether `x` ascends or descends with the index,
* and — unlike an edge test — it is not fooled by a fragment WIDER than its column. An over-wide
* table is placed at its column's left edge and overflows rightward in both directions, so its
* origin still identifies its column while its trailing edge does not.
*
* Spans are half-open — `[x, x + width)` — so that adjacent columns authored with no gutter at all
* (`w:space="0"`) do not both claim the boundary they share. That boundary is exactly where the
* later column's own content is placed, and an inclusive upper bound would hand it to the earlier
* column instead. Columns are scanned in fill order and the first containing span wins, which
* after that only matters for an overfull explicit strip whose columns genuinely overlap.
*/
export function findColumnContaining(geometry: ColumnGeometry[], x: number, originX = 0): number | null {
const cx = x - originX;
for (const col of geometry) {
if (cx >= col.x && cx < col.x + col.width) return col.index;
}
return null;
}

/**
* Index of the column containing absolute `x` (clicks in a gap map to the preceding column).
*
* The walk is direction-aware and cannot assume ascending `x`: in an RTL section column 0 sits on
* the right, so `x` DESCENDS with the index. The mirrored branch keeps the same rule the LTR branch
* states — a point in a gap belongs to the column that precedes it in FILL order — which is what
* makes a drag that crosses the gutter keep extending from the column it is leaving instead of
* jumping. Direction is read off the geometry rather than taken as an argument, so every existing
* caller keeps working unchanged.
*
* Both branches test a HALF-OPEN span, so this agrees with `findColumnContaining` on every boundary
* the two can both answer. The LTR branch gets that from `cx >= col.x`: a point on a shared edge is
* the later column's, because that is where the later column's content begins. The mirrored branch
* has to say the same thing from the other side — the shared edge is the EARLIER fill column's left
* edge there — which is `cx < col.x + col.width`, exclusive. An inclusive bound handed that point to
* the later column, contradicting the half-open span the geometry places content in, and it also
* pulled in the point one pixel-width past a column's trailing edge, which is gutter and belongs to
* the preceding column. With `w:space="0"` the two coincide and every column boundary in an RTL
* section resolved one column too far.
*/
export function getColumnAtX(geometry: ColumnGeometry[], x: number, originX = 0): number {
if (geometry.length === 0) return 0;
const cx = x - originX;
const mirrored = geometry.length > 1 && geometry[1].x < geometry[0].x;
let result = 0;
for (const col of geometry) {
if (cx >= col.x) result = col.index;
if (mirrored ? cx < col.x + col.width : cx >= col.x) result = col.index;
else break;
}
return result;
Expand All @@ -263,6 +380,7 @@ export function columnLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): boolean
a.gap === b.gap &&
a.equalWidth === b.equalWidth &&
Boolean(a.withSeparator) === Boolean(b.withSeparator) &&
(a.direction ?? 'ltr') === (b.direction ?? 'ltr') &&
widthsEqual(a.widths, b.widths) &&
widthsEqual(a.gaps, b.gaps)
);
Expand All @@ -287,6 +405,9 @@ export function columnRenderLayoutsEqual(a?: ColumnLayout, b?: ColumnLayout): bo
if (resolveColumnCount(a) !== resolveColumnCount(b)) return false;
if ((a.gap ?? 0) !== (b.gap ?? 0)) return false;
if (Boolean(a.withSeparator) !== Boolean(b.withSeparator)) return false;
// Direction IS paint-significant: it decides which side column 0 lands on, so two layouts that
// differ only here must split regions and invalidate the normalized-columns cache.
if ((a.direction ?? 'ltr') !== (b.direction ?? 'ltr')) return false;
if (mode === 'explicit') {
const ra = resolveColumnLayout(a);
const rb = resolveColumnLayout(b);
Expand Down
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;
Comment on lines +2891 to +2904

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 High

4. No producer wires w:bidi into columnlayout.direction 🐞 Bug ≡ Correctness

The consumer-side RTL geometry pipeline only activates when ColumnLayout.direction is explicitly
set, but no production path bridges the existing section-level pageDirection/w:sectPr/w:bidi
signal into SectionBreakBlock.columns.direction; only hand-built tests set direction: 'rtl'.
Consequently, real imported Hebrew or Arabic multi-column documents retain an undefined direction
that defaults to LTR, so the first column remains on the left and the reported selection bug (#3952)
is not fixed for actual users.
Agent Prompt
## Issue description

RTL column mirroring and its related geometry behavior are opt-in through `ColumnLayout.direction`, but the production import/conversion pipeline never assigns the resolved section bidi value to that field. Real sections carrying `w:sectPr/w:bidi` therefore reach layout with an undefined direction, which defaults to LTR, so the fix for #3952 works only for manually constructed test data rather than actual Hebrew or Arabic documents.

## Issue Context

`SectionDirectionContext.pageDirection` is the existing section-level signal documented as being resolved from `w:sectPr/w:bidi` and feeding section chrome such as columns. However, section layout snapshots and scheduling only carry `SectionBreakBlock.columns`; section processing clones that object, and normalization copies `input.direction` only if it was already populated.

`SectionsAdapter.setSectionDirection` is declared as a document-API interface, but no production implementation was found in this repository. The only identified OOXML section-properties parser, the test helper `readSectPr`, reads `<w:cols>` into `{count, gap}` but does not read `<w:bidi>` or set the column direction, and the documented resolver chain under `pm-adapter/src/direction/` was not found. Existing RTL column tests manually supply `direction: 'rtl'`, so they do not validate the document-to-flow-block path.

Wire the resolved section page direction into `SectionBreakBlock.columns.direction` when section-break and column metadata are produced, preserve it through section snapshots and normalization, and add an end-to-end test that begins with a section carrying `w:sectPr/w:bidi` rather than a hand-built `ColumnLayout`. If the production OOXML wiring exists elsewhere, link it explicitly in the PR description and cover it with an integration test; otherwise, treat the import-pipeline change as required before the feature reaches users.

## Fix Focus Areas

- packages/layout-engine/contracts/src/direction-context.ts[40-52]
- packages/layout-engine/contracts/src/index.ts[1994-2036]
- packages/layout-engine/contracts/src/index.ts[2890-2903]
- packages/layout-engine/layout-engine/src/section-props.ts[25-28]
- packages/layout-engine/tests/src/test-helpers/to-flow-blocks.ts[147-188]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-verified this today, and it still holds on the current tip — recording the evidence here because the earlier answer went into a top-level comment, which left this thread looking unanswered.

grep -rln "columns.*direction\s*[:=]" packages/ outside tests matches exactly one file: packages/layout-engine/contracts/src/column-layout.ts, which is where the field is defined and read. No importer, adapter, or section-processing path assigns the resolved section bidi to it. So the finding is right as stated: this PR is the axis, not the end-to-end fix, and a real Hebrew or Arabic multi-column document still renders unchanged after it merges.

That is deliberate — main has no column direction at all, so the axis has to exist before anything can feed it — but it has a consequence worth flagging before merge rather than after:

This PR says Fixes #3952, so merging it will auto-close #3952 while the reported behaviour is still present for users. That is the wrong outcome for the issue tracker whichever way the scoping goes. Two ways out, and I am happy with either:

  1. I drop the Fixes keyword to a plain reference, and Columns ignore the section's w:bidi: the first column renders on the left in RTL documents #3952 stays open until a producer lands.
  2. I add the producer wiring to this PR, so Fixes is accurate.

Say which you prefer and I will do it. Leaving this thread open until then, since it is a live decision and not a closed loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went looking for whether anything else already wires this up, since if a producer were in flight the scoping question would answer itself. It is not, and the reason turns out to be structural rather than an oversight in this PR.

What I checked:

  • All 40+ open PRs on the repo. The only direction-related one is fix(direction): auto-detect paragraph base direction via dir="auto" #3714, fix(direction): auto-detect paragraph base direction via dir="auto" — paragraph-level inline direction, touching rtl-styles.ts, render-line.ts and ParagraphNodeView.js. Different axis entirely; ECMA-376 keeps §17.3.1.6 paragraph w:bidi and §17.6.1 section w:bidi separate, and that PR is the former.
  • Merged PRs. Nothing matching bidi, direction, rtl, column or sectPr.
  • My own unmerged branches, in case I had it half-built: all four are page-number and list-numbering work in document-api/src/sections/. None touch section direction.

The structural part, which is the actual answer to "why has nobody done this":

SectionDirectionContext.pageDirection — the field this PR's description cites as already governing section chrome — has no producer either. grep -rn "pageDirection" over packages/ and shared/ returns three hits: the field declaration in direction-context.ts:47 and two generated .d.ts copies. Nothing constructs a SectionDirectionContext anywhere; grep -rn "SectionDirectionContext" outside tests and dist gives only the type definition and its re-export from index.ts.

And no importer parses w:sectPr/w:bidi at all. Every bidi hit in production code is paragraph-level or run-level (document-api's LANG_ALLOWED_KEYS, MARK_RUN_LANG_KEYS, and the Set paragraph base direction operation). Section-level w:bidi appears only inside comments in the contracts package, describing behaviour that was specified and never implemented.

So the section-direction contract is a declared-but-unbuilt surface that predates this PR. I wrote a consumer against the half that exists. That does not change the conclusion you drew — this PR alone still does not move a real document — but it does mean the missing producer is a gap in the contract rather than something this PR skipped, and wiring it needs an importer change that is a different subject from column geometry.

Which makes me lean toward option 1 from my previous comment: drop Fixes #3952 to a plain reference here, and let the issue close when a producer lands. Happy to go the other way if you would rather see it done in one piece — still your call, and I have not changed the keyword yet.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with option 1 rather than leave this hanging on your reply — the keyword is now dropped.

Fixes #3952 in the description is a plain "Part of #3952" reference, with the reason stated inline, so merging this no longer auto-closes a live issue. I also left a status note on #3952 itself recording what has landed and what is missing, so it does not get re-reported or closed by someone reading the merge.

Reversible in one edit if you would rather have the producer in this PR after all. My reasoning for not bundling it: reading w:sectPr/w:bidi and setting SectionBreakBlock.columns.direction is an importer change, a different subsystem from column geometry, and it needs an RTL multi-column fixture — which under tests/README.md's privacy rules has to be synthetic and verifiable. That is a reviewable unit on its own and a poor fit stapled onto a PR already carrying two others.

Offered on #3952 to do it as a follow-up once this lands, if you want it from me.

};

/**
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
Loading
Loading