Skip to content
Merged
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
134 changes: 134 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,125 @@ describe('layoutDocument', () => {
expect(pageWithP3?.margins).toMatchObject({ top: 40, bottom: 40, header: 150, footer: 100 });
});

it('synthesizes page 1 for section-break-only body layouts', () => {
const sectionBreakBlock: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-only',
attrs: { isFirstSection: true, source: 'sectPr' },
pageSize: { w: 500, h: 700 },
orientation: 'landscape',
margins: { top: 40, right: 30, bottom: 35, left: 25, header: 120, footer: 90 },
};

const layout = layoutDocument([sectionBreakBlock], [{ kind: 'sectionBreak' }], DEFAULT_OPTIONS);

expect(layout.pages).toHaveLength(1);
expect(layout.pages[0].fragments).toHaveLength(0);
expect(layout.pages[0].orientation).toBe('landscape');
expect(layout.pages[0].margins).toMatchObject({
top: 40,
right: 30,
bottom: 35,
left: 25,
header: 120,
footer: 90,
});
});

it('resets page numbering when synthesizing a next-page section-break-only layout', () => {
const firstSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-first',
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
pageSize: { w: 500, h: 700 },
margins: { top: 40, right: 30, bottom: 35, left: 25 },
};
const nextPageSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-next',
type: 'nextPage',
attrs: { source: 'sectPr', sectionIndex: 1 },
pageSize: { w: 520, h: 720 },
margins: { top: 45, right: 35, bottom: 40, left: 30 },
};

const layout = layoutDocument(
[firstSection, nextPageSection],
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
DEFAULT_OPTIONS,
);

expect(layout.pages).toHaveLength(1);
expect(layout.pages[0].number).toBe(1);
expect(layout.pages[0].numberText).toBe('1');
expect(layout.pages[0].sectionIndex).toBe(1);
expect(layout.pages[0].margins).toMatchObject({
top: 45,
right: 35,
bottom: 40,
left: 30,
});
});

it('resets parity bookkeeping when synthesizing an even-page section-break-only layout', () => {
const firstSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-first',
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
pageSize: { w: 500, h: 700 },
margins: { top: 40, right: 30, bottom: 35, left: 25 },
};
const evenPageSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-even',
type: 'evenPage',
attrs: { source: 'sectPr', sectionIndex: 1 },
pageSize: { w: 520, h: 720 },
margins: { top: 45, right: 35, bottom: 40, left: 30 },
};

const layout = layoutDocument(
[firstSection, evenPageSection],
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
DEFAULT_OPTIONS,
);

expect(layout.pages).toHaveLength(1);
expect(layout.pages[0].number).toBe(1);
expect(layout.pages[0].numberText).toBe('1');
expect(layout.pages[0].sectionIndex).toBe(1);
});

it('preserves explicit numbering starts for section-break-only fallback pages', () => {
const firstSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-first',
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
pageSize: { w: 500, h: 700 },
margins: { top: 40, right: 30, bottom: 35, left: 25 },
};
const nextPageSection: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'sb-next',
type: 'nextPage',
attrs: { source: 'sectPr', sectionIndex: 1 },
pageSize: { w: 520, h: 720 },
margins: { top: 45, right: 35, bottom: 40, left: 30 },
numbering: { start: 5 },
};

const layout = layoutDocument(
[firstSection, nextPageSection],
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
DEFAULT_OPTIONS,
);

expect(layout.pages).toHaveLength(1);
expect(layout.pages[0].number).toBe(1);
expect(layout.pages[0].numberText).toBe('5');
expect(layout.pages[0].sectionIndex).toBe(1);
});

