'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 } = require('./common');
/**
* 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,
imageWidthTwips: 9000, // ~15.9cm inside A4 margins
};
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 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 = 0;
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' })));
body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' })));
for (const step of ast.steps) {
const headSize = step.depth > 0 ? 26 : 30;
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
step.forceNewPage ? '' : ''));
emitTextBlocks(step, 'before-description');
if (step.descriptionText) body.push(p(run(step.descriptionText)));
const img = images.get(step.stepId);
if (img) {
relCounter += 1;
const name = `image${relCounter}.png`;
media.push({ name, data: encodePng(img) });
rels.push(``);
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
}
for (const cb of step.codeBlocks) {
body.push(p(run(cb.code || '', { size: 18, font: 'Courier New', color: '1F2937' }),
''));
}
for (const tb of step.tableBlocks || []) {
if (tb.rows && tb.rows.length) body.push(table(tb.rows), p(''));
}
emitTextBlocks(step, 'after-description');
emitTextBlocks(step, 'after-image');
}
function emitTextBlocks(step, position) {
for (const tb of step.textBlocks.filter((b) => b.position === position)) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
''
));
}
}
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')}
`,
},
...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: media.length };
}
module.exports = { exportDocx, DEFAULT_TEMPLATE };