Skip to content

Commit b2f835c

Browse files
authored
fix: large header is missing and cursor is buggy in doc with footnotes (#2659)
* chore: resolve conflicts * fix: resolve conflicts and fix odd/event headers choice * fix: improve footnotes rendering * fix: fallback to default header * fix: build error * fix: default/even header selection * fix: ts error
1 parent d32aa7d commit b2f835c

8 files changed

Lines changed: 418 additions & 86 deletions

File tree

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

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ export const getHeaderFooterType = (
8383
}
8484

8585
if (identifier.alternateHeaders) {
86-
if (pageNumber % 2 === 0 && (hasEven || hasDefault)) {
87-
return hasEven ? 'even' : 'default';
86+
if (pageNumber % 2 === 0 && hasEven) {
87+
return 'even';
8888
}
8989
if (pageNumber % 2 === 1 && (hasOdd || hasDefault)) {
9090
return hasOdd ? 'odd' : 'default';
9191
}
92+
return null;
9293
}
9394

9495
if (hasDefault) {
@@ -343,6 +344,21 @@ export function getHeaderFooterTypeForSection(
343344
const hasEven = Boolean(ids.even);
344345
const hasOdd = Boolean(ids.odd);
345346
const hasDefault = Boolean(ids.default);
347+
const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds;
348+
let hasAny = hasFirst || hasEven || hasOdd || hasDefault;
349+
if (!hasAny) {
350+
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
351+
const inheritedIds =
352+
kind === 'header' ? identifier.sectionHeaderIds.get(index) : identifier.sectionFooterIds.get(index);
353+
if (inheritedIds?.first || inheritedIds?.even || inheritedIds?.odd || inheritedIds?.default) {
354+
hasAny = true;
355+
break;
356+
}
357+
}
358+
}
359+
if (!hasAny) {
360+
hasAny = Boolean(legacyIds.first || legacyIds.even || legacyIds.odd || legacyIds.default);
361+
}
346362

347363
// Check titlePg for this specific section
348364
const sectionTitlePg = identifier.sectionTitlePg.has(sectionIndex)
@@ -357,17 +373,15 @@ export function getHeaderFooterTypeForSection(
357373
// has a 'first' header defined. Word inherits headers from previous sections when not defined,
358374
// so we let the rendering layer handle the inheritance/fallback logic.
359375
// Only return null if there's absolutely no header content anywhere.
360-
if (hasFirst || hasDefault || hasEven || hasOdd) return 'first';
376+
if (hasAny) return 'first';
361377
return null;
362378
}
363379

364380
if (identifier.alternateHeaders) {
365-
if (pageNumber % 2 === 0 && (hasEven || hasDefault)) {
366-
return hasEven ? 'even' : 'default';
367-
}
368-
if (pageNumber % 2 === 1 && (hasOdd || hasDefault)) {
369-
return hasOdd ? 'odd' : 'default';
370-
}
381+
// Keep parity-based variant selection even when this section doesn't
382+
// explicitly define that variant. Resolution/inheritance happens later.
383+
if (!hasAny) return null;
384+
return pageNumber % 2 === 0 ? 'even' : 'odd';
371385
}
372386

373387
if (hasDefault) {
@@ -412,21 +426,27 @@ export function getHeaderFooterIdForPage(
412426
});
413427
if (!variantType) return null;
414428

429+
const resolveVariantId = (ids: Partial<SectionHeaderFooterIds> | undefined): string | null => {
430+
if (!ids) return null;
431+
const direct = ids[variantType];
432+
if (direct) return direct;
433+
// With w:evenAndOddHeaders enabled, OOXML `default` is the primary/odd
434+
// page slot. It must not be used as a replacement for a missing even ref.
435+
if (variantType === 'odd' && ids.default) return ids.default;
436+
return null;
437+
};
438+
415439
// First try to get from page's sectionRefs (most specific, stamped during layout)
416440
const pageRefs = kind === 'header' ? page.sectionRefs?.headerRefs : page.sectionRefs?.footerRefs;
417-
if (pageRefs) {
418-
const idFromPage = pageRefs[variantType];
419-
if (idFromPage) return idFromPage;
420-
}
441+
const idFromPage = resolveVariantId(pageRefs);
442+
if (idFromPage) return idFromPage;
421443

422444
// Fall back to identifier's section mappings
423445
const sectionIds =
424446
kind === 'header' ? identifier.sectionHeaderIds.get(sectionIndex) : identifier.sectionFooterIds.get(sectionIndex);
425447

426-
if (sectionIds) {
427-
const idFromSection = sectionIds[variantType];
428-
if (idFromSection) return idFromSection;
429-
}
448+
const idFromSection = resolveVariantId(sectionIds);
449+
if (idFromSection) return idFromSection;
430450

431451
// Final fallback to legacy identifier fields
432452
const legacyIds = kind === 'header' ? identifier.headerIds : identifier.footerIds;

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

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,32 +1871,13 @@ export async function incrementalLayout(
18711871
reservesStabilized = true;
18721872
break;
18731873
}
1874-
// SD-1680: when reserves oscillate (typically between a state where all footnotes
1875-
// fit and a state where body packs tighter with some footnotes pushed off the
1876-
// page), prefer the element-wise max across all seen states. This matches Word's
1877-
// bias toward keeping footnotes on their ref's page rather than tight body
1878-
// packing, and avoids overflow from the body reserving less than the plan places.
1879-
const nextKey = nextReserves.join(',');
1880-
const seen = seenReserveVectors.some((v) => v.join(',') === nextKey);
1881-
if (seen) {
1882-
const allVectors = [...seenReserveVectors, nextReserves];
1883-
const mergedLength = Math.max(...allVectors.map((v) => v.length));
1884-
const merged = new Array<number>(mergedLength).fill(0);
1885-
for (const vec of allVectors) {
1886-
for (let i = 0; i < mergedLength; i += 1) {
1887-
if ((vec[i] ?? 0) > merged[i]) merged[i] = vec[i];
1888-
}
1889-
}
1890-
reserves = merged;
1891-
// Relayout with merged reserves so post-loop sees a layout consistent with the
1892-
// reserves we're about to apply — otherwise pages may collapse to the layout
1893-
// built with the smaller oscillating reserve.
1894-
layout = relayout(reserves);
1895-
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
1896-
({ measuresById } = await measureFootnoteBlocks(collectFootnoteIdsByColumn(idsByColumn)));
1897-
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
1898-
break;
1899-
}
1874+
// Reserves are oscillating. Break out; the post-reserve grow loop
1875+
// below (which is monotonic and has its own cycle detector) will
1876+
// bump any under-reserved pages to the current plan's demand.
1877+
// Merging history here would carry over large demands from early
1878+
// passes that the current layout no longer anchors, leading to
1879+
// wasted reserved space on pages that never get any footnote.
1880+
if (seenReserveVectors.some((v) => v.join(',') === nextReserves.join(','))) break;
19001881
seenReserveVectors.push(nextReserves.slice());
19011882
// Only update reserves when we will do another layout pass; otherwise layout
19021883
// would be built with the previous reserves while reserves would be nextReserves,
@@ -1923,28 +1904,14 @@ export async function incrementalLayout(
19231904
finalPageColumns,
19241905
);
19251906
let reservesAppliedToLayout = reserves;
1926-
// SD-1680: the post-loop can still mismatch the body reserve and plan placement when
1927-
// relayouting with finalPlan.reserves shifts footnote refs between pages (the newly
1928-
// relaxed page now holds refs the old reserves didn't account for). Iterate a few
1929-
// times, each step taking the element-wise max of current reserves and the new plan's
1930-
// reserves, so the final layout's reservation on every page is at least as large as
1931-
// the demand from the final ref assignment. This guarantees placements stay inside
1932-
// the band and cannot render past the page's bottom margin.
1933-
const MAX_POST_PASSES = 3;
1934-
for (let postPass = 0; postPass < MAX_POST_PASSES; postPass += 1) {
1935-
const target = reservesAppliedToLayout.slice();
1936-
const planReserves = finalPlan.reserves;
1937-
const len = Math.max(target.length, planReserves.length);
1938-
let needsRelayout = false;
1939-
for (let i = 0; i < len; i += 1) {
1940-
const applied = target[i] ?? 0;
1941-
const needed = planReserves[i] ?? 0;
1942-
if (needed > applied) {
1943-
target[i] = needed;
1944-
needsRelayout = true;
1945-
}
1907+
1908+
const vectorsEqual = (a: number[], b: number[]): boolean => {
1909+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
1910+
if ((a[i] ?? 0) !== (b[i] ?? 0)) return false;
19461911
}
1947-
if (!needsRelayout) break;
1912+
return true;
1913+
};
1914+
const applyReserves = async (target: number[]) => {
19481915
layout = relayout(target);
19491916
reservesAppliedToLayout = target;
19501917
({ columns: finalPageColumns, idsByColumn: finalIdsByColumn } = resolveFootnoteAssignments(layout));
@@ -1958,7 +1925,93 @@ export async function incrementalLayout(
19581925
reservesAppliedToLayout,
19591926
finalPageColumns,
19601927
);
1928+
};
1929+
// Grow-only convergence: ensures every page reserves at least as much
1930+
// as its plan demands, so footnotes never render past the page bottom.
1931+
// Monotonic (reserves only increase) and safe under oscillation. Needs
1932+
// several passes for growth on one page to propagate to the pages it
1933+
// spills into. If a target cycles back to one we've tried, we merge
1934+
// element-wise with the last applied target to force progress.
1935+
const growReserves = async (maxPasses: number): Promise<boolean> => {
1936+
const seen: number[][] = [reservesAppliedToLayout.slice()];
1937+
for (let pass = 0; pass < maxPasses; pass += 1) {
1938+
const target = reservesAppliedToLayout.slice();
1939+
const plan = finalPlan.reserves;
1940+
let grew = false;
1941+
for (let i = 0; i < Math.max(target.length, plan.length); i += 1) {
1942+
if ((plan[i] ?? 0) > (target[i] ?? 0)) {
1943+
target[i] = plan[i];
1944+
grew = true;
1945+
}
1946+
}
1947+
if (!grew) return true;
1948+
let next = target;
1949+
if (seen.some((prev) => vectorsEqual(prev, target))) {
1950+
const last = seen[seen.length - 1];
1951+
next = target.map((v, i) => Math.max(v, last[i] ?? 0));
1952+
if (vectorsEqual(next, reservesAppliedToLayout)) return true;
1953+
}
1954+
await applyReserves(next);
1955+
seen.push(next);
1956+
}
1957+
return false;
1958+
};
1959+
1960+
// Fast path for well-converged docs: if every page's current reserve
1961+
// already satisfies the plan and no page is carrying dead reserve,
1962+
// skip both the initial grow and the tighten loop entirely. Avoids
1963+
// up to ~20 unnecessary relayouts on documents without oscillation.
1964+
const TIGHTEN_SLACK_PX = 8;
1965+
const needsWork = (() => {
1966+
const plan = finalPlan.reserves;
1967+
const applied = reservesAppliedToLayout;
1968+
const len = Math.max(plan.length, applied.length);
1969+
for (let i = 0; i < len; i += 1) {
1970+
const a = applied[i] ?? 0;
1971+
const p = plan[i] ?? 0;
1972+
if (p > a) return true; // under-reserved — grow must bump
1973+
if (a >= TIGHTEN_SLACK_PX && p === 0) return true; // dead reserve — tighten can reclaim
1974+
}
1975+
return false;
1976+
})();
1977+
1978+
if (needsWork) {
1979+
const GROW_MAX_PASSES = 10;
1980+
if (!(await growReserves(GROW_MAX_PASSES))) {
1981+
console.warn(
1982+
'[incrementalLayout] Footnote post-reserve loop did not converge; some pages may have footnotes overflowing the reserved band.',
1983+
);
1984+
}
1985+
1986+
// Opportunistic tighten: the grow loop is monotonic, so pages whose
1987+
// plan no longer asks for a reserve (footnote content shifted to
1988+
// later pages during an earlier pass) still carry their old reserve.
1989+
// Zero those pages' reserves and regrow any that gain footnote
1990+
// content after the body reflows. Revert if regrow can't stabilize
1991+
// safely or would add pages. Iterate a few times — each tighten
1992+
// + regrow can expose a fresh set of "reserved but plan==0" pages
1993+
// after the body reflows.
1994+
const MAX_TIGHTEN_ITERATIONS = 8;
1995+
for (let iteration = 0; iteration < MAX_TIGHTEN_ITERATIONS; iteration += 1) {
1996+
const pagesToTighten: number[] = [];
1997+
for (let i = 0; i < reservesAppliedToLayout.length; i += 1) {
1998+
const applied = reservesAppliedToLayout[i] ?? 0;
1999+
const planned = finalPlan.reserves[i] ?? 0;
2000+
if (applied >= TIGHTEN_SLACK_PX && planned === 0) pagesToTighten.push(i);
2001+
}
2002+
if (pagesToTighten.length === 0) break;
2003+
const safeApplied = reservesAppliedToLayout.slice();
2004+
const safePageCount = layout.pages.length;
2005+
const tightened = reservesAppliedToLayout.slice();
2006+
for (const i of pagesToTighten) tightened[i] = 0;
2007+
await applyReserves(tightened);
2008+
if (!(await growReserves(GROW_MAX_PASSES)) || layout.pages.length > safePageCount) {
2009+
await applyReserves(safeApplied);
2010+
break;
2011+
}
2012+
}
19612013
}
2014+
19622015
const blockById = new Map<string, FlowBlock>();
19632016
finalBlocks.forEach((block) => {
19642017
blockById.set(block.id, block);

packages/layout-engine/layout-bridge/test/footnoteMultiPass.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,8 +203,13 @@ describe('Footnote multi-pass reserve loop', () => {
203203
);
204204
layoutDocSpy.mockRestore();
205205

206-
// Current regression: this scenario oscillates A -> B -> A and runs all passes (+ final relayout).
207-
// Desired behavior: detect oscillation and stop early.
208-
expect(footnoteReserveCalls.length).toBeLessThanOrEqual(3);
206+
// This scenario genuinely oscillates (A -> B -> A), so we can't collapse it
207+
// to the original ≤3 passes. The budget here bounds the combined work of
208+
// the outer convergence loop (MAX_FOOTNOTE_LAYOUT_PASSES=4), its merge
209+
// relayout, the grow-only post-reserve loop (GROW_MAX_PASSES=10), and the
210+
// opportunistic tighten loop (MAX_TIGHTEN_ITERATIONS=8). Observed actual
211+
// count is ~19; the ≤30 cap catches regressions that would balloon the
212+
// relayout count (e.g. if oscillation detection is removed or caps grow).
213+
expect(footnoteReserveCalls.length).toBeLessThanOrEqual(30);
209214
});
210215
});

0 commit comments

Comments
 (0)