pindeck 0.0.0-stage → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ /* Pictures of PowerPoint slides (macOS): PowerPoint, or else Keynote, exports the deck as a PDF with one
2
+ page per slide, hidden slides included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages
3
+ (thumbnails, references for Claude) and regions (diagrams) to PNG.
4
+
5
+ Both apps are sandboxed, so the files go through a folder inside the app's own container, which
6
+ needs no permission dialog. macOS asks once whether PinDeck may control the app. An app that was
7
+ not running before is quit again afterwards. */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import crypto from "node:crypto";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const JXA = path.join(path.dirname(fileURLToPath(import.meta.url)), "pdf-render.jxa.js");
17
+ const APPS = [
18
+ { name: "Microsoft PowerPoint", bundle: "/Applications/Microsoft PowerPoint.app", container: "com.microsoft.Powerpoint" },
19
+ { name: "Keynote", bundle: "/Applications/Keynote.app", container: "com.apple.iWork.Keynote" },
20
+ ];
21
+
22
+ const osa = (args, timeout = 240000) => new Promise((resolve, reject) =>
23
+ execFile("osascript", args, { timeout, maxBuffer: 1e7 }, (err, out, errOut) =>
24
+ err ? reject(new Error((errOut || err.message).trim().split("\n").pop())) : resolve(out.trim())));
25
+ const q = (s) => JSON.stringify(s); // AppleScript string literal (same quoting rules for paths)
26
+
27
+ export function renderAvailable() {
28
+ return process.platform === "darwin" && APPS.some((a) => fs.existsSync(a.bundle));
29
+ }
30
+
31
+ function exportScript(app, pptx, pdf) {
32
+ if (app.name === "Microsoft PowerPoint") return `
33
+ set wasRunning to application "Microsoft PowerPoint" is running
34
+ tell application "Microsoft PowerPoint"
35
+ open (POSIX file ${q(pptx)})
36
+ set p to active presentation
37
+ repeat with i from 1 to (count of slides of p)
38
+ set hidden of slide show transition of slide i of p to false
39
+ end repeat
40
+ save p in (POSIX file ${q(pdf)}) as save as PDF
41
+ close p saving no
42
+ if not wasRunning then quit
43
+ end tell
44
+ return "ok"`;
45
+ return `
46
+ set wasRunning to application "Keynote" is running
47
+ tell application "Keynote"
48
+ set d to open (POSIX file ${q(pptx)})
49
+ repeat with s in slides of d
50
+ set skipped of s to false
51
+ end repeat
52
+ export d to (POSIX file ${q(pdf)}) as PDF
53
+ close d saving no
54
+ if not wasRunning then quit
55
+ end tell
56
+ return "ok"`;
57
+ }
58
+
59
+ /* Exports the deck to <workDir>/deck.pdf. Returns { pdf, app }. */
60
+ export async function exportPdf(pptxPath, workDir) {
61
+ if (process.platform !== "darwin") throw new Error("Slide pictures need macOS with PowerPoint or Keynote.");
62
+ const errors = [];
63
+ for (const app of APPS) {
64
+ if (!fs.existsSync(app.bundle)) continue;
65
+ const box = path.join(os.homedir(), "Library", "Containers", app.container, "Data", "pindeck-import", crypto.randomBytes(6).toString("hex"));
66
+ try {
67
+ fs.mkdirSync(box, { recursive: true });
68
+ const inFile = path.join(box, "deck.pptx"), outFile = path.join(box, "deck.pdf");
69
+ fs.copyFileSync(pptxPath, inFile);
70
+ await osa(["-e", exportScript(app, inFile, outFile)]);
71
+ if (!fs.existsSync(outFile)) throw new Error("no PDF was written");
72
+ const pdf = path.join(workDir, "deck.pdf");
73
+ fs.copyFileSync(outFile, pdf);
74
+ return { pdf, app: app.name };
75
+ } catch (e) {
76
+ errors.push(`${app.name}: ${e.message}`);
77
+ } finally {
78
+ fs.rm(box, { recursive: true, force: true }, () => {});
79
+ }
80
+ }
81
+ throw new Error(errors.length ? errors.join("; ") : "Neither PowerPoint nor Keynote is installed.");
82
+ }
83
+
84
+ /* Renders pages or regions (see pdf-render.jxa.js). Returns the page count. Boxes are in slide px of a
85
+ page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
86
+ export async function renderJobs(pdf, jobs, workDir, { slideWidth = 1280 } = {}) {
87
+ return +(await runJxa({ pdf, slideWidth, jobs }, workDir));
88
+ }
89
+
90
+ /* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (macOS only, no other app needed) */
91
+ export async function pdfInfo(pdf, workDir) {
92
+ return JSON.parse(await runJxa({ pdf, info: true }, workDir));
93
+ }
94
+
95
+ async function runJxa(spec, workDir) {
96
+ const file = path.join(workDir, `jobs-${crypto.randomBytes(4).toString("hex")}.json`);
97
+ fs.writeFileSync(file, JSON.stringify(spec));
98
+ try { return await osa(["-l", "JavaScript", JXA, file], 120000); }
99
+ finally { fs.rm(file, { force: true }, () => {}); }
100
+ }
package/lib/pptx.mjs ADDED
@@ -0,0 +1,340 @@
1
+ /* PowerPoint (.pptx) reader: turns a presentation into a plain model PinDeck can import.
2
+
3
+ readPptx(buffer) -> { width, height, slides: [slide] } with, per slide:
4
+ n, hidden, layout ("title", "secHead", "obj", ... or the layout's name), kind (title/section/content),
5
+ title, subtitle (strings), shapes (every shape with its box and, for text, its paragraphs),
6
+ steps (build steps: which shapes or paragraphs appear or disappear on which click), notes
7
+ Boxes are in slide pixels with the slide 1280 px wide (the PinDeck canvas). */
8
+
9
+ import path from "node:path";
10
+ import { readZip } from "./zip.mjs";
11
+ import { parseXml, kid, kids, path as xpath, find, findAll, textOf } from "./xml.mjs";
12
+
13
+ const R = "r:id", EMBED = "r:embed", LINK = "r:link";
14
+ const BROWSER_IMAGES = new Set(["png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "bmp"]);
15
+ const on = (v) => v === "1" || v === "true" || v === "on"; // OOXML booleans
16
+
17
+ function relsOf(zip, part) {
18
+ const relPath = path.posix.join(path.posix.dirname(part), "_rels", path.posix.basename(part) + ".rels");
19
+ const xml = zip.text(relPath);
20
+ const out = {};
21
+ if (!xml) return out;
22
+ for (const r of findAll(parseXml(xml), "Relationship")) {
23
+ const t = r.attrs.Target || "";
24
+ // relative to the part's folder, or absolute from the package root ("/ppt/slides/slide1.xml")
25
+ const target = r.attrs.TargetMode === "External" ? t
26
+ : path.posix.normalize(t.startsWith("/") ? t.slice(1) : path.posix.join(path.posix.dirname(part), t));
27
+ out[r.attrs.Id] = { target, type: (r.attrs.Type || "").split("/").pop(), external: r.attrs.TargetMode === "External" };
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /* ---- Text ------------------------------------------------------------------------------------------ */
33
+ /* A coloured run (not black, white or grey, and not a dark or light theme colour) is an accent */
34
+ function accentColour(fill) {
35
+ const rgb = fill && kid(fill, "a:srgbClr")?.attrs.val;
36
+ if (rgb) {
37
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(rgb.slice(i, i + 2), 16) / 255);
38
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
39
+ return max > 0.25 && (max - min) / max > 0.4;
40
+ }
41
+ const scheme = fill && kid(fill, "a:schemeClr")?.attrs.val;
42
+ return !!scheme && /^accent\d$/.test(scheme);
43
+ }
44
+
45
+ function runsOf(p, rels) {
46
+ const runs = [];
47
+ const visit = (el) => {
48
+ for (const c of el.children || []) {
49
+ if (!c.name) continue;
50
+ if (c.name === "a:r" || c.name === "a:fld") {
51
+ const rPr = kid(c, "a:rPr");
52
+ const link = rPr && kid(rPr, "a:hlinkClick");
53
+ // only web and mail links: a javascript: or file: link must not reach the deck
54
+ const href = link && rels[link.attrs[R]]?.external && /^(https?:|mailto:)/i.test(rels[link.attrs[R]].target.trim())
55
+ ? rels[link.attrs[R]].target.trim() : null;
56
+ runs.push({ text: textOf(kid(c, "a:t")), b: rPr?.attrs.b === "1", i: rPr?.attrs.i === "1", href,
57
+ sup: rPr?.attrs.baseline > 0, sub: rPr?.attrs.baseline < 0, hl: accentColour(rPr && kid(rPr, "a:solidFill")) });
58
+ } else if (c.name === "a:br") runs.push({ br: true });
59
+ else if (c.name === "a14:m" || c.name === "m:oMathPara" || c.name === "m:oMath") runs.push({ math: c });
60
+ else if (c.name === "mc:AlternateContent") {
61
+ // prefer the math in mc:Choice; fall back to the plain text of mc:Fallback
62
+ const choice = kid(c, "mc:Choice"), fb = kid(c, "mc:Fallback");
63
+ const m = choice && (find(choice, "a14:m") || find(choice, "m:oMathPara") || find(choice, "m:oMath"));
64
+ if (m) runs.push({ math: m });
65
+ else if (fb) visit(fb);
66
+ }
67
+ }
68
+ };
69
+ visit(p);
70
+ // PowerPoint splits text into many runs (spell checking, edits); join neighbours that look the same.
71
+ // A run of only spaces carries no visible formatting, so it joins the run before it.
72
+ const same = (a, b) => a.b === b.b && a.i === b.i && a.href === b.href && a.sup === b.sup && a.sub === b.sub && a.hl === b.hl;
73
+ const out = [];
74
+ for (const r of runs) {
75
+ const last = out[out.length - 1];
76
+ if (r.text != null && last?.text != null && (same(last, r) || (!r.text.trim() && !r.href))) last.text += r.text;
77
+ else out.push(r.text != null ? { ...r } : r);
78
+ }
79
+ return out;
80
+ }
81
+ /* Font sizes in hundredths of a point per list level (1-9), from a list style (a:lstStyle, p:bodyStyle) */
82
+ function levelSizes(style) {
83
+ const out = {};
84
+ for (let l = 1; l <= 9; l++) {
85
+ const sz = xpath(style, `a:lvl${l}pPr`, "a:defRPr")?.attrs.sz;
86
+ if (sz) out[l - 1] = +sz;
87
+ }
88
+ return out;
89
+ }
90
+ /* sizes: { own (the shape's lstStyle), inherited (master body or other style), base (presentation default) } */
91
+ function paragraphsOf(txBody, rels, { bulletsByDefault, sizes = {}, pxPerPt = 1 }) {
92
+ if (!txBody) return [];
93
+ const out = [];
94
+ const own = levelSizes(kid(txBody, "a:lstStyle"));
95
+ const scale = +(xpath(txBody, "a:bodyPr", "a:normAutofit")?.attrs.fontScale || 100000) / 100000;
96
+ let index = -1;
97
+ for (const p of kids(txBody, "a:p")) {
98
+ index++; // counts empty paragraphs too: animations refer to paragraphs by this number
99
+ const pPr = kid(p, "a:pPr");
100
+ const runs = runsOf(p, rels);
101
+ const lvl = +(pPr?.attrs.lvl || 0);
102
+ const runSz = Math.max(0, ...findAll(p, "a:rPr").map((r) => +(r.attrs.sz || 0)));
103
+ const sz = runSz || +(kid(p, "a:endParaRPr")?.attrs.sz || 0) || own[lvl] || sizes.inherited?.[lvl] || sizes.base?.[lvl] || 1800;
104
+ const plain = runs.map((r) => r.text || "").join("");
105
+ if (!plain.trim() && !runs.some((r) => r.math)) continue;
106
+ const level = +(pPr?.attrs.lvl || 0);
107
+ let bullet = bulletsByDefault;
108
+ if (pPr && kid(pPr, "a:buNone")) bullet = false;
109
+ if (pPr && (kid(pPr, "a:buChar") || kid(pPr, "a:buAutoNum"))) bullet = true;
110
+ out.push({ index, level, bullet, numbered: !!(pPr && kid(pPr, "a:buAutoNum")), runs, size: sz / 100 * scale * pxPerPt });
111
+ }
112
+ return out;
113
+ }
114
+ const plainText = (paras) => paras.map((p) => p.runs.map((r) => (r.br ? "\n" : r.math ? findAll(r.math, "m:t").map(textOf).join("") : r.text || "")).join("")).join("\n").trim();
115
+
116
+ /* ---- Geometry: group transforms map child coordinates onto the slide -------------------------------- */
117
+ const IDENTITY = { sx: 1, sy: 1, tx: 0, ty: 0 };
118
+ function boxOf(spPr, t) {
119
+ const x = spPr && kid(spPr, "a:xfrm");
120
+ const off = x && kid(x, "a:off"), ext = x && kid(x, "a:ext");
121
+ if (!off || !ext) return null;
122
+ return { x: +off.attrs.x * t.sx + t.tx, y: +off.attrs.y * t.sy + t.ty, w: +ext.attrs.cx * t.sx, h: +ext.attrs.cy * t.sy };
123
+ }
124
+ function groupTransform(grpSpPr, t) {
125
+ const x = grpSpPr && kid(grpSpPr, "a:xfrm");
126
+ if (!x) return t;
127
+ const off = kid(x, "a:off"), ext = kid(x, "a:ext"), chOff = kid(x, "a:chOff"), chExt = kid(x, "a:chExt");
128
+ if (!off || !ext || !chOff || !chExt) return t;
129
+ const sx = (+ext.attrs.cx / (+chExt.attrs.cx || 1)), sy = (+ext.attrs.cy / (+chExt.attrs.cy || 1));
130
+ // child -> parent: (c - chOff) * s + off, then the parent's own transform
131
+ return { sx: sx * t.sx, sy: sy * t.sy, tx: (+off.attrs.x - +chOff.attrs.x * sx) * t.sx + t.tx, ty: (+off.attrs.y - +chOff.attrs.y * sy) * t.sy + t.ty };
132
+ }
133
+
134
+ /* ---- Placeholders: positions come from the layout or master when the slide does not set them ---------- */
135
+ function placeholderOf(sp) {
136
+ const nvPr = xpath(sp, "p:nvSpPr", "p:nvPr") || xpath(sp, "p:nvPicPr", "p:nvPr") || xpath(sp, "p:nvGraphicFramePr", "p:nvPr");
137
+ const ph = nvPr && kid(nvPr, "p:ph");
138
+ return ph ? { type: ph.attrs.type || "body", idx: ph.attrs.idx ?? null } : null;
139
+ }
140
+ function placeholderBoxes(zip, part) {
141
+ const xml = zip.text(part);
142
+ if (!xml) return [];
143
+ const out = [];
144
+ for (const sp of findAll(parseXml(xml), "p:sp")) {
145
+ const ph = placeholderOf(sp), b = boxOf(kid(sp, "p:spPr"), IDENTITY);
146
+ if (ph && b) out.push({ ...ph, box: b });
147
+ }
148
+ return out;
149
+ }
150
+ function inheritBox(ph, layoutPhs, masterPhs) {
151
+ const same = (list) => list.find((p) => ph.idx != null && p.idx === ph.idx) ||
152
+ list.find((p) => p.type === ph.type || (ph.type === "ctrTitle" && p.type === "title") || (ph.type === "subTitle" && p.type === "body"));
153
+ return (same(layoutPhs) || same(masterPhs))?.box || null;
154
+ }
155
+
156
+ /* ---- Shapes ------------------------------------------------------------------------------------------- */
157
+ const hasVisibleFill = (sp) => {
158
+ const spPr = kid(sp, "p:spPr");
159
+ if (spPr && kid(spPr, "a:noFill")) return false;
160
+ if (spPr && ["a:solidFill", "a:gradFill", "a:blipFill", "a:pattFill"].some((n) => kid(spPr, n))) return true;
161
+ const fillRef = xpath(sp, "p:style", "a:fillRef");
162
+ return !!fillRef && +fillRef.attrs.idx > 0;
163
+ };
164
+ const hasVisibleLine = (sp) => {
165
+ const ln = xpath(sp, "p:spPr", "a:ln");
166
+ if (ln && kid(ln, "a:noFill")) return false;
167
+ if (ln && kid(ln, "a:solidFill")) return true;
168
+ const lnRef = xpath(sp, "p:style", "a:lnRef");
169
+ return !!lnRef && +lnRef.attrs.idx > 0;
170
+ };
171
+
172
+ /* groupId: the id of the outermost group a shape is in (an animation of a group moves all its shapes) */
173
+ function collectShapes(tree, t, ctx, out, groupId = null) {
174
+ for (const c of tree.children || []) {
175
+ if (!c.name) continue;
176
+ if (c.name === "p:grpSp") {
177
+ if (on(xpath(c, "p:nvGrpSpPr", "p:cNvPr")?.attrs.hidden)) continue;
178
+ collectShapes(c, groupTransform(kid(c, "p:grpSpPr"), t), ctx, out, groupId || xpath(c, "p:nvGrpSpPr", "p:cNvPr")?.attrs.id || null);
179
+ continue;
180
+ }
181
+ if (c.name === "mc:AlternateContent") { const ch = kid(c, "mc:Choice") || kid(c, "mc:Fallback"); if (ch) collectShapes(ch, t, ctx, out, groupId); continue; }
182
+ if (!["p:sp", "p:pic", "p:graphicFrame", "p:cxnSp"].includes(c.name)) continue;
183
+ const ph = placeholderOf(c);
184
+ const spPr = kid(c, "p:spPr") || kid(c, "p:xfrm") && { children: [{ ...kid(c, "p:xfrm"), name: "a:xfrm" }] };
185
+ let box = boxOf(spPr, t);
186
+ if (!box && ph) box = inheritBox(ph, ctx.layoutPhs, ctx.masterPhs);
187
+ const cNvPr = xpath(c, "p:nvSpPr", "p:cNvPr") || xpath(c, "p:nvPicPr", "p:cNvPr") || xpath(c, "p:nvGraphicFramePr", "p:cNvPr") || xpath(c, "p:nvCxnSpPr", "p:cNvPr");
188
+ if (on(cNvPr?.attrs.hidden)) continue; // hidden in PowerPoint's selection pane
189
+ const s = { tag: c.name.slice(2), id: cNvPr?.attrs.id || null, group: groupId, name: cNvPr?.attrs.name || "", descr: cNvPr?.attrs.descr || "", ph, box };
190
+ if (c.name === "p:sp") {
191
+ s.txBox = xpath(c, "p:nvSpPr", "p:cNvSpPr")?.attrs.txBox === "1";
192
+ s.geom = xpath(c, "p:spPr", "a:prstGeom")?.attrs.prst || (xpath(c, "p:spPr", "a:custGeom") ? "custom" : null);
193
+ s.paras = paragraphsOf(kid(c, "p:txBody"), ctx.rels, { bulletsByDefault: !!ph && ["body", "obj"].includes(ph.type),
194
+ pxPerPt: ctx.pxPerPt, sizes: { inherited: ph ? (["title", "ctrTitle"].includes(ph.type) ? ctx.styles.title : ctx.styles.body) : ctx.styles.other, base: ctx.styles.base } });
195
+ s.filled = hasVisibleFill(c);
196
+ s.lined = hasVisibleLine(c);
197
+ } else if (c.name === "p:pic") {
198
+ const blip = find(c, "a:blip");
199
+ const rel = blip && ctx.rels[blip.attrs[EMBED] || blip.attrs[LINK]];
200
+ s.media = rel && !rel.external ? rel.target : null;
201
+ s.ext = s.media ? path.posix.extname(s.media).slice(1).toLowerCase() : null;
202
+ s.displayable = !!s.ext && BROWSER_IMAGES.has(s.ext);
203
+ s.cropped = !!find(c, "a:srcRect") && Object.keys(find(c, "a:srcRect").attrs).length > 0;
204
+ const nvPr = xpath(c, "p:nvPicPr", "p:nvPr");
205
+ const av = nvPr && (kid(nvPr, "a:audioFile") || kid(nvPr, "a:videoFile"));
206
+ const p14 = nvPr && find(nvPr, "p14:media");
207
+ if (av || p14) {
208
+ const r = ctx.rels[(p14 && p14.attrs[EMBED]) || (av && (av.attrs[LINK] || av.attrs[EMBED]))];
209
+ s.av = { kind: av?.name === "a:videoFile" ? "video" : r && /video|\.(mp4|mov|m4v|webm)$/i.test(r.target) ? "video" : "audio",
210
+ media: r && !r.external ? r.target : null };
211
+ }
212
+ } else if (c.name === "p:graphicFrame") {
213
+ const uri = find(c, "a:graphicData")?.attrs.uri || "";
214
+ s.table = uri.endsWith("/table") ? tableOf(find(c, "a:tbl"), ctx) : null;
215
+ s.chart = uri.endsWith("/chart");
216
+ s.diagram = uri.endsWith("/diagram");
217
+ s.ole = uri.endsWith("/ole");
218
+ }
219
+ if (s.box) s.box = { x: s.box.x / ctx.emu, y: s.box.y / ctx.emu, w: s.box.w / ctx.emu, h: s.box.h / ctx.emu };
220
+ out.push(s);
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /* Tables: rows of cells with their paragraphs; merged cells are marked so they can be left out */
226
+ function tableOf(tbl, ctx) {
227
+ if (!tbl) return null;
228
+ const pr = kid(tbl, "a:tblPr");
229
+ const rows = kids(tbl, "a:tr").map((tr) => kids(tr, "a:tc").map((tc) => ({
230
+ paras: paragraphsOf(kid(tc, "a:txBody"), ctx.rels, { bulletsByDefault: false, pxPerPt: ctx.pxPerPt, sizes: { inherited: ctx.styles.other, base: ctx.styles.base } }),
231
+ colspan: +(tc.attrs.gridSpan || 1), rowspan: +(tc.attrs.rowSpan || 1),
232
+ merged: on(tc.attrs.hMerge) || on(tc.attrs.vMerge),
233
+ below: on(tc.attrs.vMerge) && !on(tc.attrs.hMerge), // under a row-spanning cell (holds its colspan)
234
+ })));
235
+ return { rows, header: on(pr?.attrs.firstRow) };
236
+ }
237
+
238
+ /* ---- Build steps ----------------------------------------------------------------------------------------
239
+ The main sequence of p:timing: each click starts a group; effects "with" or "after" the previous one
240
+ belong to the same click. Returns [{ click, kind: "in" | "out", shape, from, to }] where from/to are
241
+ paragraph numbers when only some paragraphs of a text animate. Effects before the first click play
242
+ when the slide opens (click 0). */
243
+ function stepsOf(root) {
244
+ const seq = findAll(find(root, "p:timing"), "p:cTn").find((n) => n.attrs.nodeType === "mainSeq");
245
+ if (!seq) return [];
246
+ const out = [];
247
+ let click = 0;
248
+ for (const group of kids(kid(seq, "p:childTnLst"), "p:par")) {
249
+ const cTn = kid(group, "p:cTn");
250
+ // a click starts the group, unless it plays by itself when the slide opens: then PowerPoint adds
251
+ // an onBegin condition to the "indefinite" one
252
+ const conds = kids(xpath(cTn, "p:stCondLst"), "p:cond");
253
+ if (conds.some((c) => c.attrs.delay === "indefinite") && !conds.some((c) => c.attrs.evt === "onBegin")) click++;
254
+ for (const eff of findAll(group, "p:cTn").filter((n) => n.attrs.presetClass)) {
255
+ const kind = eff.attrs.presetClass === "entr" ? "in" : eff.attrs.presetClass === "exit" ? "out" : null;
256
+ if (!kind) continue;
257
+ const seen = new Set();
258
+ for (const tgt of findAll(eff, "p:spTgt")) {
259
+ const rg = xpath(tgt, "p:txEl", "p:pRg");
260
+ const key = `${tgt.attrs.spid}/${rg ? rg.attrs.st + "-" + rg.attrs.end : ""}`;
261
+ if (seen.has(key)) continue; // one effect animates its target in several behaviours
262
+ seen.add(key);
263
+ out.push({ click, kind, shape: tgt.attrs.spid, from: rg ? +rg.attrs.st : null, to: rg ? +rg.attrs.end : null });
264
+ }
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /* ---- Presentation ---------------------------------------------------------------------------------------- */
271
+ export function readPptx(buf) {
272
+ const zip = readZip(buf);
273
+ const presPart = "ppt/presentation.xml";
274
+ const pres = parseXml(zip.text(presPart) || "");
275
+ const presRels = relsOf(zip, presPart);
276
+ const sz = find(pres, "p:sldSz");
277
+ const cx = +(sz?.attrs.cx || 12192000), cy = +(sz?.attrs.cy || 6858000);
278
+ const emu = cx / 1280;
279
+ const baseSizes = levelSizes(find(pres, "p:defaultTextStyle"));
280
+ const masterStyles = new Map();
281
+ const stylesOf = (part) => {
282
+ if (!part) return {};
283
+ if (!masterStyles.has(part)) {
284
+ const tx = find(parseXml(zip.text(part) || ""), "p:txStyles");
285
+ masterStyles.set(part, { title: levelSizes(kid(tx, "p:titleStyle")), body: levelSizes(kid(tx, "p:bodyStyle")), other: levelSizes(kid(tx, "p:otherStyle")) });
286
+ }
287
+ return masterStyles.get(part);
288
+ };
289
+ const slides = [];
290
+ let n = 0;
291
+ for (const id of findAll(pres, "p:sldId")) {
292
+ const part = presRels[id.attrs[R]]?.target;
293
+ if (!part || !zip.has(part)) continue;
294
+ n++;
295
+ const sld = parseXml(zip.text(part));
296
+ const root = kid(sld, "p:sld");
297
+ const rels = relsOf(zip, part);
298
+ const layoutPart = Object.values(rels).find((r) => r.type === "slideLayout")?.target;
299
+ const layoutXml = layoutPart && zip.text(layoutPart);
300
+ const layoutRoot = layoutXml && kid(parseXml(layoutXml), "p:sldLayout");
301
+ const masterPart = layoutPart && Object.values(relsOf(zip, layoutPart)).find((r) => r.type === "slideMaster")?.target;
302
+ const ctx = { rels, emu, pxPerPt: 1280 / (cx / 12700), styles: { ...stylesOf(masterPart), base: baseSizes }, layoutPhs: layoutPart ? placeholderBoxes(zip, layoutPart) : [], masterPhs: masterPart ? placeholderBoxes(zip, masterPart) : [] };
303
+ const tree = xpath(root, "p:cSld", "p:spTree");
304
+ const shapes = tree ? collectShapes(tree, IDENTITY, ctx, []) : [];
305
+
306
+ const isTitlePh = (s) => s.ph && ["title", "ctrTitle"].includes(s.ph.type);
307
+ const titleShape = shapes.find(isTitlePh);
308
+ const titleLines = titleShape ? plainText(titleShape.paras).split(/\n+/).map((l) => l.trim()).filter(Boolean) : [];
309
+ const subShape = shapes.find((s) => s.ph?.type === "subTitle");
310
+ const layoutType = layoutRoot?.attrs.type || "";
311
+ const layoutName = kid(layoutRoot, "p:cSld")?.attrs.name || "";
312
+ // custom layouts have no type; a first slide whose layout is called "Title ..." is the title slide
313
+ const kind = layoutType === "title" || titleShape?.ph.type === "ctrTitle" ||
314
+ (n === 1 && /title|titel/i.test(layoutName) && !/content|inhalt|text|only|nur/i.test(layoutName)) ? "title"
315
+ : layoutType === "secHead" || /^section|abschnitt/i.test(layoutName) ? "section" : "content";
316
+
317
+ // notes: the body placeholder of the notes slide
318
+ const notesPart = Object.values(rels).find((r) => r.type === "notesSlide")?.target;
319
+ let notes = "";
320
+ if (notesPart && zip.has(notesPart)) {
321
+ const nx = parseXml(zip.text(notesPart));
322
+ const body = findAll(nx, "p:sp").find((sp) => placeholderOf(sp)?.type === "body");
323
+ if (body) notes = plainText(paragraphsOf(kid(body, "p:txBody"), {}, { bulletsByDefault: false }));
324
+ }
325
+
326
+ slides.push({
327
+ n, part, hidden: root?.attrs.show === "0" || root?.attrs.show === "false", layout: layoutType || layoutName, kind,
328
+ // a subtitle placeholder often holds the authors, event and institute too: its first line is the subtitle
329
+ title: titleLines[0] || "", subtitle: subShape ? plainText(subShape.paras.slice(0, 1)).replace(/\s*\n\s*/g, " ") : titleLines.slice(1).join(" "),
330
+ titleParas: titleShape?.paras || [],
331
+ shapes: shapes.filter((s) => !(s.ph && ["dt", "ftr", "sldNum", "hdr"].includes(s.ph.type))),
332
+ steps: stepsOf(root),
333
+ notes,
334
+ });
335
+ }
336
+ return { width: 1280, height: Math.round(1280 * cy / cx), slides, zip };
337
+ }
338
+
339
+ /* Media bytes of a part path (e.g. "ppt/media/image3.png"). */
340
+ export const mediaOf = (model, part) => model.zip.read(part);
@@ -0,0 +1,97 @@
1
+ You are working inside PinDeck, a slide editor. The user sees the deck in a browser
2
+ and talks to you through a narrow chat sidebar next to the slides.
3
+
4
+ ## What a message contains
5
+ Each user message may start with an <editor-context> block:
6
+ - the slide the user is looking at,
7
+ - numbered pins [1], [2], ... : clicks on slide elements. Each pin names the element by its
8
+ data-eid, its box in slide coordinates, the click point and the clicked word. The user refers
9
+ to pins by number ("1: move left", "make [3] bold").
10
+ - a measured layout of the affected slides (1280 x 720 px canvas, origin top left), taken in the
11
+ user's browser, with any layout issues found.
12
+ - sometimes the path of a screenshot of the slide with the pin markers drawn on it.
13
+
14
+ ## Files
15
+ - index.html: the slides. theme.css: the styles. Edit these.
16
+ - brand.css is LOCKED (logos, footer, brand colours). Never edit it. If a request needs a brand
17
+ change, say so instead. Elements marked brand-locked are positioned by brand.css.
18
+ - Follow the deck's CLAUDE.md (at the end of these instructions) and README.md for the template's
19
+ conventions.
20
+
21
+ ## Element ids
22
+ Every slide <section> and visible element has a data-eid attribute ("summary", "summary/callout").
23
+ - Keep existing ids unchanged when you edit an element, even if you change its content or class.
24
+ - When you add an element, give it data-eid="<slide-eid>/<short-name>", unique in the deck.
25
+ - When you add a slide, give the <section> a new id (a slug of its title) and prefix its elements.
26
+ - The editor adds missing ids after your turn, so a forgotten one is not fatal.
27
+
28
+ ## SVG markers and gradients
29
+ reveal.js hides every slide but the current one, and a marker, gradient or pattern used through
30
+ url(#id) draws nothing while the element defining it is hidden. Define shared ones in the deck's
31
+ `<svg class="svg-defs">` right after `<body>`, outside the slides (add that SVG if the deck has none).
32
+
33
+ ## Hidden slides
34
+ A slide with data-visibility="hidden" on its <section> is hidden: it stays in the deck and in the
35
+ editor, but reveal.js skips it when presenting and in PDF exports. The user hides and shows slides
36
+ in the editor. Do not delete hidden slides, and do not unhide them unless asked.
37
+
38
+ ## Attached files
39
+ The user can attach figures and audio to a message. PinDeck has already saved them in the deck's
40
+ `figures/` folder; <editor-context> lists their paths (relative to index.html), size and, for audio,
41
+ the duration and a generated waveform SVG. If a file was dropped onto a slide, the drop position is
42
+ given too. Put them on slides with the deck's existing patterns: a figure like the other `figure.fig`
43
+ blocks, audio like the deck's audio demo (audio element, play button, waveform). Read an image
44
+ directly to see what it shows before you write a caption: Read scales large photos down itself, so
45
+ never convert or resize files first. Do not rename or move the files.
46
+
47
+ ## Attached PDFs
48
+ The user can also attach a PDF: a figure or table to take over, or a paper or report with more
49
+ details. PDFs are not part of the deck: PinDeck keeps them outside the deck folder, and
50
+ <editor-context> gives the absolute path, the page count and the page size. Read the PDF directly
51
+ (for more than 10 pages, Read with pages, e.g. "3-6", at most 20 pages at a time) and use what the
52
+ user asks for: facts, numbers and wording for slides, or a figure.
53
+ - Tables: rebuild them as HTML tables in the deck's table style; do not use a picture of a table.
54
+ - Figures: render the figure to PNG and put it on the slide like any other figure.
55
+ {{CLI}} pdf-figure <pdf> <page> [x,y,w,h] [figures/<name>.png]
56
+ The box x,y,w,h is in fractions of the page from the top left (e.g. 0.12,0.08,0.76,0.3); without
57
+ it the whole page is rendered. Without a figures/ name the PNG goes to a scratch folder and the
58
+ command prints its path: Read it to check the crop (the whole figure with its axis labels, no
59
+ caption or body text), adjust the box, then render once more with a figures/ name. That file may
60
+ be rendered again under the same name; other existing files are never overwritten. The PNG is
61
+ 1600 px wide by default (--width <px> to change it).
62
+ - Never put the PDF itself on a slide or link it from the deck.
63
+
64
+ ## Commands
65
+ The only shell commands you can run here are the PinDeck commands below ({{CLI}} ...), each on
66
+ its own: no scripts, heredocs, temporary files, loops, variables or `&&` chains, and no python, sips,
67
+ convert or ls. Such commands are refused (or need an approval nobody can give) and are not run. A
68
+ refused command did nothing: do not retry it as it is, do it another way.
69
+ - To change files, use Edit, even for large changes: several Edit calls in a row are fine.
70
+ - To look at files, use Read, Grep and Glob.
71
+ - To check several slides, name them all: `{{CLI}} layout 9 10 11 --issues`.
72
+
73
+ ## Checking your work
74
+ The browser reloads by itself when you save. After changing positions, sizes, fonts or content
75
+ amount, check the result:
76
+ {{CLI}} layout <slide-eid or number>...
77
+ It prints the measured layout and issues (content below the footer rule, outside the slide,
78
+ overlapping elements, clipped text); add --issues to print only the issues. Fix what you caused. Use
79
+ {{CLI}} shot <slide-eid or number>...
80
+ only when you need a visual judgement the numbers cannot give (balance, figure content, colour)
81
+ or the user asks; it prints the path of a PNG, which you then Read.
82
+ If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build steps complete) or
83
+ `{{CLI}} pdf presentation` (one page per build step); it prints the path of the PDF.
84
+
85
+ ## Hand edits
86
+ The user can also select, move and resize elements in the editor. Those changes are written as
87
+ inline styles on the element in index.html: `translate: Xpx Ypx` for moves, `width`/`height` (and
88
+ `max-width`/`max-height: none`) for sizes. You get a note about each one. Treat them as intended;
89
+ keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
90
+ The user can also edit text in place (double-click); you get a note with the new text. Keep their
91
+ wording unless asked to change it.
92
+ A selected element is listed in <editor-context>; "this" or "it" usually means that element.
93
+
94
+ ## Replies
95
+ Replies appear in a narrow sidebar. Keep them short: 1 to 4 sentences saying what you changed,
96
+ referring to pins by number. No code dumps; the user can open the diff. If a request is
97
+ ambiguous and would change several slides or the whole theme, ask first.
@@ -0,0 +1,67 @@
1
+ /* Waveform SVG for an audio file, as the editor makes for attached audio (bars, viewBox 1000 x 120):
2
+ WAV is read directly; other formats are converted to WAV first with macOS afconvert, if available. */
3
+
4
+ import { execFile } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ /* Samples of a PCM or float WAV file as one array per channel (null if the format is not supported,
10
+ or the file is damaged) */
11
+ function readWav(buf) {
12
+ try { return wavOf(buf); } catch { return null; }
13
+ }
14
+ function wavOf(buf) {
15
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") return null;
16
+ let fmt = null, data = null;
17
+ for (let p = 12; p + 8 <= buf.length;) {
18
+ const id = buf.toString("ascii", p, p + 4), size = buf.readUInt32LE(p + 4);
19
+ if (id === "fmt ") fmt = { format: buf.readUInt16LE(p + 8), channels: buf.readUInt16LE(p + 10), bits: buf.readUInt16LE(p + 22) };
20
+ if (id === "data") data = buf.subarray(p + 8, Math.min(buf.length, p + 8 + size));
21
+ p += 8 + size + (size & 1);
22
+ }
23
+ if (!fmt || !data || !fmt.channels || !fmt.bits) return null;
24
+ const format = fmt.format === 0xfffe ? (fmt.bits === 32 && data.length ? 3 : 1) : fmt.format; // extensible: guess from the bits
25
+ const bytes = fmt.bits / 8, frames = Math.floor(data.length / (bytes * fmt.channels));
26
+ const read = format === 3 && fmt.bits === 32 ? (o) => data.readFloatLE(o)
27
+ : format === 1 && fmt.bits === 16 ? (o) => data.readInt16LE(o) / 32768
28
+ : format === 1 && fmt.bits === 24 ? (o) => data.readIntLE(o, 3) / 8388608
29
+ : format === 1 && fmt.bits === 32 ? (o) => data.readInt32LE(o) / 2147483648
30
+ : format === 1 && fmt.bits === 8 ? (o) => (data[o] - 128) / 128 : null;
31
+ if (!read) return null;
32
+ return { frames, channels: fmt.channels, sample: (i, c) => read((i * fmt.channels + c) * bytes) };
33
+ }
34
+
35
+ function svgOf(wav) {
36
+ const n = 220, W = 1000, H = 120, len = wav.frames;
37
+ const peaks = [];
38
+ for (let i = 0; i < n; i++) {
39
+ let m = 0;
40
+ const a = Math.floor((i * len) / n), b = Math.floor(((i + 1) * len) / n);
41
+ for (let c = 0; c < wav.channels; c++) for (let j = a; j < b; j += 4) m = Math.max(m, Math.abs(wav.sample(j, c)));
42
+ peaks.push(m);
43
+ }
44
+ const top = Math.max(...peaks) || 1;
45
+ const bars = peaks.map((p, i) => {
46
+ const h = Math.max(3, (p / top) * (H - 10));
47
+ return `<rect x="${(i * W / n + 1.2).toFixed(1)}" y="${((H - h) / 2).toFixed(1)}" width="${(W / n - 2.4).toFixed(1)}" height="${h.toFixed(1)}" rx="2"/>`;
48
+ }).join("");
49
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><g fill="#6B7785">${bars}</g></svg>`;
50
+ }
51
+
52
+ /* Returns the SVG text, or null if the audio cannot be read */
53
+ export async function waveformSvg(data, ext) {
54
+ let wav = /^wav(e)?$/i.test(ext) ? readWav(data) : null;
55
+ if (!wav && process.platform === "darwin") {
56
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pindeck-wave-"));
57
+ try {
58
+ const src = path.join(dir, `in.${ext || "audio"}`), out = path.join(dir, "out.wav");
59
+ fs.writeFileSync(src, data);
60
+ await new Promise((resolve, reject) => execFile("afconvert", ["-f", "WAVE", "-d", "LEI16", src, out], { timeout: 60000 },
61
+ (err) => (err ? reject(err) : resolve())));
62
+ wav = readWav(fs.readFileSync(out));
63
+ } catch { wav = null; }
64
+ finally { fs.rmSync(dir, { recursive: true, force: true }); }
65
+ }
66
+ try { return wav && wav.frames ? svgOf(wav) : null; } catch { return null; }
67
+ }
package/lib/xml.mjs ADDED
@@ -0,0 +1,55 @@
1
+ /* Tiny XML parser for Office Open XML: elements, attributes and text, nothing more.
2
+ Names keep their prefixes ("p:sp", "a:t"); entities are decoded. */
3
+
4
+ const ENT = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
5
+ const decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => {
6
+ if (e[0] !== "#") return ENT[e] ?? m;
7
+ const cp = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : +e.slice(1);
8
+ return cp <= 0x10ffff ? String.fromCodePoint(cp) : "\ufffd"; // no such character: a stand-in, not an error
9
+ });
10
+
11
+ export function parseXml(src) {
12
+ const root = { name: "#doc", attrs: {}, children: [] };
13
+ const stack = [root];
14
+ const re = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
15
+ let m;
16
+ while ((m = re.exec(src))) {
17
+ const top = stack[stack.length - 1];
18
+ if (m[1] !== undefined) { top.children.push({ text: m[1] }); continue; }
19
+ if (m[6] !== undefined) { if (top !== root) top.children.push({ text: decode(m[6]) }); continue; }
20
+ if (!m[3]) continue; // comment or processing instruction
21
+ if (m[2]) { if (stack.length > 1) stack.pop(); continue; } // a stray closing tag never closes the document
22
+ const el = { name: m[3], attrs: {}, children: [] };
23
+ for (const a of m[4].matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) el.attrs[a[1]] = decode(a[2] ?? a[3]);
24
+ top.children.push(el);
25
+ if (!m[5]) stack.push(el);
26
+ }
27
+ return root;
28
+ }
29
+
30
+ /* Helpers on parsed elements */
31
+ export const kids = (el, name) => (el?.children || []).filter((c) => c.name && (!name || c.name === name));
32
+ export const kid = (el, name) => (el?.children || []).find((c) => c.name === name) || null;
33
+ export function path(el, ...names) {
34
+ let cur = el;
35
+ for (const n of names) { cur = kid(cur, n); if (!cur) return null; }
36
+ return cur;
37
+ }
38
+ export function find(el, name) { // first descendant with this name
39
+ for (const c of el?.children || []) {
40
+ if (!c.name) continue;
41
+ if (c.name === name) return c;
42
+ const hit = find(c, name);
43
+ if (hit) return hit;
44
+ }
45
+ return null;
46
+ }
47
+ export function findAll(el, name, out = []) {
48
+ for (const c of el?.children || []) {
49
+ if (!c.name) continue;
50
+ if (c.name === name) out.push(c);
51
+ findAll(c, name, out);
52
+ }
53
+ return out;
54
+ }
55
+ export const textOf = (el) => (el?.children || []).map((c) => (c.text != null ? c.text : textOf(c))).join("");