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
+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 };