diff --git a/core/templates.js b/core/templates.js new file mode 100644 index 0000000..87e4c94 --- /dev/null +++ b/core/templates.js @@ -0,0 +1,99 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { zipSync, unzipSync } = require('./zip'); +const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./util'); + +/** + * Per-format export templates stored under settings/templates//. + * Templates are plain JSON option objects merged over each exporter's + * defaults, shareable as .sfglt zip files. + */ + +const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; + +class TemplateManager { + constructor(templatesDir) { + this.dir = templatesDir; + } + + formatDir(format) { + if (!FORMATS.includes(format)) throw new Error(`unknown export format: ${format}`); + return path.join(this.dir, format); + } + + fileFor(format, name) { + if (!/^[A-Za-z0-9 _-]+$/.test(name)) throw new Error(`bad template name: ${name}`); + return path.join(this.formatDir(format), `${name}.template.json`); + } + + list(format) { + const dir = this.formatDir(format); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((f) => f.endsWith('.template.json')) + .map((f) => f.slice(0, -'.template.json'.length)) + .sort(); + } + + load(format, name) { + const file = this.fileFor(format, name); + if (!fs.existsSync(file)) return null; + return readJsonSync(file).options || {}; + } + + save(format, name, options) { + writeJsonSync(this.fileFor(format, name), { + schemaVersion: 1, format, name, updatedAt: nowIso(), options, + }); + return name; + } + + rename(format, oldName, newName) { + const src = this.fileFor(format, oldName); + if (!fs.existsSync(src)) throw new Error(`template not found: ${oldName}`); + const options = readJsonSync(src).options; + this.save(format, newName, options); + fs.rmSync(src); + } + + duplicate(format, name, copyName) { + const options = this.load(format, name); + if (options === null) throw new Error(`template not found: ${name}`); + return this.save(format, copyName || `${name} copy`, options); + } + + remove(format, name) { + fs.rmSync(this.fileFor(format, name), { force: true }); + } + + /** Export one template as a shareable .sfglt file. */ + exportTemplate(format, name, destFile) { + const options = this.load(format, name); + if (options === null) throw new Error(`template not found: ${name}`); + const manifest = { format: 'stepforge-template-archive', formatVersion: 1, exportFormat: format, name, exportedAt: nowIso() }; + atomicWriteFileSync(destFile, zipSync([ + { name: 'manifest.json', data: JSON.stringify(manifest, null, 2) }, + { name: 'template.json', data: JSON.stringify({ options }, null, 2) }, + ])); + return destFile; + } + + /** Import a .sfglt file; returns { format, name }. */ + importTemplate(file) { + const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); + if (!entries.has('manifest.json') || !entries.has('template.json')) { + throw new Error('not a StepForge template archive'); + } + const manifest = JSON.parse(entries.get('manifest.json').toString('utf8')); + if (manifest.format !== 'stepforge-template-archive') throw new Error('unsupported template archive'); + const { options } = JSON.parse(entries.get('template.json').toString('utf8')); + let name = manifest.name || 'imported'; + if (this.list(manifest.exportFormat).includes(name)) name = `${name} (imported)`; + this.save(manifest.exportFormat, name, options || {}); + return { format: manifest.exportFormat, name }; + } +} + +module.exports = { TemplateManager, FORMATS }; diff --git a/core/util.js b/core/util.js index c2e9e00..65b9d6e 100644 --- a/core/util.js +++ b/core/util.js @@ -98,7 +98,8 @@ function slugify(text, fallback = 'untitled') { const slug = String(text || '') .normalize('NFKD') .replace(/[̀-ͯ]/g, '') - .replace(/[^a-zA-Z0-9._ -]+/g, '') + .toLowerCase() + .replace(/[^a-z0-9._ -]+/g, '') .trim() .replace(/\s+/g, '-') .slice(0, 80); diff --git a/exporters/docx.js b/exporters/docx.js new file mode 100644 index 0000000..c67204f --- /dev/null +++ b/exporters/docx.js @@ -0,0 +1,171 @@ +'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 }; diff --git a/exporters/gif.js b/exporters/gif.js new file mode 100644 index 0000000..0dbfab1 --- /dev/null +++ b/exporters/gif.js @@ -0,0 +1,87 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { encodeGif } = require('../core/gif'); +const raster = require('../core/raster'); +const { guideSlug, renderAllImages } = require('./common'); + +/** + * Animated GIF exporter: one frame per image step, optional title card, + * optional title overlay and progress bar per frame. + */ + +const DEFAULT_TEMPLATE = { + width: 800, + frameDelayCs: 220, + loop: 0, + titleCard: true, + titleOverlay: true, + progressBar: true, + background: '#FFFFFF', +}; + +function exportGifGuide(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + const images = renderAllImages(ast); + const stepsWithImages = ast.steps.filter((s) => images.has(s.stepId)); + if (!stepsWithImages.length) throw new Error('gif export: guide has no image steps'); + + // Frame height derives from the median aspect ratio so most shots fit. + const ratios = stepsWithImages.map((s) => { + const img = images.get(s.stepId); + return img.height / img.width; + }).sort((a, b) => a - b); + const ratio = ratios[Math.floor(ratios.length / 2)]; + const W = tpl.width; + const H = Math.round(W * ratio) + (tpl.titleOverlay ? 36 : 0) + (tpl.progressBar ? 8 : 0); + const bg = raster.parseColor(tpl.background, [255, 255, 255, 255]); + + const frames = []; + + if (tpl.titleCard) { + const card = raster.createImage(W, H, [31, 41, 55, 255]); + raster.drawTextCentered(card, W / 2, H / 2 - 14, fitText(ast.guide.title, W, 22), 22, [255, 255, 255, 255]); + raster.drawTextCentered(card, W / 2, H / 2 + 18, `${stepsWithImages.length} steps`, 12, [156, 163, 175, 255]); + frames.push(card); + } + + let n = 0; + for (const step of stepsWithImages) { + n += 1; + const frame = raster.createImage(W, H, bg); + const headerH = tpl.titleOverlay ? 36 : 0; + const footerH = tpl.progressBar ? 8 : 0; + const availH = H - headerH - footerH; + + const src = images.get(step.stepId); + let dw = W, dh = Math.round((src.height / src.width) * W); + if (dh > availH) { dh = availH; dw = Math.round((src.width / src.height) * availH); } + const scaled = raster.resize(src, dw, dh); + raster.drawImage(frame, scaled, Math.round((W - dw) / 2), headerH + Math.round((availH - dh) / 2)); + + if (tpl.titleOverlay) { + raster.fillRect(frame, 0, 0, W, headerH, [31, 41, 55, 255]); + raster.drawText(frame, 10, 10, fitText(`${step.number}. ${step.title || ''}`, W - 20, 14), 14, [255, 255, 255, 255]); + } + if (tpl.progressBar) { + raster.fillRect(frame, 0, H - footerH, W, footerH, [229, 231, 235, 255]); + raster.fillRect(frame, 0, H - footerH, Math.round((W * n) / stepsWithImages.length), footerH, [37, 99, 235, 255]); + } + frames.push(frame); + } + + fs.mkdirSync(outDir, { recursive: true }); + const file = path.join(outDir, `${guideSlug(ast)}.gif`); + fs.writeFileSync(file, encodeGif(frames, { delayCs: tpl.frameDelayCs, loop: tpl.loop })); + return { file, frameCount: frames.length, width: W, height: H }; +} + +function fitText(text, maxWidthPx, sizePx) { + const scale = Math.max(1, Math.round(sizePx / 8)); + const maxChars = Math.max(4, Math.floor(maxWidthPx / (8 * scale)) - 1); + const t = String(text); + return t.length > maxChars ? `${t.slice(0, maxChars - 1)}…` : t; +} + +module.exports = { exportGifGuide, DEFAULT_TEMPLATE }; diff --git a/exporters/image-bundle.js b/exporters/image-bundle.js new file mode 100644 index 0000000..6842112 --- /dev/null +++ b/exporters/image-bundle.js @@ -0,0 +1,54 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { guideSlug, writeStepImages } = require('./common'); +const raster = require('../core/raster'); +const { decodePng, encodePng } = require('../core/png'); + +/** + * Image bundle exporter: one annotated PNG per image step plus a + * metadata.json describing the guide, with an optional watermark overlay. + */ + +const DEFAULT_TEMPLATE = { + watermarkPath: '', // PNG overlaid bottom-right when set + watermarkOpacity: 0.6, +}; + +function exportImageBundle(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + fs.mkdirSync(outDir, { recursive: true }); + const images = writeStepImages(ast, outDir); + + if (tpl.watermarkPath && fs.existsSync(tpl.watermarkPath)) { + const mark = decodePng(fs.readFileSync(tpl.watermarkPath)); + const alpha = Math.round(255 * Math.max(0, Math.min(1, tpl.watermarkOpacity))); + for (const { relPath } of images.values()) { + const file = path.join(outDir, relPath); + const img = decodePng(fs.readFileSync(file)); + const faded = raster.cloneImage(mark); + for (let i = 3; i < faded.data.length; i += 4) { + faded.data[i] = Math.round((faded.data[i] * alpha) / 255); + } + raster.drawImage(img, faded, Math.max(0, img.width - mark.width - 12), Math.max(0, img.height - mark.height - 12)); + fs.writeFileSync(file, encodePng(img)); + } + } + + const meta = { + format: 'stepforge-image-bundle', + version: 1, + guide: { title: ast.guide.title, generatedAt: ast.generatedAt }, + steps: ast.steps.map((step) => ({ + number: step.number, + title: step.title, + image: images.get(step.stepId)?.relPath || null, + })), + }; + const metaFile = path.join(outDir, `${guideSlug(ast)}-bundle.json`); + fs.writeFileSync(metaFile, JSON.stringify(meta, null, 2) + '\n'); + return { file: metaFile, imageCount: images.size }; +} + +module.exports = { exportImageBundle, DEFAULT_TEMPLATE }; diff --git a/exporters/index.js b/exporters/index.js new file mode 100644 index 0000000..897feb3 --- /dev/null +++ b/exporters/index.js @@ -0,0 +1,31 @@ +'use strict'; + +const { exportJson } = require('./json'); +const { exportMarkdown } = require('./markdown'); +const { exportHtmlSimple, exportHtmlRich } = require('./html'); +const { exportPdf } = require('./pdf'); +const { exportGifGuide } = require('./gif'); +const { exportImageBundle } = require('./image-bundle'); +const { exportDocx } = require('./docx'); +const { exportPptx } = require('./pptx'); + +/** Unified dispatch: format id -> exporter(ast, outDir, templateOptions). */ +const EXPORTERS = { + json: exportJson, + markdown: exportMarkdown, + 'html-simple': exportHtmlSimple, + 'html-rich': exportHtmlRich, + pdf: exportPdf, + gif: exportGifGuide, + 'image-bundle': exportImageBundle, + docx: exportDocx, + pptx: exportPptx, +}; + +function runExport(format, ast, outDir, templateOptions = {}) { + const exporter = EXPORTERS[format]; + if (!exporter) throw new Error(`unknown export format: ${format}`); + return exporter(ast, outDir, templateOptions); +} + +module.exports = { EXPORTERS, runExport }; diff --git a/exporters/pdf.js b/exporters/pdf.js new file mode 100644 index 0000000..89c862c --- /dev/null +++ b/exporters/pdf.js @@ -0,0 +1,169 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { PdfBuilder } = require('../core/pdf'); +const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common'); +const { htmlToText } = require('../core/util'); + +/** + * PDF exporter: cover block, optional TOC, one section per step with the + * annotated screenshot, text blocks, code blocks, and tables. Generated + * natively from the Render AST (see build/agent_audit.md for the fallback + * rationale). Bookmarks navigate to each step. + */ + +const PAGE_SIZES = { + a4: { width: 595.28, height: 841.89 }, + letter: { width: 612, height: 792 }, +}; + +const DEFAULT_TEMPLATE = { + pageSize: 'a4', + margin: 48, + includeCover: true, + includeToc: true, + includeImages: true, + imageMaxHeightRatio: 0.55, // of usable page height + accentColor: [37, 99, 235], +}; + +function exportPdf(ast, outDir, template = {}) { + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + const size = PAGE_SIZES[tpl.pageSize] || PAGE_SIZES.a4; + const pdf = new PdfBuilder({ pageWidth: size.width, pageHeight: size.height }); + const M = tpl.margin; + const usableW = size.width - 2 * M; + const usableH = size.height - 2 * M; + const images = tpl.includeImages ? renderAllImages(ast) : new Map(); + + let y = M; + const ensure = (needed) => { + if (y + needed > size.height - M) { + pdf.addPage(); + y = M; + } + }; + const writeLines = (text, { size: fs_ = 10.5, font = 'F1', color = [0, 0, 0], leading = 1.35, indent = 0 } = {}) => { + for (const line of pdf.wrapText(text, fs_, usableW - indent, font)) { + ensure(fs_ * leading); + pdf.text(line, M + indent, y, { size: fs_, font, color }); + y += fs_ * leading; + } + }; + + pdf.addPage(); + + if (tpl.includeCover) { + y = M + usableH * 0.18; + pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor }); + y += 6; + writeLines(ast.guide.title, { size: 26, font: 'F2' }); + y += 8; + if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] }); + y += 14; + writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); + pdf.addPage(); + y = M; + } + + if (tpl.includeToc && ast.steps.length > 1) { + writeLines('Contents', { size: 16, font: 'F2' }); + y += 4; + for (const step of ast.steps) { + writeLines(`${step.number}. ${step.title || 'Untitled step'}`, { + size: 10.5, indent: 14 * step.depth, + }); + } + pdf.addPage(); + y = M; + } + + let first = true; + for (const step of ast.steps) { + if (step.forceNewPage && !first) { pdf.addPage(); y = M; } + first = false; + ensure(40); + pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); + const headSize = step.depth > 0 ? 12 : 14; + writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); + pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); + y += 8; + + emitBlocks(step, 'before-description'); + if (step.descriptionText) { writeLines(step.descriptionText); y += 4; } + + const img = images.get(step.stepId); + if (img) { + const maxH = usableH * tpl.imageMaxHeightRatio; + let w = usableW; + let h = (img.height / img.width) * w; + if (h > maxH) { h = maxH; w = (img.width / img.height) * h; } + ensure(h + 6); + pdf.image(img, M, y, w, h); + y += h + 10; + } + + for (const cb of step.codeBlocks) { + const lines = String(cb.code || '').split('\n'); + const lineH = 9 * 1.3; + ensure(Math.min(lines.length, 4) * lineH + 12); + const boxH = lines.length * lineH + 10; + pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] }); + y += 6; + for (const line of lines) { + ensure(lineH); + pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] }); + y += lineH; + } + y += 10; + } + + for (const tb of step.tableBlocks || []) { + if (!tb.rows || !tb.rows.length) continue; + const cols = Math.max(...tb.rows.map((r) => r.length)); + const colW = usableW / cols; + for (let r = 0; r < tb.rows.length; r++) { + const rowH = 16; + ensure(rowH + 2); + if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] }); + pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 }); + for (let c = 0; c < cols; c++) { + pdf.text(String(tb.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, { + size: 9, font: r === 0 ? 'F2' : 'F1', + }); + } + y += rowH; + } + y += 8; + } + + emitBlocks(step, 'after-description'); + emitBlocks(step, 'after-image'); + y += 10; + } + + function emitBlocks(step, position) { + for (const tb of step.textBlocks.filter((b) => b.position === position)) { + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; + const blockH = 16 + bodyLines.length * 13; + ensure(blockH + 4); + pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor }); + pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' }); + let by = y + 16; + for (const line of bodyLines) { + pdf.text(line, M + 10, by, { size: 9.5 }); + by += 13; + } + y += blockH + 6; + } + } + + fs.mkdirSync(outDir, { recursive: true }); + const file = path.join(outDir, `${guideSlug(ast)}.pdf`); + fs.writeFileSync(file, pdf.build()); + return { file, imageCount: images.size, pageCount: pdf.pages.length }; +} + +module.exports = { exportPdf, DEFAULT_TEMPLATE }; diff --git a/exporters/pptx.js b/exporters/pptx.js new file mode 100644 index 0000000..89b2286 --- /dev/null +++ b/exporters/pptx.js @@ -0,0 +1,217 @@ +'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 } = require('./common'); + +/** + * PPTX exporter: a title slide plus one 16:9 slide per step (title bar + + * screenshot + description). PresentationML written directly. + */ + +const DEFAULT_TEMPLATE = { + includeImages: true, + titleSlide: true, +}; + +const SLIDE_W = 12192000; // EMU, 16:9 +const SLIDE_H = 6858000; +const EMU_PER_PX = 9525; + +let shapeIdCounter = 10; // reset per export for deterministic output + +function textBox(x, y, w, h, runsXml) { + return `` + + `` + + `${runsXml}`; +} + +function para(text, { size = 1800, bold = false, color = '111827' } = {}) { + return `${escapeXml(text)}`; +} + +function picture(relId, x, y, w, h) { + return `` + + `` + + ``; +} + +function slideXml(content) { + return ` + + + +${content} +`; +} + +const THEME_XML = ` + + + + + +`; + +const MASTER_XML = ` + + + + + + +`; + +const LAYOUT_XML = ` + + + + + +`; + +function exportPptx(ast, outDir, template = {}) { + shapeIdCounter = 10; + const tpl = { ...DEFAULT_TEMPLATE, ...template }; + const images = tpl.includeImages ? renderAllImages(ast) : new Map(); + + const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] } + + if (tpl.titleSlide) { + slides.push({ + xml: slideXml( + textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) + + textBox(914400, 3600000, SLIDE_W - 1828800, 800000, + para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' })) + ), + rels: [], media: [], + }); + } + + let mediaCounter = 0; + for (const step of ast.steps) { + let content = textBox(457200, 274638, SLIDE_W - 914400, 700000, + para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true })); + const rels = []; + const media = []; + + const img = images.get(step.stepId); + if (img) { + mediaCounter += 1; + const name = `image${mediaCounter}.png`; + media.push({ name, data: encodePng(img) }); + const relId = 2; // rId1 = layout, rId2 = image + rels.push({ id: relId, name }); + // Fit image into a centered region below the title. + const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000); + let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; + const scale = Math.min(maxW / w, maxH / h, 1); + w = Math.round(w * scale); h = Math.round(h * scale); + content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h); + } + if (step.descriptionText) { + content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000, + para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); + } + slides.push({ xml: slideXml(content), rels, media }); + } + + const entries = []; + const overrides = []; + const presRels = [ + ``, + ]; + const sldIds = []; + + slides.forEach((slide, i) => { + const n = i + 1; + entries.push({ name: `ppt/slides/slide${n}.xml`, data: slide.xml }); + overrides.push(``); + const slideRels = [ + ``, + ...slide.rels.map((r) => ``), + ]; + entries.push({ + name: `ppt/slides/_rels/slide${n}.xml.rels`, + data: ` +${slideRels.join('')}`, + }); + for (const m of slide.media) entries.push({ name: `ppt/media/${m.name}`, data: m.data, store: true }); + presRels.push(``); + sldIds.push(``); + }); + + entries.push( + { + name: '[Content_Types].xml', + data: ` + + + + + + + + +${overrides.join('\n')} +`, + }, + { + name: '_rels/.rels', + data: ` + + +`, + }, + { + name: 'ppt/presentation.xml', + data: ` + + +${sldIds.join('')} + +`, + }, + { + name: 'ppt/_rels/presentation.xml.rels', + data: ` +${presRels.join('')}`, + }, + { name: 'ppt/slideMasters/slideMaster1.xml', data: MASTER_XML }, + { + name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels', + data: ` + + + +`, + }, + { name: 'ppt/slideLayouts/slideLayout1.xml', data: LAYOUT_XML }, + { + name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels', + data: ` + + +`, + }, + { name: 'ppt/theme/theme1.xml', data: THEME_XML }, + ); + + fs.mkdirSync(outDir, { recursive: true }); + const file = path.join(outDir, `${guideSlug(ast)}.pptx`); + fs.writeFileSync(file, zipSync(entries)); + return { file, slideCount: slides.length, imageCount: mediaCounter }; +} + +module.exports = { exportPptx, DEFAULT_TEMPLATE }; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js new file mode 100644 index 0000000..e9060d2 --- /dev/null +++ b/tests/unit/exporters-binary.test.js @@ -0,0 +1,242 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { buildRenderAst } = require('../../core/renderast'); +const { exportPdf } = require('../../exporters/pdf'); +const { exportGifGuide } = require('../../exporters/gif'); +const { exportImageBundle } = require('../../exporters/image-bundle'); +const { exportDocx } = require('../../exporters/docx'); +const { exportPptx } = require('../../exporters/pptx'); +const { TemplateManager } = require('../../core/templates'); +const { runExport, EXPORTERS } = require('../../exporters'); +const { unzipSync } = require('../../core/zip'); +const { decodePng, encodePng } = require('../../core/png'); +const raster = require('../../core/raster'); +const { decodeGif } = require('./gifdecode'); +const { buildFixtureGuide } = require('./fixture-guide'); +const { makeTmpDir, rmrf } = require('./helpers'); + +function hasTool(cmd) { + try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; } +} + +/** Tiny XML well-formedness check: balanced tags, single root. */ +function assertWellFormedXml(xml, label) { + const body = xml.replace(/<\?xml[^?]*\?>/, '').trim(); + const stack = []; + const re = /<(\/?)([A-Za-z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g; + let m; + let roots = 0; + while ((m = re.exec(body)) !== null) { + const [, closing, tag, , selfClose] = m; + if (closing) { + const open = stack.pop(); + assert.equal(open, tag, `${label}: closes <${open}>`); + if (!stack.length) roots++; + } else if (!selfClose) { + stack.push(tag); + } else if (!stack.length) roots++; + } + assert.equal(stack.length, 0, `${label}: unclosed tags ${stack.join(',')}`); + assert.ok(roots >= 1, `${label}: no root element`); +} + +function fixtureAst(t, label) { + const root = makeTmpDir(label); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + return { ast: buildRenderAst(store, guide.guideId), root, store, guide }; +} + +test('PDF export: valid document, bookmarks per step, images embedded', (t) => { + const { ast, root } = fixtureAst(t, 'pdfx'); + const out = path.join(root, 'out'); + const { file, pageCount, imageCount } = exportPdf(ast, out); + + assert.equal(imageCount, 2); + assert.ok(pageCount >= 3, 'cover + toc + content'); + const text = fs.readFileSync(file).toString('latin1'); + assert.ok(text.startsWith('%PDF-1.4')); + // One outline item per step. + const outlineTitles = [...text.matchAll(/\/Title \(([^)]*)\)/g)].map((m) => m[1]); + assert.deepEqual(outlineTitles, [ + '1. Open AcmeSync settings', + '1.1. Verify the gear icon is visible', + '2. Enable nightly backups', + ]); + // Two image XObjects. + assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2); +}); + +test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => { + const { ast, root } = fixtureAst(t, 'pdfgs'); + const { file, pageCount } = exportPdf(ast, path.join(root, 'out')); + const out = execFileSync('gs', ['-dBATCH', '-dNOPAUSE', '-sDEVICE=nullpage', file], { stdio: 'pipe' }).toString(); + assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`)); +}); + +test('GIF export: title card + one frame per image step, valid animation', (t) => { + const { ast, root } = fixtureAst(t, 'gifx'); + const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 }); + + const gif = decodeGif(fs.readFileSync(file)); + assert.equal(frameCount, 3, 'title card + 2 image steps'); + assert.equal(gif.frames.length, 3); + assert.equal(gif.width, 320); + assert.equal(gif.loops, 0); + for (const frame of gif.frames) { + assert.equal(frame.indices.length, gif.width * gif.height, 'frame fully decodes'); + } +}); + +test('GIF export honors template options (no title card/overlay/progress)', (t) => { + const { ast, root } = fixtureAst(t, 'gifopt'); + const { file, frameCount, height } = exportGifGuide(ast, path.join(root, 'out'), { + width: 320, titleCard: false, titleOverlay: false, progressBar: false, + }); + assert.equal(frameCount, 2); + // Without header/footer the frame height equals the scaled screenshot height. + assert.equal(height, Math.round(320 * (200 / 320))); + assert.equal(decodeGif(fs.readFileSync(file)).frames.length, 2); +}); + +test('image bundle: annotated PNGs + metadata that references them', (t) => { + const { ast, root } = fixtureAst(t, 'bundle'); + const out = path.join(root, 'out'); + const { file, imageCount } = exportImageBundle(ast, out); + + assert.equal(imageCount, 2); + const meta = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(meta.steps.length, 3); + for (const step of meta.steps) { + if (step.image) { + const img = decodePng(fs.readFileSync(path.join(out, step.image))); + assert.equal(img.width, 320); + } + } + // The empty substep has no image entry. + assert.equal(meta.steps.filter((s) => s.image).length, 2); +}); + +test('image bundle watermark is composited into the output pixels', (t) => { + const { ast, root } = fixtureAst(t, 'wm'); + const out = path.join(root, 'out'); + // Solid magenta watermark; bottom-right corner must turn magenta-ish. + const mark = raster.createImage(24, 24, [255, 0, 255, 255]); + const markFile = path.join(root, 'mark.png'); + fs.writeFileSync(markFile, encodePng(mark)); + + exportImageBundle(ast, out, { watermarkPath: markFile, watermarkOpacity: 1 }); + const meta = JSON.parse(fs.readFileSync(path.join(out, 'configure-acmesync-backups-bundle.json'), 'utf8')); + const imgPath = meta.steps.find((s) => s.image).image; + const img = decodePng(fs.readFileSync(path.join(out, imgPath))); + const p = ((img.height - 24) * img.width + (img.width - 24)) * 4; + assert.ok(img.data[p] > 200 && img.data[p + 2] > 200 && img.data[p + 1] < 60, + 'watermark pixels present in bottom-right corner'); +}); + +test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (t) => { + const { ast, root } = fixtureAst(t, 'docx'); + const { file, imageCount } = exportDocx(ast, path.join(root, 'out')); + + 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']) { + 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'); + + // 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, 2); + for (const target of relTargets) { + assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`); + const img = decodePng(entries.get(`word/${target}`)); + assert.equal(img.width, 320); + } + const docXml = entries.get('word/document.xml').toString('utf8'); + const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]); + const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]); + assert.deepEqual(embeds.sort(), relIds.sort()); + + // unzip CLI also accepts the package (it is a plain zip). + assert.ok(entries.size >= 6); +}); + +test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => { + const { ast, root } = fixtureAst(t, 'pptx'); + const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out')); + + assert.equal(slideCount, 4, 'title slide + 3 steps'); + 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', 'ppt/presentation.xml', + 'ppt/_rels/presentation.xml.rels', 'ppt/slideMasters/slideMaster1.xml', + 'ppt/slideLayouts/slideLayout1.xml', 'ppt/theme/theme1.xml', + ]) { + assert.ok(entries.has(required), `missing ${required}`); + } + for (let i = 1; i <= slideCount; i++) { + const xml = entries.get(`ppt/slides/slide${i}.xml`); + assert.ok(xml, `slide${i}.xml present`); + assertWellFormedXml(xml.toString('utf8'), `slide${i}`); + } + // presentation.xml references each slide and the count matches. + const pres = entries.get('ppt/presentation.xml').toString('utf8'); + assert.equal((pres.match(/ { + const root = makeTmpDir('tpl'); + t.after(() => rmrf(root)); + const tm = new TemplateManager(path.join(root, 'templates')); + + tm.save('pdf', 'compact', { includeCover: false, margin: 24 }); + assert.deepEqual(tm.list('pdf'), ['compact']); + assert.deepEqual(tm.load('pdf', 'compact'), { includeCover: false, margin: 24 }); + + tm.duplicate('pdf', 'compact'); + tm.rename('pdf', 'compact copy', 'tight'); + assert.deepEqual(tm.list('pdf'), ['compact', 'tight']); + + // Share as .sfglt and import into a fresh manager. + const shared = path.join(root, 'tight.sfglt'); + tm.exportTemplate('pdf', 'tight', shared); + const tm2 = new TemplateManager(path.join(root, 'templates2')); + const imported = tm2.importTemplate(shared); + assert.equal(imported.format, 'pdf'); + assert.deepEqual(tm2.load('pdf', imported.name), { includeCover: false, margin: 24 }); + + tm.remove('pdf', 'compact'); + assert.deepEqual(tm.list('pdf'), ['tight']); + assert.throws(() => tm.save('pdf', '../evil', {})); + assert.throws(() => tm.list('exe')); +}); + +test('a saved template changes exporter behavior through runExport', (t) => { + const { ast, root } = fixtureAst(t, 'tplrun'); + const tm = new TemplateManager(path.join(root, 'templates')); + tm.save('pdf', 'no-cover', { includeCover: false, includeToc: false }); + + const withDefaults = runExport('pdf', ast, path.join(root, 'out1')); + const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover')); + assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages'); + + assert.equal(Object.keys(EXPORTERS).length, 9, 'all nine formats wired'); + assert.throws(() => runExport('exe', ast, path.join(root, 'out3'))); +});