Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions packages/layout-engine/layout-bridge/src/headerFooterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,32 @@ export function buildMultiSectionIdentifier(
identifier.footerIds.odd = identifier.footerIds.odd ?? converterIds.footerIds.odd ?? null;
}

// PM section metadata often lists only headerReference types present in that sectPr snapshot;
// converter.headerIds still has even/odd from the full package. Merge those into per-section
// rows so getHeaderFooterTypeForSection sees hasEven and returns 'even' on even pages.
if (converterIds?.headerIds) {
const c = converterIds.headerIds;
for (const [idx, row] of identifier.sectionHeaderIds) {
identifier.sectionHeaderIds.set(idx, {
default: row.default ?? c.default ?? null,
first: row.first ?? c.first ?? null,
even: row.even ?? c.even ?? null,
odd: row.odd ?? c.odd ?? null,
});
}
}
if (converterIds?.footerIds) {
const c = converterIds.footerIds;
for (const [idx, row] of identifier.sectionFooterIds) {
identifier.sectionFooterIds.set(idx, {
default: row.default ?? c.default ?? null,
first: row.first ?? c.first ?? null,
even: row.even ?? c.even ?? null,
odd: row.odd ?? c.odd ?? null,
});
}
}

return identifier;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,8 @@ export async function incrementalLayout(
footnoteReservedByPageIndex,
headerContentHeights,
footerContentHeights,
headerContentHeightsByRId,
footerContentHeightsByRId,
remeasureParagraph: (block: FlowBlock, maxWidth: number, firstLineIndent?: number) =>
remeasureParagraph(block as ParagraphBlock, maxWidth, firstLineIndent),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect, vi } from 'vitest';
import type { FlowBlock, Measure } from '@superdoc/contracts';
import type { HeaderFooterConstraints } from '@superdoc/layout-engine';
import * as layoutEngine from '@superdoc/layout-engine';
import { incrementalLayout } from '../src/incrementalLayout';

const makeParagraph = (id: string, text: string, pmStart: number): FlowBlock => ({
kind: 'paragraph',
id,
runs: [{ text, fontFamily: 'Arial', fontSize: 12, pmStart, pmEnd: pmStart + text.length }],
});

const makeMeasure = (lineHeight: number, textLength: number): Measure => ({
kind: 'paragraph',
lines: [
{
fromRun: 0,
fromChar: 0,
toRun: 0,
toChar: textLength,
width: 200,
ascent: lineHeight * 0.8,
descent: lineHeight * 0.2,
lineHeight,
},
],
totalHeight: lineHeight,
});

const makeMultiLineMeasure = (lineHeight: number, lineCount: number): Measure => {
const lines = Array.from({ length: lineCount }, (_, i) => ({
fromRun: 0,
fromChar: i,
toRun: 0,
toChar: i + 1,
width: 200,
ascent: lineHeight * 0.8,
descent: lineHeight * 0.2,
lineHeight,
}));
return {
kind: 'paragraph',
lines,
totalHeight: lineCount * lineHeight,
};
};

