Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1bed2d0
feat(layout): footnote-aware body pagination (SD-3049/3050/3051)
tupizz May 11, 2026
b3b89b6
feat(footnote): honor w:numFmt / w:numStart + customMarkFollows (SD-2…
tupizz May 11, 2026
cb1ca5a
docs(footnote): sd-2656 plan + implementation report
tupizz May 11, 2026
cc4eac4
Merge branch 'main' into tadeu/sd-2656-feature-footnote-rendering-fid…
tupizz May 14, 2026
dce9811
fix(footnote): close review gaps in SD-2656 (demand recharge, endnote…
tupizz May 15, 2026
7957529
fix(footnote): list demand + customMark suppresses body marker (SD-2656)
tupizz May 15, 2026
7548481
Merge branch 'main' into tadeu/sd-2656-feature-footnote-rendering-fid…
tupizz May 15, 2026
64f2059
fix(footnote): dedupe block demand by footnote id (SD-2656)
tupizz May 15, 2026
88f6a3f
fix(footnote): charge block demand once, on anchor page (SD-2656)
tupizz May 15, 2026
b873f5f
Merge branch 'main' into tadeu/sd-2656-feature-footnote-rendering-fid…
tupizz May 18, 2026
5beba69
fix(footnote): flip separator widths to match ECMA-376 (SD-2985)
tupizz May 18, 2026
bbd7eda
fix(footnote): customMark refs do not consume an ordinal (SD-2986/SD-…
tupizz May 18, 2026
e2fb857
fix(endnote): suppress body marker for customMark refs (parity with f…
tupizz May 18, 2026
0f8435d
feat(footnote): honor section-level w:footnotePr + numRestart=eachSec…
tupizz May 18, 2026
57c4046
feat(footnote): numRestart=eachPage counter math (helper) (SD-2986)
tupizz May 18, 2026
bf68627
Merge branch 'tadeu/sd-2656-feature-footnote-rendering-fidelity' of g…
tupizz May 18, 2026
181022b
feat(footnote): classify imported separator + continuationNotice cont…
tupizz May 18, 2026
f739373
feat(footnote): read + plumb w:pos placement attribute (SD-2986)
tupizz May 18, 2026
dd941d3
Merge branch 'main' into tadeu/sd-2656-feature-footnote-rendering-fid…
tupizz May 18, 2026
9d91802
fix(footnote): marker is plain superscript + gap before body (SD-2656)
tupizz May 18, 2026
314f1d6
Merge branch 'tadeu/sd-2656-feature-footnote-rendering-fidelity' of g…
tupizz May 18, 2026
6a38dd8
Merge remote-tracking branch 'origin/main' into tadeu/sd-2656-feature…
tupizz May 20, 2026
7548d28
feat(layout-engine): range-aware footnote demand + bodyMaxY-anchored …
tupizz May 20, 2026
e138776
chore: remove internal SD-2656 planning docs from branch
tupizz May 20, 2026
7b5a3e8
Merge branch 'main' into tadeu/sd-2656-feature-footnote-rendering-fid…
tupizz May 20, 2026
31d2173
fix(footnote): bottom-anchor band painting to match Word convention (…
tupizz May 21, 2026
4a3daaf
Merge branch 'main' of github.com:superdoc-dev/superdoc into tadeu/sd…
tupizz May 21, 2026
5ed53ee
fix(footnote): address PR review comments (SD-2656)
tupizz May 21, 2026
a743c9a
feat(footnote): split-aware pagination + minimum-start demand model (…
tupizz May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
438 changes: 402 additions & 36 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts

Large diffs are not rendered by default.

428 changes: 428 additions & 0 deletions packages/layout-engine/layout-bridge/test/footnoteBodyDemand.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
import { describe, it, expect, vi } from 'vitest';
import type { FlowBlock, Measure, ParagraphBlock } from '@superdoc/contracts';
import { incrementalLayout } from '../src/incrementalLayout';

/**
* SD-2656: every footnote ref in the input MUST have its body content
* rendered somewhere in the output. The new no-reserve architecture had
* a bug (caught visually on the reference fixture's page 16) where two footnotes anchored
* in the same paragraph could end up with the second one missing from the
* band — body extended too far, the planner ran out of band space, and
* the second fn was pushed to "pending" without being rendered.
*/
describe('SD-2656: footnote completeness — every ref renders', () => {
it('renders every anchored footnote even when many cluster on one page', async () => {
const BODY_LINE_HEIGHT = 24;
const FN_LINE_HEIGHT = 14;
const FN_LINES = 2;

// 12 body lines, each ~24 px, plus 4 fn refs anchored in the same
// single body block. Each footnote is short. The page should easily
// hold body + all 4 fns + overhead.
let pos = 0;
const text = 'a b c d e f g h i j k l';
const block: FlowBlock = {
kind: 'paragraph',
id: 'body-0',
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart: pos, pmEnd: pos + text.length }],
};
pos += text.length;

const refs = [
{ id: '1', pos: 4 },
{ id: '2', pos: 8 },
{ id: '3', pos: 14 },
{ id: '4', pos: 22 },
];
const fnBlocks = new Map<string, FlowBlock[]>();
for (const r of refs) {
fnBlocks.set(r.id, [
{
kind: 'paragraph',
id: `footnote-${r.id}-0-paragraph`,
runs: [{ text: `fn body ${r.id}`, fontFamily: 'Arial', fontSize: 10, pmStart: 0, pmEnd: 12 }],
},
]);
}

const measureBlock = vi.fn(async (block: FlowBlock) => {
if (block.id.startsWith('footnote-')) {
const lines = Array.from({ length: FN_LINES }, (_, i) => ({
fromRun: 0,
fromChar: i,
toRun: 0,
toChar: i + 1,
width: 200,
ascent: FN_LINE_HEIGHT * 0.8,
descent: FN_LINE_HEIGHT * 0.2,
lineHeight: FN_LINE_HEIGHT,
}));
return { kind: 'paragraph', lines, totalHeight: lines.length * FN_LINE_HEIGHT } as Measure;
}
const lineCount = 12;
const lines = Array.from({ length: lineCount }, (_, i) => ({
fromRun: 0,
fromChar: i,
toRun: 0,
toChar: i + 1,
width: 200,
ascent: BODY_LINE_HEIGHT * 0.8,
descent: BODY_LINE_HEIGHT * 0.2,
lineHeight: BODY_LINE_HEIGHT,
}));
return { kind: 'paragraph', lines, totalHeight: lineCount * BODY_LINE_HEIGHT } as Measure;
});

const margins = { top: 72, right: 72, bottom: 72, left: 72 };
const pageHeight = 900;

const result = await incrementalLayout(
[],
null,
[block],
{
pageSize: { w: 612, h: pageHeight },
margins,
footnotes: { refs, blocksById: fnBlocks, topPadding: 4, dividerHeight: 2 },
},
measureBlock,
);

// Every ref id should appear in at least one fn fragment somewhere
// in the rendered layout (band painter is required to either place
// the fn fully or split it across pages — never silently drop).
const renderedFnIds = new Set<string>();
for (const page of result.layout.pages) {
for (const f of page.fragments) {
if (typeof f.blockId !== 'string') continue;
const m = f.blockId.match(/^footnote-(\d+)-/);
if (m) renderedFnIds.add(m[1]);
}
}
for (const r of refs) {
expect(renderedFnIds.has(r.id)).toBe(true);
}
});

// Reproduces the reference fixture's page-10 case: a long stretch of body
// before a paragraph that anchors two short fns. Earlier SD dropped fn 2
// because the legacy convergence loop left a stale per-page reserve that
// misled body's demand check.
it('places both fn 1 and fn 2 at the tail of a dense body page', async () => {
const BODY_LH = 37;
const FN_LH = 14;
const SP_AFTER = 16;

let pos = 0;
const bodyBlocks: FlowBlock[] = [];
for (let i = 0; i < 17; i += 1) {
const t = `dense body paragraph ${i.toString().padStart(2, '0')}`;
bodyBlocks.push({
kind: 'paragraph',
id: `body-${i}`,
runs: [{ text: t, fontFamily: 'Arial', fontSize: 12, pmStart: pos, pmEnd: pos + t.length }],
});
pos += t.length + 1;
}
const tail = 'paragraph ending with two refs ab cd';
bodyBlocks.push({
kind: 'paragraph',
id: 'anchor-para',
runs: [{ text: tail, fontFamily: 'Arial', fontSize: 12, pmStart: pos, pmEnd: pos + tail.length }],
});
const refs2 = [
{ id: '1', pos: pos + 30 },
{ id: '2', pos: pos + 33 },
];
const fnBlocks2 = new Map<string, FlowBlock[]>();
for (const r of refs2) {
fnBlocks2.set(r.id, [
{
kind: 'paragraph',
id: `footnote-${r.id}-0-paragraph`,
runs: [{ text: `short fn ${r.id} body`, fontFamily: 'Arial', fontSize: 10, pmStart: 0, pmEnd: 22 }],
attrs: { spacing: { after: SP_AFTER } },
} as ParagraphBlock,
]);
}
const measureBlock2 = vi.fn(async (b: FlowBlock) => {
if (b.id.startsWith('footnote-')) {
return {
kind: 'paragraph',
lines: [
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 22, width: 200, ascent: 11, descent: 3, lineHeight: FN_LH },
],
totalHeight: FN_LH,
} as Measure;
}
return {
kind: 'paragraph',
lines: [
{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 30, width: 200, ascent: 29, descent: 8, lineHeight: BODY_LH },
],
totalHeight: BODY_LH,
} as Measure;
});
const result2 = await incrementalLayout(
[],
null,
bodyBlocks,
{
pageSize: { w: 612, h: 1056 },
margins: { top: 96, right: 72, bottom: 96, left: 72 },
footnotes: { refs: refs2, blocksById: fnBlocks2, topPadding: 4, dividerHeight: 6 },
},
measureBlock2,
);
const renderedFnIds2 = new Set<string>();
for (const page of result2.layout.pages) {
for (const f of page.fragments) {
if (typeof f.blockId !== 'string') continue;
const m = f.blockId.match(/^footnote-(\d+)-/);
if (m) renderedFnIds2.add(m[1]);
}
}
expect(renderedFnIds2.has('1')).toBe(true);
expect(renderedFnIds2.has('2')).toBe(true);
// Both fns must live on the same page as their anchor paragraph.
let anchorPage = -1;
for (let pi = 0; pi < result2.layout.pages.length; pi++) {
if (result2.layout.pages[pi].fragments.some((f) => f.blockId === 'anchor-para')) {
anchorPage = pi;
break;
}
}
expect(anchorPage).toBeGreaterThanOrEqual(0);
const pagesOf2 = (id: string) =>
result2.layout.pages
.map((p, idx) =>
p.fragments.some((f) => typeof f.blockId === 'string' && f.blockId.startsWith(`footnote-${id}-`)) ? idx : -1,
)
.filter((i) => i >= 0);
expect(pagesOf2('1')).toContain(anchorPage);
expect(pagesOf2('2')).toContain(anchorPage);
});

it('keeps both refs from the same paragraph on the same page when they fit', async () => {
// Mirrors the reference fixture's page-16 scenario: a paragraph with two
// closely-clustered refs whose anchors live on different lines of
// the SAME paragraph. Both should land on the same page's band.
const BODY_LINE_HEIGHT = 24;
const FN_LINE_HEIGHT = 14;

const text = 'L1 line1 L2 line2 L3 line3';
const block: FlowBlock = {
kind: 'paragraph',
id: 'two-ref-para',
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart: 0, pmEnd: text.length }],
};
const refs = [
{ id: 'A', pos: 8 },
{ id: 'B', pos: 16 },
];
const fnBlocks = new Map<string, FlowBlock[]>();
for (const r of refs) {
fnBlocks.set(r.id, [
{
kind: 'paragraph',
id: `footnote-${r.id}-0-paragraph`,
runs: [{ text: `fn ${r.id}`, fontFamily: 'Arial', fontSize: 10, pmStart: 0, pmEnd: 8 }],
},
]);
}
const measureBlock = vi.fn(async (b: FlowBlock) => {
if (b.id.startsWith('footnote-')) {
return {
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: 4,
width: 200,
ascent: 11,
descent: 3,
lineHeight: FN_LINE_HEIGHT,
},
],
totalHeight: FN_LINE_HEIGHT,
} as Measure;
}
const lines = Array.from({ length: 6 }, (_, i) => ({
fromRun: 0,
fromChar: i,
toRun: 0,
toChar: i + 1,
width: 200,
ascent: 19,
descent: 5,
lineHeight: BODY_LINE_HEIGHT,
}));
return { kind: 'paragraph', lines, totalHeight: 6 * BODY_LINE_HEIGHT } as Measure;
});

const result = await incrementalLayout(
[],
null,
[block],
{
pageSize: { w: 612, h: 900 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
footnotes: { refs, blocksById: fnBlocks, topPadding: 4, dividerHeight: 2 },
},
measureBlock,
);

// Collect the page each fn id landed on.
const pageOfFn = new Map<string, number>();
for (let pageIndex = 0; pageIndex < result.layout.pages.length; pageIndex++) {
for (const f of result.layout.pages[pageIndex].fragments) {
if (typeof f.blockId !== 'string') continue;
const m = f.blockId.match(/^footnote-([A-Z])-/);
if (m) pageOfFn.set(m[1], pageIndex);
}
}
expect(pageOfFn.has('A')).toBe(true);
expect(pageOfFn.has('B')).toBe(true);
expect(pageOfFn.get('A')).toBe(pageOfFn.get('B'));
});
});
Loading
Loading