Skip to content

Commit a1e1f9a

Browse files
committed
feat(footnote): absorb one-line footnote widows by bumping reserve (SD-2656)
Adds a `runWidowOrphanAbsorb` pass between the convergence loop and the preferred-reserve scorer. For every page whose predicted footnote tail is one line short (≤ 24 px), bumps the reserve to the page's preferred value, bypassing the scorer's page-count-growth gate. The scorer's gate exists to prevent global regressions when a trial trades local fidelity for added pages. For one-line widows the trade is bounded — Word's pagination always absorbs them. The implementation reuses the existing buildFootnoteLedgers, applyReserves, growReserves, and capReserveForRelayout helpers; the only new logic is the threshold filter and the unconditional bump. ## Threshold rationale Threshold = 24 px (one line of footnote text plus slack). Measurements on the Carlsbad fixture: at threshold = 35 px the absorb pass creates new cluster splits on pages 25-29; at threshold = 24 px no regression is measurable. 24 is the largest value with a clean profile across the two test fixtures. ## Trade-off This pass may grow the document to absorb widows. On the IRA fixture, six one-line widows bump cleanly but force the doc 45 → 48 pages. The "revert on grow" guard would make the pass a no-op everywhere unless a doc has body slack (test fixtures do not). The trade is accepted for docs whose layouts genuinely have nowhere to absorb a widow without growth. Future work pairs this with body paragraph widow/orphan controls so the body absorbs the pushed line for free. ## Verified - layout-bridge 1281, layout-engine 657, layout-tests 332 — all green. - Carlsbad: unchanged at 46p / 3 splits (no one-line tails to absorb). - IRA: 45p / 17 splits → 48p / 9 splits (8 widows absorbed, 3 page cost).
1 parent 2cc68fa commit a1e1f9a

1 file changed

Lines changed: 31 additions & 0 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2615,6 +2615,37 @@ export async function incrementalLayout(
26152615
}
26162616
}
26172617

2618+
// Absorb one-line footnote widows by bumping their reserve to
2619+
// preferred. The scorer would reject this as a page-count regression;
2620+
// for one-line tails the cost is bounded and Word's pagination always
2621+
// absorbs them.
2622+
const ONE_LINE_TAIL_PX = 24;
2623+
const runWidowOrphanAbsorb = async () => {
2624+
const ledgers = buildFootnoteLedgers(finalPlan, reservesAppliedToLayout, layout.pages.length);
2625+
const target = reservesAppliedToLayout.slice();
2626+
let bumped = 0;
2627+
for (const ledger of ledgers) {
2628+
const tailPx = ledger.continuationOut.reduce((s, e) => s + (e.remainingHeightPx || 0), 0);
2629+
if (tailPx <= 0 || tailPx > ONE_LINE_TAIL_PX) continue;
2630+
const requested = capReserveForRelayout(
2631+
ledger.preferredReservePx,
2632+
ledger.pageIndex,
2633+
layout,
2634+
reservesAppliedToLayout,
2635+
);
2636+
if (requested > (target[ledger.pageIndex] ?? 0)) {
2637+
target[ledger.pageIndex] = requested;
2638+
bumped += 1;
2639+
}
2640+
}
2641+
if (bumped === 0) return;
2642+
const safeApplied = reservesAppliedToLayout.slice();
2643+
await applyReserves(target);
2644+
if (!(await growReserves(GROW_MAX_PASSES))) {
2645+
await applyReserves(safeApplied);
2646+
}
2647+
};
2648+
await runWidowOrphanAbsorb();
26182649
await runPreferredReserveTrials();
26192650

26202651
const blockById = new Map<string, FlowBlock>();

0 commit comments

Comments
 (0)