/**
* Footnote reserve relayout must keep headerContentHeightsByRId / footerContentHeightsByRId.
* Otherwise per-rId header height is dropped, topMargin is not inflated, and body overlaps a tall header.
*/
describe('Footnote relayout preserves headerContentHeightsByRId', () => {
it('passes by-RId header maps on every layoutDocument call that reserves footnote space', async () => {
const BODY_LINE_HEIGHT = 20;
const FOOTNOTE_LINE_HEIGHT = 12;
const HEADER_CONTENT_HEIGHT = 100;
const LINES_ON_PAGE_1_WITHOUT_RESERVE = 12;
const FOOTNOTE_LINES = 5;

const headerBlock = makeParagraph('hdr-rId1-line', 'Tall header line', 0);

let pos = 0;
const bodyBlocks: FlowBlock[] = [];
for (let i = 0; i < LINES_ON_PAGE_1_WITHOUT_RESERVE; i += 1) {
const text = `Line ${i + 1}.`;
bodyBlocks.push(makeParagraph(`body-${i}`, text, pos));
pos += text.length + 1;
}
const refPos = pos - 2;
const footnoteBlock = makeParagraph(
'footnote-1-0-paragraph',
'Footnote content that spans multiple lines here.',
0,
);

const measureBlock = vi.fn(async (block: FlowBlock) => {
if (block.id.startsWith('hdr-')) {
return makeMeasure(HEADER_CONTENT_HEIGHT, block.runs?.[0]?.text?.length ?? 1);
}
if (block.id.startsWith('footnote-')) {
return makeMultiLineMeasure(FOOTNOTE_LINE_HEIGHT, FOOTNOTE_LINES);
}
const textLength = block.kind === 'paragraph' ? (block.runs?.[0]?.text?.length ?? 1) : 1;
return makeMeasure(BODY_LINE_HEIGHT, textLength);
});

const contentHeight = 240;
const margins = { top: 72, right: 72, bottom: 72, left: 72, header: 72, footer: 72 };
const pageHeight = contentHeight + margins.top + margins.bottom;
const pageWidth = 612;
const contentWidth = pageWidth - margins.left - margins.right;

const constraints: HeaderFooterConstraints = {
width: contentWidth,
height: margins.top,
pageWidth,
pageHeight,
margins: { left: margins.left, right: margins.right, top: margins.top, bottom: margins.bottom },
};

const layoutDocSpy = vi.spyOn(layoutEngine, 'layoutDocument');

const { layout } = await incrementalLayout(
[],
null,
bodyBlocks,
{
pageSize: { w: pageWidth, h: pageHeight },
margins,
sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'rId1' } }],
footnotes: {
refs: [{ id: '1', pos: refPos }],
blocksById: new Map([['1', [footnoteBlock]]]),
topPadding: 4,
dividerHeight: 2,
},
},
measureBlock,
{
headerBlocksByRId: new Map([['rId1', [headerBlock]]]),
constraints,
},
);

const reserveCalls = layoutDocSpy.mock.calls.filter((call) => {
const opts = call[2] as { footnoteReservedByPageIndex?: number[] };
return opts?.footnoteReservedByPageIndex?.some((h) => h > 0);
});
layoutDocSpy.mockRestore();

expect(reserveCalls.length).toBeGreaterThanOrEqual(1);
for (const call of reserveCalls) {
const opts = call[2] as {
headerContentHeightsByRId?: Map<string, number>;
};
expect(opts.headerContentHeightsByRId).toBeInstanceOf(Map);
expect(opts.headerContentHeightsByRId?.get('rId1')).toBeGreaterThanOrEqual(HEADER_CONTENT_HEIGHT - 1);
}

const page1 = layout.pages[0];
const headerDistance = page1.margins?.header ?? margins.header;
const minBodyTop = Math.max(margins.top, headerDistance + HEADER_CONTENT_HEIGHT);
const firstBody = page1.fragments.find((f) => String(f.blockId).startsWith('body-') && f.kind === 'para');
expect(firstBody && 'y' in firstBody && typeof firstBody.y === 'number').toBe(true);
expect((firstBody as { y: number }).y).toBeGreaterThanOrEqual(minBodyTop - 1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,22 @@ describe('headerFooterUtils', () => {
expect(identifier.headerIds.first).toBe('section-h-first');
// Converter IDs should only fill in gaps
expect(identifier.headerIds.even).toBe('converter-h-even');
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('converter-h-even');
expect(identifier.footerIds.default).toBe('section-f-default');
expect(identifier.footerIds.odd).toBe('converter-f-odd');
});

it('fills per-section header maps from converter when section metadata omits even', () => {
const sectionMetadata: SectionMetadata[] = [{ sectionIndex: 0, headerRefs: { default: 'r-default' } }];
const identifier = buildMultiSectionIdentifier(
sectionMetadata,
{ alternateHeaders: true },
{ headerIds: { default: 'r-default', even: 'r-even' } },
);
expect(identifier.sectionHeaderIds.get(0)?.even).toBe('r-even');
expect(getHeaderFooterTypeForSection(2, 0, identifier, { kind: 'header', sectionPageNumber: 2 })).toBe('even');
});

it('should handle missing converterIds parameter gracefully', () => {
const sectionMetadata: SectionMetadata[] = [
{
Expand Down
151 changes: 151 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
PageBreakBlock,
TableBlock,
TableMeasure,
SectionMetadata,
} from '@superdoc/contracts';
import { layoutDocument, layoutHeaderFooter, type LayoutOptions } from './index.js';

Expand Down Expand Up @@ -818,6 +819,156 @@ describe('layoutDocument', () => {
expect(fragment.pmEnd).toBe(12);
});

it('inflates top margin using transitive inherited first-header rId (multi-hop section metadata)', () => {
const m = { top: 72, bottom: 72, left: 72, right: 72, header: 72, footer: 72 };
const sb0: FlowBlock = {
kind: 'sectionBreak',
id: 'sb-0',
type: 'continuous',
margins: m,
headerRefs: { default: 'd0', first: 'f0' },
attrs: { isFirstSection: true, sectionIndex: 0 },
};
const sb1: FlowBlock = {
kind: 'sectionBreak',
id: 'sb-1',
type: 'nextPage',
margins: m,
headerRefs: { default: 'd1' },
attrs: { sectionIndex: 1 },
};
const sb2: FlowBlock = {
kind: 'sectionBreak',
id: 'sb-2',
type: 'nextPage',
margins: m,
headerRefs: { default: 'd2' },
attrs: { sectionIndex: 2 },
};

const lineHeight = 40;
const blocks: FlowBlock[] = [
sb0,
{ kind: 'paragraph', id: 'p0', runs: [] },
sb1,
{ kind: 'paragraph', id: 'p1', runs: [] },
sb2,
{ kind: 'paragraph', id: 'p2', runs: [] },
];
const measures: Measure[] = [
{ kind: 'sectionBreak' },
makeMeasure(Array(20).fill(lineHeight)),
{ kind: 'sectionBreak' },
makeMeasure(Array(20).fill(lineHeight)),
{ kind: 'sectionBreak' },
makeMeasure([lineHeight]),
];

const tallFirst = 220;
const sectionMetadata: SectionMetadata[] = [
{ sectionIndex: 0, titlePg: true, headerRefs: { default: 'd0', first: 'f0' } },
{ sectionIndex: 1, titlePg: true, headerRefs: { default: 'd1' } },
{ sectionIndex: 2, titlePg: true, headerRefs: { default: 'd2' } },
];

const layout = layoutDocument(blocks, measures, {
pageSize: { w: 500, h: 600 },
margins: m,
sectionMetadata,
headerContentHeightsByRId: new Map([['f0', tallFirst]]),
});

const section2Page = layout.pages.find((p) => p.sectionIndex === 2);
expect(section2Page).toBeDefined();
expect(section2Page!.margins.top).toBeGreaterThanOrEqual(72 + tallFirst - 1);
});

it('uses physical page number for even/odd variant selection when oddEvenHeadersFooters is enabled', () => {
const m = { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 };

// Single section with alternateHeaders. Odd physical pages get 'odd' variant,
// even physical pages get 'even' variant. Each variant has a different header
// height so we can verify the correct one drives margin inflation.
const sb0: FlowBlock = {
kind: 'sectionBreak',
id: 'sb-0',
type: 'continuous',
margins: m,
headerRefs: { default: 'd0', even: 'e0', odd: 'o0' },
attrs: { isFirstSection: true, sectionIndex: 0 },
};

const lineHeight = 20;
const blocks: FlowBlock[] = [sb0, { kind: 'paragraph', id: 'p0', runs: [] }];
const measures: Measure[] = [
{ kind: 'sectionBreak' },
makeMeasure(Array(80).fill(lineHeight)), // enough content for multiple pages
];

const evenHeight = 120;
const oddHeight = 80;
const sectionMetadata: SectionMetadata[] = [
{ sectionIndex: 0, headerRefs: { default: 'd0', even: 'e0', odd: 'o0' } },
];

const layout = layoutDocument(blocks, measures, {
pageSize: { w: 612, h: 792 },
margins: m,
sectionMetadata,
oddEvenHeadersFooters: true,
headerContentHeightsByRId: new Map([
['e0', evenHeight],
['o0', oddHeight],
]),
});

expect(layout.pages.length).toBeGreaterThanOrEqual(2);

// Page 1 (physical 1, odd): odd variant → margin inflated by oddHeight
const page1 = layout.pages[0];
expect(page1.margins.top).toBeGreaterThanOrEqual(36 + oddHeight - 1);

// Page 2 (physical 2, even): even variant → margin inflated by evenHeight
const page2 = layout.pages[1];
expect(page2.margins.top).toBeGreaterThanOrEqual(36 + evenHeight - 1);
// Even margin should be larger than odd margin since evenHeight > oddHeight
expect(page2.margins.top).toBeGreaterThan(page1.margins.top);
});

it('does not use even/odd variants when oddEvenHeadersFooters is not set', () => {
const m = { top: 72, bottom: 72, left: 72, right: 72, header: 36, footer: 36 };
const sb0: FlowBlock = {
kind: 'sectionBreak',
id: 'sb-0',
type: 'continuous',
margins: m,
headerRefs: { default: 'd0', even: 'e0' },
attrs: { isFirstSection: true, sectionIndex: 0 },
};

const lineHeight = 20;
const blocks: FlowBlock[] = [sb0, { kind: 'paragraph', id: 'p0', runs: [] }];
const measures: Measure[] = [{ kind: 'sectionBreak' }, makeMeasure(Array(80).fill(lineHeight))];

const tallEven = 200;
const layout = layoutDocument(blocks, measures, {
pageSize: { w: 612, h: 792 },
margins: m,
sectionMetadata: [{ sectionIndex: 0, headerRefs: { default: 'd0', even: 'e0' } }],
// oddEvenHeadersFooters NOT set
headerContentHeightsByRId: new Map([
['e0', tallEven],
['d0', 10],
]),
});

// Without oddEvenHeadersFooters, all pages use 'default' variant —
// margins should NOT be inflated by the even header height
for (const page of layout.pages) {
expect(page.margins.top).toBeLessThan(36 + tallEven);
}
});

it('applies section break margins to subsequent pages', () => {
const sectionBreakBlock: FlowBlock = {
kind: 'sectionBreak',
Expand Down
Loading
Loading