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