- 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,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 };
|
||||
Reference in New Issue
Block a user