'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, stepContentGroups } = require('./common'); const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** * PPTX exporter: a title slide plus one 16:9 slide per step, with * positioned text blocks laid out around the step title, description, and * screenshot. 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 SLIDE_MARGIN = 914400; const TITLE_Y = 420000; const TITLE_H = 620000; const TITLE_RULE_Y = 1120000; const CONTENT_Y = 1500000; const CONTENT_FOOTER_Y = 720000; const CALL_OUT_HEIGHT = 620000; const CALL_OUT_GAP = 90000; const CALL_OUT_BAR_W = 24000; 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 `` + `` + ``; } const CALLOUT_STYLE = { info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' }, success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' }, warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' }, error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' }, }; function estimateWrappedLines(text, charsPerLine) { const raw = String(text || '').trim(); if (!raw) return 0; return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0); } function calloutHeight(tb) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72); return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000)); } function calloutXml(tb, x, y, w) { const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info; const height = calloutHeight(tb); const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`; const titlePara = para(label, { size: 1400, bold: true, color: style.color }); const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : ''; const innerX = x + CALL_OUT_BAR_W + 24000; const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000); return { height, xml: [ rectShape(x, y, w, height, style.fill), rectShape(x, y, CALL_OUT_BAR_W, height, style.accent), textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`), ].join(''), }; } function descriptionHeight(text) { const lines = estimateWrappedLines(text, 95); if (!lines) return 0; return Math.max(360000, 260000 + (lines * 130000)); } 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(SLIDE_MARGIN, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })); titleContent += rectShape(SLIDE_MARGIN, 3300000, 2200000, 14000, '2563EB'); titleContent += textBox(SLIDE_MARGIN, 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) { const { beforeTitle, afterTitle, beforeDescription, afterDescription, beforeImage, afterImage, } = stepContentGroups(step); let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y; const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y; const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y; const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y; let y = beforeTitleY; for (const tb of beforeTitle) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H, para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB'); y = contentY; for (const tb of afterTitle) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } for (const tb of beforeDescription) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } if (step.descriptionText) { const descH = descriptionHeight(step.descriptionText); content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000), para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); y += Math.max(360000, descH || 420000) + 90000; } for (const tb of afterDescription) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } for (const tb of beforeImage) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); 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 the remaining centered region before the trailing blocks. const maxW = SLIDE_W - 1219200; const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000); 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), y, w, h); y += h + 100000; } for (const tb of afterImage) { const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); content += block.xml; y += block.height + CALL_OUT_GAP; } 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 };