From 6d77a4b1ce6a5326c5212fb3594a2e37673b96b2 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 7 Apr 2026 19:39:45 -0700 Subject: [PATCH 1/2] fix(layout-engine): reset pagination before section-break fallback --- .../layout-engine/src/index.test.ts | 40 +++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 18 +++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index 0d11fbae0b..d356814d69 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -956,6 +956,31 @@ 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('section break with only header margin stores header distance', () => { const sectionBreakBlock: FlowBlock = { kind: 'sectionBreak', @@ -2605,6 +2630,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', diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 3391ef0918..83b7ccb14e 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -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 = { @@ -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 @@ -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; @@ -2284,6 +2297,10 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options } } + if (allowSectionBreakOnlyPageFallback && pages.length === 0 && hasOnlySectionBreakBlocks(blocks)) { + paginator.ensurePage(); + } + // 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) { @@ -2605,6 +2622,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. From 67a14c5d92729581f6b05b946c0700cf292bdbbc Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 7 Apr 2026 20:11:11 -0700 Subject: [PATCH 2/2] fix(layout-engine): reset pagination before blank-page fallback --- .../layout-engine/src/index.test.ts | 94 +++++++++++++++++++ .../layout-engine/layout-engine/src/index.ts | 37 ++++++++ 2 files changed, 131 insertions(+) diff --git a/packages/layout-engine/layout-engine/src/index.test.ts b/packages/layout-engine/layout-engine/src/index.test.ts index d356814d69..43c58b74a4 100644 --- a/packages/layout-engine/layout-engine/src/index.test.ts +++ b/packages/layout-engine/layout-engine/src/index.test.ts @@ -981,6 +981,100 @@ describe('layoutDocument', () => { }); }); + 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', diff --git a/packages/layout-engine/layout-engine/src/index.ts b/packages/layout-engine/layout-engine/src/index.ts index 83b7ccb14e..1cb5e155ae 100644 --- a/packages/layout-engine/layout-engine/src/index.ts +++ b/packages/layout-engine/layout-engine/src/index.ts @@ -1095,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 = { @@ -1122,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; @@ -1165,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, @@ -1246,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 @@ -1763,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 @@ -1773,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 }; @@ -2275,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();