Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19
+73
-2
@@ -89,6 +89,18 @@ function headingStyleForDepth(depth) {
|
|||||||
return `Heading${Math.min(3, depth + 1)}`;
|
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) {
|
function table(rows) {
|
||||||
const cols = Math.max(...rows.map((r) => r.length));
|
const cols = Math.max(...rows.map((r) => r.length));
|
||||||
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
|
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>`;
|
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 = {}) {
|
function exportDocx(ast, outDir, template = {}) {
|
||||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||||
|
|
||||||
const media = []; // { name, data }
|
const media = []; // { name, data }
|
||||||
const rels = []; // relationship XML strings
|
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;
|
let stepImageCount = 0;
|
||||||
|
|
||||||
|
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
|
||||||
|
|
||||||
const iconRelIds = {};
|
const iconRelIds = {};
|
||||||
for (const level of ['info', 'success', 'warn', 'error']) {
|
for (const level of ['info', 'success', 'warn', 'error']) {
|
||||||
const icon = calloutIcon(level);
|
const icon = calloutIcon(level);
|
||||||
@@ -162,7 +231,7 @@ function exportDocx(ast, outDir, template = {}) {
|
|||||||
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
|
||||||
body.push(p(
|
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 }),
|
||||||
`${step.forceNewPage ? '<w:pageBreakBefore/>' : ''}<w:pStyle w:val="${headingStyleForDepth(step.depth)}"/>`
|
headingParagraphProps(step.depth, step.forceNewPage)
|
||||||
));
|
));
|
||||||
|
|
||||||
const { before, rest } = stepContentGroups(step);
|
const { before, rest } = stepContentGroups(step);
|
||||||
@@ -215,6 +284,7 @@ ${body.join('\n')}
|
|||||||
<Default Extension="xml" ContentType="application/xml"/>
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
<Default Extension="png" ContentType="image/png"/>
|
<Default Extension="png" ContentType="image/png"/>
|
||||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
<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"/>
|
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
|
||||||
</Types>`,
|
</Types>`,
|
||||||
},
|
},
|
||||||
@@ -235,6 +305,7 @@ ${rels.join('\n')}
|
|||||||
</Relationships>`,
|
</Relationships>`,
|
||||||
},
|
},
|
||||||
{ name: 'word/settings.xml', data: settingsXml },
|
{ 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 })),
|
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -285,18 +285,20 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (
|
|||||||
|
|
||||||
assert.equal(imageCount, 2);
|
assert.equal(imageCount, 2);
|
||||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
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}`);
|
assert.ok(entries.has(required), `missing ${required}`);
|
||||||
}
|
}
|
||||||
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
|
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
|
||||||
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
|
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
|
||||||
assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml');
|
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.
|
// Every relationship target exists in the package, every embed has a rel.
|
||||||
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
|
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
|
||||||
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
|
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('settings.xml'));
|
||||||
|
assert.ok(relTargets.includes('styles.xml'));
|
||||||
|
|
||||||
const mediaTargets = relTargets.filter((target) => target.startsWith('media/'));
|
const mediaTargets = relTargets.filter((target) => target.startsWith('media/'));
|
||||||
assert.equal(mediaTargets.length, 6);
|
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('TOC \\o "1-3" \\h \\z \\u'));
|
||||||
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
|
assert.ok(docXml.includes('w:pStyle w:val="Heading1"'));
|
||||||
assert.ok(docXml.includes('w:pStyle w:val="Heading2"'));
|
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).
|
// unzip CLI also accepts the package (it is a plain zip).
|
||||||
assert.ok(entries.size >= 6);
|
assert.ok(entries.size >= 6);
|
||||||
|
|||||||
Reference in New Issue
Block a user