// Zero-dependency .pptx writer — a deterministic renderer so any model (even a weak local one) // can produce a real, editable PowerPoint deck by emitting structured JSON instead of code. // A .pptx is an OPC zip of OOXML parts; we generate the minimal valid set: content types, // package rels, presentation + master + blank layout + theme, one slide part per slide // (explicit text boxes, no placeholder inheritance), optional embedded images and speaker notes. import { readFileSync } from "node:fs"; import { extname, isAbsolute, resolve } from "node:path"; import { EMU, IMAGE_TYPES, XML_DECL, esc, imageSize, relsXml, zip } from "./ooxml.ts"; export interface PptxBullet { text: string; level?: number; // 0-based indent level } export interface PptxChartDatum { label: string; value: number; } export interface PptxMetric { value: string; // the headline figure, e.g. "340+" or "99.9%" label?: string; // what it measures } export interface PptxSlideSpec { title?: string; subtitle?: string; // present (with no bullets) ⇒ centered title-slide treatment bullets?: (string | PptxBullet)[]; image?: string; // path to a local png/jpg/gif, embedded into the file chart?: { data: PptxChartDatum[]; unit?: string }; // horizontal bar chart, drawn as native shapes metrics?: PptxMetric[]; // a row of big KPI figures notes?: string; // speaker notes } export interface PptxSpec { title?: string; // deck title for document properties author?: string; slides: PptxSlideSpec[]; } // --------------------------------------------------------------------------- // OOXML part builders. All geometry in EMU (914400/inch); slide is 16:9. const SLIDE_W = 12192000; // 13.333in const SLIDE_H = 6858000; // 7.5in const NS = `xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"`; const COLOR = { title: "202124", body: "3C4043", accent: "4472C4", muted: "5F6368" }; const inch = (n: number): number => Math.round(n * EMU); function emptyTree(shapes = ""): string { return ( `` + `${shapes}` ); } interface Box { x: number; y: number; w: number; h: number; } function textShape( id: number, name: string, box: Box, paragraphs: string, opts: { anchor?: "t" | "ctr"; align?: "l" | "ctr" } = {}, ): string { return ( `` + `` + `` + `${paragraphs}` ); } function para(text: string, opts: { sz: number; bold?: boolean; color: string; align?: "l" | "ctr"; bullet?: boolean; level?: number }): string { const lvl = Math.min(Math.max(opts.level ?? 0, 0), 4); const marL = 285750 + lvl * 457200; const pPr = `` + `` + (opts.bullet ? `` : ``) + ``; const rPr = ``; return `${pPr}${rPr}${esc(text)}`; } function accentBar(id: number, box: Box): string { return ( `` + `` + `` + `` ); } /** A filled rectangle (rounded when `round`) — the building block for charts and KPI tiles. */ function rectShape(id: number, box: Box, fill: string, round = false): string { return ( `` + `` + `` + `` + `` ); } /** Horizontal bar chart as native shapes: label, bar, value. Editable in PowerPoint, no image needed. */ function chartShapes(startId: number, box: Box, data: PptxChartDatum[], unit = ""): { xml: string; nextId: number } { const rows = data.slice(0, 10).filter((d) => d && typeof d.label === "string"); if (!rows.length) return { xml: "", nextId: startId }; const max = Math.max(...rows.map((d) => Math.abs(Number(d.value) || 0)), 1); const rowH = Math.min(inch(0.62), box.h / rows.length); const barH = Math.round(rowH * 0.52); const labelW = Math.round(box.w * 0.28); const valueW = inch(1.1); const trackW = box.w - labelW - valueW - inch(0.2); let id = startId; const out: string[] = []; rows.forEach((d, i) => { const v = Number(d.value) || 0; const y = box.y + i * rowH; const barW = Math.max(inch(0.04), (Math.abs(v) / max) * trackW); // label out.push( textShape(id++, "ChartLabel", { x: box.x, y: y + Math.round((rowH - barH) / 2) - inch(0.06), w: labelW, h: rowH }, para(d.label, { sz: 1200, color: COLOR.body })), ); // track + bar out.push(rectShape(id++, { x: box.x + labelW, y: y + (rowH - barH) / 2, w: trackW, h: barH }, "EDF0F6", true)); out.push(rectShape(id++, { x: box.x + labelW, y: y + (rowH - barH) / 2, w: barW, h: barH }, COLOR.accent, true)); // value out.push( textShape(id++, "ChartValue", { x: box.x + labelW + trackW + inch(0.15), y: y + Math.round((rowH - barH) / 2) - inch(0.06), w: valueW, h: rowH }, para(`${v}${unit}`, { sz: 1200, bold: true, color: COLOR.title })), ); }); return { xml: out.join(""), nextId: id }; } /** A row of KPI tiles: big figure over a caption. */ function metricShapes(startId: number, box: Box, metrics: PptxMetric[]): { xml: string; nextId: number } { const items = metrics.slice(0, 4).filter((m) => m && (m.value != null || m.label)); if (!items.length) return { xml: "", nextId: startId }; const gap = inch(0.25); const w = (box.w - gap * (items.length - 1)) / items.length; const h = Math.min(box.h, inch(1.9)); let id = startId; const out: string[] = []; items.forEach((m, i) => { const x = box.x + i * (w + gap); out.push(rectShape(id++, { x, y: box.y, w, h }, "F4F6FA", true)); out.push(textShape(id++, "MetricValue", { x, y: box.y + inch(0.28), w, h: inch(0.9) }, para(String(m.value ?? ""), { sz: 3600, bold: true, color: COLOR.accent, align: "ctr" }))); if (m.label) out.push(textShape(id++, "MetricLabel", { x, y: box.y + inch(1.15), w, h: inch(0.5) }, para(m.label, { sz: 1200, color: COLOR.muted, align: "ctr" }))); }); return { xml: out.join(""), nextId: id }; } function picShape(id: number, relId: string, box: Box, natural: { w: number; h: number } | null): string { let { x, y, w, h } = box; if (natural && natural.w > 0 && natural.h > 0) { const scale = Math.min(w / natural.w, h / natural.h); const fw = Math.round(natural.w * scale); const fh = Math.round(natural.h * scale); x += Math.round((w - fw) / 2); y += Math.round((h - fh) / 2); w = fw; h = fh; } return ( `` + `` + `` ); } function themeXml(name: string): string { const fills = `` + `` + ``; const lines = [12700, 19050, 25400] .map((w) => ``) .join(""); return ( `${XML_DECL}` + `` + `` + `` + `` + `` + `` + `` + `${fills}${lines}` + `` + `${fills}` ); } // --------------------------------------------------------------------------- /** Render a deck spec to .pptx bytes. Image paths resolve against `cwd`. Throws on bad input. */ export function buildPptx(spec: PptxSpec, cwd = process.cwd()): Buffer { const raw = spec.slides ?? []; if (!Array.isArray(raw) || raw.length === 0) throw new Error("generate_pptx: `slides` must be a non-empty array of slide objects"); const slides: PptxSlideSpec[] = raw.map((s) => (typeof s === "string" ? { title: s } : (s ?? {}))); if (slides.length > 200) throw new Error("generate_pptx: too many slides (max 200)"); const entries: { name: string; data: Buffer }[] = []; const put = (name: string, xml: string): void => { entries.push({ name, data: Buffer.from(xml, "utf8") }); }; const usedImageExts = new Set(); const overrides: string[] = []; const anyNotes = slides.some((s) => s.notes?.trim()); let media = 0; // Per-slide parts. slides.forEach((s, i) => { const n = i + 1; const shapes: string[] = []; const rels: { id: string; type: string; target: string }[] = [{ id: "rId1", type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" }]; let shapeId = 2; let nextRel = 2; // Tolerate weak-model shapes: a lone string (split on newlines) or a JSON-stringified array. let rawBullets: unknown = s.bullets ?? []; if (typeof rawBullets === "string") { const str: string = rawBullets; try { rawBullets = str.trim().startsWith("[") ? JSON.parse(str) : str.split(/\r?\n/).filter(Boolean); } catch { rawBullets = str.split(/\r?\n/).filter(Boolean); } } if (!Array.isArray(rawBullets)) throw new Error(`generate_pptx: slide ${n}: \`bullets\` must be an array of strings or {text, level} objects`); const bullets = rawBullets.map((b) => (typeof b === "string" ? { text: b, level: 0 } : { text: String((b as PptxBullet)?.text ?? ""), level: Number((b as PptxBullet)?.level) || 0 })); const isTitleSlide = !bullets.length && !s.image && (i === 0 || s.subtitle != null); let imageRel: string | null = null; let natural: { w: number; h: number } | null = null; if (s.image) { const abs = isAbsolute(s.image) ? s.image : resolve(cwd, s.image); const kind = IMAGE_TYPES[extname(abs).toLowerCase()]; if (!kind) throw new Error(`generate_pptx: slide ${n}: unsupported image type "${extname(abs)}" (png/jpg/gif)`); const data = readFileSync(abs); // throws a clear ENOENT if missing media++; const ext = kind === "jpeg" ? "jpeg" : kind; usedImageExts.add(ext); entries.push({ name: `ppt/media/image${media}.${ext}`, data }); imageRel = `rId${nextRel++}`; rels.push({ id: imageRel, type: "image", target: `../media/image${media}.${ext}` }); natural = imageSize(data, kind); } if (isTitleSlide) { if (s.title) shapes.push(textShape(shapeId++, "Title", { x: inch(1), y: inch(2.5), w: SLIDE_W - inch(2), h: inch(1.5) }, para(s.title, { sz: 4400, bold: true, color: COLOR.title, align: "ctr" }), { anchor: "ctr" })); shapes.push(accentBar(shapeId++, { x: (SLIDE_W - inch(1.5)) / 2, y: inch(4.1), w: inch(1.5), h: inch(0.06) })); if (s.subtitle) shapes.push(textShape(shapeId++, "Subtitle", { x: inch(1), y: inch(4.35), w: SLIDE_W - inch(2), h: inch(1) }, para(s.subtitle, { sz: 2000, color: COLOR.muted, align: "ctr" }))); } else { if (s.title) { shapes.push(textShape(shapeId++, "Title", { x: inch(0.6), y: inch(0.35), w: SLIDE_W - inch(1.2), h: inch(0.9) }, para(s.title, { sz: 2800, bold: true, color: COLOR.title }))); shapes.push(accentBar(shapeId++, { x: inch(0.6), y: inch(1.25), w: inch(1.2), h: inch(0.05) })); } if (s.subtitle) shapes.push(textShape(shapeId++, "Subtitle", { x: inch(0.6), y: inch(1.45), w: SLIDE_W - inch(1.2), h: inch(0.6) }, para(s.subtitle, { sz: 1600, color: COLOR.muted }))); const bodyY = s.subtitle ? inch(2.1) : inch(1.6); const bodyH = SLIDE_H - bodyY - inch(0.5); // KPI row sits above the body; bullets/visual share the remaining height. let contentY = bodyY; let contentH = bodyH; const metrics = Array.isArray(s.metrics) ? s.metrics : null; if (metrics?.length) { const m = metricShapes(shapeId, { x: inch(0.6), y: contentY, w: SLIDE_W - inch(1.2), h: inch(1.9) }, metrics); shapes.push(m.xml); shapeId = m.nextId; contentY += inch(2.15); contentH -= inch(2.15); } const chartData = Array.isArray(s.chart?.data) ? s.chart!.data : null; // A visual (chart or image) sits beside bullets when both are present, else fills the width. const visual = chartData?.length ? "chart" : imageRel ? "image" : null; if (bullets.length) { const bodyW = visual ? inch(6.4) : SLIDE_W - inch(1.2); const sz = bullets.length > 8 ? 1400 : 1800; shapes.push(textShape(shapeId++, "Body", { x: inch(0.6), y: contentY, w: bodyW, h: contentH }, bullets.map((b) => para(b.text, { sz, color: COLOR.body, bullet: true, level: b.level })).join(""))); if (visual === "chart") { const c = chartShapes(shapeId, { x: inch(7.1), y: contentY + inch(0.1), w: SLIDE_W - inch(7.7), h: contentH - inch(0.2) }, chartData!, s.chart?.unit ?? ""); shapes.push(c.xml); shapeId = c.nextId; } else if (visual === "image") { shapes.push(picShape(shapeId++, imageRel!, { x: inch(7.3), y: contentY, w: SLIDE_W - inch(7.9), h: contentH }, natural)); } } else if (visual === "chart") { const c = chartShapes(shapeId, { x: inch(1.2), y: contentY + inch(0.2), w: SLIDE_W - inch(2.4), h: contentH - inch(0.4) }, chartData!, s.chart?.unit ?? ""); shapes.push(c.xml); shapeId = c.nextId; } else if (visual === "image") { shapes.push(picShape(shapeId++, imageRel!, { x: inch(1), y: contentY, w: SLIDE_W - inch(2), h: contentH }, natural)); } } if (s.notes?.trim()) { const notesRel = `rId${nextRel++}`; rels.push({ id: notesRel, type: "notesSlide", target: `../notesSlides/notesSlide${n}.xml` }); const notesBody = `` + `${s.notes.split("\n").map((line) => `${esc(line)}`).join("")}`; put(`ppt/notesSlides/notesSlide${n}.xml`, `${XML_DECL}${emptyTree(notesBody)}`); put( `ppt/notesSlides/_rels/notesSlide${n}.xml.rels`, relsXml([ { id: "rId1", type: "notesMaster", target: "../notesMasters/notesMaster1.xml" }, { id: "rId2", type: "slide", target: `../slides/slide${n}.xml` }, ]), ); overrides.push(``); } put(`ppt/slides/slide${n}.xml`, `${XML_DECL}${emptyTree(shapes.join(""))}`); put(`ppt/slides/_rels/slide${n}.xml.rels`, relsXml(rels)); overrides.push(``); }); // Presentation, master, layout, themes. const presRels: { id: string; type: string; target: string }[] = [{ id: "rId1", type: "slideMaster", target: "slideMasters/slideMaster1.xml" }]; slides.forEach((_, i) => presRels.push({ id: `rId${i + 2}`, type: "slide", target: `slides/slide${i + 1}.xml` })); if (anyNotes) presRels.push({ id: `rId${slides.length + 2}`, type: "notesMaster", target: "notesMasters/notesMaster1.xml" }); put( "ppt/presentation.xml", `${XML_DECL}` + (anyNotes ? `` : "") + `${slides.map((_, i) => ``).join("")}` + ``, ); put("ppt/_rels/presentation.xml.rels", relsXml(presRels)); put( "ppt/slideMasters/slideMaster1.xml", `${XML_DECL}${emptyTree()}` + `` + ``, ); put( "ppt/slideMasters/_rels/slideMaster1.xml.rels", relsXml([ { id: "rId1", type: "slideLayout", target: "../slideLayouts/slideLayout1.xml" }, { id: "rId2", type: "theme", target: "../theme/theme1.xml" }, ]), ); put("ppt/slideLayouts/slideLayout1.xml", `${XML_DECL}${emptyTree()}`); put("ppt/slideLayouts/_rels/slideLayout1.xml.rels", relsXml([{ id: "rId1", type: "slideMaster", target: "../slideMasters/slideMaster1.xml" }])); put("ppt/theme/theme1.xml", themeXml("ada")); if (anyNotes) { put( "ppt/notesMasters/notesMaster1.xml", `${XML_DECL}${emptyTree()}` + ``, ); put("ppt/notesMasters/_rels/notesMaster1.xml.rels", relsXml([{ id: "rId1", type: "theme", target: "../theme/theme2.xml" }])); put("ppt/theme/theme2.xml", themeXml("ada-notes")); overrides.push(``); overrides.push(``); } // Document properties. put( "docProps/core.xml", `${XML_DECL}` + `${esc(spec.title ?? "")}${esc(spec.author ?? "ada")}`, ); put( "docProps/app.xml", `${XML_DECL}ada${slides.length}`, ); // Package-level parts. const defaults = `` + [...usedImageExts].map((e) => ``).join(""); put( "[Content_Types].xml", `${XML_DECL}${defaults}` + `` + `` + `` + `` + `${overrides.join("")}` + `` + ``, ); put( "_rels/.rels", relsXml([ { id: "rId1", type: "officeDocument", target: "ppt/presentation.xml" }, { id: "rId3", type: "extended-properties", target: "docProps/app.xml" }, ]).replace( "", ``, ), ); return zip(entries); }