- 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:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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')));
|
||||
});
|
||||
Reference in New Issue
Block a user