Skip to content

Commit 07bb47b

Browse files
committed
fix: large header is missing and cursor is buggy in doc with footnotes
1 parent 179190e commit 07bb47b

File tree

11 files changed

+460
-54
lines changed

11 files changed

+460
-54
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1742,6 +1742,8 @@ export async function incrementalLayout(
17421742
footnoteReservedByPageIndex,
17431743
headerContentHeights,
17441744
footerContentHeights,
1745+
headerContentHeightsByRId,
1746+
footerContentHeightsByRId,
17451747
remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) =>
17461748
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
17471749
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import type { FlowBlock, Measure } from '@superdoc/contracts';
3+
import type { HeaderFooterConstraints } from '@superdoc/layout-engine';
4+
import * as layoutEngine from '@superdoc/layout-engine';
5+
import { incrementalLayout } from '../src/incrementalLayout';
6+
7+
const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({
8+
kind: 'paragraph',
9+
id,
10+
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }],
11+
});
12+
13+
const makeMeasure = (lineHeight: number, textLength: number): Measure => ({
14+
kind: 'paragraph',
15+
lines: [
16+
{
17+
fromRun: 0,
18+
fromChar: 0,
19+
toRun: 0,
20+
toChar: textLength,
21+
width: 200,
22+
ascent: lineHeight * 0.8,
23+
descent: lineHeight * 0.2,
24+
lineHeight,
25+
},
26+
],
27+
totalHeight: lineHeight,
28+
});
29+
30+
const makeMultiLineMeasure = (lineHeight: number, lineCount: number): Measure => {
31+
const lines = Array.from({ length: lineCount }, (_, i) => ({
32+
fromRun: 0,
33+
fromChar: i,
34+
toRun: 0,
35+
toChar: i + 1,
36+
width: 200,
37+
ascent: lineHeight * 0.8,
38+
descent: lineHeight * 0.2,
39+
lineHeight,
40+
}));
41+
return {
42+
kind: 'paragraph',
43+
lines,
44+
totalHeight: lineCount * lineHeight,
45+
};
46+
};
47+
48+
/**
49+
* Footnote reserve relayout must keep headerContentHeightsByRId / footerContentHeightsByRId.
50+
* Otherwise per-rId header height is dropped, topMargin is not inflated, and body overlaps a tall header.
51+
*/
52+
describe('Footnote relayout preserves headerContentHeightsByRId', () => {
53+
it('passes by-RId header maps on every layoutDocument call that reserves footnote space', async () => {
54+
const BODY_LINE_HEIGHT = 20;
55+
const FOOTNOTE_LINE_HEIGHT = 12;
56+
const HEADER_CONTENT_HEIGHT = 100;
57+
const LINES_ON_PAGE_1_WITHOUT_RESERVE = 12;
58+
const FOOTNOTE_LINES = 5;
59+
60+
const headerBlock = makeParagraph('hdr-rId1-line', 'Tall header line', 0);
61+
62+
let pos = 0;
63+
const bodyBlocks: FlowBlock[] = [];
64+
for (let i = 0; i < LINES_ON_PAGE_1_WITHOUT_RESERVE; i += 1) {
65+
const text = `Line ${i + 1}.`;
66+
bodyBlocks.push(makeParagraph(`body-${i}`, text, pos));
67+
pos += text.length + 1;
68+
}
69+
const refPos = pos - 2;
70+
const footnoteBlock = makeParagraph(
71+
'footnote-1-0-paragraph',
72+
'Footnote content that spans multiple lines here.',
73+
0,
74+
);
75+
76+
const measureBlock = vi.fn(async (block: FlowBlock) => {
77+
if (block.id.startsWith('hdr-')) {
78+
return makeMeasure(HEADER_CONTENT_HEIGHT, block.runs?.[0]?.text?.length ?? 1);
79+
}
80+
if (block.id.startsWith('footnote-')) {
81+
return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, FOOTNOTE_LINES);
82+
}
83+
const textLength = block.kind === 'paragraph' ? (block.runs?.[0]?.text?.length ?? 1) : 1;
84+
return makeMeasure(BODY_LINE_HEIGHT, textLength);
85+
});
86+
87+
const contentHeight = 240;
88+
const margins = { top: 72, right: 72, bottom: 72, left: 72, header: 72, footer: 72 };
89+
const pageHeight = contentHeight + margins.top + margins.bottom;
90+
const pageWidth = 612;
91+
const contentWidth = pageWidth - margins.left - margins.right;
92+
93+
const constraints: HeaderFooterConstraints = {
94+
width: contentWidth,
95+
height: margins.top,
96+
pageWidth,
97+
pageHeight,
98+
margins: { left: margins.left, right: margins.right, top: margins.top, bottom: margins.bottom },
99+
};
100+
101+
const layoutDocSpy = vi.spyOn(layoutEngine, 'layoutDocument');
102+
103+
const { layout } = await incrementalLayout(
104+
[],
105+
null,
106+
bodyBlocks,
107+
{
108+
pageSize: { w: pageWidth, h: pageHeight },
109+
margins,
110+
sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rId1' } }],
111+
footnotes: {
112+
refs: [{ id: '1', pos: refPos }],
113+
blocksById: new Map([['1', [footnoteBlock]]]),
114+
topPadding: 4,
115+
dividerHeight: 2,
116+
},
117+
},
118+
measureBlock,
119+
{
120+
headerBlocksByRId: new Map([['rId1', [headerBlock]]]),
121+
constraints,
122+
},
123+
);
124+
125+
const reserveCalls = layoutDocSpy.mock.calls.filter((call) => {
126+
const opts = call[2] as { footnoteReservedByPageIndex?: number[] };
127+
return opts?.footnoteReservedByPageIndex?.some((h) => h > 0);
128+
});
129+
layoutDocSpy.mockRestore();
130+
131+
expect(reserveCalls.length).toBeGreaterThanOrEqual(1);
132+
for (const call of reserveCalls) {
133+
const opts = call[2] as {
134+
headerContentHeightsByRId?: Map<string, number>;
135+
};
136+
expect(opts.headerContentHeightsByRId).toBeInstanceOf(Map);
137+
expect(opts.headerContentHeightsByRId?.get('rId1')).toBeGreaterThanOrEqual(HEADER_CONTENT_HEIGHT - 1);
138+
}
139+
140+
const page1 = layout.pages[0];
141+
const headerDistance = page1.margins?.header ?? margins.header;
142+
const minBodyTop = Math.max(margins.top, headerDistance + HEADER_CONTENT_HEIGHT);
143+
const firstBody = page1.fragments.find((f) => String(f.blockId).startsWith('body-') && f.kind === 'para');
144+
expect(firstBody && 'y' in firstBody && typeof firstBody.y === 'number').toBe(true);
145+
expect((firstBody as { y: number }).y).toBeGreaterThanOrEqual(minBodyTop - 1);
146+
});
147+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import type { FlowBlock, Layout, Measure, SectionMetadata } from '@superdoc/contracts';
3+
import { layoutPerRIdHeaderFooters } from './HeaderFooterPerRidLayout.js';
4+
5+
vi.mock('@superdoc/measuring-dom', () => ({
6+
measureBlock: vi.fn(
7+
async () =>
8+
({
9+
kind: 'paragraph',
10+
lines: [
11+
{
12+
fromRun: 0,
13+
fromChar: 0,
14+
toRun: 0,
15+
toChar: 1,
16+
width: 100,
17+
ascent: 10,
18+
descent: 4,
19+
lineHeight: 14,
20+
},
21+
],
22+
totalHeight: 14,
23+
}) satisfies Measure,
24+
),
25+
}));
26+
27+
const makeParagraph = (id: string, text: string): FlowBlock => ({
28+
kind: 'paragraph',
29+
id,
30+
runs: [{ text, fontFamily: 'Arial', fontSize: 12 }],
31+
});
32+
33+
describe('layoutPerRIdHeaderFooters', () => {
34+
it('lays out first-page header refs per section instead of only default refs in multi-section docs', async () => {
35+
const deps = {
36+
headerLayoutsByRId: new Map(),
37+
footerLayoutsByRId: new Map(),
38+
};
39+
40+
const layout: Layout = {
41+
pageSize: { w: 612, h: 792 },
42+
pages: [
43+
{ number: 1, fragments: [], sectionIndex: 0 },
44+
{ number: 2, fragments: [], sectionIndex: 0 },
45+
{ number: 3, fragments: [], sectionIndex: 1 },
46+
],
47+
};
48+
49+
const sectionMetadata: SectionMetadata[] = [
50+
{
51+
sectionIndex: 0,
52+
titlePg: true,
53+
pageSize: { w: 612, h: 792 },
54+
margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36 },
55+
headerRefs: {
56+
default: 'rId-header-default',
57+
first: 'rId-header-first',
58+
},
59+
},
60+
{
61+
sectionIndex: 1,
62+
titlePg: false,
63+
pageSize: { w: 612, h: 792 },
64+
margins: { top: 90, right: 72, bottom: 72, left: 72, header: 24 },
65+
headerRefs: {
66+
default: 'rId-header-default',
67+
},
68+
},
69+
];
70+
71+
await layoutPerRIdHeaderFooters(
72+
{
73+
headerBlocksByRId: new Map([
74+
['rId-header-default', [makeParagraph('header-default', 'Default header')]],
75+
['rId-header-first', [makeParagraph('header-first', 'First page header')]],
76+
]),
77+
footerBlocksByRId: new Map(),
78+
constraints: {
79+
width: 468,
80+
height: 648,
81+
pageWidth: 612,
82+
pageHeight: 792,
83+
margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 },
84+
overflowBaseHeight: 36,
85+
},
86+
},
87+
layout,
88+
sectionMetadata,
89+
deps,
90+
);
91+
92+
expect(deps.headerLayoutsByRId.has('rId-header-default::s0')).toBe(true);
93+
expect(deps.headerLayoutsByRId.has('rId-header-first::s0')).toBe(true);
94+
});
95+
96+
it('lays out inherited first-page refs for later sections with their own constraints', async () => {
97+
const deps = {
98+
headerLayoutsByRId: new Map(),
99+
footerLayoutsByRId: new Map(),
100+
};
101+
102+
const layout: Layout = {
103+
pageSize: { w: 612, h: 792 },
104+
pages: [
105+
{ number: 1, fragments: [], sectionIndex: 0 },
106+
{ number: 2, fragments: [], sectionIndex: 1 },
107+
],
108+
};
109+
110+
const sectionMetadata: SectionMetadata[] = [
111+
{
112+
sectionIndex: 0,
113+
titlePg: true,
114+
pageSize: { w: 612, h: 792 },
115+
margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36 },
116+
headerRefs: {
117+
default: 'rId-header-default-0',
118+
first: 'rId-header-first-shared',
119+
},
120+
},
121+
{
122+
sectionIndex: 1,
123+
titlePg: true,
124+
pageSize: { w: 612, h: 792 },
125+
margins: { top: 90, right: 72, bottom: 72, left: 72, header: 24 },
126+
headerRefs: {
127+
default: 'rId-header-default-1',
128+
},
129+
},
130+
];
131+
132+
await layoutPerRIdHeaderFooters(
133+
{
134+
headerBlocksByRId: new Map([
135+
['rId-header-default-0', [makeParagraph('header-default-0', 'Default section 0')]],
136+
['rId-header-default-1', [makeParagraph('header-default-1', 'Default section 1')]],
137+
['rId-header-first-shared', [makeParagraph('header-first-shared', 'Shared first page header')]],
138+
]),
139+
footerBlocksByRId: new Map(),
140+
constraints: {
141+
width: 468,
142+
height: 648,
143+
pageWidth: 612,
144+
pageHeight: 792,
145+
margins: { left: 72, right: 72, top: 72, bottom: 72, header: 36 },
146+
overflowBaseHeight: 36,
147+
},
148+
},
149+
layout,
150+
sectionMetadata,
151+
deps,
152+
);
153+
154+
expect(deps.headerLayoutsByRId.has('rId-header-first-shared::s0')).toBe(true);
155+
expect(deps.headerLayoutsByRId.has('rId-header-first-shared::s1')).toBe(true);
156+
});
157+
});

0 commit comments

Comments
 (0)