'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages } = require('./common');
/**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
* screenshot + description). PresentationML written directly.
*/
const DEFAULT_TEMPLATE = {
includeImages: true,
titleSlide: true,
};
const SLIDE_W = 12192000; // EMU, 16:9
const SLIDE_H = 6858000;
const EMU_PER_PX = 9525;
let shapeIdCounter = 10; // reset per export for deterministic output
function textBox(x, y, w, h, runsXml) {
return `` +
`` +
`${runsXml}`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `${escapeXml(text)}`;
}
function picture(relId, x, y, w, h) {
return `` +
`` +
``;
}
function slideXml(content) {
return `
${content}
`;
}
const THEME_XML = `
`;
const MASTER_XML = `
`;
const LAYOUT_XML = `
`;
function exportPptx(ast, outDir, template = {}) {
shapeIdCounter = 10;
const tpl = { ...DEFAULT_TEMPLATE, ...template };
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
if (tpl.titleSlide) {
slides.push({
xml: slideXml(
textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) +
textBox(914400, 3600000, SLIDE_W - 1828800, 800000,
para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' }))
),
rels: [], media: [],
});
}
let mediaCounter = 0;
for (const step of ast.steps) {
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000,
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true }));
const rels = [];
const media = [];
const img = images.get(step.stepId);
if (img) {
mediaCounter += 1;
const name = `image${mediaCounter}.png`;
media.push({ name, data: encodePng(img) });
const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name });
// Fit image into a centered region below the title.
const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h);
}
if (step.descriptionText) {
content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000,
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
}
slides.push({ xml: slideXml(content), rels, media });
}
const entries = [];
const overrides = [];
const presRels = [
``,
];
const sldIds = [];
slides.forEach((slide, i) => {
const n = i + 1;
entries.push({ name: `ppt/slides/slide${n}.xml`, data: slide.xml });
overrides.push(``);
const slideRels = [
``,
...slide.rels.map((r) => ``),
];
entries.push({
name: `ppt/slides/_rels/slide${n}.xml.rels`,
data: `
${slideRels.join('')}`,
});
for (const m of slide.media) entries.push({ name: `ppt/media/${m.name}`, data: m.data, store: true });
presRels.push(``);
sldIds.push(``);
});
entries.push(
{
name: '[Content_Types].xml',
data: `
${overrides.join('\n')}
`,
},
{
name: '_rels/.rels',
data: `
`,
},
{
name: 'ppt/presentation.xml',
data: `
${sldIds.join('')}
`,
},
{
name: 'ppt/_rels/presentation.xml.rels',
data: `
${presRels.join('')}`,
},
{ name: 'ppt/slideMasters/slideMaster1.xml', data: MASTER_XML },
{
name: 'ppt/slideMasters/_rels/slideMaster1.xml.rels',
data: `
`,
},
{ name: 'ppt/slideLayouts/slideLayout1.xml', data: LAYOUT_XML },
{
name: 'ppt/slideLayouts/_rels/slideLayout1.xml.rels',
data: `
`,
},
{ name: 'ppt/theme/theme1.xml', data: THEME_XML },
);
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.pptx`);
fs.writeFileSync(file, zipSync(entries));
return { file, slideCount: slides.length, imageCount: mediaCounter };
}
module.exports = { exportPptx, DEFAULT_TEMPLATE };