'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, tocEntries } = require('./document-layout'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one * heading per step with positioned text blocks around the description and * screenshot, plus 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 pageBreak() { return p(''); } // Width (in twips) of the text column inside the A4 page margins used by // below (11906 - 1134*2), i.e. where TOC page-number tabs land. const TOC_TAB_POS = 9638; /** Bookmark name anchoring a step's heading, referenced by its TOC entry. */ function bookmarkName(step) { return `toc_${String(step.number).replace(/\./g, '_')}`; } /** A `PAGEREF ` field, cached as "1" until Word recalculates it. */ function pageRefField(anchor) { return '' + ` PAGEREF ${anchor} \\h ` + '' + '1' + ''; } /** One TOC line: hyperlink to the step's heading, dot leader, page number. */ function tocEntryContent(entry) { const anchor = bookmarkName(entry.step); return `${run(`${entry.number}. ${entry.title}`, { size: 20 })}` + '' + pageRefField(anchor); } /** * The TOC as real, navigable entries (one per step) rather than a bare * "Update contents in Word" placeholder, so the table is correct on first * open. Still wrapped in a `TOC` field (spanning the first..last paragraph) * so Word can refresh page numbers via Update Field / the updateFields prompt. */ function tocFieldParagraphs(ast) { const entries = tocEntries(ast); const beginField = '' + ' TOC \\o "1-3" \\h \\z \\u ' + ''; const endField = ''; return entries.map((entry, i) => { const pPr = `` + ``; const lead = i === 0 ? beginField : ''; const trail = i === entries.length - 1 ? endField : ''; return `${pPr}${lead}${tocEntryContent(entry)}${trail}`; }); } 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)}`; 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 stylesXml() { const headingStyle = (styleId, name, outlineLvl, size, color) => ` `; const tocStyle = (level) => ` `; return ` ${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')} ${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')} ${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')} ${tocStyle(1)} ${tocStyle(2)} ${tocStyle(3)} `; } 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; rId2 for styles.xml let bookmarkCounter = 0; let stepImageCount = 0; 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(...tocFieldParagraphs(ast)); body.push(pageBreak()); } const emitTextBlock = (tb) => { const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; body.push(p( `${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; const bookmarkId = ++bookmarkCounter; const anchor = bookmarkName(step); const { beforeTitle, afterTitle, beforeDescription, afterDescription, beforeImage, afterImage, rest, } = stepContentGroups(step); for (const tb of beforeTitle) emitTextBlock(tb); body.push(p( `` + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) + ``, headingParagraphProps(step.depth, step.forceNewPage) )); for (const tb of afterTitle) emitTextBlock(tb); for (const tb of beforeDescription) emitTextBlock(tb); if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' }))); for (const tb of afterDescription) emitTextBlock(tb); for (const tb of beforeImage) emitTextBlock(tb); 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 tb of afterImage) emitTextBlock(tb); 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 }, { name: 'word/styles.xml', data: stylesXml() }, ...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 };