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'));