From 590eab648973a8e339ed1cae0393908913a802f8 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 20 Apr 2026 15:46:11 -0300 Subject: [PATCH 1/2] fix(pm-adapter): emit pending section break before TOC/docPartObj SDTs (SD-2557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Word document stores a `w:sectPr` on a paragraph immediately before a TOC (or other `docPartObj`) SDT, the section break was dropped. The TOC ended up rendering on the same page as the prior section's content instead of starting a new page as Word does. Root cause in pm-adapter: - `findParagraphsWithSectPr` (sections/analysis.ts) recurses into `index`, `bibliography`, and `tableOfAuthorities` to count their children as paragraphs, but NOT into `documentPartObject` / `tableOfContents`. - As a result, section ranges treat a TOC SDT as a single opaque unit — its children don't occupy paragraph indices. - When processing flow, `handleParagraphNode` / `handleIndexNode` / `handleBibliographyNode` / `handleTableOfAuthoritiesNode` each emit a pending section break before the paragraph whose index matches `nextSection.startParagraphIndex`. - `handleDocumentPartObjectNode` and `handleTableOfContentsNode` did NOT run this check, so the deferred break only fired on the next body paragraph AFTER the SDT. The SDT's content rendered in the PREVIOUS section, with no page break before it. Fix: - Add `emitPendingSectionBreakForParagraph(sectionState, nextBlockId, blocks, recordBlockKind)` helper in sections/breaks.ts that centralizes the "check, emit, advance" pattern. - Call the helper at the top of `handleDocumentPartObjectNode` and `handleTableOfContentsNode` — once per SDT. Since the SDT's children don't affect `currentParagraphIndex` (`findParagraphsWithSectPr` skips them), the check fires correctly at the SDT boundary: if the SDT sits at a section boundary, the nextPage break is emitted so the SDT renders on a new page. - No changes to section-range computation — counting stays consistent. Verified against both fixtures from the issue (Highstreet Proposal Sample, Heffernan Proposal Sample): cover stays on its own page, TOC starts on a new page, matching Word. Tests: - 3 new unit tests in document-part-object.test.ts covering: - Section break emitted at SDT boundary - No emission when SDT is not at a section boundary - No-op when sectionState is undefined - 1740 pm-adapter tests pass (up from 1737), 604 layout-engine tests pass. --- .../src/sdt/document-part-object.test.ts | 91 +++++++++++++++++++ .../src/sdt/document-part-object.ts | 13 +++ .../layout-engine/pm-adapter/src/sdt/toc.ts | 6 ++ .../pm-adapter/src/sections/breaks.ts | 52 +++++++++++ .../pm-adapter/src/sections/index.ts | 1 + 5 files changed, 163 insertions(+) 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..9135b92478 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,96 @@ 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, + }); + + it('emits a section break before the TOC when the SDT sits at a section boundary', () => { + // Repro for SD-2557: a nextPage section break on the empty paragraph + // immediately before a TOC docPartObj was dropped, keeping the TOC on + // the same page as the prior section's content. + 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' }); + + // currentParagraphIndex === nextSection.startParagraphIndex → the next + // paragraph-flow entry would start section 1. The SDT IS that entry. + 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); + }); + + it('does not emit a section break when the SDT is not at a section boundary', () => { + 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' }); + + // currentParagraphIndex (2) < startParagraphIndex (5): not at boundary yet. + 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); + }); + + it('is a no-op when sectionState is undefined', () => { + 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' }); + + mockContext.sectionState = undefined; + + expect(() => handleDocumentPartObjectNode(node, mockContext)).not.toThrow(); + expect(mockContext.blocks.find((b) => b.kind === 'sectionBreak')).toBeUndefined(); + }); + }); }); }); 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..d2667985b4 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,16 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC positions, bookmarks, hyperlinkConfig, + sectionState, converters, converterContext, enableComments, trackedChangesConfig, themeColors, } = context; + + emitPendingSectionBreakForParagraph({ sectionState, nextBlockId, blocks, recordBlockKind }); + const docPartGallery = getDocPartGallery(node); const docPartObjectId = getDocPartObjectId(node); const tocInstruction = getNodeInstruction(node); diff --git a/packages/layout-engine/pm-adapter/src/sdt/toc.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.ts index dd5246dccb..05bc5918f5 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'; /** @@ -173,11 +174,16 @@ export function handleTableOfContentsNode(node: PMNode, context: NodeHandlerCont trackedChangesConfig, bookmarks, hyperlinkConfig, + sectionState, converters, converterContext, themeColors, enableComments, } = context; + + // See handleDocumentPartObjectNode for rationale (SD-2557). + emitPendingSectionBreakForParagraph({ sectionState, nextBlockId, blocks, recordBlockKind }); + const tocInstruction = getNodeInstruction(node); const paragraphToFlowBlocks = converters.paragraphToFlowBlocks; 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'; From 158dd29d64ec2769f73d3466b8c9fd49672bf10f Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 20 Apr 2026 15:59:25 -0300 Subject: [PATCH 2/2] fix(pm-adapter): recurse into docPartObj for section ranges + per-child emission (SD-2557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial fix only handled the sectPr BEFORE a TOC docPartObj (on the empty paragraph that precedes the SDT). It missed the SECOND sectPr that Word commonly stores on the trailing empty paragraph INSIDE the TOC SDT — which signals "TOC section ends here, next section starts on new page". Because `findParagraphsWithSectPr` did not recurse into `documentPartObject` or `tableOfContents`, that inner sectPr was never discovered, so no section-range boundary was built between the TOC and the following body content. SuperDoc rendered the TOC and the first body section stacked on the same page (just one page later than before the first fix). Complete fix: 1. `findParagraphsWithSectPr` (sections/analysis.ts) now recurses into `documentPartObject` and `tableOfContents` in addition to `index` / `bibliography` / `tableOfAuthorities`. This lets section-range analysis see sectPrs stored anywhere inside a TOC SDT. 2. `handleDocumentPartObjectNode` (non-TOC branch) emits the pending section break before each child paragraph and advances `currentParagraphIndex` — matching the pattern in `handleIndexNode`, `handleBibliographyNode`, and `handleTableOfAuthoritiesNode`. 3. `processTocChildren` (toc.ts) accepts `sectionState` via its context arg and performs the same per-child emit + increment dance as the paragraph handlers. This handles the TOC branch of `handleDocumentPartObjectNode` and the nested `tableOfContents` recursion path. With all three changes, the Highstreet fixture now renders exactly like Word: - Page 1: cover - Page 2: TOC alone - Page 3: ABOUT US body - Page 4: ON BEHALF OF HIGHSTREET - Page 5: WORKERS COMPENSATION Tests: - 4 new tests in document-part-object.test.ts (non-TOC emission, non-boundary no-op, undefined state, sectionState passthrough to processTocChildren) - 1741/1741 pm-adapter, 604/604 layout-engine, 11377/11377 super-editor --- .../src/sdt/document-part-object.test.ts | 62 ++++++++++++++----- .../src/sdt/document-part-object.ts | 10 ++- .../layout-engine/pm-adapter/src/sdt/toc.ts | 13 ++++ .../pm-adapter/src/sections/analysis.ts | 18 +++++- 4 files changed, 82 insertions(+), 21 deletions(-) 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 9135b92478..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 @@ -503,21 +503,23 @@ describe('document-part-object', () => { currentParagraphIndex: startParagraphIndex, }); - it('emits a section break before the TOC when the SDT sits at a section boundary', () => { - // Repro for SD-2557: a nextPage section break on the empty paragraph - // immediately before a TOC docPartObj was dropped, keeping the TOC on - // the same page as the prior section's content. + // 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: 'TOC Entry' }] }], + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], }; - vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Table of Contents'); - vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1'); + 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 next - // paragraph-flow entry would start section 1. The SDT IS that entry. + // 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); @@ -525,19 +527,22 @@ describe('document-part-object', () => { 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 SDT is not at a section boundary', () => { + 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: 'TOC Entry' }] }], + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], }; - vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Table of Contents'); - vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1'); + 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 yet. + // currentParagraphIndex (2) < startParagraphIndex (5): not at boundary. const state = sectionFixture(5); state.currentParagraphIndex = 2; mockContext.sectionState = state as unknown as NodeHandlerContext['sectionState']; @@ -546,15 +551,17 @@ describe('document-part-object', () => { 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: 'TOC Entry' }] }], + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Page Number' }] }], }; - vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Table of Contents'); - vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1'); + 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' }); @@ -563,6 +570,27 @@ describe('document-part-object', () => { 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 d2667985b4..705e4037ca 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 @@ -44,8 +44,6 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC themeColors, } = context; - emitPendingSectionBreakForParagraph({ sectionState, nextBlockId, blocks, recordBlockKind }); - const docPartGallery = getDocPartGallery(node); const docPartObjectId = getDocPartObjectId(node); const tocInstruction = getNodeInstruction(node); @@ -65,13 +63,18 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC trackedChangesConfig, 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, @@ -88,6 +91,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.ts b/packages/layout-engine/pm-adapter/src/sdt/toc.ts index 05bc5918f5..557cffcc4b 100644 --- a/packages/layout-engine/pm-adapter/src/sdt/toc.ts +++ b/packages/layout-engine/pm-adapter/src/sdt/toc.ts @@ -102,6 +102,7 @@ export function processTocChildren( converters: NestedConverters; converterContext: ConverterContext; themeColors?: ThemeColorPalette; + sectionState?: NodeHandlerContext['sectionState']; }, outputArrays: { blocks: FlowBlock[]; @@ -114,6 +115,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, @@ -141,6 +152,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); 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); } };