diff --git a/exporters/docx.js b/exporters/docx.js index bf845ea..3b44e00 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -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(''); } -function tocFieldParagraph() { - return p([ - '', - ' TOC \\o "1-3" \\h \\z \\u ', - '', - 'Update contents in Word', - '', - ].join('')); +// Width (in twips) of the text column inside the A4 page margins used by +// 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 ` field, cached as "1" until Word recalculates it. */ +function pageRefField(anchor) { + return '' + + ` PAGEREF ${anchor} \\h ` + + '' + + '1' + + ''; +} + +/** One TOC line: hyperlink to the step's heading, dot leader, page number. */ +function tocEntryContent(entry) { + const anchor = bookmarkName(entry.step); + return `${run(`${entry.number}. ${entry.title}`, { size: 20 })}` + + '' + + 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 = '' + + ' TOC \\o "1-3" \\h \\z \\u ' + + ''; + const endField = ''; + + return entries.map((entry, i) => { + const pPr = `` + + ``; + const lead = i === 0 ? beginField : ''; + const trail = i === entries.length - 1 ? endField : ''; + return `${pPr}${lead}${tocEntryContent(entry)}${trail}`; + }); } function headingStyleForDepth(depth) { @@ -136,6 +174,25 @@ function stylesXml() { `; + const tocStyle = (level) => ` + + + + + + + + + + + + + + + + + `; + return ` @@ -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)} `; } @@ -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(``); @@ -195,7 +256,7 @@ function exportDocx(ast, outDir, template = {}) { run('Contents', { bold: true, size: 28 }), '' )); - 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 }), + `` + + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) + + ``, headingParagraphProps(step.depth, step.forceNewPage) )); diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index a82659c..848d75e 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -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>/g)] + .map((m) => ({ anchor: m[1], text: /]*>([^<]*)<\/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(``), `bookmark for ${anchor}`); + } + // Bookmark ids are unique. + const bookmarkIds = [...docXml.matchAll(/ 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'));