it('section break with only header margin stores header distance', () => {
const sectionBreakBlock: FlowBlock = {
kind: 'sectionBreak',
Expand Down Expand Up @@ -2605,6 +2724,21 @@ describe('layoutHeaderFooter', () => {
expect(layout.pages).toEqual([]);
});

it('does not synthesize blank pages for section-break-only header/footer layouts', () => {
const sectionBreakBlock: SectionBreakBlock = {
kind: 'sectionBreak',
id: 'header-sb',
attrs: { isFirstSection: true, source: 'sectPr' },
pageSize: { w: 200, h: 80 },
margins: { top: 0, right: 0, bottom: 0, left: 0 },
};

const layout = layoutHeaderFooter([sectionBreakBlock], [{ kind: 'sectionBreak' }], { width: 200, height: 80 });

expect(layout.pages).toEqual([]);
expect(layout.height).toBe(0);
});

it('uses image measure height when fragment height missing', () => {
const imageBlock: FlowBlock = {
kind: 'image',
Expand Down
55 changes: 55 additions & 0 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,14 @@ export type LayoutOptions = {
* overlay behavior in paragraph-free header/footer regions.
*/
allowParagraphlessAnchoredTableFallback?: boolean;
/**
* Allow body layout to synthesize page 1 when section metadata exists but no
* renderable body blocks survive conversion.
*
* Header/footer layout keeps this disabled to preserve existing empty-region
* behavior for paragraph-free overlays.
*/
allowSectionBreakOnlyPageFallback?: boolean;
};

export type HeaderFooterConstraints = {
Expand Down Expand Up @@ -591,6 +599,10 @@ const shouldSkipRedundantPageBreakBefore = (block: PageBreakBlock, state: PageSt
return isAtTopOfFreshPage;
};

const hasOnlySectionBreakBlocks = (blocks: readonly FlowBlock[]): boolean => {
return blocks.length > 0 && blocks.every((block) => block.kind === 'sectionBreak');
};

// List constants sourced from shared/common

// Context types moved to modular layouters
Expand Down Expand Up @@ -813,6 +825,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
let activeColumns = cloneColumnLayout(options.columns);
let pendingColumns: ColumnLayout | null = null;
const allowParagraphlessAnchoredTableFallback = options.allowParagraphlessAnchoredTableFallback !== false;
const allowSectionBreakOnlyPageFallback = options.allowSectionBreakOnlyPageFallback !== false;

// Track active and pending orientation
let activeOrientation: 'portrait' | 'landscape' | null = null;
Expand Down Expand Up @@ -1082,6 +1095,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
let activeNumberFormat: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash' =
'decimal';
let activePageCounter = 1;
let activeSectionPageCounterStart = activePageCounter;
let pendingNumbering: SectionNumbering | null = null;
// Section header/footer ref tracking state
type SectionRefs = {
Expand Down Expand Up @@ -1109,6 +1123,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
if (typeof initialSectionMetadata?.numbering?.start === 'number') {
activePageCounter = initialSectionMetadata.numbering.start;
activeSectionPageCounterStart = activePageCounter;
}
let activeSectionRefs: SectionRefs | null = null;
let pendingSectionRefs: SectionRefs | null = null;
Expand Down Expand Up @@ -1152,6 +1167,22 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (!state) {
// Track if we're entering a new section (pendingSectionIndex was just set)
const isEnteringNewSection = pendingSectionIndex !== null;
const isApplyingPendingSection =
pendingTopMargin !== null ||
pendingBottomMargin !== null ||
pendingLeftMargin !== null ||
pendingRightMargin !== null ||
pendingHeaderDistance !== null ||
pendingFooterDistance !== null ||
pendingPageSize !== null ||
pendingColumns !== null ||
pendingOrientation !== null ||
pendingNumbering !== null ||
pendingSectionRefs !== null ||
pendingSectionIndex !== null ||
pendingVAlign !== undefined ||
pendingSectionBaseTopMargin !== null ||
pendingSectionBaseBottomMargin !== null;

const applied = applyPendingToActive({
activeTopMargin,
Expand Down Expand Up @@ -1233,6 +1264,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
activeSectionBaseBottomMargin = pendingSectionBaseBottomMargin;
pendingSectionBaseBottomMargin = null;
}
if (isApplyingPendingSection) {
activeSectionPageCounterStart = activePageCounter;
}
pageCount += 1;

// Calculate the page number for this new page
Expand Down Expand Up @@ -1750,6 +1784,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (sectionMetadata.numbering.format) activeNumberFormat = sectionMetadata.numbering.format;
if (typeof sectionMetadata.numbering.start === 'number') {
activePageCounter = sectionMetadata.numbering.start;
activeSectionPageCounterStart = activePageCounter;
}
} else {
// Non-first section: schedule for next page
Expand All @@ -1760,6 +1795,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
if (effectiveBlock.numbering.format) activeNumberFormat = effectiveBlock.numbering.format;
if (typeof effectiveBlock.numbering.start === 'number') {
activePageCounter = effectiveBlock.numbering.start;
activeSectionPageCounterStart = activePageCounter;
}
} else {
pendingNumbering = { ...effectiveBlock.numbering };
Expand Down Expand Up @@ -2262,6 +2298,20 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
// a final blank page for continuous final sections.
paginator.pruneTrailingEmptyPages();

const resetPaginationStateForBlankPageFallback = (): void => {
pageCount = 0;
activePageCounter = activeSectionPageCounterStart;
sectionFirstPageNumbers.clear();
};

if (
pages.length === 0 &&
((allowParagraphlessAnchoredTableFallback && paragraphlessAnchoredTables.length > 0) ||
(allowSectionBreakOnlyPageFallback && hasOnlySectionBreakBlocks(blocks)))
) {
resetPaginationStateForBlankPageFallback();
}

if (allowParagraphlessAnchoredTableFallback && pages.length === 0 && paragraphlessAnchoredTables.length > 0) {
const state = paginator.ensurePage();

Expand All @@ -2284,6 +2334,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
}
}

if (allowSectionBreakOnlyPageFallback && pages.length === 0 && hasOnlySectionBreakBlocks(blocks)) {
paginator.ensurePage();
Comment thread
harbournick marked this conversation as resolved.
}

// Post-process pages with vertical alignment (center, bottom, both)
// For each page, calculate content bounds and apply Y offset to all fragments
for (const page of pages) {
Expand Down Expand Up @@ -2605,6 +2659,7 @@ export function layoutHeaderFooter(
pageSize: { w: width, h: height },
margins: { top: 0, right: 0, bottom: 0, left: 0 },
allowParagraphlessAnchoredTableFallback: false,
allowSectionBreakOnlyPageFallback: false,
});

// Post-normalize page-relative anchored fragment Y positions for footers.
Expand Down
Loading