Skip to content

Commit 67e59a9

Browse files
authored
fix(layout-engine): reset pagination before section-break fallback (#2744)
* fix(layout-engine): reset pagination before section-break fallback * fix(layout-engine): reset pagination before blank-page fallback
1 parent 484ee8a commit 67e59a9

File tree

2 files changed

+189
-0
lines changed

2 files changed

+189
-0
lines changed

packages/layout-engine/layout-engine/src/index.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,125 @@ describe('layoutDocument', () => {
956956
expect(pageWithP3?.margins).toMatchObject({ top: 40, bottom: 40, header: 150, footer: 100 });
957957
});
958958

959+
it('synthesizes page 1 for section-break-only body layouts', () => {
960+
const sectionBreakBlock: SectionBreakBlock = {
961+
kind: 'sectionBreak',
962+
id: 'sb-only',
963+
attrs: { isFirstSection: true, source: 'sectPr' },
964+
pageSize: { w: 500, h: 700 },
965+
orientation: 'landscape',
966+
margins: { top: 40, right: 30, bottom: 35, left: 25, header: 120, footer: 90 },
967+
};
968+
969+
const layout = layoutDocument([sectionBreakBlock], [{ kind: 'sectionBreak' }], DEFAULT_OPTIONS);
970+
971+
expect(layout.pages).toHaveLength(1);
972+
expect(layout.pages[0].fragments).toHaveLength(0);
973+
expect(layout.pages[0].orientation).toBe('landscape');
974+
expect(layout.pages[0].margins).toMatchObject({
975+
top: 40,
976+
right: 30,
977+
bottom: 35,
978+
left: 25,
979+
header: 120,
980+
footer: 90,
981+
});
982+
});
983+
984+
it('resets page numbering when synthesizing a next-page section-break-only layout', () => {
985+
const firstSection: SectionBreakBlock = {
986+
kind: 'sectionBreak',
987+
id: 'sb-first',
988+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
989+
pageSize: { w: 500, h: 700 },
990+
margins: { top: 40, right: 30, bottom: 35, left: 25 },
991+
};
992+
const nextPageSection: SectionBreakBlock = {
993+
kind: 'sectionBreak',
994+
id: 'sb-next',
995+
type: 'nextPage',
996+
attrs: { source: 'sectPr', sectionIndex: 1 },
997+
pageSize: { w: 520, h: 720 },
998+
margins: { top: 45, right: 35, bottom: 40, left: 30 },
999+
};
1000+
1001+
const layout = layoutDocument(
1002+
[firstSection, nextPageSection],
1003+
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
1004+
DEFAULT_OPTIONS,
1005+
);
1006+
1007+
expect(layout.pages).toHaveLength(1);
1008+
expect(layout.pages[0].number).toBe(1);
1009+
expect(layout.pages[0].numberText).toBe('1');
1010+
expect(layout.pages[0].sectionIndex).toBe(1);
1011+
expect(layout.pages[0].margins).toMatchObject({
1012+
top: 45,
1013+
right: 35,
1014+
bottom: 40,
1015+
left: 30,
1016+
});
1017+
});
1018+
1019+
it('resets parity bookkeeping when synthesizing an even-page section-break-only layout', () => {
1020+
const firstSection: SectionBreakBlock = {
1021+
kind: 'sectionBreak',
1022+
id: 'sb-first',
1023+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
1024+
pageSize: { w: 500, h: 700 },
1025+
margins: { top: 40, right: 30, bottom: 35, left: 25 },
1026+
};
1027+
const evenPageSection: SectionBreakBlock = {
1028+
kind: 'sectionBreak',
1029+
id: 'sb-even',
1030+
type: 'evenPage',
1031+
attrs: { source: 'sectPr', sectionIndex: 1 },
1032+
pageSize: { w: 520, h: 720 },
1033+
margins: { top: 45, right: 35, bottom: 40, left: 30 },
1034+
};
1035+
1036+
const layout = layoutDocument(
1037+
[firstSection, evenPageSection],
1038+
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
1039+
DEFAULT_OPTIONS,
1040+
);
1041+
1042+
expect(layout.pages).toHaveLength(1);
1043+
expect(layout.pages[0].number).toBe(1);
1044+
expect(layout.pages[0].numberText).toBe('1');
1045+
expect(layout.pages[0].sectionIndex).toBe(1);
1046+
});
1047+
1048+
it('preserves explicit numbering starts for section-break-only fallback pages', () => {
1049+
const firstSection: SectionBreakBlock = {
1050+
kind: 'sectionBreak',
1051+
id: 'sb-first',
1052+
attrs: { isFirstSection: true, source: 'sectPr', sectionIndex: 0 },
1053+
pageSize: { w: 500, h: 700 },
1054+
margins: { top: 40, right: 30, bottom: 35, left: 25 },
1055+
};
1056+
const nextPageSection: SectionBreakBlock = {
1057+
kind: 'sectionBreak',
1058+
id: 'sb-next',
1059+
type: 'nextPage',
1060+
attrs: { source: 'sectPr', sectionIndex: 1 },
1061+
pageSize: { w: 520, h: 720 },
1062+
margins: { top: 45, right: 35, bottom: 40, left: 30 },
1063+
numbering: { start: 5 },
1064+
};
1065+
1066+
const layout = layoutDocument(
1067+
[firstSection, nextPageSection],
1068+
[{ kind: 'sectionBreak' }, { kind: 'sectionBreak' }],
1069+
DEFAULT_OPTIONS,
1070+
);
1071+
1072+
expect(layout.pages).toHaveLength(1);
1073+
expect(layout.pages[0].number).toBe(1);
1074+
expect(layout.pages[0].numberText).toBe('5');
1075+
expect(layout.pages[0].sectionIndex).toBe(1);
1076+
});
1077+
9591078
it('section break with only header margin stores header distance', () => {
9601079
const sectionBreakBlock: FlowBlock = {
9611080
kind: 'sectionBreak',
@@ -2605,6 +2724,21 @@ describe('layoutHeaderFooter', () => {
26052724
expect(layout.pages).toEqual([]);
26062725
});
26072726

2727+
it('does not synthesize blank pages for section-break-only header/footer layouts', () => {
2728+
const sectionBreakBlock: SectionBreakBlock = {
2729+
kind: 'sectionBreak',
2730+
id: 'header-sb',
2731+
attrs: { isFirstSection: true, source: 'sectPr' },
2732+
pageSize: { w: 200, h: 80 },
2733+
margins: { top: 0, right: 0, bottom: 0, left: 0 },
2734+
};
2735+
2736+
const layout = layoutHeaderFooter([sectionBreakBlock], [{ kind: 'sectionBreak' }], { width: 200, height: 80 });
2737+
2738+
expect(layout.pages).toEqual([]);
2739+
expect(layout.height).toBe(0);
2740+
});
2741+
26082742
it('uses image measure height when fragment height missing', () => {
26092743
const imageBlock: FlowBlock = {
26102744
kind: 'image',

packages/layout-engine/layout-engine/src/index.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,14 @@ export type LayoutOptions = {
518518
* overlay behavior in paragraph-free header/footer regions.
519519
*/
520520
allowParagraphlessAnchoredTableFallback?: boolean;
521+
/**
522+
* Allow body layout to synthesize page 1 when section metadata exists but no
523+
* renderable body blocks survive conversion.
524+
*
525+
* Header/footer layout keeps this disabled to preserve existing empty-region
526+
* behavior for paragraph-free overlays.
527+
*/
528+
allowSectionBreakOnlyPageFallback?: boolean;
521529
};
522530

523531
export type HeaderFooterConstraints = {
@@ -591,6 +599,10 @@ const shouldSkipRedundantPageBreakBefore = (block: PageBreakBlock, state: PageSt
591599
return isAtTopOfFreshPage;
592600
};
593601

602+
const hasOnlySectionBreakBlocks = (blocks: readonly FlowBlock[]): boolean => {
603+
return blocks.length > 0 && blocks.every((block) => block.kind === 'sectionBreak');
604+
};
605+
594606
// List constants sourced from shared/common
595607

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

817830
// Track active and pending orientation
818831
let activeOrientation: 'portrait' | 'landscape' | null = null;
@@ -1082,6 +1095,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
10821095
let activeNumberFormat: 'decimal' | 'lowerLetter' | 'upperLetter' | 'lowerRoman' | 'upperRoman' | 'numberInDash' =
10831096
'decimal';
10841097
let activePageCounter = 1;
1098+
let activeSectionPageCounterStart = activePageCounter;
10851099
let pendingNumbering: SectionNumbering | null = null;
10861100
// Section header/footer ref tracking state
10871101
type SectionRefs = {
@@ -1109,6 +1123,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
11091123
}
11101124
if (typeof initialSectionMetadata?.numbering?.start === 'number') {
11111125
activePageCounter = initialSectionMetadata.numbering.start;
1126+
activeSectionPageCounterStart = activePageCounter;
11121127
}
11131128
let activeSectionRefs: SectionRefs | null = null;
11141129
let pendingSectionRefs: SectionRefs | null = null;
@@ -1152,6 +1167,22 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
11521167
if (!state) {
11531168
// Track if we're entering a new section (pendingSectionIndex was just set)
11541169
const isEnteringNewSection = pendingSectionIndex !== null;
1170+
const isApplyingPendingSection =
1171+
pendingTopMargin !== null ||
1172+
pendingBottomMargin !== null ||
1173+
pendingLeftMargin !== null ||
1174+
pendingRightMargin !== null ||
1175+
pendingHeaderDistance !== null ||
1176+
pendingFooterDistance !== null ||
1177+
pendingPageSize !== null ||
1178+
pendingColumns !== null ||
1179+
pendingOrientation !== null ||
1180+
pendingNumbering !== null ||
1181+
pendingSectionRefs !== null ||
1182+
pendingSectionIndex !== null ||
1183+
pendingVAlign !== undefined ||
1184+
pendingSectionBaseTopMargin !== null ||
1185+
pendingSectionBaseBottomMargin !== null;
11551186

11561187
const applied = applyPendingToActive({
11571188
activeTopMargin,
@@ -1233,6 +1264,9 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
12331264
activeSectionBaseBottomMargin = pendingSectionBaseBottomMargin;
12341265
pendingSectionBaseBottomMargin = null;
12351266
}
1267+
if (isApplyingPendingSection) {
1268+
activeSectionPageCounterStart = activePageCounter;
1269+
}
12361270
pageCount += 1;
12371271

12381272
// Calculate the page number for this new page
@@ -1750,6 +1784,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
17501784
if (sectionMetadata.numbering.format) activeNumberFormat = sectionMetadata.numbering.format;
17511785
if (typeof sectionMetadata.numbering.start === 'number') {
17521786
activePageCounter = sectionMetadata.numbering.start;
1787+
activeSectionPageCounterStart = activePageCounter;
17531788
}
17541789
} else {
17551790
// Non-first section: schedule for next page
@@ -1760,6 +1795,7 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
17601795
if (effectiveBlock.numbering.format) activeNumberFormat = effectiveBlock.numbering.format;
17611796
if (typeof effectiveBlock.numbering.start === 'number') {
17621797
activePageCounter = effectiveBlock.numbering.start;
1798+
activeSectionPageCounterStart = activePageCounter;
17631799
}
17641800
} else {
17651801
pendingNumbering = { ...effectiveBlock.numbering };
@@ -2262,6 +2298,20 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options
22622298
// a final blank page for continuous final sections.
22632299
paginator.pruneTrailingEmptyPages();
22642300

2301+
const resetPaginationStateForBlankPageFallback = (): void => {
2302+
pageCount = 0;
2303+
activePageCounter = activeSectionPageCounterStart;
2304+
sectionFirstPageNumbers.clear();
2305+
};
2306+
2307+
if (
2308+
pages.length === 0 &&
2309+
((allowParagraphlessAnchoredTableFallback && paragraphlessAnchoredTables.length > 0) ||
2310+
(allowSectionBreakOnlyPageFallback && hasOnlySectionBreakBlocks(blocks)))
2311+
) {
2312+
resetPaginationStateForBlankPageFallback();
2313+
}
2314+
22652315
if (allowParagraphlessAnchoredTableFallback && pages.length === 0 && paragraphlessAnchoredTables.length > 0) {
22662316
const state = paginator.ensurePage();
22672317

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

2337+
if (allowSectionBreakOnlyPageFallback && pages.length === 0 && hasOnlySectionBreakBlocks(blocks)) {
2338+
paginator.ensurePage();
2339+
}
2340+
22872341
// Post-process pages with vertical alignment (center, bottom, both)
22882342
// For each page, calculate content bounds and apply Y offset to all fragments
22892343
for (const page of pages) {
@@ -2605,6 +2659,7 @@ export function layoutHeaderFooter(
26052659
pageSize: { w: width, h: height },
26062660
margins: { top: 0, right: 0, bottom: 0, left: 0 },
26072661
allowParagraphlessAnchoredTableFallback: false,
2662+
allowSectionBreakOnlyPageFallback: false,
26082663
});
26092664

26102665
// Post-normalize page-relative anchored fragment Y positions for footers.

0 commit comments

Comments
 (0)