docx headings for toc

This commit is contained in:
2026-06-15 14:37:02 -05:00
parent f79bbfed9f
commit d67afd2bc9
2 changed files with 84 additions and 4 deletions
+73 -2
View File
@@ -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('<w:pageBreakBefore/>');
parts.push(`<w:pStyle w:val="${headingStyleForDepth(depth)}"/>`);
parts.push(`<w:outlineLvl w:val="${headingOutlineLevelForDepth(depth)}"/>`);
return parts.join('');
}
function table(rows) {
const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
@@ -106,15 +118,72 @@ function table(rows) {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
}
function stylesXml() {
const headingStyle = (styleId, name, outlineLvl, size, color) => `
<w:style w:type="paragraph" w:styleId="${styleId}">
<w:name w:val="${name}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:uiPriority w:val="${9 + outlineLvl}"/>
<w:qFormat/>
<w:unhideWhenUsed/>
<w:pPr>
<w:keepNext/>
<w:keepLines/>
<w:spacing w:before="${outlineLvl === 0 ? 360 : outlineLvl === 1 ? 240 : 180}" w:after="120"/>
<w:outlineLvl w:val="${outlineLvl}"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:sz w:val="${size}"/>
<w:szCs w:val="${size}"/>
<w:color w:val="${color}"/>
</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>
<w:rPrDefault>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr>
<w:spacing w:after="160"/>
</w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:uiPriority w:val="1"/>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:style>
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
</w:styles>`;
}
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(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
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 ? '<w:pageBreakBefore/>' : ''}<w:pStyle w:val="${headingStyleForDepth(step.depth)}"/>`
headingParagraphProps(step.depth, step.forceNewPage)
));
const { before, rest } = stepContentGroups(step);
@@ -215,6 +284,7 @@ ${body.join('\n')}
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`,
},
@@ -235,6 +305,7 @@ ${rels.join('\n')}
</Relationships>`,
},
{ 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 })),
];
+11 -2
View File
@@ -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 &quot;1-3&quot; \\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);