'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'); const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** * 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, includeToc: true, }; const SLIDE_W = 12192000; // EMU, 16:9 const SLIDE_H = 6858000; const EMU_PER_PX = 9525; const TOC_ENTRY_START_Y = 2300000; const TOC_ENTRY_SPACING = 255000; const TOC_ENTRY_HEIGHT = 220000; const TOC_BOTTOM_MARGIN = 500000; const TOC_MAX_ENTRIES_PER_SLIDE = Math.max( 1, Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1, ); let shapeIdCounter = 10; // reset per export for deterministic output function textBox(x, y, w, h, runsXml) { return `` + `` + `${runsXml}`; } function rectShape(x, y, w, h, fill) { return `` + `` + ``; } 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} `; } function chunkArray(items, size) { const chunks = []; for (let i = 0; i < items.length; i += size) { chunks.push(items.slice(i, i + size)); } return chunks; } function tocSlideXml(ast, entries, { continued = false } = {}) { let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000, para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true })); tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB'); tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000, para(guideSummary(ast), { size: 1500, color: '6B7280' })); entries.forEach((entry, index) => { const x = 914400 + (entry.depth * 220000); const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING); tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB'); tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT, para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 })); }); return slideXml(tocContent); } 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}] } const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : []; if (tpl.titleSlide) { const metaLines = guideMetaLines(ast); let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); titleContent += textBox(914400, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })); titleContent += rectShape(914400, 3300000, 2200000, 14000, '2563EB'); titleContent += textBox(914400, 3500000, SLIDE_W - 1828800, 1100000, [para(guideSummary(ast), { size: 1800, color: '6B7280' }), ...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join('')); slides.push({ xml: slideXml(titleContent), rels: [], media: [], }); } if (toc.length) { const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE); tocPages.forEach((page, index) => { slides.push({ xml: tocSlideXml(ast, page, { continued: index > 0 }), rels: [], media: [], }); }); } let mediaCounter = 0; for (const step of ast.steps) { let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); content += textBox(457200, 420000, SLIDE_W - 914400, 620000, para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); content += rectShape(457200, 1120000, 2400000, 12000, '2563EB'); 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 - 1870000 - (step.descriptionText ? 650000 : 260000); 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), 1500000, w, h); } if (step.descriptionText) { content += textBox(457200, SLIDE_H - 720000, SLIDE_W - 914400, 600000, 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 };