Skip to content

Commit 763801c

Browse files
authored
feat(footnote): reserve full footnote demand at body slice time (SD-2656) (#3597)
Replaces the body slicer's ORDERED-MINIMUM acceptance rule with ORDERED-PREFERRED. The slicer now reserves each anchored footnote's full height up front, instead of just the first line of the last anchor. The body naturally backs off enough lines to fit every anchored footnote whole on its anchor page — matching Word's pagination behavior, which knows each footnote's full demand at every line decision rather than reserving a minimum and patching later. ## Architectural rationale The previous five-layer pipeline (mandatory-minimum planner → body slicer → convergence loop → preferred-reserve scorer → post-hoc widow absorb) existed to compensate for the deliberate under-reservation at layer 1. Each downstream layer fixed a symptom of layer 1's optimism. By reserving the full demand at slice time, the symptoms disappear and the downstream layers can be simplified or removed in follow-up work. This is the cleaner shape: one place that decides demand, no back-and-forth between layers. ## Fixture results | Fixture | Before | After | |---|---|---| | Carlsbad | 46p / 3 splits | 46p / 0 splits | | IRA | 48p / 9 splits | 46p / 0 splits | | SPA | 53p / 7 splits | 53p / 0 splits | | IT-923 COI | 50p / 15 splits (Phase 1 era) | 54p / 1 split | | MRL | 5p / 0 splits | 5p / 0 splits | Cost is a small page-count growth (≤ +4 pages on packed legal docs like COI; ≤ +1 on most others). Word would also grow these documents under similar packing pressure. The single remaining split (COI fn 32) is a footnote large enough that no single page accommodates it without itself overflowing — a genuine forced split that Word would also produce. ## Test sweep (all green) - layout-engine 657 / layout-bridge 1281 / layout-tests 332 The Phase 1 dead-reserve concern (24 IT-923 pages had `deadReserve > 30 px` under preferred demand) is mitigated by the codex correctness fixes shipped earlier on the SD-2656 branch — the column-state carryover that exaggerated dead-reserve drift is gone.
1 parent a1e1f9a commit 763801c

1 file changed

Lines changed: 14 additions & 25 deletions

File tree

packages/layout-engine/layout-engine/src/layout-paragraph.ts

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -955,34 +955,23 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
955955
// commit-first-line rule keeps making progress and the band may end
956956
// up clipped — but that case is handled by the planner's continuation
957957
// split (separate fix path).
958-
// SD-2656 Phase 1: body acceptance uses the ORDERED MINIMUM only.
959-
//
960-
// ORDERED demand = sum(fullHeight of non-last) + firstLineHeight(last)
961-
// + overhead.
962-
//
963-
// This is the rule's minimum: cluster's non-last anchors must fit fully,
964-
// and the last anchor needs at least its first line. The body's
965-
// acceptance rule is "the next line fits if ordered demand still fits".
966-
//
967-
// Why ORDERED only (not preferred): Phase 0 ledger diagnostics on
968-
// IT-923 showed that 24 pages had `deadReserve > 30 px` — body
969-
// reserved space for the PREFERRED (full of all) demand, but the
970-
// planner only painted ORDERED-equivalent content. That dead reserve
971-
// was the drift fuel. With ORDERED as the acceptance rule, body packs
972-
// tighter; the planner uses any leftover capacity opportunistically
973-
// (Phase 2) to extend the last anchor or drain continuations.
974-
const computeOrderedDemandForRange = (pmStart: number, pmEnd: number): number => {
958+
// Reserve the full footnote cluster height up front, so the body slicer
959+
// backs off enough lines that every anchored footnote fits whole on its
960+
// own page. This matches Word's pagination, which knows each footnote's
961+
// full demand at every line decision rather than reserving a minimum
962+
// and patching later. Cost: bodies that previously packed to the brink
963+
// grow ≤ 1–4 pages per fixture; gain: footnote splits drop to ~0 on
964+
// fixtures we measured (Carlsbad, IRA, SPA, IT-923 COI, MRL).
965+
const computeFootnoteClusterDemand = (pmStart: number, pmEnd: number): number => {
975966
const candidate = ctx.getFootnoteAnchorsForBlockId
976967
? ctx.getFootnoteAnchorsForBlockId(block.id, pmStart, pmEnd)
977968
: [];
978969
const committed = state.footnoteAnchorsThisPage ?? [];
979970
if (candidate.length === 0 && committed.length === 0) return 0;
980-
const cluster = [...committed, ...candidate];
981-
const lastIdx = cluster.length - 1;
982-
let ordered = 0;
983-
for (let i = 0; i < lastIdx; i += 1) ordered += cluster[i].fullHeight;
984-
if (lastIdx >= 0) ordered += cluster[lastIdx].firstLineHeight;
985-
return ordered;
971+
let demand = 0;
972+
for (const anchor of committed) demand += anchor.fullHeight;
973+
for (const anchor of candidate) demand += anchor.fullHeight;
974+
return demand;
986975
};
987976

988977
const previewRange = computeFragmentPmRange(block, lines, fromLine, fromLine + 1);
@@ -992,7 +981,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
992981
// Re-evaluates against current state after advanceColumn (footnoteAnchorsThisPage
993982
// resets on a fresh page, so demand can shrink).
994983
const computePreviewBottom = () => {
995-
const demand = computeOrderedDemandForRange(previewRange.pmStart ?? 0, previewRange.pmEnd ?? 0);
984+
const demand = computeFootnoteClusterDemand(previewRange.pmStart ?? 0, previewRange.pmEnd ?? 0);
996985
return computeEffectiveBottom(demand, previewRefs);
997986
};
998987
let effectiveBottom = computePreviewBottom();
@@ -1046,7 +1035,7 @@ export function layoutParagraphBlock(ctx: ParagraphLayoutContext, anchors?: Para
10461035
// line if ordered demand (full of non-last + firstLine of last)
10471036
// still fits. The planner uses any leftover capacity opportunistically
10481037
// (continuations, extending the last anchor).
1049-
const orderedDemand = computeOrderedDemandForRange(range.pmStart ?? 0, range.pmEnd ?? 0);
1038+
const orderedDemand = computeFootnoteClusterDemand(range.pmStart ?? 0, range.pmEnd ?? 0);
10501039
const nextRefs = ctx.getFootnoteRefCountForBlockId
10511040
? ctx.getFootnoteRefCountForBlockId(block.id, range.pmStart, range.pmEnd)
10521041
: 0;

0 commit comments

Comments
 (0)