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
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
*/
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -75,6 +92,7 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
blocks.push(block);
recordBlockKind?.(block.kind);
}
if (sectionState) sectionState.currentParagraphIndex++;
}
}
}
Expand Down
Loading
Loading