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