'use strict';
const fs = require('node:fs');
const path = require('node:path');
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 raster = require('../core/raster');
/**
* DOCX exporter: WordprocessingML built directly (no dependency), one
* heading + description + screenshot per step, text blocks, code blocks
* (Courier), and tables.
*/
const DEFAULT_TEMPLATE = {
includeImages: true,
includeToc: true,
imageWidthTwips: 9000, // ~15.9cm inside A4 margins
};
// Callout styling per text-block level, matching the colors used in the
// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note
success: { fill: 'ECFDF5', color: '047857' }, // green — Tip
warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning
error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important
};
const EMU_PER_PX = 9525; // 96 dpi
function p(children, props = '') {
return `${props ? `${props}` : ''}${children}`;
}
function run(text, { bold = false, size = 22, font = '', color = '' } = {}) {
const rpr = [
bold ? '' : '',
``,
font ? `` : '',
color ? `` : '',
].join('');
const lines = String(text).split('\n');
return lines.map((line, i) =>
`${i > 0 ? '' : ''}${rpr}${escapeXml(line)}`
).join('');
}
function drawing(relId, widthPx, heightPx, maxWidthTwips) {
// scale to maxWidth (twips -> px at 96dpi: twips/15)
const maxWpx = maxWidthTwips / 15;
let w = widthPx, h = heightPx;
if (w > maxWpx) { h = Math.round((h * maxWpx) / w); w = Math.round(maxWpx); }
const cx = Math.round(w * EMU_PER_PX), cy = Math.round(h * EMU_PER_PX);
return `` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
``;
}
function calloutIcon(level) {
const img = raster.createImage(24, 24, [0, 0, 0, 0]);
const fill = {
info: [37, 99, 235, 255],
success: [16, 185, 129, 255],
warn: [245, 158, 11, 255],
error: [239, 68, 68, 255],
}[level] || [37, 99, 235, 255];
raster.fillOval(img, 1, 1, 22, 22, fill);
const glyph = level === 'success' ? 'v' : level === 'warn' ? '!' : level === 'error' ? 'x' : 'i';
raster.drawTextCentered(img, 12, 13, glyph, 12, [255, 255, 255, 255]);
return img;
}
function pageBreak() {
return p('');
}
function headingStyleForDepth(depth) {
return `Heading${Math.min(3, depth + 1)}`;
}
function table(rows) {
const cols = Math.max(...rows.map((r) => r.length));
const grid = `${''.repeat(cols)}`;
const borders = '' +
['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((s) => ``).join('') +
'';
const body = rows.map((row, ri) => {
const cells = [];
for (let c = 0; c < cols; c++) {
cells.push(`${p(run(row[c] ?? '', { bold: ri === 0, size: 20 }))}`);
}
return `${cells.join('')}`;
}).join('');
return `${borders}${grid}${body}`;
}
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 stepImageCount = 0;
const iconRelIds = {};
for (const level of ['info', 'success', 'warn', 'error']) {
const icon = calloutIcon(level);
const relId = ++relCounter;
iconRelIds[level] = relId;
const name = `callout-${level}.png`;
media.push({ name, data: encodePng(icon) });
rels.push(``);
}
const body = [];
body.push(p(
run(ast.guide.title, { bold: true, size: 48 }),
''
));
if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' })));
for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' })));
body.push(p(run(guideSummary(ast), { size: 18, color: '888888' })));
body.push(pageBreak());
if (tpl.includeToc && ast.steps.length > 1) {
body.push(p(
run('Contents', { bold: true, size: 28 }),
''
));
body.push(p(
`Update contents in Word`
));
body.push(pageBreak());
}
const emitTextBlock = (tb) => {
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
const iconRelId = iconRelIds[tb.level] || iconRelIds.info;
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
`${drawing(iconRelId, 16, 16, 240)}${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`,
``
));
};
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;
body.push(p(
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
`${step.forceNewPage ? '' : ''}`
));
const { before, rest } = stepContentGroups(step);
for (const tb of before) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
const img = images.get(step.stepId);
if (img) {
const relId = ++relCounter;
const name = `image${relCounter}.png`;
media.push({ name, data: encodePng(img) });
rels.push(``);
body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
stepImageCount += 1;
}
for (const block of rest) {
if (block.kind === 'text') {
emitTextBlock(block);
} else if (block.kind === 'code') {
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
''));
} else if (block.kind === 'table') {
if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
}
}
}
const settingsXml = `
`;
const documentXml = `
${body.join('\n')}
`;
const entries = [
{
name: '[Content_Types].xml',
data: `
`,
},
{
name: '_rels/.rels',
data: `
`,
},
{ name: 'word/document.xml', data: documentXml },
{
name: 'word/_rels/document.xml.rels',
data: `
${rels.join('\n')}
`,
},
{ name: 'word/settings.xml', data: settingsXml },
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
];
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.docx`);
fs.writeFileSync(file, zipSync(entries));
return { file, imageCount: stepImageCount };
}
module.exports = { exportDocx, DEFAULT_TEMPLATE };