Skip to content

Commit adf4ea6

Browse files
authored
fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680) (#2881)
* fix(layout-bridge): keep footnote fragments within the reserved band (SD-1680) When multiple footnotes competed for space on a single page, footnote fragments could render past the bottom page margin — sometimes 150+ pixels into the footer area or off the page entirely. Caused by three interacting issues in `computeFootnoteLayoutPlan` + the multi-pass reserve loop: 1. Placement used `maxReserve` (~the whole page) as the height ceiling, so the plan happily placed more footnote content than the body layout had actually reserved at the bottom. 2. The multi-pass stabilization loop could oscillate when reducing the reserve shifted refs between pages, and exit with a `reserves` vector that didn't match what the plan placed — leaving body layout and footnote plan inconsistent. 3. The post-loop "reservesDiffer → relayout" branch could then reintroduce the mismatch by laying out with one reserve vector while the final plan operated on another. Fixes: - `computeFootnoteLayoutPlan`: when `baseReserve > 0`, cap `placeFootnote`'s `availableHeight` to `baseReserve` (the band actually reserved by the body layout), not `maxReserve`. Excess footnotes are pushed to the next page via the existing `pendingByColumn` mechanism. Initial pass (`baseReserve === 0`) still uses `maxReserve` to seed a meaningful reserve for subsequent passes. - Multi-pass loop: merge reserves element-wise with `max(prev, next)` each pass. Reserves are monotonically non-decreasing, so the loop is guaranteed to converge (bounded by `maxReserve` per page) and the old `[247] ↔ [394]` oscillation can't recur. - Post-loop: drop the `reservesDiffer → relayout` branch. With the monotonic loop already converged and placement bounded by `reserves`, `finalPlan`'s slices always fit within the body's reserved band. The extra relayout was the mechanism that reintroduced inconsistency. Visual validation (in-browser) on the 3 fixtures from the Linear issue: - reporter-test.docx: max overflow 156px → 0px - harvey-nvca.docx: overflow present → 0px - torke-carlsbad.docx: overflow present → 0px Tests: - 2 new tests in footnoteBandOverflow.test.ts asserting the invariant "every footnote fragment's bottom-Y ≤ page's physical bottom limit" for both multi-footnote pressure and oversized-footnote cases. - 1168 layout-bridge tests pass, 604 layout-engine tests pass, 1737 pm-adapter tests pass, 11385 super-editor tests pass. Follow-up (Step 3, separate PR): footnote-aware body pagination — consult footnote heights when deciding whether paragraph P fits on page N, rather than reactively shrinking body via reserves. This would eliminate the multi-pass loop entirely. * fix(layout-bridge): drive footnote reserve from per-page demand (SD-1680) Cap footnote placement at each page's measured demand (capped at maxReserve) rather than the body's prior-pass reserve, and merge element-wise max across oscillating reserve vectors before the post-loop relayout. Ensures every page's reserve is large enough to hold its placed footnotes so fragments cannot render past the bottom margin, and iterates the post-loop relayout until the layout's reserve per page matches the final plan's demand.
1 parent 781b8cd commit adf4ea6

2 files changed

Lines changed: 303 additions & 15 deletions

File tree

packages/layout-engine/layout-bridge/src/incrementalLayout.ts

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,34 @@ export async function incrementalLayout(
13291329
const columns = pageColumns.get(pageIndex);
13301330
const columnCount = Math.max(1, Math.floor(columns?.count ?? 1));
13311331

1332+
// SD-1680: cap placement to the footnote demand on this page (capped by maxReserve).
1333+
// Demand = sum of measured heights of all footnote refs anchored here, plus the
1334+
// separator/padding/gap overhead they would incur when stacked. Capping placement
1335+
// at `min(demand, maxReserve)` (rather than `baseReserve`) decouples the plan's
1336+
// placement from the body's prior-pass reserve: the plan reports how much band
1337+
// the footnotes actually need, the body grows its reserve to match on the next
1338+
// pass, and placement never exceeds maxReserve so footnotes cannot render past
1339+
// the page's bottom margin.
1340+
let demand = 0;
1341+
for (let columnIndex = 0; columnIndex < columnCount; columnIndex += 1) {
1342+
const ids = idsByColumn.get(pageIndex)?.get(columnIndex) ?? [];
1343+
let columnDemand = 0;
1344+
ids.forEach((id, idx) => {
1345+
const ranges = rangesByFootnoteId.get(id) ?? [];
1346+
let rangesHeight = 0;
1347+
ranges.forEach((range) => {
1348+
const spacingAfter = 'spacingAfter' in range ? (range.spacingAfter ?? 0) : 0;
1349+
rangesHeight += range.height + spacingAfter;
1350+
});
1351+
columnDemand += rangesHeight + (idx > 0 ? safeGap : 0);
1352+
});
1353+
if (columnDemand > 0) {
1354+
columnDemand += safeSeparatorSpacingBefore + safeDividerHeight + safeTopPadding;
1355+
}
1356+
if (columnDemand > demand) demand = columnDemand;
1357+
}
1358+
const placementCeiling = demand > 0 ? Math.min(Math.ceil(demand), maxReserve) : maxReserve;
1359+
13321360
const pendingForPage = new Map<number, Array<{ id: string; ranges: FootnoteRange[] }>>();
13331361
pendingByColumn.forEach((entries, columnIndex) => {
13341362
const targetIndex = columnIndex < columnCount ? columnIndex : Math.max(0, columnCount - 1);
@@ -1366,7 +1394,7 @@ export async function incrementalLayout(
13661394
: 0;
13671395
const overhead = isFirstSlice ? separatorBefore + separatorHeight + safeTopPadding : 0;
13681396
const gapBefore = !isFirstSlice ? safeGap : 0;
1369-
const availableHeight = Math.max(0, maxReserve - usedHeight - overhead - gapBefore);
1397+
const availableHeight = Math.max(0, placementCeiling - usedHeight - overhead - gapBefore);
13701398
const { slice, remainingRanges } = fitFootnoteContent(
13711399
id,
13721400
ranges,
@@ -1375,7 +1403,7 @@ export async function incrementalLayout(
13751403
columnIndex,
13761404
isContinuation,
13771405
measuresById,
1378-
isFirstSlice && maxReserve > 0,
1406+
isFirstSlice && placementCeiling > 0,
13791407
);
13801408

13811409
if (slice.ranges.length === 0) {
@@ -1757,7 +1785,7 @@ export async function incrementalLayout(
17571785
// so each page gets the correct reserve (avoids "too much" on one page and "not enough" on another).
17581786
if (reserves.some((h) => h > 0)) {
17591787
let reservesStabilized = false;
1760-
const seenReserveKeys = new Set<string>([reserves.join(',')]);
1788+
const seenReserveVectors: number[][] = [reserves.slice()];
17611789
for (let pass = 0; pass < MAX_FOOTNOTE_LAYOUT_PASSES; pass += 1) {
17621790
layout = relayout(reserves);
17631791
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
@@ -1773,13 +1801,33 @@ export async function incrementalLayout(
17731801
reservesStabilized = true;
17741802
break;
17751803
}
1776-
// Detect oscillation: if we've produced a reserve vector we already tried,
1777-
// the loop will never converge. Break early to avoid wasted relayout passes.
1804+
// SD-1680: when reserves oscillate (typically between a state where all footnotes
1805+
// fit and a state where body packs tighter with some footnotes pushed off the
1806+
// page), prefer the element-wise max across all seen states. This matches Word's
1807+
// bias toward keeping footnotes on their ref's page rather than tight body
1808+
// packing, and avoids overflow from the body reserving less than the plan places.
17781809
const nextKey = nextReserves.join(',');
1779-
if (seenReserveKeys.has(nextKey)) {
1810+
const seen = seenReserveVectors.some((v) => v.join(',') === nextKey);
1811+
if (seen) {
1812+
const allVectors = [...seenReserveVectors, nextReserves];
1813+
const mergedLength = Math.max(...allVectors.map((v) => v.length));
1814+
const merged = new Array<number>(mergedLength).fill(0);
1815+
for (const vec of allVectors) {
1816+
for (let i = 0; i < mergedLength; i += 1) {
1817+
if ((vec[i] ?? 0) > merged[i]) merged[i] = vec[i];
1818+
}
1819+
}
1820+
reserves = merged;
1821+
// Relayout with merged reserves so post-loop sees a layout consistent with the
1822+
// reserves we're about to apply — otherwise pages may collapse to the layout
1823+
// built with the smaller oscillating reserve.
1824+
layout = relayout(reserves);
1825+
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
1826+
({ measuresById } = await measureFootnoteBlocks(collectFootnoteIdsByColumn(idsByColumn)));
1827+
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
17801828
break;
17811829
}
1782-
seenReserveKeys.add(nextKey);
1830+
seenReserveVectors.push(nextReserves.slice());
17831831
// Only update reserves when we will do another layout pass; otherwise layout
17841832
// would be built with the previous reserves while reserves would be nextReserves,
17851833
// and the plan/injection phase could place footnotes in the wrong band.
@@ -1804,15 +1852,31 @@ export async function incrementalLayout(
18041852
reserves,
18051853
finalPageColumns,
18061854
);
1807-
const finalReserves = finalPlan.reserves;
18081855
let reservesAppliedToLayout = reserves;
1809-
const reservesDiffer =
1810-
finalReserves.length !== reserves.length ||
1811-
finalReserves.some((h, i) => (reserves[i] ?? 0) !== h) ||
1812-
reserves.some((h, i) => (finalReserves[i] ?? 0) !== h);
1813-
if (reservesDiffer) {
1814-
layout = relayout(finalReserves);
1815-
reservesAppliedToLayout = finalReserves;
1856+
// SD-1680: the post-loop can still mismatch the body reserve and plan placement when
1857+
// relayouting with finalPlan.reserves shifts footnote refs between pages (the newly
1858+
// relaxed page now holds refs the old reserves didn't account for). Iterate a few
1859+
// times, each step taking the element-wise max of current reserves and the new plan's
1860+
// reserves, so the final layout's reservation on every page is at least as large as
1861+
// the demand from the final ref assignment. This guarantees placements stay inside
1862+
// the band and cannot render past the page's bottom margin.
1863+
const MAX_POST_PASSES = 3;
1864+
for (let postPass = 0; postPass < MAX_POST_PASSES; postPass += 1) {
1865+
const target = reservesAppliedToLayout.slice();
1866+
const planReserves = finalPlan.reserves;
1867+
const len = Math.max(target.length, planReserves.length);
1868+
let needsRelayout = false;
1869+
for (let i = 0; i < len; i += 1) {
1870+
const applied = target[i] ?? 0;
1871+
const needed = planReserves[i] ?? 0;
1872+
if (needed > applied) {
1873+
target[i] = needed;
1874+
needsRelayout = true;
1875+
}
1876+
}
1877+
if (!needsRelayout) break;
1878+
layout = relayout(target);
1879+
reservesAppliedToLayout = target;
18161880
({ columns: finalPageColumns, idsByColumn: finalIdsByColumn } = resolveFootnoteAssignments(layout));
18171881
({ blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(
18181882
collectFootnoteIdsByColumn(finalIdsByColumn),
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* SD-1680: Footnotes render past the bottom page margin when multiple footnotes
3+
* need more total height than the reserved band allows. Verifies that the plan
4+
* output is internally consistent with the body layout's bottom margin — i.e.,
5+
* no footnote fragment's bottom-Y exceeds the top of the physical bottom margin.
6+
*/
7+
8+
import { describe, it, expect, vi } from 'vitest';
9+
import type { FlowBlock, Measure, Fragment } from '@superdoc/contracts';
10+
import { incrementalLayout } from '../src/incrementalLayout';
11+
12+
const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({
13+
kind: 'paragraph',
14+
id,
15+
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }],
16+
});
17+
18+
const makeSingleLineMeasure = (lineHeight: number, textLength: number): Measure => ({
19+
kind: 'paragraph',
20+
lines: [
21+
{
22+
fromRun: 0,
23+
fromChar: 0,
24+
toRun: 0,
25+
toChar: textLength,
26+
width: 200,
27+
ascent: lineHeight * 0.8,
28+
descent: lineHeight * 0.2,
29+
lineHeight,
30+
},
31+
],
32+
totalHeight: lineHeight,
33+
});
34+
35+
const makeMultiLineMeasure = (lineHeight: number, lineCount: number): Measure => {
36+
const lines = Array.from({ length: lineCount }, (_, i) => ({
37+
fromRun: 0,
38+
fromChar: i,
39+
toRun: 0,
40+
toChar: i + 1,
41+
width: 200,
42+
ascent: lineHeight * 0.8,
43+
descent: lineHeight * 0.2,
44+
lineHeight,
45+
}));
46+
return { kind: 'paragraph', lines, totalHeight: lineCount * lineHeight };
47+
};
48+
49+
const PAGE_PHYSICAL_BOTTOM_MARGIN = 72;
50+
const BODY_LINE_HEIGHT = 20;
51+
const FOOTNOTE_LINE_HEIGHT = 12;
52+
53+
describe('SD-1680: Footnote band must not overflow the reserved area', () => {
54+
/**
55+
* Scenario: multiple medium-size footnotes compete for a single page's band.
56+
* Page body fills to have all 4 refs on page 1. Each footnote is long enough that
57+
* the total needed reserve exceeds what a single pass' body layout anticipated.
58+
* Before the fix, footnotes stacked past the reserved band and rendered past the
59+
* page's physical bottom margin. After the fix, excess footnotes must be
60+
* pushed to a later page (or split) and the band must stay within its reserve.
61+
*/
62+
it('keeps every footnote fragment within the band on multi-footnote pages', async () => {
63+
// Realistic-ish scenario: ~20 body paragraphs across 2-3 pages, with 2 footnote
64+
// refs per page. Each footnote is medium-sized (4 lines). Ample body slack so
65+
// the layout converges within MAX_FOOTNOTE_LAYOUT_PASSES.
66+
const BODY_PARAS = 20;
67+
const FOOTNOTE_LINES = 4;
68+
const FOOTNOTE_COUNT = 4;
69+
70+
let pos = 0;
71+
const bodyBlocks: FlowBlock[] = [];
72+
const refs: Array<{ id: string; pos: number }> = [];
73+
const blocksById = new Map<string, FlowBlock[]>();
74+
75+
for (let i = 0; i < BODY_PARAS; i += 1) {
76+
const text = `Body paragraph ${i + 1}.`;
77+
const para = makeParagraph(`body-${i}`, text, pos);
78+
bodyBlocks.push(para);
79+
80+
// Spread refs so 2 land on each page
81+
if (refs.length < FOOTNOTE_COUNT && i % 3 === 1) {
82+
const refId = String(refs.length + 1);
83+
refs.push({ id: refId, pos: pos + 2 });
84+
const fnBlock = makeParagraph(
85+
`footnote-${refId}-0-paragraph`,
86+
`Footnote ${refId} content spanning multiple lines.`,
87+
0,
88+
);
89+
blocksById.set(refId, [fnBlock]);
90+
}
91+
92+
pos += text.length + 1;
93+
}
94+
95+
const measureBlock = vi.fn(async (block: FlowBlock) => {
96+
if (block.id.startsWith('footnote-')) {
97+
return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, FOOTNOTE_LINES);
98+
}
99+
return makeSingleLineMeasure(BODY_LINE_HEIGHT, 20);
100+
});
101+
102+
// Page holds ~10 body paragraphs (200px). With footnote reserve, body shrinks
103+
// enough that refs can move between pages, but everything must stay bounded.
104+
const contentHeight = 10 * BODY_LINE_HEIGHT; // 200px
105+
const pageHeight = contentHeight + 72 + PAGE_PHYSICAL_BOTTOM_MARGIN;
106+
107+
const result = await incrementalLayout(
108+
[],
109+
null,
110+
bodyBlocks,
111+
{
112+
pageSize: { w: 612, h: pageHeight },
113+
margins: { top: 72, right: 72, bottom: PAGE_PHYSICAL_BOTTOM_MARGIN, left: 72 },
114+
footnotes: {
115+
refs,
116+
blocksById,
117+
topPadding: 4,
118+
dividerHeight: 2,
119+
},
120+
},
121+
measureBlock,
122+
);
123+
124+
const { layout } = result;
125+
const pageH = pageHeight;
126+
127+
// INVARIANT: on every page, every footnote fragment's bottom edge must be ≤ the
128+
// top of the physical bottom margin (pageH - PAGE_PHYSICAL_BOTTOM_MARGIN).
129+
// Anything below that point has rendered past the reserved band into the
130+
// footer area — the core SD-1680 bug.
131+
const overflows: string[] = [];
132+
for (const page of layout.pages) {
133+
const pageBottomLimit = (page.size?.h ?? pageH) - PAGE_PHYSICAL_BOTTOM_MARGIN;
134+
const footnoteFragments = page.fragments.filter(
135+
(f: Fragment) =>
136+
typeof f.blockId === 'string' && f.blockId.startsWith('footnote-') && !f.blockId.includes('separator'),
137+
);
138+
for (const f of footnoteFragments) {
139+
// For paragraph fragments, height is computed from fromLine/toLine * lineHeight.
140+
// The footnote measure's totalHeight is FOOTNOTE_LINES * FOOTNOTE_LINE_HEIGHT.
141+
let fragHeight = 0;
142+
if (f.kind === 'para' && typeof f.toLine === 'number' && typeof f.fromLine === 'number') {
143+
fragHeight = (f.toLine - f.fromLine) * FOOTNOTE_LINE_HEIGHT;
144+
} else if (typeof (f as { height?: number }).height === 'number') {
145+
fragHeight = (f as { height: number }).height;
146+
}
147+
const fragBottom = (f.y ?? 0) + fragHeight;
148+
if (fragBottom > pageBottomLimit + 1) {
149+
overflows.push(
150+
`page ${page.number}: fragment ${f.blockId} bottom=${fragBottom.toFixed(1)} exceeds limit ${pageBottomLimit.toFixed(1)} by ${(fragBottom - pageBottomLimit).toFixed(1)}px`,
151+
);
152+
}
153+
}
154+
}
155+
156+
expect(overflows).toEqual([]);
157+
});
158+
159+
/**
160+
* A single huge footnote (taller than the page's max reserve) must be split
161+
* across pages with a continuation on the next page — never rendered as one
162+
* monolithic block that extends off the page.
163+
*/
164+
it('splits an oversized footnote across pages rather than overflowing', async () => {
165+
const BIG_FOOTNOTE_LINES = 30; // way bigger than a single page's band
166+
const refPos = 5;
167+
168+
const bodyBlocks: FlowBlock[] = [makeParagraph('body-0', 'Body before footnote ref.', 0)];
169+
const footnoteBlock = makeParagraph(
170+
'footnote-1-0-paragraph',
171+
'A very long footnote that should not fit on a single page band.',
172+
0,
173+
);
174+
175+
const measureBlock = vi.fn(async (block: FlowBlock) => {
176+
if (block.id.startsWith('footnote-')) {
177+
return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, BIG_FOOTNOTE_LINES);
178+
}
179+
return makeSingleLineMeasure(BODY_LINE_HEIGHT, 26);
180+
});
181+
182+
const contentHeight = 200;
183+
const pageHeight = contentHeight + 72 + PAGE_PHYSICAL_BOTTOM_MARGIN;
184+
185+
const result = await incrementalLayout(
186+
[],
187+
null,
188+
bodyBlocks,
189+
{
190+
pageSize: { w: 612, h: pageHeight },
191+
margins: { top: 72, right: 72, bottom: PAGE_PHYSICAL_BOTTOM_MARGIN, left: 72 },
192+
footnotes: {
193+
refs: [{ id: '1', pos: refPos }],
194+
blocksById: new Map([['1', [footnoteBlock]]]),
195+
topPadding: 4,
196+
dividerHeight: 2,
197+
},
198+
},
199+
measureBlock,
200+
);
201+
202+
const { layout } = result;
203+
const pageBottomLimit = pageHeight - PAGE_PHYSICAL_BOTTOM_MARGIN;
204+
205+
// No single fragment may extend past the page's bottom limit.
206+
for (const page of layout.pages) {
207+
const pageBottomThisPage = (page.size?.h ?? pageHeight) - PAGE_PHYSICAL_BOTTOM_MARGIN;
208+
const footnoteFragments = page.fragments.filter(
209+
(f: Fragment) =>
210+
typeof f.blockId === 'string' && f.blockId.startsWith('footnote-') && !f.blockId.includes('separator'),
211+
);
212+
for (const f of footnoteFragments) {
213+
let fragHeight = 0;
214+
if (f.kind === 'para' && typeof f.toLine === 'number' && typeof f.fromLine === 'number') {
215+
fragHeight = (f.toLine - f.fromLine) * FOOTNOTE_LINE_HEIGHT;
216+
} else if (typeof (f as { height?: number }).height === 'number') {
217+
fragHeight = (f as { height: number }).height;
218+
}
219+
const fragBottom = (f.y ?? 0) + fragHeight;
220+
expect(fragBottom).toBeLessThanOrEqual(pageBottomThisPage + 1);
221+
}
222+
}
223+
});
224+
});

0 commit comments

Comments
 (0)