@@ -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 ) ;
0 commit comments