Skip to content

Commit 49ab1af

Browse files
chittolinagchittolinacaio-pizzol
authored
SD-2508 - fix: toc not being displayed inside w:sdt (#2850)
* fix: toc not being displayed inside w:sdt * refactor: simplified code * fix(pm-adapter): prefer child node TOC instruction in custom-gallery SDTs Word stores TOC field codes on the child tableOfContents node, not the wrapper SDT. The new branch was passing the wrapper's tocInstruction, which is undefined for Custom TOC docs, silently dropping per-TOC options like '\o "1-3"'. Mirror processTocChildren's existing recursion (toc.ts:162-172) and prefer the child's instruction. Adds 3 unit tests for the new branch covering instruction preference, fallback to the wrapper, and the Array.isArray guard. * fix: add themeColors and sectionState to tableOfContents processing * refactor: separate params into their own variables --------- Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com> Co-authored-by: Caio Pizzol <caio@superdoc.dev>
1 parent 2fa9769 commit 49ab1af

2 files changed

Lines changed: 136 additions & 0 deletions

File tree

packages/layout-engine/pm-adapter/src/sdt/document-part-object.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,116 @@ describe('document-part-object', () => {
438438
});
439439
});
440440

441+
// ==================== Table Children Tests ====================
442+
describe('Table children', () => {
443+
it('should process tableOfContents children for non-"Table of Contents" gallery types (e.g. "Custom Table of Contents")', () => {
444+
const tocNode: PMNode = {
445+
type: 'tableOfContents',
446+
content: [{ type: 'paragraph', content: [] }],
447+
attrs: { instruction: 'TOC \\o "1-3"' },
448+
};
449+
const node: PMNode = {
450+
type: 'documentPartObject',
451+
content: [tocNode],
452+
attrs: { docPartGallery: 'Custom Table of Contents' },
453+
};
454+
455+
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Custom Table of Contents');
456+
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1');
457+
vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
458+
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(undefined as never);
459+
460+
handleDocumentPartObjectNode(node, mockContext);
461+
462+
expect(tocModule.processTocChildren).toHaveBeenCalledOnce();
463+
const callArgs = vi.mocked(tocModule.processTocChildren).mock.calls[0];
464+
expect(callArgs[0]).toEqual(tocNode.content);
465+
expect(callArgs[1]).toMatchObject({ docPartGallery: 'Custom Table of Contents' });
466+
});
467+
468+
it('should prefer the child tableOfContents instruction over the wrapper SDT instruction', () => {
469+
// In real "Custom Table of Contents" docs, Word stores the TOC field codes on
470+
// the child node, not the wrapper SDT. The new branch must read from the child
471+
// first, otherwise per-TOC options like '\\o "1-3"' are silently dropped.
472+
const childInstruction = 'TOC \\o "1-1" \\h \\z \\u';
473+
const tocNode: PMNode = {
474+
type: 'tableOfContents',
475+
content: [{ type: 'paragraph', content: [] }],
476+
attrs: { instruction: childInstruction },
477+
};
478+
const node: PMNode = {
479+
type: 'documentPartObject',
480+
content: [tocNode],
481+
attrs: { docPartGallery: 'Custom Table of Contents' },
482+
};
483+
484+
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Custom Table of Contents');
485+
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1');
486+
// Wrapper SDT has no instruction; child carries the TOC field codes
487+
vi.mocked(metadataModule.getNodeInstruction).mockImplementation((n: PMNode) =>
488+
n.type === 'tableOfContents' ? childInstruction : undefined,
489+
);
490+
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(undefined as never);
491+
492+
handleDocumentPartObjectNode(node, mockContext);
493+
494+
expect(tocModule.processTocChildren).toHaveBeenCalledOnce();
495+
const callArgs = vi.mocked(tocModule.processTocChildren).mock.calls[0];
496+
expect(callArgs[1]).toMatchObject({ tocInstruction: childInstruction });
497+
});
498+
499+
it('should fall back to the wrapper SDT instruction when the child tableOfContents has none', () => {
500+
const wrapperInstruction = 'TOC \\o "1-3"';
501+
const tocNode: PMNode = {
502+
type: 'tableOfContents',
503+
content: [{ type: 'paragraph', content: [] }],
504+
};
505+
const node: PMNode = {
506+
type: 'documentPartObject',
507+
content: [tocNode],
508+
attrs: { docPartGallery: 'Custom Table of Contents' },
509+
};
510+
511+
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Custom Table of Contents');
512+
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1');
513+
// Only the wrapper SDT carries an instruction; the child doesn't
514+
vi.mocked(metadataModule.getNodeInstruction).mockImplementation((n: PMNode) =>
515+
n.type === 'documentPartObject' ? wrapperInstruction : undefined,
516+
);
517+
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(undefined as never);
518+
519+
handleDocumentPartObjectNode(node, mockContext);
520+
521+
expect(tocModule.processTocChildren).toHaveBeenCalledOnce();
522+
const callArgs = vi.mocked(tocModule.processTocChildren).mock.calls[0];
523+
expect(callArgs[1]).toMatchObject({ tocInstruction: wrapperInstruction });
524+
});
525+
526+
it('should not call processTocChildren when the tableOfContents child has no content array', () => {
527+
// Guards against the Array.isArray check that the new branch added; without
528+
// it, processTocChildren would be invoked with a non-array and crash.
529+
const tocNode: PMNode = {
530+
type: 'tableOfContents',
531+
// no content
532+
attrs: { instruction: 'TOC \\o "1-3"' },
533+
};
534+
const node: PMNode = {
535+
type: 'documentPartObject',
536+
content: [tocNode],
537+
attrs: { docPartGallery: 'Custom Table of Contents' },
538+
};
539+
540+
vi.mocked(metadataModule.getDocPartGallery).mockReturnValue('Custom Table of Contents');
541+
vi.mocked(metadataModule.getDocPartObjectId).mockReturnValue('toc-1');
542+
vi.mocked(metadataModule.getNodeInstruction).mockReturnValue(undefined);
543+
vi.mocked(metadataModule.resolveNodeSdtMetadata).mockReturnValue(undefined as never);
544+
545+
handleDocumentPartObjectNode(node, mockContext);
546+
547+
expect(tocModule.processTocChildren).not.toHaveBeenCalled();
548+
});
549+
});
550+
441551
// ==================== Edge Cases ====================
442552
describe('Edge cases', () => {
443553
it('should handle docPartGallery with different case sensitivity', () => {

packages/layout-engine/pm-adapter/src/sdt/document-part-object.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,32 @@ export function handleDocumentPartObjectNode(node: PMNode, context: NodeHandlerC
8989
recordBlockKind?.(block.kind);
9090
}
9191
if (sectionState) sectionState.currentParagraphIndex++;
92+
} else if (child.type === 'tableOfContents' && Array.isArray(child.content)) {
93+
// A nested tableOfContents node (e.g. from a "Custom Table of Contents" SDT where
94+
// the TOC field codes were preprocessed into an sd:tableOfContents element).
95+
// Word stores the TOC field codes on the child node, not the wrapper SDT - prefer
96+
// the child's instruction so per-TOC options aren't lost (mirrors the recursion
97+
// inside processTocChildren in toc.ts).
98+
const metadata = {
99+
docPartGallery: docPartGallery ?? '',
100+
docPartObjectId,
101+
tocInstruction: getNodeInstruction(child) ?? tocInstruction,
102+
sdtMetadata: docPartSdtMetadata,
103+
};
104+
const tocContext = {
105+
nextBlockId,
106+
positions,
107+
bookmarks,
108+
hyperlinkConfig,
109+
enableComments,
110+
trackedChangesConfig,
111+
themeColors,
112+
converters,
113+
converterContext,
114+
sectionState,
115+
};
116+
const output = { blocks, recordBlockKind };
117+
processTocChildren(child.content, metadata, tocContext, output);
92118
}
93119
}
94120
}

0 commit comments

Comments
 (0)