fix(layout): give the footnote band its own column layout and separator placement

NA
2mo ago
+2743-38

Fixes #3960

[!IMPORTANT]
Depends on #3953 and cannot merge before it. main has no column direction at all today;
#3953 is what gives ColumnLayout its direction and mirrors the strip in RTL, and the footnote
band inherits that axis. Merging this first would not compile against main.

Two commits are mine — fix(layout): give the footnote band its own column layout and separator placement and docs(layout): state the footnote band's separator invariants accurately. Everything below them in the diff belongs to #3953 and disappears once it merges.
CONTRIBUTING.md asks for main, so the base is main and the stack shows up in the diff.

A Hebrew (RTL) document with a two-column continuous section and <w15:footnoteColumns w:val="1"/> on every sectPr printed its footnotes in a strip one body column wide, with the separator rule on the wrong side of it. Word prints one strip across the content area, rule at the right.

Geometry read out of the DOM in Chrome against the packaged dist, before the change (data-layout-story identifies the band):

Elementxwidth
body paragraph, single-column section516554
body paragraph, column 0516253
body paragraph, column 1816253
note band516253

A4 with 1800tw side margins and a 708tw gutter: content area 8306tw = 554px at 96dpi, one body column (8306 - 708) / 2 = 3799tw = 253px. After the change the band is emitted at the content-area origin across its full width (x = marginLeft, width = 553.73px), with both columns' notes in one stack in note order. That is asserted directly on the layout in footnoteBandColumns.test.ts rather than eyeballed in a browser.

One visible consequence worth naming: the w:separator rule is 0.5x the note column's width, which is pre-existing, so a full-width band gets a proportionally longer rule -- 277px rather than 126px here. That follows from the band being wider and is not something this PR changes; if Word's rule is a fixed length instead, that is a separate fix to the factor.

Two independent defects. They are in one commit because the shared files carry hunks from both, and a hand-split would have produced a half-broken intermediate; the sections below and the commit message separate them, and I am happy to split the PR itself in two if you would rather review them apart.

1. The band reused the body's w:cols

w15:footnoteColumns was never read anywhere in the tree, and every note-plane stage took its geometry from the body. resolveFootnoteBandColumns is now the single place that decision is made, and four stages moved onto the band's plane together, because a disagreement between any two of them is visible:

  • Measurement width — notes were measured at a body column's width, and specifically at the minimum body column width anywhere in the document, so one two-column section in the middle narrowed the notes of every single-column section too. It is now the narrowest band in the document.
  • Reference → stack assignment — keyed by the body column the anchor sits in; now mapped through the band's count, so several body columns can feed one stack, in note order.
  • The planner and its reserve — a merged band is one stack whose height is the sum of the per-column stacks, not their max, so the old reserve let the band run past the bottom margin.
  • The painted x/width, plus the retained-band and coupled-pagination paths and the warm seed.

Absent the element, every path is byte-identical to before.

2. The separator rule was pinned to the column's left edge

<w:separator/> is a run inside its own paragraph in footnotes.xml, so its placement is that paragraph's resolved inline direction, w:jc and w:ind.

It is deliberately not keyed on the section's w:bidi, which is the tempting wrong fix. The axes are independent (§17.6.1 vs §17.3.1.6, as direction-context.ts already states) and they come apart: a Hebrew section whose separator paragraph resolves LTR gets a rule on the left in Word, and the reverse for an LTR section with an RTL separator paragraph. Nothing about the section reaches resolveFootnoteSeparatorX — its inputs are the note column's geometry and that one paragraph — and both divergent cases have tests. w:jc resolves exactly the way resolveTextAlign already resolves it for text.

What this PR does not do

The importer half, which is where the two new inputs get populated:

InputSource
SectionBreakBlock.footnoteColumns, LayoutOptions.footnoteColumnsw:sectPr/w15:footnoteColumns/@w:val (0/absent = match the body)
footnotes.separatorParagraph, footnotes.continuationSeparatorParagraphthe resolved w:pPr of w:footnote w:type="separator" / "continuationSeparator", through the style chain — a separator paragraph usually declares nothing and inherits from w:docDefaults/w:pPrDefault

