Add binary exporters and template manager
Template tests / tests (push) Failing after 52s

- Native PDF exporter (cover, TOC, bookmarks, images, code, tables,
  text blocks); validated under Ghostscript
- Animated GIF exporter (title card, title overlay, progress bar)
- Image bundle exporter with watermark compositing
- DOCX and PPTX emitters (hand-built OOXML over our zip writer) with
  structural + relationship + XML well-formedness validation in tests
- Per-format template manager with .sfglt share archives
- Unified export dispatcher covering all nine formats
- 10 more workflow tests (52 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 16:59:19 -05:00
parent ca73db68e3
commit a5bbdde480
9 changed files with 1072 additions and 1 deletions
+99
View File
@@ -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/<format>/.
* 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 };
+2 -1
View File
@@ -98,7 +98,8 @@ function slugify(text, fallback = 'untitled') {
const slug = String(text || '') const slug = String(text || '')
.normalize('NFKD') .normalize('NFKD')
.replace(/[̀-ͯ]/g, '') .replace(/[̀-ͯ]/g, '')
.replace(/[^a-zA-Z0-9._ -]+/g, '') .toLowerCase()
.replace(/[^a-z0-9._ -]+/g, '')
.trim() .trim()
.replace(/\s+/g, '-') .replace(/\s+/g, '-')
.slice(0, 80); .slice(0, 80);
+171
View File
@@ -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 `<w:p>${props ? `<w:pPr>${props}</w:pPr>` : ''}${children}</w:p>`;
}
function run(text, { bold = false, size = 22, font = '', color = '' } = {}) {
const rpr = [
bold ? '<w:b/>' : '',
`<w:sz w:val="${size}"/>`,
font ? `<w:rFonts w:ascii="${font}" w:hAnsi="${font}"/>` : '',
color ? `<w:color w:val="${color}"/>` : '',
].join('');
const lines = String(text).split('\n');
return lines.map((line, i) =>
`${i > 0 ? '<w:r><w:br/></w:r>' : ''}<w:r><w:rPr>${rpr}</w:rPr><w:t xml:space="preserve">${escapeXml(line)}</w:t></w:r>`
).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 `<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0">` +
`<wp:extent cx="${cx}" cy="${cy}"/><wp:docPr id="${relId}" name="Screenshot ${relId}"/>` +
`<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">` +
`<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
`<pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">` +
`<pic:nvPicPr><pic:cNvPr id="${relId}" name="img${relId}"/><pic:cNvPicPr/></pic:nvPicPr>` +
`<pic:blipFill><a:blip r:embed="rId${relId}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>` +
`<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="${cx}" cy="${cy}"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr>` +
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
}
function table(rows) {
const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
const borders = '<w:tblBorders>' +
['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((s) => `<w:${s} w:val="single" w:sz="4" w:color="C8CCD2"/>`).join('') +
'</w:tblBorders>';
const body = rows.map((row, ri) => {
const cells = [];
for (let c = 0; c < cols; c++) {
cells.push(`<w:tc><w:tcPr><w:tcW w:w="2400" w:type="dxa"/></w:tcPr>${p(run(row[c] ?? '', { bold: ri === 0, size: 20 }))}</w:tc>`);
}
return `<w:tr>${cells.join('')}</w:tr>`;
}).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
}
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 ? '<w:pageBreakBefore/>' : ''));
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(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
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' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
}
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 }) : ''),
'<w:shd w:val="clear" w:fill="F9FAFB"/>'
));
}
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<w:body>
${body.join('\n')}
<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134"/></w:sectPr>
</w:body>
</w:document>`;
const entries = [
{
name: '[Content_Types].xml',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`,
},
{
name: '_rels/.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`,
},
{ name: 'word/document.xml', data: documentXml },
{
name: 'word/_rels/document.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${rels.join('\n')}
</Relationships>`,
},
...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 };
+87
View File
@@ -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 };
+54
View File
@@ -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 };
+31
View File
@@ -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 };
+169
View File
@@ -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 };
+217
View File
@@ -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 `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="TextBox"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr>` +
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
}
function picture(relId, x, y, w, h) {
return `<p:pic><p:nvPicPr><p:cNvPr id="${relId + 100}" name="Screenshot"/><p:cNvPicPr/><p:nvPr/></p:nvPicPr>` +
`<p:blipFill><a:blip r:embed="rId${relId}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
}
function slideXml(content) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
${content}
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
}
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
<a:themeElements>
<a:clrScheme name="StepForge"><a:dk1><a:srgbClr val="111827"/></a:dk1><a:lt1><a:srgbClr val="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F3F4F6"/></a:lt2><a:accent1><a:srgbClr val="2563EB"/></a:accent1><a:accent2><a:srgbClr val="10B981"/></a:accent2><a:accent3><a:srgbClr val="F59E0B"/></a:accent3><a:accent4><a:srgbClr val="EF4444"/></a:accent4><a:accent5><a:srgbClr val="8B5CF6"/></a:accent5><a:accent6><a:srgbClr val="EC4899"/></a:accent6><a:hlink><a:srgbClr val="2563EB"/></a:hlink><a:folHlink><a:srgbClr val="7C3AED"/></a:folHlink></a:clrScheme>
<a:fontScheme name="StepForge"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme>
<a:fmtScheme name="StepForge"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln><a:ln w="28575"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>
</a:themeElements></a:theme>`;
const MASTER_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
</p:spTree></p:cSld>
<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>
<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>
</p:sldMaster>`;
const LAYOUT_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" type="blank">
<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
</p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>`;
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 = [
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>`,
];
const sldIds = [];
slides.forEach((slide, i) => {
const n = i + 1;
entries.push({ name: `ppt/slides/slide${n}.xml`, data: slide.xml });
overrides.push(`<Override PartName="/ppt/slides/slide${n}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`);
const slideRels = [
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>`,
...slide.rels.map((r) => `<Relationship Id="rId${r.id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${r.name}"/>`),
];
entries.push({
name: `ppt/slides/_rels/slide${n}.xml.rels`,
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${slideRels.join('')}</Relationships>`,
});
for (const m of slide.media) entries.push({ name: `ppt/media/${m.name}`, data: m.data, store: true });
presRels.push(`<Relationship Id="rId${n + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${n}.xml"/>`);
sldIds.push(`<p:sldId id="${256 + i}" r:id="rId${n + 1}"/>`);
});
entries.push(
{
name: '[Content_Types].xml',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>
${overrides.join('\n')}
</Types>`,
},
{
name: '_rels/.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
</Relationships>`,
},
{
name: 'ppt/presentation.xml',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst>${sldIds.join('')}</p:sldIdLst>
<p:sldSz cx="${SLIDE_W}" cy="${SLIDE_H}"/><p:notesSz cx="${SLIDE_H}" cy="${SLIDE_W}"/>
</p:presentation>`,
},
{
name: 'ppt/_rels/presentation.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${presRels.join('')}</Relationships>`,
},
{ name: 'ppt/slideMasters/slideMaster1.xml', data: MASTER_XML },
{
name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`,
},
{ name: 'ppt/slideLayouts/slideLayout1.xml', data: LAYOUT_XML },
{
name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`,
},
{ 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 };
+242
View File
@@ -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}: </${tag}> 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(/<p:sldId /g) || []).length, slideCount);
// image rels on slides resolve to media files.
for (let i = 1; i <= slideCount; i++) {
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
for (const m of rels.matchAll(/Target="\.\.\/media\/([^"]+)"/g)) {
assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`);
}
}
});
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
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')));
});