Skip to content

Commit 4e92dd4

Browse files
committed
refactor(footnote): clarify preferred reserve scoring
1 parent 533cdc2 commit 4e92dd4

4 files changed

Lines changed: 908 additions & 1 deletion

File tree

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import type { FootnotePageLedger, Layout } from '@superdoc/contracts';
2+
3+
export type FootnoteWindowScoreReason =
4+
| 'globally-safe'
5+
| 'cluster-spill'
6+
| 'new-mandatory-only'
7+
| 'candidate-not-improved'
8+
| 'page-count-grew'
9+
| 'dead-reserve-bloat';
10+
11+
export type FootnotePreferredReserveCandidate = {
12+
pageIndex: number;
13+
anchorIds: string[];
14+
mandatoryReservePx: number;
15+
preferredReservePx: number;
16+
reserveDeltaPx: number;
17+
actualBandHeightPx: number;
18+
lastAnchorRenderedLines: number;
19+
};
20+
21+
export type FootnoteWindowStats = {
22+
totalPages: number;
23+
mandatoryOnlyCount: number;
24+
deadReserveSum: number;
25+
clusterSplitCount: number;
26+
candidateRenderedLines?: number;
27+
/**
28+
* Analyzer-only metric. Runtime layout does not know the Word baseline, but
29+
* the scorer can carry this when a caller has external alignment data.
30+
*/
31+
driftEvents?: number;
32+
};
33+
34+
export type FootnoteWindowScoreInput = {
35+
beforeLayout: Layout;
36+
afterLayout: Layout;
37+
candidatePageIndex: number;
38+
candidateAnchorId?: string;
39+
beforeLedger: FootnotePageLedger[];
40+
afterLedger: FootnotePageLedger[];
41+
windowAhead?: number;
42+
preferredDeltaThresholdPx?: number;
43+
mandatoryOnlyTolerancePx?: number;
44+
deadReserveBloatThresholdPx?: number;
45+
wholeDocumentDeadReserveBloatThresholdPx?: number;
46+
};
47+
48+
export type FootnoteWindowScoreResult = {
49+
accept: boolean;
50+
reason: FootnoteWindowScoreReason;
51+
before: FootnoteWindowStats;
52+
after: FootnoteWindowStats;
53+
};
54+
55+
type FootnoteLedgerDiagnostics = {
56+
mandatoryOnlyCount: number;
57+
mandatoryOnlyAnchorIds: Set<string>;
58+
deadReserveSum: number;
59+
clusterSplitCount: number;
60+
clusterSplitAnchorIds: Set<string>;
61+
};
62+
63+
const DEFAULT_WINDOW_AHEAD = 3;
64+
const DEFAULT_PREFERRED_DELTA_THRESHOLD_PX = 8;
65+
const DEFAULT_MANDATORY_ONLY_TOLERANCE_PX = 2;
66+
const DEFAULT_DEAD_RESERVE_BLOAT_THRESHOLD_PX = 128;
67+
const DEFAULT_WHOLE_DOCUMENT_DEAD_RESERVE_BLOAT_THRESHOLD_PX = 128;
68+
const FULL_ANCHOR_RENDER_SENTINEL = Number.MAX_SAFE_INTEGER;
69+
const DEFAULT_TRIAL_TARGET_COUNT = 12;
70+
71+
export const isMandatoryOnlyFootnotePage = (
72+
ledger: FootnotePageLedger,
73+
preferredDeltaThresholdPx = DEFAULT_PREFERRED_DELTA_THRESHOLD_PX,
74+
mandatoryOnlyTolerancePx = DEFAULT_MANDATORY_ONLY_TOLERANCE_PX,
75+
): boolean => {
76+
if (ledger.anchorIds.length === 0) return false;
77+
return (
78+
Math.abs(ledger.actualBandHeightPx - ledger.mandatoryReservePx) <= mandatoryOnlyTolerancePx &&
79+
ledger.preferredReservePx - ledger.mandatoryReservePx > preferredDeltaThresholdPx &&
80+
ledger.lastAnchorRenderedLines <= 1
81+
);
82+
};
83+
84+
export const getPreferredReserveCandidates = (
85+
ledgers: FootnotePageLedger[],
86+
preferredDeltaThresholdPx = DEFAULT_PREFERRED_DELTA_THRESHOLD_PX,
87+
mandatoryOnlyTolerancePx = DEFAULT_MANDATORY_ONLY_TOLERANCE_PX,
88+
): FootnotePreferredReserveCandidate[] => {
89+
return ledgers
90+
.filter((ledger) => isMandatoryOnlyFootnotePage(ledger, preferredDeltaThresholdPx, mandatoryOnlyTolerancePx))
91+
.map((ledger) => ({
92+
pageIndex: ledger.pageIndex,
93+
anchorIds: ledger.anchorIds.slice(),
94+
mandatoryReservePx: ledger.mandatoryReservePx,
95+
preferredReservePx: ledger.preferredReservePx,
96+
reserveDeltaPx: ledger.preferredReservePx - ledger.mandatoryReservePx,
97+
actualBandHeightPx: ledger.actualBandHeightPx,
98+
lastAnchorRenderedLines: ledger.lastAnchorRenderedLines,
99+
}));
100+
};
101+
102+
export const getPreferredReserveTrialTargets = (
103+
candidate: FootnotePreferredReserveCandidate,
104+
currentReservePx: number,
105+
preferredDeltaThresholdPx = DEFAULT_PREFERRED_DELTA_THRESHOLD_PX,
106+
maxTargets = DEFAULT_TRIAL_TARGET_COUNT,
107+
): number[] => {
108+
const current = Number.isFinite(currentReservePx) ? Math.max(0, currentReservePx) : 0;
109+
const floor = Math.max(current, candidate.mandatoryReservePx);
110+
const ceiling = Math.max(floor, candidate.preferredReservePx);
111+
const delta = ceiling - floor;
112+
if (delta <= preferredDeltaThresholdPx) return [];
113+
114+
const targets = new Set<number>();
115+
const addTarget = (value: number) => {
116+
if (!Number.isFinite(value)) return;
117+
const rounded = Math.ceil(Math.max(floor, Math.min(ceiling, value)));
118+
if (rounded - floor > preferredDeltaThresholdPx) targets.add(rounded);
119+
};
120+
121+
// Try full preferred first, then smaller partial reserves. Full preferred is
122+
// Word-like when safe, but large legal footnotes often need an intermediate
123+
// target: more than firstLine, less than the whole last footnote.
124+
[1, 0.75, 0.5, 0.33, 0.25, 0.15].forEach((fraction) => addTarget(floor + delta * fraction));
125+
[96, 72, 48, 24, 12].forEach((px) => addTarget(floor + Math.min(delta, px)));
126+
127+
return Array.from(targets)
128+
.sort((a, b) => b - a)
129+
.slice(0, Math.max(1, maxTargets));
130+
};
131+
132+
export const collectFootnoteLedgers = (layout: Layout): FootnotePageLedger[] => {
133+
return layout.pages.flatMap((page) => (page.footnoteLedger ? [page.footnoteLedger] : []));
134+
};
135+
136+
const getWindowBounds = (layout: Layout, candidatePageIndex: number, windowAhead: number) => ({
137+
windowStart: Math.max(0, candidatePageIndex),
138+
windowEnd: Math.min(layout.pages.length - 1, candidatePageIndex + Math.max(0, windowAhead)),
139+
});
140+
141+
const getDocumentBounds = (layout: Layout) => ({
142+
windowStart: 0,
143+
windowEnd: Math.max(0, layout.pages.length - 1),
144+
});
145+
146+
const isInWindow = (ledger: FootnotePageLedger, windowStart: number, windowEnd: number) =>
147+
ledger.pageIndex >= windowStart && ledger.pageIndex <= windowEnd;
148+
149+
const getLastAnchorId = (ledger: FootnotePageLedger | undefined): string | undefined => {
150+
if (!ledger || ledger.anchorIds.length === 0) return undefined;
151+
return ledger.anchorIds[ledger.anchorIds.length - 1];
152+
};
153+
154+
const collectLedgerDiagnostics = (
155+
ledgers: FootnotePageLedger[],
156+
windowStart: number,
157+
windowEnd: number,
158+
preferredDeltaThresholdPx: number,
159+
mandatoryOnlyTolerancePx: number,
160+
): FootnoteLedgerDiagnostics => {
161+
const diagnostics: FootnoteLedgerDiagnostics = {
162+
mandatoryOnlyCount: 0,
163+
mandatoryOnlyAnchorIds: new Set<string>(),
164+
deadReserveSum: 0,
165+
clusterSplitCount: 0,
166+
clusterSplitAnchorIds: new Set<string>(),
167+
};
168+
169+
for (const ledger of ledgers) {
170+
if (!isInWindow(ledger, windowStart, windowEnd)) continue;
171+
172+
diagnostics.deadReserveSum += Math.max(0, ledger.deadReservePx);
173+
174+
if (isMandatoryOnlyFootnotePage(ledger, preferredDeltaThresholdPx, mandatoryOnlyTolerancePx)) {
175+
diagnostics.mandatoryOnlyCount += 1;
176+
const id = getLastAnchorId(ledger);
177+
if (id) diagnostics.mandatoryOnlyAnchorIds.add(id);
178+
}
179+
180+
const anchorIds = new Set(ledger.anchorIds);
181+
let splitsCurrentAnchorCluster = false;
182+
for (const entry of ledger.continuationOut) {
183+
if (!anchorIds.has(entry.id)) continue;
184+
diagnostics.clusterSplitAnchorIds.add(entry.id);
185+
splitsCurrentAnchorCluster = true;
186+
}
187+
if (splitsCurrentAnchorCluster) diagnostics.clusterSplitCount += 1;
188+
}
189+
190+
return diagnostics;
191+
};
192+
193+
const hasNewId = (after: Set<string>, before: Set<string>): boolean => {
194+
for (const id of after) {
195+
if (!before.has(id)) return true;
196+
}
197+
return false;
198+
};
199+
200+
const getCandidateRenderedLines = (
201+
ledgers: FootnotePageLedger[],
202+
candidateAnchorId: string | undefined,
203+
): number | undefined => {
204+
if (!candidateAnchorId) return undefined;
205+
206+
const anchorLedger = ledgers.find((ledger) => ledger.anchorIds.includes(candidateAnchorId));
207+
if (!anchorLedger) return undefined;
208+
209+
const lastAnchorId = getLastAnchorId(anchorLedger);
210+
if (lastAnchorId === candidateAnchorId) return anchorLedger.lastAnchorRenderedLines;
211+
212+
// If the candidate is no longer the last anchor on its page, the mandatory
213+
// rule requires it to be rendered fully before the new last anchor receives
214+
// only its first line. Treat that as a direct improvement over first-line
215+
// rendering while still letting continuationOut checks catch impossible
216+
// split states elsewhere.
217+
return FULL_ANCHOR_RENDER_SENTINEL;
218+
};
219+
220+
const candidateRenderedLinesImproved = (before: FootnoteWindowStats, after: FootnoteWindowStats): boolean =>
221+
typeof before.candidateRenderedLines === 'number' &&
222+
typeof after.candidateRenderedLines === 'number' &&
223+
after.candidateRenderedLines > before.candidateRenderedLines;
224+
225+
export const summarizeFootnoteWindow = (
226+
layout: Layout,
227+
ledgers: FootnotePageLedger[],
228+
candidatePageIndex: number,
229+
windowAhead = DEFAULT_WINDOW_AHEAD,
230+
preferredDeltaThresholdPx = DEFAULT_PREFERRED_DELTA_THRESHOLD_PX,
231+
mandatoryOnlyTolerancePx = DEFAULT_MANDATORY_ONLY_TOLERANCE_PX,
232+
candidateAnchorId?: string,
233+
): FootnoteWindowStats => {
234+
const { windowStart, windowEnd } = getWindowBounds(layout, candidatePageIndex, windowAhead);
235+
const diagnostics = collectLedgerDiagnostics(
236+
ledgers,
237+
windowStart,
238+
windowEnd,
239+
preferredDeltaThresholdPx,
240+
mandatoryOnlyTolerancePx,
241+
);
242+
243+
return {
244+
totalPages: layout.pages.length,
245+
mandatoryOnlyCount: diagnostics.mandatoryOnlyCount,
246+
deadReserveSum: diagnostics.deadReserveSum,
247+
// A current-page anchor split is a continuation created by the page's own
248+
// anchor cluster. Continuation-in from prior pages is tracked separately and
249+
// is not counted here as a newly introduced split.
250+
clusterSplitCount: diagnostics.clusterSplitCount,
251+
candidateRenderedLines: getCandidateRenderedLines(ledgers, candidateAnchorId),
252+
};
253+
};
254+
255+
export const scoreFootnoteWindow = (input: FootnoteWindowScoreInput): FootnoteWindowScoreResult => {
256+
const windowAhead = input.windowAhead ?? DEFAULT_WINDOW_AHEAD;
257+
const preferredDeltaThresholdPx = input.preferredDeltaThresholdPx ?? DEFAULT_PREFERRED_DELTA_THRESHOLD_PX;
258+
const mandatoryOnlyTolerancePx = input.mandatoryOnlyTolerancePx ?? DEFAULT_MANDATORY_ONLY_TOLERANCE_PX;
259+
const deadReserveBloatThresholdPx = input.deadReserveBloatThresholdPx ?? DEFAULT_DEAD_RESERVE_BLOAT_THRESHOLD_PX;
260+
const wholeDocumentDeadReserveBloatThresholdPx =
261+
input.wholeDocumentDeadReserveBloatThresholdPx ?? DEFAULT_WHOLE_DOCUMENT_DEAD_RESERVE_BLOAT_THRESHOLD_PX;
262+
const beforeCandidateLedger = input.beforeLedger.find((ledger) => ledger.pageIndex === input.candidatePageIndex);
263+
const candidateAnchorId = input.candidateAnchorId ?? getLastAnchorId(beforeCandidateLedger);
264+
265+
const before = summarizeFootnoteWindow(
266+
input.beforeLayout,
267+
input.beforeLedger,
268+
input.candidatePageIndex,
269+
windowAhead,
270+
preferredDeltaThresholdPx,
271+
mandatoryOnlyTolerancePx,
272+
candidateAnchorId,
273+
);
274+
const after = summarizeFootnoteWindow(
275+
input.afterLayout,
276+
input.afterLedger,
277+
input.candidatePageIndex,
278+
windowAhead,
279+
preferredDeltaThresholdPx,
280+
mandatoryOnlyTolerancePx,
281+
candidateAnchorId,
282+
);
283+
const beforeBounds = getWindowBounds(input.beforeLayout, input.candidatePageIndex, windowAhead);
284+
const afterBounds = getWindowBounds(input.afterLayout, input.candidatePageIndex, windowAhead);
285+
const beforeWindowDiagnostics = collectLedgerDiagnostics(
286+
input.beforeLedger,
287+
beforeBounds.windowStart,
288+
beforeBounds.windowEnd,
289+
preferredDeltaThresholdPx,
290+
mandatoryOnlyTolerancePx,
291+
);
292+
const afterWindowDiagnostics = collectLedgerDiagnostics(
293+
input.afterLedger,
294+
afterBounds.windowStart,
295+
afterBounds.windowEnd,
296+
preferredDeltaThresholdPx,
297+
mandatoryOnlyTolerancePx,
298+
);
299+
const beforeDocumentBounds = getDocumentBounds(input.beforeLayout);
300+
const afterDocumentBounds = getDocumentBounds(input.afterLayout);
301+
const beforeDocumentDiagnostics = collectLedgerDiagnostics(
302+
input.beforeLedger,
303+
beforeDocumentBounds.windowStart,
304+
beforeDocumentBounds.windowEnd,
305+
preferredDeltaThresholdPx,
306+
mandatoryOnlyTolerancePx,
307+
);
308+
const afterDocumentDiagnostics = collectLedgerDiagnostics(
309+
input.afterLedger,
310+
afterDocumentBounds.windowStart,
311+
afterDocumentBounds.windowEnd,
312+
preferredDeltaThresholdPx,
313+
mandatoryOnlyTolerancePx,
314+
);
315+
316+
if (after.totalPages > before.totalPages) {
317+
return { accept: false, reason: 'page-count-grew', before, after };
318+
}
319+
if (
320+
after.clusterSplitCount > before.clusterSplitCount ||
321+
hasNewId(afterWindowDiagnostics.clusterSplitAnchorIds, beforeWindowDiagnostics.clusterSplitAnchorIds)
322+
) {
323+
return { accept: false, reason: 'cluster-spill', before, after };
324+
}
325+
if (
326+
afterDocumentDiagnostics.clusterSplitAnchorIds.size > beforeDocumentDiagnostics.clusterSplitAnchorIds.size ||
327+
hasNewId(afterDocumentDiagnostics.clusterSplitAnchorIds, beforeDocumentDiagnostics.clusterSplitAnchorIds)
328+
) {
329+
return { accept: false, reason: 'cluster-spill', before, after };
330+
}
331+
if (hasNewId(afterWindowDiagnostics.mandatoryOnlyAnchorIds, beforeWindowDiagnostics.mandatoryOnlyAnchorIds)) {
332+
return { accept: false, reason: 'new-mandatory-only', before, after };
333+
}
334+
if (hasNewId(afterDocumentDiagnostics.mandatoryOnlyAnchorIds, beforeDocumentDiagnostics.mandatoryOnlyAnchorIds)) {
335+
return { accept: false, reason: 'new-mandatory-only', before, after };
336+
}
337+
if (after.deadReserveSum > before.deadReserveSum + deadReserveBloatThresholdPx) {
338+
return { accept: false, reason: 'dead-reserve-bloat', before, after };
339+
}
340+
if (
341+
afterDocumentDiagnostics.deadReserveSum >
342+
beforeDocumentDiagnostics.deadReserveSum + wholeDocumentDeadReserveBloatThresholdPx
343+
) {
344+
return { accept: false, reason: 'dead-reserve-bloat', before, after };
345+
}
346+
if (!candidateRenderedLinesImproved(before, after)) {
347+
return { accept: false, reason: 'candidate-not-improved', before, after };
348+
}
349+
350+
return { accept: true, reason: 'globally-safe', before, after };
351+
};

0 commit comments

Comments
 (0)