Until they are populated, documents render exactly as they do today: the band resolves to the body layout it already used, and the separator keeps its historical left edge. The w15:footnoteColumns reader added to to-flow-blocks.ts is the reference implementation, prefix and 0 default included.

Design decisions worth a look

  • A band with more columns than the body clamps back to the body. The note planner carries a column's overflow to the same column on the next page, never sideways, so honouring a wider count would paint a half-width strip beside an empty one and push notes onto later pages — strictly worse than the body-matching band the default produces. Rationale is in the helper's doc comment.
  • Note measurement stays one global pass. footnoteConstraints is a single object and a note's section is only known after the body is paginated, so the width is the narrowest band in the document. Painting clamps to min(bandColumnWidth, measurementWidth), so measurement and paint can never disagree; a document mixing band widths under-fills its wider bands rather than spilling out of its narrower ones. Per-section note measurement is the follow-up that removes the compromise.
  • w:ind narrows the separator's extent; firstLine/hanging are not read. They move the first line of a text paragraph and I have no evidence Word applies them to a lone separator run — better handled once the behaviour is known than guessed at.

Testing

44 new tests across 4 files, including the exact sectPr set of the document that surfaced this, rebuilt synthetically (no .docx fixture added).

  • npx vp test run --root packages/layout-engine/contracts — 32 files, 538 pass
  • npx vp test run --root packages/layout-engine/layout-bridge — 96 files, 1895 pass
  • npx vp test run --root packages/layout-engine/tests — 284 pass, plus one pre-existing failure noted below
  • npx tsc -b tsconfig.references.json — clean
  • vp lint on the touched trees — no errors; vp fmt applied
  • node scripts/check-public-boundary.mjs — OK

Two things I could not run in my checkout, both unrelated to this change: pnpm test at the root (the root prepare script fails in my shell) and anything scoped to packages/superdoc (installed engine 0.10.0-next.12 vs the declared 0.10.1-next.1). Also pre-existing and untouched here: architecture-boundaries.test.ts Guard E flags getBoundingClientRect in painters/dom/src/ruler/ruler-renderer.ts.

No public API surface was grown — the new helpers are exports of @superdoc/contracts, which is a private workspace package and is not re-exported from superdoc's public index, so no tests/consumer-typecheck/ fixture applies.

Verified through the paint path, not just the layout

Because a layout assertion does not prove anything reaches the screen, I also ran the full incrementalLayout -> resolve -> DomPainter pipeline and read style.left / style.width back off the painted elements. Measured, in CSS px:

CaseMeasuredExpected
merged band, both notesleft 120, width 553.73full content area
merged band, note ordernote 1 top 1004.53 < note 2 top 1016.53note order preserved
merged band, separatorwidth 276.87, left 396.87half the band, at its right edge
footnoteColumns omittedtwo strips of width 253.27, two separatorstoday's behaviour, unchanged
tall notes (2 x 200px, merged)note tops 624.53 and 826.53, gap 202the stack is the SUM, not the max
tall notes, band bottom1026.53 vs the page limit 1026.53inside the bottom margin, bottom-anchored
RTL section, LTR separator paragraphleft 120the LEFT edge
LTR section, RTL separator paragraphleft 396.87the RIGHT edge

The last two are the pair that a section-direction fix would get backwards, so they are measured rather than argued.

The painter itself was checked for the same class of bug and is clean: applyFragmentFrame / applyResolvedFragmentFrame write style.left / style.width straight from the fragment's own x / width and never re-derive them from page.columns.

One limit worth naming: text fragment wrappers get no explicit style.height, so the "band bottom" figures above are a painted top read from the DOM plus the measured line height, not a second independent DOM measurement.

No screenshot: the change is geometry, and it is pinned numerically instead.

Stacking

Repeated from the top because it decides merge order: this is built on #3953, which is what gives ColumnLayout its direction and mirrors the strip in RTL. main has no column direction at all, so the band has no axis to inherit and this does not stand alone -- #3953 has to land first. The two commits named at the top are mine; everything below them belongs to that PR and disappears from this diff once it merges.

🤖 Generated with Claude Code