Fix DOCX table of contents to render real entries instead of a placeholder
Template tests / tests (push) Successful in 1m44s

Previously the "Contents" page just emitted a TOC field whose cached
result was the literal text "Update contents in Word" - so unless a
user manually ran Update Field in Word, the TOC showed nothing useful
(and many viewers never run that update at all).

Now each step heading is wrapped in a bookmark, and the TOC is built
as real TOC1/2/3-styled paragraphs with hyperlinks to those bookmarks
and PAGEREF fields for page numbers, matching the entry list other
exporters already produce via tocEntries(). The whole thing stays
wrapped in the original `TOC \o "1-3" \h \z \u` field so Word can still
refresh page numbers, but the document is correct on first open even
without that step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-06-15 17:26:50 -05:00
co-authored by Claude Sonnet 4.6
parent a201b05c9e
commit f37a2ec141
2 changed files with 116 additions and 11 deletions
+76 -11
View File
@@ -6,7 +6,7 @@ const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { guideMetaLines, guideSummary } = require('./document-layout');
const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout');
/**
* DOCX exporter: WordprocessingML built directly (no dependency), one
@@ -70,14 +70,52 @@ function pageBreak() {
return p('<w:r><w:br w:type="page"/></w:r>');
}
function tocFieldParagraph() {
return p([
'<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>',
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>',
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>',
'<w:r><w:rPr><w:i/></w:rPr><w:t xml:space="preserve">Update contents in Word</w:t></w:r>',
'<w:r><w:fldChar w:fldCharType="end"/></w:r>',
].join(''));
// Width (in twips) of the text column inside the A4 page margins used by
// <w:sectPr> below (11906 - 1134*2), i.e. where TOC page-number tabs land.
const TOC_TAB_POS = 9638;
/** Bookmark name anchoring a step's heading, referenced by its TOC entry. */
function bookmarkName(step) {
return `toc_${String(step.number).replace(/\./g, '_')}`;
}
/** A `PAGEREF <anchor>` field, cached as "1" until Word recalculates it. */
function pageRefField(anchor) {
return '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
`<w:r><w:instrText xml:space="preserve"> PAGEREF ${anchor} \\h </w:instrText></w:r>` +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
'<w:r><w:t>1</w:t></w:r>' +
'<w:r><w:fldChar w:fldCharType="end"/></w:r>';
}
/** One TOC line: hyperlink to the step's heading, dot leader, page number. */
function tocEntryContent(entry) {
const anchor = bookmarkName(entry.step);
return `<w:hyperlink w:anchor="${anchor}">${run(`${entry.number}. ${entry.title}`, { size: 20 })}</w:hyperlink>` +
'<w:r><w:tab/></w:r>' +
pageRefField(anchor);
}
/**
* The TOC as real, navigable entries (one per step) rather than a bare
* "Update contents in Word" placeholder, so the table is correct on first
* open. Still wrapped in a `TOC` field (spanning the first..last paragraph)
* so Word can refresh page numbers via Update Field / the updateFields prompt.
*/
function tocFieldParagraphs(ast) {
const entries = tocEntries(ast);
const beginField = '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>' +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>';
const endField = '<w:r><w:fldChar w:fldCharType="end"/></w:r>';
return entries.map((entry, i) => {
const pPr = `<w:pStyle w:val="TOC${Math.min(3, Math.max(1, entry.depth + 1))}"/>` +
`<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>`;
const lead = i === 0 ? beginField : '';
const trail = i === entries.length - 1 ? endField : '';
return `<w:p><w:pPr>${pPr}</w:pPr>${lead}${tocEntryContent(entry)}${trail}</w:p>`;
});
}
function headingStyleForDepth(depth) {
@@ -136,6 +174,25 @@ function stylesXml() {
</w:rPr>
</w:style>`;
const tocStyle = (level) => `
<w:style w:type="paragraph" w:styleId="TOC${level}">
<w:name w:val="toc ${level}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:autoRedefine/>
<w:uiPriority w:val="39"/>
<w:unhideWhenUsed/>
<w:pPr>
<w:spacing w:after="60"/>
<w:ind w:left="${(level - 1) * 360}"/>
<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>
</w:pPr>
<w:rPr>
<w:sz w:val="20"/>
<w:szCs w:val="20"/>
</w:rPr>
</w:style>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
@@ -165,6 +222,9 @@ function stylesXml() {
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
${tocStyle(1)}
${tocStyle(2)}
${tocStyle(3)}
</w:styles>`;
}
@@ -175,6 +235,7 @@ function exportDocx(ast, outDir, template = {}) {
const media = []; // { name, data }
const rels = []; // relationship XML strings
let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
let bookmarkCounter = 0;
let stepImageCount = 0;
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
@@ -195,7 +256,7 @@ function exportDocx(ast, outDir, template = {}) {
run('Contents', { bold: true, size: 28 }),
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
));
body.push(tocFieldParagraph());
body.push(...tocFieldParagraphs(ast));
body.push(pageBreak());
}
@@ -211,8 +272,12 @@ function exportDocx(ast, outDir, template = {}) {
for (const step of ast.steps) {
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
const bookmarkId = ++bookmarkCounter;
const anchor = bookmarkName(step);
body.push(p(
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
headingParagraphProps(step.depth, step.forceNewPage)
));
+40
View File
@@ -339,6 +339,46 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
assert.ok(entries.size >= 6);
});
test('DOCX export: TOC contains a real, navigable entry per step', (t) => {
const { ast, root } = fixtureAst(t, 'docxtoc');
const { file } = exportDocx(ast, path.join(root, 'out'));
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
const docXml = entries.get('word/document.xml').toString('utf8');
const stylesXml = entries.get('word/styles.xml').toString('utf8');
// One TOC entry per step, in document order, hyperlinked by step number.
const tocLinks = [...docXml.matchAll(/<w:hyperlink w:anchor="([^"]+)">(.*?)<\/w:hyperlink>/g)]
.map((m) => ({ anchor: m[1], text: /<w:t[^>]*>([^<]*)<\/w:t>/.exec(m[2])[1] }));
assert.deepEqual(tocLinks.map((e) => e.text), ast.steps.map((step) => `${step.number}. ${step.title || 'Untitled step'}`));
// Each TOC entry's anchor matches a bookmark wrapping that step's heading.
for (const { anchor } of tocLinks) {
assert.match(docXml, new RegExp(`<w:bookmarkStart w:id="\\d+" w:name="${anchor}"/>`), `bookmark for ${anchor}`);
}
// Bookmark ids are unique.
const bookmarkIds = [...docXml.matchAll(/<w:bookmarkStart w:id="(\d+)"/g)].map((m) => m[1]);
assert.equal(new Set(bookmarkIds).size, bookmarkIds.length);
// Page numbers are real PAGEREF fields (one per entry), not static text.
const pageRefs = [...docXml.matchAll(/PAGEREF (\S+) \\h/g)].map((m) => m[1]);
assert.deepEqual(pageRefs, tocLinks.map((e) => e.anchor));
// The outer TOC field still wraps the whole table (so Word can refresh it).
assert.ok(docXml.includes('w:fldChar w:fldCharType="begin" w:dirty="true"'));
assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u'));
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
assert.ok(docXml.includes('w:outlineLvl w:val="0"'));
assert.ok(docXml.includes('w:outlineLvl w:val="1"'));
// TOC entry styles are defined and indent deeper levels further.
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC1"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC2"'));
assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="TOC3"'));
assertWellFormedXml(docXml, 'document.xml');
});
test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => {
const { ast, root } = fixtureAst(t, 'pptx');
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));