diff --git a/exporters/docx.js b/exporters/docx.js index bd7d445..c21469e 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -89,6 +89,18 @@ function headingStyleForDepth(depth) { return `Heading${Math.min(3, depth + 1)}`; } +function headingOutlineLevelForDepth(depth) { + return Math.min(2, Math.max(0, depth)); +} + +function headingParagraphProps(depth, forceNewPage = false) { + const parts = []; + if (forceNewPage) parts.push(''); + parts.push(``); + parts.push(``); + return parts.join(''); +} + function table(rows) { const cols = Math.max(...rows.map((r) => r.length)); const grid = `${''.repeat(cols)}`; @@ -106,15 +118,72 @@ function table(rows) { return `${borders}${grid}${body}`; } +function stylesXml() { + const headingStyle = (styleId, name, outlineLvl, size, color) => ` + + + + + + + + + + + + + + + + + + + + `; + + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + ${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')} + ${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')} + ${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')} +`; +} + function exportDocx(ast, outDir, template = {}) { const tpl = { ...DEFAULT_TEMPLATE, ...template }; const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const media = []; // { name, data } const rels = []; // relationship XML strings - let relCounter = 1; // rId1 reserved for settings.xml + let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml let stepImageCount = 0; + rels.push(``); + const iconRelIds = {}; for (const level of ['info', 'success', 'warn', 'error']) { const icon = calloutIcon(level); @@ -162,7 +231,7 @@ function exportDocx(ast, outDir, template = {}) { const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; body.push(p( run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), - `${step.forceNewPage ? '' : ''}` + headingParagraphProps(step.depth, step.forceNewPage) )); const { before, rest } = stepContentGroups(step); @@ -215,6 +284,7 @@ ${body.join('\n')} + `, }, @@ -235,6 +305,7 @@ ${rels.join('\n')} `, }, { name: 'word/settings.xml', data: settingsXml }, + { name: 'word/styles.xml', data: stylesXml() }, ...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })), ]; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index b8d97e0..80f69e6 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -285,18 +285,20 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( assert.equal(imageCount, 2); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); - for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml']) { + for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml', 'word/styles.xml']) { assert.ok(entries.has(required), `missing ${required}`); } assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml'); assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types'); assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml'); + assertWellFormedXml(entries.get('word/styles.xml').toString('utf8'), 'styles.xml'); // Every relationship target exists in the package, every embed has a rel. const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8'); const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]); - assert.equal(relTargets.length, 7); + assert.equal(relTargets.length, 8); assert.ok(relTargets.includes('settings.xml')); + assert.ok(relTargets.includes('styles.xml')); const mediaTargets = relTargets.filter((target) => target.startsWith('media/')); assert.equal(mediaTargets.length, 6); @@ -324,6 +326,13 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( 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"')); + + const stylesXml = entries.get('word/styles.xml').toString('utf8'); + assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading1"')); + assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading2"')); + assert.ok(stylesXml.includes('w:style w:type="paragraph" w:styleId="Heading3"')); // unzip CLI also accepts the package (it is a plain zip). assert.ok(entries.size >= 6);