diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts index 767398b0f1..81a2b15d01 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts @@ -473,5 +473,124 @@ describe('document-part-object', () => { expect(callArgs[1].tocInstruction).toBeUndefined(); }); }); + + // ==================== Pending section-break emission (SD-2557) ==================== + describe('pending section break at SDT boundary', () => { + const sectionFixture = (startParagraphIndex: number) => ({ + ranges: [ + { + sectionIndex: 0, + startParagraphIndex: 0, + endParagraphIndex: 0, + sectPr: null, + margins: null, + headerRefs: {}, + footerRefs: {}, + type: 'nextPage', + }, + { + sectionIndex: 1, + startParagraphIndex, + endParagraphIndex: 10, + sectPr: null, + margins: null, + headerRefs: {}, + footerRefs: {}, + type: 'nextPage', + }, + ], + currentSectionIndex: 0, + currentParagraphIndex: startParagraphIndex, + }); + + // For the TOC branch, per-child emission now lives inside `processTocChildren` + // (which is mocked in these tests). The non-TOC branch below exercises the + // inline per-child emission path directly. + it('emits a section break before a docPartObj non-TOC child at a section boundary', () => { + // Repro for SD-2557 at the non-TOC path: same root cause — the handler + // processes child paragraphs but previously skipped the section-break check. + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], + }; + vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery'); + vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1'); + vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' }); + + // currentParagraphIndex === nextSection.startParagraphIndex → the first + // child paragraph is the start of section 1. + mockContext.sectionState = sectionFixture(3) as unknown as NodeHandlerContext['sectionState']; + + handleDocumentPartObjectNode(node, mockContext); + + const sectionBreak = mockContext.blocks.find((b) => b.kind === 'sectionBreak'); + expect(sectionBreak).toBeDefined(); + expect(mockContext.sectionState!.currentSectionIndex).toBe(1); + // Counter must advance past the child paragraph so subsequent body + // content sees the correct paragraph index. + expect(mockContext.sectionState!.currentParagraphIndex).toBe(4); + }); + + it('does not emit a section break when the child is not at a section boundary', () => { + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], + }; + vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery'); + vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1'); + vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' }); + + // currentParagraphIndex (2) < startParagraphIndex (5): not at boundary. + const state = sectionFixture(5); + state.currentParagraphIndex = 2; + mockContext.sectionState = state as unknown as NodeHandlerContext['sectionState']; + + handleDocumentPartObjectNode(node, mockContext); + + expect(mockContext.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined(); + expect(mockContext.sectionState!.currentSectionIndex).toBe(0); + // Counter still advances past the processed child. + expect(mockContext.sectionState!.currentParagraphIndex).toBe(3); + }); + + it('is a no-op when sectionState is undefined', () => { + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], + }; + vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Building Block Gallery'); + vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('bb-1'); + vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' }); + + mockContext.sectionState = undefined; + + expect(() => handleDocumentPartObjectNode(node, mockContext)).not.toThrow(); + expect(mockContext.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined(); + }); + + it('passes sectionState through to processTocChildren for TOC gallery', () => { + const node: PMNode = { + type: 'documentPartObject', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'TOC Entry' }] }], + }; + vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Table of Contents'); + vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1'); + vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined); + vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue({ type: 'docPartObject' }); + + const state = sectionFixture(3); + mockContext.sectionState = state as unknown as NodeHandlerContext['sectionState']; + + handleDocumentPartObjectNode(node, mockContext); + + // processTocChildren is mocked; just verify it received sectionState + // so the helper-inside-processTocChildren pattern can work end-to-end. + const callArgs = vi.mocked(tocModule.processTocChildren).mock.calls[0]; + expect(callArgs[2]).toMatchObject({ sectionState: state }); + }); + }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts index 045ca3c91b..facc5b8ef8 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts @@ -6,6 +6,7 @@ */ import type { PMNode, NodeHandlerContext } from '../types.js'; +import { emitPendingSectionBreakForParagraph } from '../sections/index.js'; import { getDocPartGallery, getDocPartObjectId, getNodeInstruction, resolveNodeSdtMetadata } from './metadata.js'; import { processTocChildren } from './toc.js'; @@ -14,6 +15,14 @@ import { processTocChildren } from './toc.js'; * Processes TOC children for Table of Contents galleries. * For other gallery types (page numbers, etc.), processes child paragraphs normally. * + * If a preceding paragraph carried a `w:sectPr` whose next section starts at + * this SDT, emit the pending section break BEFORE processing children so the + * SDT's paragraphs render on the new page (see SD-2557). `findParagraphsWithSectPr` + * doesn't recurse into `documentPartObject`, so its child paragraphs don't bump + * `currentParagraphIndex` — and without this call, the deferred break would only + * fire on the next body paragraph AFTER the SDT, leaving e.g. a TOC on the + * prior page with the cover content. + * * @param node - Document part object node to process * @param context - Shared handler context */ @@ -27,12 +36,14 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC positions, bookmarks, hyperlinkConfig, + sectionState, converters, converterContext, enableComments, trackedChangesConfig, themeColors, } = context; + const docPartGallery = getDocPartGallery(node); const docPartObjectId = getDocPartObjectId(node); const tocInstruction = getNodeInstruction(node); @@ -50,15 +61,21 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC hyperlinkConfig, enableComments, trackedChangesConfig, + themeColors, converters, converterContext, + sectionState, }, { blocks, recordBlockKind }, ); } else if (paragraphToFlowBlocks) { - // For non-ToC gallery types (page numbers, etc.), process child paragraphs normally + // For non-ToC gallery types (page numbers, etc.), process child paragraphs normally. + // `findParagraphsWithSectPr` recurses into documentPartObject (SD-2557), so child + // paragraph indices ARE counted — we must mirror that by emitting pending section + // breaks and advancing currentParagraphIndex per child. for (const child of node.content) { if (child.type === 'paragraph') { + emitPendingSectionBreakForParagraph({ sectionState, nextBlockId, blocks, recordBlockKind }); const childBlocks = paragraphToFlowBlocks({ para: child, nextBlockId, @@ -75,6 +92,7 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC blocks.push(block); recordBlockKind?.(block.kind); } + if (sectionState) sectionState.currentParagraphIndex++; } } } diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts index 86a6e2a70a..d05216e23b 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/toc.test.ts @@ -3,8 +3,8 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { applyTocMetadata, processTocChildren } from './toc.js'; -import type { PMNode } from '../types.js'; +import { applyTocMetadata, processTocChildren, handleTableOfContentsNode } from './toc.js'; +import type { PMNode, NodeHandlerContext } from '../types.js'; import type { FlowBlock, ParagraphBlock, SdtMetadata } from '@superdoc/contracts'; describe('toc', () => { @@ -96,7 +96,9 @@ describe('toc', () => { expect(blocks[0].attrs?.isTocEntry).toBe(true); }); - it('handles null metadata values', () => { + it('does not fabricate sdt metadata when gallery is missing', () => { + // A direct `tableOfContents` PM node has no enclosing w:sdt in OOXML, + // so we must not invent a docPartObject SDT metadata entry for it. const blocks: ParagraphBlock[] = [ { kind: 'paragraph', @@ -112,12 +114,7 @@ describe('toc', () => { }); expect(blocks[0].attrs?.isTocEntry).toBe(true); - expect(blocks[0].attrs?.sdt).toEqual({ - type: 'docPartObject', - gallery: null, - uniqueId: null, - instruction: null, - }); + expect(blocks[0].attrs?.sdt).toBeUndefined(); expect(blocks[0].attrs?.tocInstruction).toBeUndefined(); }); @@ -479,5 +476,194 @@ describe('toc', () => { }), ); }); + + it('forwards themeColors to the paragraph converter', () => { + const children: PMNode[] = [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Entry' }], + }, + ]; + const blocks: FlowBlock[] = []; + const themeColors = { accent1: '#ff0000' } as never; + + const mockParagraphConverter = vi.fn(() => [ + { + kind: 'paragraph', + id: 'p1', + runs: [{ text: 'Entry', fontFamily: 'Arial', fontSize: 12 }], + } as ParagraphBlock, + ]); + + processTocChildren( + children, + { docPartGallery: 'Table of Contents' }, + { + nextBlockId: () => 'id', + positions: new Map(), + bookmarks: new Map(), + hyperlinkConfig: mockHyperlinkConfig, + enableComments: true, + converters: { paragraphToFlowBlocks: mockParagraphConverter } as never, + converterContext: mockConverterContext, + themeColors, + }, + { blocks }, + ); + + expect(mockParagraphConverter).toHaveBeenCalledWith(expect.objectContaining({ themeColors })); + }); + }); + + // ==================== handleTableOfContentsNode (direct node) ==================== + describe('handleTableOfContentsNode', () => { + const baseContext = (overrides: Partial = {}): NodeHandlerContext => { + const paragraphConverter = vi.fn((params: { para: PMNode }) => { + const text = (params.para.content as { text: string }[] | undefined)?.[0]?.text ?? ''; + return [ + { + kind: 'paragraph', + id: `p-${text}`, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], + } as ParagraphBlock, + ]; + }); + return { + blocks: [], + recordBlockKind: vi.fn(), + nextBlockId: (kind: string) => `${kind}-id`, + positions: new Map(), + defaultFont: 'Arial', + defaultSize: 12, + bookmarks: new Map(), + hyperlinkConfig: { mode: 'preserve' } as never, + enableComments: true, + converterContext: { docx: {} } as never, + converters: { paragraphToFlowBlocks: paragraphConverter } as never, + trackedChangesConfig: undefined as never, + ...overrides, + }; + }; + + const sectionStateAt = (startParagraphIndex: number, currentParagraphIndex: number) => + ({ + ranges: [ + { + sectionIndex: 0, + startParagraphIndex: 0, + endParagraphIndex: 0, + sectPr: null, + margins: null, + headerRefs: {}, + footerRefs: {}, + type: 'nextPage', + }, + { + sectionIndex: 1, + startParagraphIndex, + endParagraphIndex: 99, + sectPr: null, + margins: null, + headerRefs: {}, + footerRefs: {}, + type: 'nextPage', + }, + ], + currentSectionIndex: 0, + currentParagraphIndex, + }) as unknown as NodeHandlerContext['sectionState']; + + it('advances currentParagraphIndex once per child paragraph', () => { + const node: PMNode = { + type: 'tableOfContents', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Ch 1' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Ch 2' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Ch 3' }] }, + ], + }; + const ctx = baseContext({ sectionState: sectionStateAt(100, 5) }); + + handleTableOfContentsNode(node, ctx); + + // 3 TOC children processed → counter advanced 3 times + expect(ctx.sectionState!.currentParagraphIndex).toBe(8); + }); + + it('emits a section break before the TOC child that starts the next section', () => { + // Section boundary sits at paragraph index 6: third child of the TOC. + // currentParagraphIndex starts at 4; children consume indices 4, 5, 6. + const node: PMNode = { + type: 'tableOfContents', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'A' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'B' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'C' }] }, + ], + }; + const ctx = baseContext({ sectionState: sectionStateAt(6, 4) }); + + handleTableOfContentsNode(node, ctx); + + const breakIndex = ctx.blocks.findIndex((b) => b.kind === 'sectionBreak'); + const thirdEntryIndex = ctx.blocks.findIndex((b) => b.kind === 'paragraph' && (b as ParagraphBlock).id === 'p-C'); + expect(breakIndex).toBeGreaterThanOrEqual(0); + expect(breakIndex).toBeLessThan(thirdEntryIndex); + expect(ctx.sectionState!.currentSectionIndex).toBe(1); + }); + + it('does not fabricate attrs.sdt on entries (no enclosing SDT)', () => { + const node: PMNode = { + type: 'tableOfContents', + attrs: { instruction: 'TOC \\o "1-3"' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }], + }; + const ctx = baseContext(); + + handleTableOfContentsNode(node, ctx); + + const entry = ctx.blocks[0] as ParagraphBlock; + expect(entry.attrs?.isTocEntry).toBe(true); + expect(entry.attrs?.tocInstruction).toBe('TOC \\o "1-3"'); + expect(entry.attrs?.sdt).toBeUndefined(); + }); + + it('is a no-op when sectionState is undefined', () => { + const node: PMNode = { + type: 'tableOfContents', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }], + }; + const ctx = baseContext(); + + expect(() => handleTableOfContentsNode(node, ctx)).not.toThrow(); + expect(ctx.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined(); + expect((ctx.blocks[0] as ParagraphBlock).attrs?.isTocEntry).toBe(true); + }); + + it('forwards themeColors to the paragraph converter', () => { + const paragraphConverter = vi.fn((params: { para: PMNode }) => { + const text = (params.para.content as { text: string }[] | undefined)?.[0]?.text ?? ''; + return [ + { + kind: 'paragraph', + id: `p-${text}`, + runs: [{ text, fontFamily: 'Arial', fontSize: 12 }], + } as ParagraphBlock, + ]; + }); + const themeColors = { accent1: '#112233' } as never; + const node: PMNode = { + type: 'tableOfContents', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Entry' }] }], + }; + const ctx = baseContext({ + converters: { paragraphToFlowBlocks: paragraphConverter } as never, + themeColors, + }); + + handleTableOfContentsNode(node, ctx); + + expect(paragraphConverter).toHaveBeenCalledWith(expect.objectContaining({ themeColors })); + }); }); }); diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.ts index dd5246dccb..116bdcd556 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/toc.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/toc.ts @@ -17,6 +17,7 @@ import type { ConverterContext, ThemeColorPalette, } from '../types.js'; +import { emitPendingSectionBreakForParagraph } from '../sections/index.js'; import { applySdtMetadataToParagraphBlocks, getNodeInstruction } from './metadata.js'; /** @@ -38,8 +39,10 @@ export function applyTocMetadata( if (block.kind === 'paragraph') { if (!block.attrs) block.attrs = {}; block.attrs.isTocEntry = true; - // Store TOC metadata as SDT for proper typing - if (!block.attrs.sdt) { + // Only fabricate SDT metadata when the TOC came from a w:sdt/w:docPartObj + // wrapper (gallery is set). A direct `tableOfContents` PM node has no + // enclosing SDT, so inventing one here would mislead downstream consumers. + if (!block.attrs.sdt && metadata.gallery) { block.attrs.sdt = { type: 'docPartObject', gallery: metadata.gallery, @@ -86,7 +89,9 @@ export function applyTocMetadata( export function processTocChildren( children: readonly PMNode[], metadata: { - docPartGallery: string; + // Optional: only set when the TOC is wrapped in a w:sdt/w:docPartObj. + // Direct `tableOfContents` PM nodes omit this — no SDT metadata is fabricated. + docPartGallery?: string; docPartObjectId?: string; tocInstruction?: string; sdtMetadata?: SdtMetadata; @@ -101,6 +106,7 @@ export function processTocChildren( converters: NestedConverters; converterContext: ConverterContext; themeColors?: ThemeColorPalette; + sectionState?: NodeHandlerContext['sectionState']; }, outputArrays: { blocks: FlowBlock[]; @@ -113,6 +119,16 @@ export function processTocChildren( children.forEach((child) => { if (child.type === 'paragraph') { + // SD-2557: emit any pending section break before this child. `findParagraphsWithSectPr` + // recurses into documentPartObject, so TOC child paragraph indices are part of the + // section-range counting — advance the counter after processing to stay in sync. + emitPendingSectionBreakForParagraph({ + sectionState: context.sectionState, + nextBlockId: context.nextBlockId, + blocks, + recordBlockKind, + }); + // Direct paragraph child - convert and tag const paragraphBlocks = paragraphConverter({ para: child, @@ -121,6 +137,7 @@ export function processTocChildren( trackedChangesConfig: context.trackedChangesConfig, bookmarks: context.bookmarks, hyperlinkConfig: context.hyperlinkConfig, + themeColors: context.themeColors, converters: context.converters, enableComments: context.enableComments, converterContext: context.converterContext, @@ -140,6 +157,8 @@ export function processTocChildren( blocks.push(block); recordBlockKind?.(block.kind); }); + + if (context.sectionState) context.sectionState.currentParagraphIndex++; } else if (child.type === 'tableOfContents' && Array.isArray(child.content)) { // Nested tableOfContents - recurse with potentially different instruction const childInstruction = getNodeInstruction(child); @@ -156,8 +175,13 @@ export function processTocChildren( } /** - * Handle table of contents nodes. - * Processes child paragraphs and marks them as TOC entries. + * Handle direct `tableOfContents` PM nodes (not wrapped in a `documentPartObject` + * SDT). Delegates to `processTocChildren` — the single code path that also + * services `handleDocumentPartObjectNode`. This keeps the section-range + * counting contract intact: `findParagraphsWithSectPr` counts every + * `tableOfContents` child, and `processTocChildren` advances + * `sectionState.currentParagraphIndex` per child so deferred section breaks + * fire at the right paragraph boundary (SD-2557). * * @param node - Table of contents node to process * @param context - Shared handler context @@ -165,45 +189,25 @@ export function processTocChildren( export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerContext): void { if (!Array.isArray(node.content)) return; - const { - blocks, - recordBlockKind, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - hyperlinkConfig, - converters, - converterContext, - themeColors, - enableComments, - } = context; - const tocInstruction = getNodeInstruction(node); - const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; - - node.content.forEach((child) => { - if (child.type === 'paragraph') { - const paragraphBlocks = paragraphToFlowBlocks({ - para: child, - nextBlockId, - positions, - trackedChangesConfig, - bookmarks, - themeColors, - hyperlinkConfig, - converters, - enableComments, - converterContext, - }); - paragraphBlocks.forEach((block) => { - if (block.kind === 'paragraph') { - if (!block.attrs) block.attrs = {}; - block.attrs.isTocEntry = true; - if (tocInstruction) block.attrs.tocInstruction = tocInstruction; - } - blocks.push(block); - recordBlockKind?.(block.kind); - }); - } - }); + processTocChildren( + node.content, + { + // No enclosing SDT — omit gallery so applyTocMetadata does not fabricate + // a docPartObject sdt entry on each TOC paragraph. + tocInstruction: getNodeInstruction(node), + }, + { + nextBlockId: context.nextBlockId, + positions: context.positions, + bookmarks: context.bookmarks, + trackedChangesConfig: context.trackedChangesConfig, + hyperlinkConfig: context.hyperlinkConfig, + enableComments: context.enableComments, + themeColors: context.themeColors, + converters: context.converters, + converterContext: context.converterContext, + sectionState: context.sectionState, + }, + { blocks: context.blocks, recordBlockKind: context.recordBlockKind }, + ); } diff --git a/packages/layout-engine/pm-adapter/src/sections/analysis.ts b/packages/layout-engine/pm-adapter/src/sections/analysis.ts index 3a0cae9013..925b0561f5 100644 --- a/packages/layout-engine/pm-adapter/src/sections/analysis.ts +++ b/packages/layout-engine/pm-adapter/src/sections/analysis.ts @@ -96,7 +96,23 @@ export function findParagraphsWithSectPr(doc: PMNode): { return; } - if (node.type === 'index' || node.type === 'bibliography' || node.type === 'tableOfAuthorities') { + // Recurse into container node types that wrap body paragraphs. Children + // of these nodes are counted as paragraphs for section-range purposes and + // their handlers increment `currentParagraphIndex` + call the section-break + // emission helper per child. + // + // `documentPartObject` / `tableOfContents` are important for SD-2557: + // Word stores the closing sectPr of a TOC section on the trailing empty + // paragraph INSIDE the SDT. Without recursion, that sectPr is invisible to + // section-range analysis and the nextPage break between TOC and the next + // body section is silently dropped. + if ( + node.type === 'index' || + node.type === 'bibliography' || + node.type === 'tableOfAuthorities' || + node.type === 'documentPartObject' || + node.type === 'tableOfContents' + ) { getNodeChildren(node).forEach(visitNode); } }; diff --git a/packages/layout-engine/pm-adapter/src/sections/breaks.ts b/packages/layout-engine/pm-adapter/src/sections/breaks.ts index 8501b2230a..4b4ed6f030 100644 --- a/packages/layout-engine/pm-adapter/src/sections/breaks.ts +++ b/packages/layout-engine/pm-adapter/src/sections/breaks.ts @@ -190,3 +190,55 @@ export function shouldRequirePageBoundary(current: SectionRange, next: SectionRa export function hasIntrinsicBoundarySignals(_: SectionRange): boolean { return false; } + +/** + * Minimal mutable sectionState shape used by section-break emission helpers. + * Kept local so callers can pass `NodeHandlerContext['sectionState']` directly. + */ +interface SectionStateMutable { + ranges: SectionRange[]; + currentSectionIndex: number; + currentParagraphIndex: number; +} + +/** + * Emit a pending section break before a paragraph if the current paragraph + * index matches the start of the next section. + * + * Centralizes the "check, emit, advance" pattern used by paragraph and SDT + * handlers. SDT handlers that process children as an opaque block (e.g. + * TOC/docPartObj where child paragraphs aren't counted by + * `findParagraphsWithSectPr`) should call this ONCE at the SDT boundary — + * if the SDT sits at a section boundary, this emits the break so the SDT's + * contents render on the new page. + * + * No-op when: + * - sectionState is undefined or has no ranges + * - currentParagraphIndex doesn't match the next section's startParagraphIndex + * + * Side effects (when emitted): + * - Pushes a sectionBreak block onto `blocks` + * - Invokes `recordBlockKind` + * - Increments `sectionState.currentSectionIndex` + */ +export function emitPendingSectionBreakForParagraph(args: { + sectionState: SectionStateMutable | undefined; + nextBlockId: BlockIdGenerator; + blocks: FlowBlock[]; + recordBlockKind?: (kind: FlowBlock['kind']) => void; +}): void { + const { sectionState, nextBlockId, blocks, recordBlockKind } = args; + if (!sectionState || sectionState.ranges.length === 0) return; + + const nextSection = sectionState.ranges[sectionState.currentSectionIndex + 1]; + if (!nextSection || sectionState.currentParagraphIndex !== nextSection.startParagraphIndex) return; + + const currentSection = sectionState.ranges[sectionState.currentSectionIndex]; + const requiresPageBoundary = + shouldRequirePageBoundary(currentSection, nextSection) || hasIntrinsicBoundarySignals(nextSection); + const extraAttrs = requiresPageBoundary ? { requirePageBoundary: true } : undefined; + const sectionBreak = createSectionBreakBlock(nextSection, nextBlockId, extraAttrs); + blocks.push(sectionBreak); + recordBlockKind?.(sectionBreak.kind); + sectionState.currentSectionIndex++; +} diff --git a/packages/layout-engine/pm-adapter/src/sections/index.ts b/packages/layout-engine/pm-adapter/src/sections/index.ts index 64b41423fb..5f293b3d9d 100644 --- a/packages/layout-engine/pm-adapter/src/sections/index.ts +++ b/packages/layout-engine/pm-adapter/src/sections/index.ts @@ -41,4 +41,5 @@ export { isSectionBreakBlock, signaturesEqual, shallowObjectEquals, + emitPendingSectionBreakForParagraph, } from './breaks.js';