Skills › Visual Explainer › visual-explainer › export.mjs
export.mjs
354 lines · plugins/visual-explainer/pptx/export.mjs at 9d5f880
1#!/usr/bin/env node
2import { realpathSync } from "node:fs";
3import { mkdir, readFile } from "node:fs/promises";
4import { dirname, extname } from "node:path";
5import { fileURLToPath } from "node:url";
6import { parse } from "node-html-parser";
7import pptxgen from "pptxgenjs";
9const scriptPath = fileURLToPath(import.meta.url);
10const SLIDE_W = 13.333;
11const SLIDE_H = 7.5;
12const MARGIN_X = 0.65;
13const COLORS = {
14 bg: "0F172A",
15 surface: "172033",
16 text: "F8FAFC",
17 dim: "CBD5E1",
18 accent: "D4A73A",
19 border: "334155",
20};
21const IGNORE_SELECTORS = [
22 "script",
23 "style",
24 "button",
25 "svg",
26 "canvas",
27 "nav",
28 ".deck-progress",
29 ".deck-dots",
30 ".deck-counter",
31 ".deck-hint",
32 ".deck-nav",
33 ".reader-rail",
34 ".reader-panel",
35 ".outline-overlay",
36 ".help-overlay",
37 ".zoom-controls",
38].join(",");
40function usage() {
41 return [
42 "Usage: visual-explainer-pptx <input.html> [output.pptx]",
43 "",
44 "Best-effort static export for simple visual-explainer HTML slide decks.",
45 "The HTML deck remains the source of truth. Animations, reader navigation, responsive layout, and Mermaid rendering are not preserved.",
46 ].join("\n");
47}
49function textOf(node) {
50 return (node?.textContent ?? "")
51 .replace(/\u00a0/g, " ")
52 .replace(/[ \t\f\v]+/g, " ")
53 .replace(/\s*\n\s*/g, "\n")
54 .trim();
55}
57function oneLine(node) {
58 return textOf(node).replace(/\s+/g, " ").trim();
59}
61function unique(values) {
62 const seen = new Set();
63 const output = [];
64 for (const value of values) {
65 const key = value.toLowerCase();
66 if (!value || seen.has(key)) continue;
67 seen.add(key);
68 output.push(value);
69 }
70 return output;
71}
73function truncate(value, max) {
74 if (value.length <= max) return value;
75 return `${value.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
76}
78function outputPathFor(inputPath, explicitPath) {
79 if (explicitPath) return explicitPath;
80 return extname(inputPath) ? inputPath.replace(/\.[^.]+$/, ".pptx") : `${inputPath}.pptx`;
81}
83function cloneCleanSlide(slide) {
84 const root = parse(slide.toString());
85 const copy = root.querySelector(".slide") ?? root;
86 for (const ignored of copy.querySelectorAll(IGNORE_SELECTORS)) {
87 if (ignored.classList?.contains("diagram-source")) continue;
88 ignored.remove();
89 }
90 return copy;
91}
93function firstText(slide, selectors) {
94 for (const selector of selectors) {
95 const value = oneLine(slide.querySelector(selector));
96 if (value) return value;
97 }
98 return "";
99}
101function extractTables(slide) {
102 const tables = [];
103 for (const table of slide.querySelectorAll("table")) {
104 const rows = [];
105 for (const tr of table.querySelectorAll("tr")) {
106 const cells = tr.querySelectorAll("th,td").map((cell) => truncate(oneLine(cell), 90));
107 if (cells.some(Boolean)) rows.push(cells);
108 }
109 if (rows.length) tables.push(rows);
110 table.remove();
111 }
112 return tables;
113}
115function extractCodeBlocks(slide) {
116 const blocks = [];
117 for (const code of slide.querySelectorAll("pre")) {
118 const text = textOf(code);
119 if (text) blocks.push(truncate(text, 850));
120 code.remove();
121 }
122 return blocks;
123}
125function extractDiagramPlaceholders(slide) {
126 const diagrams = [];
127 for (const diagram of slide.querySelectorAll(".mermaid, .diagram-source")) {
128 const text = textOf(diagram);
129 diagrams.push(text ? `Diagram source: ${truncate(text.replace(/\s+/g, " "), 220)}` : "Diagram placeholder: render from the HTML deck for full fidelity.");
130 diagram.remove();
131 }
132 return diagrams;
133}
135function extractSlide(slide, index) {
136 const clean = cloneCleanSlide(slide);
137 const tables = extractTables(clean);
138 const diagrams = extractDiagramPlaceholders(clean);
139 const codeBlocks = extractCodeBlocks(clean);
140 const headings = clean.querySelectorAll("h1,h2,h3,h4").map(oneLine).filter(Boolean);
141 const title = headings[0] || `Slide ${index + 1}`;
142 const subtitle = firstText(clean, [".slide__subtitle", ".subtitle", ".eyebrow", ".kicker"]);
143 const listItems = clean.querySelectorAll("li").map((item) => truncate(oneLine(item), 160)).filter(Boolean);
144 const paragraphs = clean.querySelectorAll("p").map((paragraph) => truncate(oneLine(paragraph), 220)).filter(Boolean);
145 const cardTexts = clean.querySelectorAll("article, aside, .card, .panel, .metric-card")
146 .map((card) => truncate(oneLine(card), 220))
147 .filter(Boolean);
149 const body = unique([
150 ...diagrams,
151 ...listItems,
152 ...cardTexts,
153 ...paragraphs,
154 ].filter((value) => value !== title && value !== subtitle));
156 return {
157 title: truncate(title, 140),
158 subtitle: truncate(subtitle, 180),
159 body: body.slice(0, 10),
160 tables: tables.slice(0, 2),
161 codeBlocks: codeBlocks.slice(0, 2),
162 };
163}
165function findSlideSections(root) {
166 const sections = root.querySelectorAll("section.slide");
167 if (sections.length) return sections;
168 return root.querySelectorAll(".slide").filter((node) => node.querySelector("h1,h2,h3,h4,p,li,table,pre"));
169}
171function addFooter(slide, index, total) {
172 slide.addShape("line", { x: MARGIN_X, y: 6.95, w: SLIDE_W - MARGIN_X * 2, h: 0, line: { color: COLORS.border, pt: 1 } });
173 slide.addText(`Best-effort PPTX export · ${index + 1}/${total}`, {
174 x: MARGIN_X,
175 y: 7.05,
176 w: 7.5,
177 h: 0.25,
178 fontFace: "Aptos",
179 fontSize: 8,
180 color: COLORS.dim,
181 margin: 0,
182 });
183}
185function addTitle(slide, data) {
186 slide.addText(data.title, {
187 x: MARGIN_X,
188 y: 0.45,
189 w: SLIDE_W - MARGIN_X * 2,
190 h: 0.72,
191 fontFace: "Aptos Display",
192 fontSize: data.title.length > 72 ? 24 : 30,
193 bold: true,
194 color: COLORS.text,
195 margin: 0,
196 fit: "shrink",
197 });
198 if (data.subtitle) {
199 slide.addText(data.subtitle, {
200 x: MARGIN_X,
201 y: 1.22,
202 w: SLIDE_W - MARGIN_X * 2,
203 h: 0.35,
204 fontFace: "Aptos",
205 fontSize: 12,
206 color: COLORS.dim,
207 margin: 0,
208 fit: "shrink",
209 });
210 }
211}
213function addBody(slide, items, startY, maxHeight) {
214 if (!items.length) return startY;
215 slide.addText(items.map((item) => `• ${item}`).join("\n"), {
216 x: MARGIN_X,
217 y: startY,
218 w: 5.85,
219 h: maxHeight,
220 fontFace: "Aptos",
221 fontSize: items.length > 7 ? 12 : 14,
222 color: COLORS.text,
223 margin: 0.05,
224 fit: "shrink",
225 });
226 return startY + maxHeight + 0.2;
227}
229function addTables(slide, tables, startY) {
230 if (!tables.length) return;
231 const table = tables[0].slice(0, 9).map((row) => row.slice(0, 5));
232 const colCount = Math.max(...table.map((row) => row.length));
233 const rows = table.map((row) => Array.from({ length: colCount }, (_, index) => row[index] ?? ""));
234 slide.addTable(rows, {
235 x: 6.85,
236 y: startY,
237 w: 5.8,
238 h: 3.9,
239 colW: Array.from({ length: colCount }, () => 5.8 / colCount),
240 border: { type: "solid", pt: 0.5, color: COLORS.border },
241 color: COLORS.text,
242 fontFace: "Aptos",
243 fontSize: rows.length > 6 || colCount > 3 ? 8 : 10,
244 margin: 0.05,
245 valign: "top",
246 });
247}
249function addCode(slide, codeBlocks, startY) {
250 if (!codeBlocks.length) return;
251 slide.addText(codeBlocks[0], {
252 x: 6.85,
253 y: startY,
254 w: 5.8,
255 h: 3.9,
256 fontFace: "Courier New",
257 fontSize: 8,
258 color: COLORS.text,
259 fill: { color: COLORS.surface, transparency: 0 },
260 margin: 0.12,
261 fit: "shrink",
262 });
263}
265function addFallbackPanel(slide) {
266 slide.addText("Static export", {
267 x: 6.85,
268 y: 1.75,
269 w: 5.8,
270 h: 0.35,
271 fontFace: "Aptos",
272 fontSize: 12,
273 bold: true,
274 color: COLORS.accent,
275 margin: 0,
276 });
277 slide.addText("Use the HTML deck for animations, Mermaid rendering, reader navigation, custom fonts, and responsive layout.", {
278 x: 6.85,
279 y: 2.15,
280 w: 5.8,
281 h: 1.2,
282 fontFace: "Aptos",
283 fontSize: 13,
284 color: COLORS.dim,
285 margin: 0,
286 fit: "shrink",
287 });
288}
290function renderPptx(slides, outputPath) {
291 const pptx = new pptxgen();
292 pptx.layout = "LAYOUT_WIDE";
293 pptx.author = "visual-explainer";
294 pptx.company = "visual-explainer";
295 pptx.subject = "Best-effort static export from a visual-explainer HTML slide deck";
296 pptx.title = slides[0]?.title ?? "Visual Explainer Deck";
297 pptx.lang = "en-US";
298 pptx.theme = {
299 headFontFace: "Aptos Display",
300 bodyFontFace: "Aptos",
301 lang: "en-US",
302 };
304 slides.forEach((data, index) => {
305 const slide = pptx.addSlide();
306 slide.background = { color: COLORS.bg };
307 slide.addShape("rect", { x: 0, y: 0, w: SLIDE_W, h: SLIDE_H, fill: { color: COLORS.bg }, line: { color: COLORS.bg } });
308 slide.addShape("rect", { x: 0, y: 0, w: 0.16, h: SLIDE_H, fill: { color: COLORS.accent }, line: { color: COLORS.accent } });
309 addTitle(slide, data);
310 addBody(slide, data.body, 1.75, 4.8);
311 if (data.tables.length) addTables(slide, data.tables, 1.75);
312 else if (data.codeBlocks.length) addCode(slide, data.codeBlocks, 1.75);
313 else addFallbackPanel(slide);
314 addFooter(slide, index, slides.length);
315 });
317 return pptx.writeFile({ fileName: outputPath });
318}
320export async function exportPptx(inputPath, explicitOutputPath) {
321 if (!inputPath) throw new Error("input.html is required");
322 const outputPath = outputPathFor(inputPath, explicitOutputPath);
323 const html = await readFile(inputPath, "utf8");
324 const root = parse(html);
325 const slideSections = findSlideSections(root);
326 if (!slideSections.length) throw new Error("No slide sections found. Best-effort PPTX export expects a visual-explainer HTML deck with <section class=\"slide\"> elements.");
327 const slides = slideSections.map(extractSlide).filter((slide) => slide.title || slide.body.length || slide.tables.length || slide.codeBlocks.length);
328 if (!slides.length) throw new Error("No exportable slide content found.");
329 await mkdir(dirname(outputPath), { recursive: true });
330 await renderPptx(slides, outputPath);
331 return { inputPath, outputPath, slideCount: slides.length };
332}
334async function main() {
335 const args = process.argv.slice(2);
336 if (args.includes("--help") || args.includes("-h")) {
337 process.stdout.write(`${usage()}\n`);
338 return;
339 }
340 const [inputPath, outputPath] = args;
341 const result = await exportPptx(inputPath, outputPath);
342 process.stdout.write(`Wrote ${result.outputPath}\nSlides: ${result.slideCount}\nBest-effort static export. Use the HTML deck for full fidelity.\n`);
343}
345function isMainModule() {
346 return Boolean(process.argv[1] && realpathSync(process.argv[1]) === scriptPath);
347}
349if (isMainModule()) {
350 main().catch((error) => {
351 process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
352 process.exitCode = 1;
353 });
354}