pindeck 2.8.2 → 2.10.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.
- package/README.md +211 -23
- package/cli.mjs +6 -3
- package/lib/capabilities.mjs +118 -0
- package/lib/claude.mjs +8 -3
- package/lib/deck-files.mjs +94 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pdf-render.jxa.js +5 -3
- package/lib/pdf.mjs +4 -2
- package/lib/platform-notice.mjs +6 -6
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +6 -3
- package/public/bridge.js +248 -69
- package/public/editor.css +1 -0
- package/public/editor.js +82 -24
- package/public/index.html +2 -2
- package/server.mjs +630 -155
package/lib/eids.mjs
CHANGED
|
@@ -145,8 +145,97 @@ function find(node, pred) {
|
|
|
145
145
|
return hit;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/*
|
|
148
|
+
/* ---- Decks made without reveal.js ----------------------------------------------------------------
|
|
149
|
+
A deck names its slides with <meta name="pindeck:slides" content="main.deck > section.slide">, a CSS
|
|
150
|
+
selector, and its size with <meta name="pindeck:size" content="1440x1080">. Selectors stay simple, so
|
|
151
|
+
the server and the browser find the same slides: tag names, #id, .class, [attr] and [attr="value"],
|
|
152
|
+
joined by spaces (descendant) or ">" (child), in a comma-separated list. */
|
|
153
|
+
|
|
154
|
+
const metaOf = (tree, name) => {
|
|
155
|
+
const m = find(tree, (c) => c.name === "meta" && c.attrs.name?.value.trim().toLowerCase() === `pindeck:${name}`);
|
|
156
|
+
return m ? decode(m.attrs.content?.value || "").trim() || null : null;
|
|
157
|
+
};
|
|
158
|
+
/* The deck's slide selector, or null for a reveal.js deck */
|
|
159
|
+
export const slideSelector = (tree) => metaOf(tree, "slides");
|
|
160
|
+
/* The deck's slide size from <meta name="pindeck:size">: { width, height } or null */
|
|
161
|
+
export function deckSize(tree) {
|
|
162
|
+
const m = /^(\d{2,5})\s*[x×*,\s]\s*(\d{2,5})$/i.exec(metaOf(tree, "size") || "");
|
|
163
|
+
return m ? { width: +m[1], height: +m[2] } : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const IDENT = "-?[_a-zA-Z\\u00a0-\\uffff][\\w\\u00a0-\\uffff-]*";
|
|
167
|
+
const COMPOUND = new RegExp(`^(?:(\\*|${IDENT})|#(${IDENT})|\\.(${IDENT})|\\[\\s*(${IDENT})\\s*(?:=\\s*(?:"([^"]*)"|'([^']*)'|(${IDENT}))\\s*)?\\])`);
|
|
168
|
+
/* "main.deck > section.slide, aside.extra" -> one list of compounds and combinators per selector.
|
|
169
|
+
Throws with the part it cannot read (pseudo-classes, +, ~ ...). */
|
|
170
|
+
export function parseSelector(sel) {
|
|
171
|
+
const parts = []; // top-level commas only: not inside [attr="a,b"]
|
|
172
|
+
let quote = null, depth = 0, from = 0;
|
|
173
|
+
for (let i = 0; i < sel.length; i++) {
|
|
174
|
+
const c = sel[i];
|
|
175
|
+
if (quote) { if (c === quote) quote = null; }
|
|
176
|
+
else if (c === '"' || c === "'") quote = c;
|
|
177
|
+
else if (c === "[") depth++;
|
|
178
|
+
else if (c === "]") depth--;
|
|
179
|
+
else if (c === "," && !depth) { parts.push(sel.slice(from, i)); from = i + 1; }
|
|
180
|
+
}
|
|
181
|
+
parts.push(sel.slice(from));
|
|
182
|
+
return parts.map((part) => {
|
|
183
|
+
const steps = []; // [{ tag, ids, classes, attrs }, ">" | " ", { ... }, ...]
|
|
184
|
+
let s = part.trim(), cur = null;
|
|
185
|
+
if (!s) throw new Error(`empty selector in "${sel}"`);
|
|
186
|
+
while (s) {
|
|
187
|
+
const ws = /^\s+/.exec(s);
|
|
188
|
+
const gt = /^\s*>\s*/.exec(s);
|
|
189
|
+
if (gt) { if (!cur) throw new Error(`"${part.trim()}" starts with ">"`); steps.push(">"); cur = null; s = s.slice(gt[0].length); continue; }
|
|
190
|
+
if (ws) { steps.push(" "); cur = null; s = s.slice(ws[0].length); continue; }
|
|
191
|
+
const m = COMPOUND.exec(s);
|
|
192
|
+
if (!m) throw new Error(`"${s.slice(0, 20)}" in "${part.trim()}" is not supported (use tags, #id, .class, [attr], spaces and ">")`);
|
|
193
|
+
if (!cur) { cur = { tag: null, ids: [], classes: [], attrs: [] }; steps.push(cur); }
|
|
194
|
+
if (m[1]) {
|
|
195
|
+
if (cur.tag || cur.ids.length || cur.classes.length || cur.attrs.length) throw new Error(`a tag name must come first in "${part.trim()}"`);
|
|
196
|
+
cur.tag = m[1] === "*" ? null : m[1].toLowerCase();
|
|
197
|
+
}
|
|
198
|
+
else if (m[2]) cur.ids.push(m[2]);
|
|
199
|
+
else if (m[3]) cur.classes.push(m[3]);
|
|
200
|
+
else cur.attrs.push({ name: m[4].toLowerCase(), value: m[5] ?? m[6] ?? m[7] ?? null });
|
|
201
|
+
s = s.slice(m[0].length);
|
|
202
|
+
}
|
|
203
|
+
if (typeof steps.at(-1) === "string") throw new Error(`"${part.trim()}" ends with a combinator`);
|
|
204
|
+
return steps;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
function compoundMatches(node, c) {
|
|
208
|
+
if (!node.attrs) return false; // the root
|
|
209
|
+
if (c.tag && node.name !== c.tag) return false;
|
|
210
|
+
if (c.ids.some((id) => node.attrs.id?.value !== id)) return false;
|
|
211
|
+
const cls = classesOf(node);
|
|
212
|
+
if (c.classes.some((k) => !cls.includes(k))) return false;
|
|
213
|
+
return c.attrs.every((a) => node.attrs[a.name] && (a.value == null || node.attrs[a.name].value === a.value));
|
|
214
|
+
}
|
|
215
|
+
/* steps[i] matches node, and the steps before it match its ancestors as their combinators say */
|
|
216
|
+
function stepsMatch(node, steps, i = steps.length - 1) {
|
|
217
|
+
if (!compoundMatches(node, steps[i])) return false;
|
|
218
|
+
if (i === 0) return true;
|
|
219
|
+
if (steps[i - 1] === ">") return !!node.parent && stepsMatch(node.parent, steps, i - 2);
|
|
220
|
+
for (let a = node.parent; a; a = a.parent) if (stepsMatch(a, steps, i - 2)) return true;
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
/* Elements matching the selector, in document order; an element holding another match is a stack of
|
|
224
|
+
slides, not a slide (as with reveal.js's vertical stacks) */
|
|
225
|
+
function selectorSlides(tree, sel) {
|
|
226
|
+
let list;
|
|
227
|
+
try { list = parseSelector(sel); } catch { return []; }
|
|
228
|
+
const hits = [];
|
|
229
|
+
walk(tree, (c) => { if (list.some((steps) => stepsMatch(c, steps))) hits.push(c); });
|
|
230
|
+
const inside = (a, b) => { for (let p = b.parent; p; p = p.parent) if (p === a) return true; return false; };
|
|
231
|
+
return hits.filter((h) => !hits.some((o) => o !== h && inside(h, o)));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. A deck that names
|
|
235
|
+
its slides with <meta name="pindeck:slides"> has the elements that selector matches instead. */
|
|
149
236
|
export function findSlides(tree) {
|
|
237
|
+
const sel = slideSelector(tree);
|
|
238
|
+
if (sel) return selectorSlides(tree, sel);
|
|
150
239
|
const slidesEl = find(tree, (c) => classesOf(c).includes("slides"));
|
|
151
240
|
if (!slidesEl) return [];
|
|
152
241
|
const out = [];
|
|
@@ -187,6 +276,7 @@ function elementName(node) {
|
|
|
187
276
|
export function assignEids(html) {
|
|
188
277
|
const tree = parse(html);
|
|
189
278
|
const sections = findSlides(tree);
|
|
279
|
+
const isSlide = slideSelector(tree) ? ((c) => sections.includes(c)) : ((c) => c.name === "section");
|
|
190
280
|
const used = new Set();
|
|
191
281
|
const edits = []; // { at, insert } or { start, end, replace }
|
|
192
282
|
const assigned = [], renamed = [];
|
|
@@ -240,7 +330,7 @@ export function assignEids(html) {
|
|
|
240
330
|
// Pass 2: elements inside the slide
|
|
241
331
|
const visit = (node, inSvg) => {
|
|
242
332
|
for (const c of node.children) {
|
|
243
|
-
if (c
|
|
333
|
+
if (isSlide(c)) continue; // nested slide, handled on its own
|
|
244
334
|
const cls = classesOf(c);
|
|
245
335
|
if (c.name === "aside" && cls.includes("notes")) continue;
|
|
246
336
|
if (c.attrs["data-noeid"]) continue;
|
|
@@ -423,10 +513,11 @@ const slideKind = (n) => classList(n).find((c) => /-slide$/.test(c)) || "content
|
|
|
423
513
|
content title and two placeholder bullets, from a content slide (the current one if it is one)
|
|
424
514
|
section a section divider, from the deck's first divider
|
|
425
515
|
duplicate a copy of the current slide
|
|
426
|
-
Element ids are left out; assignEids() gives the new slide fresh ones.
|
|
516
|
+
Element ids are left out; assignEids() gives the new slide fresh ones. In a deck made without reveal.js,
|
|
517
|
+
the slide's own id is left out too: such decks (and Present) go to slides by their id.
|
|
427
518
|
Returns { html, index } (index of the new slide) or { error }. */
|
|
428
519
|
export function insertSlide(html, kind, afterEid) {
|
|
429
|
-
const
|
|
520
|
+
const tree = parse(html), slides = findSlides(tree);
|
|
430
521
|
if (!slides.length) return { error: "The deck has no slides." };
|
|
431
522
|
const after = slides.find((s) => s.attrs["data-eid"]?.value === afterEid) || slides.at(-1);
|
|
432
523
|
const hasHead = (s) => { let h = false; walk({ children: [s] }, (c) => { if (/^h[1-3]$/.test(c.name)) h = true; }); return h; };
|
|
@@ -437,6 +528,7 @@ export function insertSlide(html, kind, afterEid) {
|
|
|
437
528
|
if (!tpl) return { error: kind === "section" ? "This deck has no section divider to model a new one on." : "This deck has no content slide to model a new one on." };
|
|
438
529
|
|
|
439
530
|
let frag = html.slice(tpl.start, elementEnd(html, tpl)).replace(/\s+data-eid="[^"]*"/g, "");
|
|
531
|
+
if (slideSelector(tree)) frag = frag.replace(/^(<[^>]*?)\s+id=(?:"[^"]*"|'[^']*'|[^\s>]+)/, "$1"); // the opening tag only
|
|
440
532
|
if (kind !== "duplicate") frag = placeholderSlide(frag, kind, slides.filter((s, i) => i <= slides.indexOf(after) && slideKind(s) === "section-slide").length + 1);
|
|
441
533
|
|
|
442
534
|
const indent = lineIndent(html, after.start);
|
|
@@ -500,12 +592,13 @@ function slideBlock(html, s) {
|
|
|
500
592
|
are. Slides inside vertical stacks are not moved. Returns { html, changed, from, index } or { error }. */
|
|
501
593
|
export function moveSlide(html, eid, beforeEid) {
|
|
502
594
|
const byEid = (all, e) => all.find((s) => s.attrs["data-eid"]?.value === e);
|
|
503
|
-
const
|
|
595
|
+
const tree = parse(html), slides = findSlides(tree);
|
|
596
|
+
const bySelector = !!slideSelector(tree); // such decks have no vertical stacks
|
|
504
597
|
const slide = byEid(slides, eid), target = beforeEid == null ? null : byEid(slides, beforeEid);
|
|
505
598
|
if (!slide || (beforeEid != null && !target)) return { error: "Slide not found in the file." };
|
|
506
|
-
if ([slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
|
|
599
|
+
if (!bySelector && [slide, target].some((s) => s?.parent.name === "section")) return { error: "Slides inside a vertical stack cannot be moved yet." };
|
|
507
600
|
const from = slides.indexOf(slide);
|
|
508
|
-
const top = slide.parent.children.filter((c) => c.name === "section");
|
|
601
|
+
const top = slide.parent.children.filter((c) => (bySelector ? slides.includes(c) : c.name === "section"));
|
|
509
602
|
const next = top[top.indexOf(slide) + 1] || null;
|
|
510
603
|
if (target === slide || target === next) return { html, changed: false, from, index: from };
|
|
511
604
|
|
|
@@ -520,7 +613,7 @@ export function moveSlide(html, eid, beforeEid) {
|
|
|
520
613
|
out = cut.slice(0, at) + text + `\n\n${indent}` + cut.slice(at);
|
|
521
614
|
} else {
|
|
522
615
|
// A final vertical stack is absent from the leaf-slide list, but we must insert after it.
|
|
523
|
-
const last = find(parse(cut), (c) => classesOf(c).includes("slides"))
|
|
616
|
+
const last = bySelector ? rest.at(-1) : find(parse(cut), (c) => classesOf(c).includes("slides"))
|
|
524
617
|
.children.filter((c) => c.name === "section").at(-1);
|
|
525
618
|
const at = elementEnd(cut, last);
|
|
526
619
|
out = cut.slice(0, at) + `\n\n${indent}` + text + cut.slice(at);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/* Which slide framework a deck uses. PinDeck edits reveal.js decks: <section> slides inside an element
|
|
2
|
+
with class "slides" (inside <div class="reveal">). Any other deck can name its slides itself with
|
|
3
|
+
<meta name="pindeck:slides" content="CSS selector"> (see lib/eids.mjs) and is then edited too. Other
|
|
4
|
+
frameworks are recognised by their markup and scripts, so the user gets a clear "not supported (yet)"
|
|
5
|
+
before PinDeck changes anything, instead of an editor without slides. Only tags and scripts are looked
|
|
6
|
+
at, never the slides' text, so a talk that mentions "impress.js" is not taken for one. */
|
|
7
|
+
|
|
8
|
+
import { parse, findSlides, slideSelector, deckSize, parseSelector } from "./eids.mjs";
|
|
9
|
+
|
|
10
|
+
const OTHERS = [ // name, markers; the first match wins (Marp before bespoke.js, which it is built on)
|
|
11
|
+
["Marp", /\bmarpit\b|bespoke-marp|data-marpit|marp-cli/i],
|
|
12
|
+
["Slidev", /slidev/i],
|
|
13
|
+
["impress.js", /id=["']impress["']|impress(\.min)?\.js/i],
|
|
14
|
+
["remark", /remark(-latest)?(\.min)?\.js|remark\.create\s*\(/i],
|
|
15
|
+
["Spectacle", /<script[^>]+src=["'][^"']*spectacle/i],
|
|
16
|
+
["Shower", /class=["'][^"']*\bshower\b|shower-core/i],
|
|
17
|
+
["deck.js", /deck-container|deck\.core(\.min)?\.js/i],
|
|
18
|
+
["bespoke.js", /bespoke(\.min)?\.js|bespoke\.from\s*\(/i],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/* Tags and script contents only: the slides' text could name any framework */
|
|
22
|
+
const markup = (html) => [...String(html).matchAll(/<script\b[^>]*>[\s\S]*?<\/script>|<[^>]+>/gi)].map((m) => m[0]).join("\n");
|
|
23
|
+
|
|
24
|
+
/* { name, supported }: reveal.js decks are supported, including one without slides yet, and so is a deck
|
|
25
|
+
whose <meta name="pindeck:slides"> selector finds slides and whose <meta name="pindeck:size"> gives their
|
|
26
|
+
size ({ name: "selector", selector, supported }) */
|
|
27
|
+
export function detectFramework(html) {
|
|
28
|
+
const tree = parse(html), selector = slideSelector(tree);
|
|
29
|
+
if (selector) return { name: "selector", selector, supported: findSlides(tree).length > 0 && !!deckSize(tree) };
|
|
30
|
+
if (findSlides(tree).length) return { name: "reveal.js", supported: true };
|
|
31
|
+
const tags = markup(html);
|
|
32
|
+
const other = OTHERS.find(([, re]) => re.test(tags));
|
|
33
|
+
if (other) return { name: other[0], supported: false };
|
|
34
|
+
if (/class=["'][^"']*\breveal\b/i.test(tags) || /Reveal\.initialize\s*\(/.test(tags)) return { name: "reveal.js", supported: true };
|
|
35
|
+
return { name: null, supported: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HOW = `A deck made otherwise can name its slides in its <head>: ` +
|
|
39
|
+
`<meta name="pindeck:slides" content="main.deck > section.slide"> (a CSS selector for the slides) and ` +
|
|
40
|
+
`<meta name="pindeck:size" content="1440x1080"> (their size in px).`;
|
|
41
|
+
|
|
42
|
+
/* The message for a deck PinDeck cannot edit, or null */
|
|
43
|
+
export function unsupportedMessage(html, file) {
|
|
44
|
+
const fw = detectFramework(html);
|
|
45
|
+
if (fw.supported) return null;
|
|
46
|
+
if (fw.name === "selector") {
|
|
47
|
+
// the slides are found: the size is what is missing (headless Chrome and the PDF pages need it)
|
|
48
|
+
if (findSlides(parse(html)).length) return `${file}: <meta name="pindeck:size"> is missing or not a size ` +
|
|
49
|
+
`such as content="1440x1080" (the slides' width and height in px). PinDeck opens the slides at that size ` +
|
|
50
|
+
`for screenshots and PDFs. Nothing was changed.`;
|
|
51
|
+
let why = "matches no elements";
|
|
52
|
+
try { parseSelector(fw.selector); } catch (e) { why = `cannot be used: ${e.message}`; }
|
|
53
|
+
return `${file}: the slide selector "${fw.selector}" in <meta name="pindeck:slides"> ${why}. Nothing was changed.`;
|
|
54
|
+
}
|
|
55
|
+
const what = fw.name ? `${file} is a ${fw.name} deck.` :
|
|
56
|
+
`${file} has no reveal.js slides (a <div class="reveal"> with <div class="slides"> holding <section> slides).`;
|
|
57
|
+
return `${what} PinDeck works with reveal.js decks; other frameworks may follow. ${HOW} ` +
|
|
58
|
+
`Nothing was changed.`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* The deck's own instructions for Claude (CLAUDE.md, AGENTS.md), with @path imports expanded.
|
|
2
|
+
|
|
3
|
+
PinDeck does not load CLAUDE.md files from parent folders the way Claude Code does: those are usually
|
|
4
|
+
written for coding work PinDeck's Claude cannot do (tests, commits, package managers), they change
|
|
5
|
+
with where the deck happens to sit, and on a shared server a parent folder may be writable by others.
|
|
6
|
+
A deck pulls in shared rules explicitly instead: "Follow @../shared/STYLE.md" includes that file.
|
|
7
|
+
|
|
8
|
+
An import is "@" followed by a path (relative to the file it is in, absolute, or ~/...), at the start
|
|
9
|
+
of a line or after a space or "(". It is expanded only when that path is a text file: an e-mail address
|
|
10
|
+
or an @mention stays as it is. Imports in code spans and code blocks are left alone. Included files may
|
|
11
|
+
import others, up to 5 levels deep; each file is included once (no cycles), and all included text
|
|
12
|
+
together is limited to 200 KB. The imported text follows the file that imports it. */
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const MAX_DEPTH = 5, MAX_BYTES = 200_000;
|
|
19
|
+
|
|
20
|
+
/* Apply fn to the text outside code: fenced blocks (``` or ~~~) and `code spans` stay as they are */
|
|
21
|
+
function outsideCode(text, fn) {
|
|
22
|
+
let fence = null;
|
|
23
|
+
return text.split("\n").map((line) => {
|
|
24
|
+
const f = /^\s*(```|~~~)/.exec(line);
|
|
25
|
+
if (f) { fence = fence ? (fence === f[1] ? null : fence) : f[1]; return line; }
|
|
26
|
+
if (fence) return line;
|
|
27
|
+
return line.split(/(`+[^`]*`+)/).map((part, i) => (i % 2 ? part : fn(part))).join("");
|
|
28
|
+
}).join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolveRef = (ref, dir) => (ref === "~" || ref.startsWith("~/") ? path.join(os.homedir(), ref.slice(1))
|
|
32
|
+
: path.resolve(dir, ref));
|
|
33
|
+
|
|
34
|
+
function readText(file) {
|
|
35
|
+
try {
|
|
36
|
+
if (!fs.statSync(file).isFile()) return null;
|
|
37
|
+
const buf = fs.readFileSync(file);
|
|
38
|
+
return buf.includes(0) ? null : buf.toString("utf8");
|
|
39
|
+
} catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expand(file, text, depth, seen, budget) {
|
|
43
|
+
const dir = path.dirname(file), found = [];
|
|
44
|
+
const body = outsideCode(text, (part) => part.replace(/(^|[\s(])@([^\s`()<>"']+)/g, (m, pre, raw) => {
|
|
45
|
+
const ref = raw.replace(/[.,:;!?]+$/, ""), tail = raw.slice(ref.length); // "see @STYLE.md." ends a sentence
|
|
46
|
+
const abs = resolveRef(ref, dir);
|
|
47
|
+
const content = readText(abs);
|
|
48
|
+
if (content == null) return m; // not a file: an e-mail address, an @mention, a typo
|
|
49
|
+
let real = abs;
|
|
50
|
+
try { real = fs.realpathSync(abs); } catch {}
|
|
51
|
+
const note = (why) => `${pre}${ref} (${why})${tail}`;
|
|
52
|
+
if (seen.has(real)) return note("included once, above or below");
|
|
53
|
+
if (depth >= MAX_DEPTH) return note("not included: imports nested too deep");
|
|
54
|
+
if (Buffer.byteLength(content) > budget.left) return note("not included: imported files over 200 KB in all");
|
|
55
|
+
budget.left -= Buffer.byteLength(content);
|
|
56
|
+
seen.add(real);
|
|
57
|
+
found.push({ ref, abs, content });
|
|
58
|
+
return note("included below");
|
|
59
|
+
}));
|
|
60
|
+
const parts = [body.trim()];
|
|
61
|
+
for (const imp of found) {
|
|
62
|
+
parts.push(`### ${imp.ref} (included by ${path.basename(file)})\n` + expand(imp.abs, imp.content, depth + 1, seen, budget));
|
|
63
|
+
}
|
|
64
|
+
return parts.filter(Boolean).join("\n\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* A deck instruction file with its imports, or null when it is missing or empty */
|
|
68
|
+
export function readInstructions(file) {
|
|
69
|
+
const text = readText(file);
|
|
70
|
+
if (text == null || !text.trim()) return null;
|
|
71
|
+
let real = file;
|
|
72
|
+
try { real = fs.realpathSync(file); } catch {}
|
|
73
|
+
return expand(file, text, 0, new Set([real]), { left: MAX_BYTES });
|
|
74
|
+
}
|
package/lib/pdf-render.jxa.js
CHANGED
|
@@ -39,10 +39,12 @@ function run(argv) {
|
|
|
39
39
|
null, w, h, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0);
|
|
40
40
|
const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep);
|
|
41
41
|
$.NSGraphicsContext.saveGraphicsState;
|
|
42
|
-
$.NSGraphicsContext.
|
|
43
|
-
$.NSColor.whiteColor.setFill;
|
|
44
|
-
$.NSRectFill($.NSMakeRect(0, 0, w, h));
|
|
42
|
+
$.NSGraphicsContext.setCurrentContext(ctx);
|
|
45
43
|
const cg = ctx.CGContext;
|
|
44
|
+
// a white page, as in a PDF viewer (and as Poppler renders on Linux): most PDFs paint no background,
|
|
45
|
+
// and a transparent picture would show dark text on a dark slide
|
|
46
|
+
$.CGContextSetRGBFillColor(cg, 1, 1, 1, 1);
|
|
47
|
+
$.CGContextFillRect(cg, $.CGRectMake(0, 0, w, h));
|
|
46
48
|
$.CGContextScaleCTM(cg, scale, scale);
|
|
47
49
|
$.CGContextTranslateCTM(cg, tx, ty);
|
|
48
50
|
page.drawWithBoxToContext($.kPDFDisplayBoxMediaBox, cg);
|
package/lib/pdf.mjs
CHANGED
|
@@ -46,7 +46,8 @@ function chromeRun(chrome, args, profile, done, timeout) {
|
|
|
46
46
|
proc.stdout.on("data", (d) => { out += d; check(); });
|
|
47
47
|
proc.stderr.on("data", (d) => { err += d; check(); });
|
|
48
48
|
proc.on("error", (e) => finish(e));
|
|
49
|
-
|
|
49
|
+
// "close", not "exit": at exit Chrome's last output (the line saying the file is written) may still be unread
|
|
50
|
+
proc.on("close", () => (done(out, err) ? finish(null, out) : finish(new Error("Chrome exited early."))));
|
|
50
51
|
});
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -61,7 +62,8 @@ export async function printLayout(chrome, url, profile) {
|
|
|
61
62
|
/* Step 2: print a (static) page to a PDF file. */
|
|
62
63
|
export async function printPdf(chrome, url, out, profile) {
|
|
63
64
|
try { fs.rmSync(out, { force: true }); } catch {}
|
|
64
|
-
|
|
65
|
+
// the virtual-time budget lets Chrome follow PinDeck's login start page to the print page before printing
|
|
66
|
+
await chromeRun(chrome, ["--virtual-time-budget=5000", "--no-pdf-header-footer", `--print-to-pdf=${out}`, url], profile,
|
|
65
67
|
(_o, e) => /bytes written to file/.test(e), 90000);
|
|
66
68
|
if (!fs.existsSync(out)) throw new Error("Chrome did not write the PDF.");
|
|
67
69
|
return out;
|
package/lib/platform-notice.mjs
CHANGED
|
@@ -2,13 +2,13 @@ import http from 'node:http';
|
|
|
2
2
|
|
|
3
3
|
export function createPlatformNoticeServer(platform) {
|
|
4
4
|
const windows = platform === 'win32';
|
|
5
|
-
const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck
|
|
5
|
+
const heading = windows ? 'Windows support is on the roadmap' : 'PinDeck runs on macOS and Linux';
|
|
6
6
|
const explanation = windows
|
|
7
|
-
? 'PinDeck
|
|
8
|
-
: 'This version of PinDeck
|
|
9
|
-
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck ·
|
|
7
|
+
? 'PinDeck runs on macOS and, as a beta, on Linux. Windows support is planned for a future version; there is no release date yet.'
|
|
8
|
+
: 'This version of PinDeck runs on macOS and, as a beta, on Linux. This operating system is not supported.';
|
|
9
|
+
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PinDeck · system not supported</title><style>
|
|
10
10
|
:root{font-family:system-ui,sans-serif;color:#243348;background:#f4f6f8;color-scheme:light}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px}main{width:min(100%,620px);padding:40px;background:white;border:1px solid #dde3ea;border-radius:18px;box-shadow:0 12px 40px #20334d0a}.brand{font-weight:700;color:#c36516;margin:0 0 32px}h1{font-size:clamp(26px,5vw,36px);line-height:1.2;margin:0 0 20px}p{line-height:1.65}a{color:#945012}footer{margin-top:28px;font-size:14px;color:#596577}
|
|
11
|
-
</style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can
|
|
11
|
+
</style></head><body><main><p class="brand">PinDeck</p><h1>${heading}</h1><p>${explanation}</p><p>Your deck has not been opened or changed, and no AI backend has been started.</p><p>You can run PinDeck on a Mac or a Linux machine (and use it from this browser through an SSH tunnel), or follow updates on <a href="https://github.com/ehabets/PinDeck">GitHub</a>.</p><footer>Close this tab and press Ctrl+C in the terminal to stop this notice.</footer></main></body></html>`;
|
|
12
12
|
return http.createServer((req, res) => {
|
|
13
13
|
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { Allow: 'GET, HEAD' }); return res.end(); }
|
|
14
14
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
|
|
@@ -28,7 +28,7 @@ export async function showPlatformNotice(port, platform = process.platform) {
|
|
|
28
28
|
});
|
|
29
29
|
server.listen(port, '127.0.0.1', () => {
|
|
30
30
|
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
31
|
-
console.log(`PinDeck
|
|
31
|
+
console.log(`PinDeck runs on macOS and Linux only. Open http://127.0.0.1:${server.address().port}/ for details. Press Ctrl+C to stop.`);
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
}
|
package/lib/pptx-render.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
/* Pictures of PowerPoint slides
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/* Pictures of PowerPoint slides, and of PDF pages.
|
|
2
|
+
|
|
3
|
+
macOS: PowerPoint, or else Keynote, exports the deck as a PDF with one page per slide, hidden slides
|
|
4
|
+
included; macOS PDFKit (pdf-render.jxa.js) then renders whole pages (thumbnails, references for Claude)
|
|
5
|
+
and regions (diagrams, PDF figures) to PNG.
|
|
6
|
+
Linux: LibreOffice (soffice --headless) exports the PDF, and Poppler (pdfinfo, pdftoppm) renders it.
|
|
7
|
+
Both are optional; lib/capabilities.mjs turns the features on when they are found.
|
|
4
8
|
|
|
5
9
|
Both apps are sandboxed, so the files go through a folder inside the app's own container, which
|
|
6
10
|
needs no permission dialog. macOS asks once whether PinDeck may control the app. An app that was
|
|
@@ -11,7 +15,8 @@ import crypto from "node:crypto";
|
|
|
11
15
|
import fs from "node:fs";
|
|
12
16
|
import os from "node:os";
|
|
13
17
|
import path from "node:path";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
19
|
+
import { findExecutable } from "./capabilities.mjs";
|
|
15
20
|
|
|
16
21
|
const JXA = path.join(path.dirname(fileURLToPath(import.meta.url)), "pdf-render.jxa.js");
|
|
17
22
|
const APPS = [
|
|
@@ -24,8 +29,16 @@ const osa = (args, timeout = 240000) => new Promise((resolve, reject) =>
|
|
|
24
29
|
err ? reject(new Error((errOut || err.message).trim().split("\n").pop())) : resolve(out.trim())));
|
|
25
30
|
const q = (s) => JSON.stringify(s); // AppleScript string literal (same quoting rules for paths)
|
|
26
31
|
|
|
32
|
+
const mac = () => process.platform === "darwin";
|
|
33
|
+
const tool = (...names) => names.map((n) => findExecutable(n).path).find(Boolean) || null;
|
|
34
|
+
const run = (cmd, args, timeout = 120000) => new Promise((resolve, reject) =>
|
|
35
|
+
execFile(cmd, args, { timeout, maxBuffer: 1e7 }, (err, out, errOut) =>
|
|
36
|
+
err ? reject(new Error(String(errOut || err.message).trim().split("\n").pop())) : resolve(String(out))));
|
|
37
|
+
export const popplerAvailable = () => !!(tool("pdftoppm") && tool("pdfinfo"));
|
|
38
|
+
export const sofficePath = () => tool("soffice", "libreoffice");
|
|
39
|
+
|
|
27
40
|
export function renderAvailable() {
|
|
28
|
-
return
|
|
41
|
+
return mac() ? APPS.some((a) => fs.existsSync(a.bundle)) : !!(sofficePath() && popplerAvailable());
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
function exportScript(app, pptx, pdf) {
|
|
@@ -58,7 +71,7 @@ return "ok"`;
|
|
|
58
71
|
|
|
59
72
|
/* Exports the deck to <workDir>/deck.pdf. Returns { pdf, app }. */
|
|
60
73
|
export async function exportPdf(pptxPath, workDir) {
|
|
61
|
-
if (
|
|
74
|
+
if (!mac()) return exportPdfLibreOffice(pptxPath, workDir);
|
|
62
75
|
const errors = [];
|
|
63
76
|
for (const app of APPS) {
|
|
64
77
|
if (!fs.existsSync(app.bundle)) continue;
|
|
@@ -81,17 +94,73 @@ export async function exportPdf(pptxPath, workDir) {
|
|
|
81
94
|
throw new Error(errors.length ? errors.join("; ") : "Neither PowerPoint nor Keynote is installed.");
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
/*
|
|
85
|
-
|
|
97
|
+
/* Linux: LibreOffice exports the deck, hidden slides included (so page n is slide n). Its own profile in
|
|
98
|
+
the work folder, so a LibreOffice the user has open, or a stale lock, does not get in the way. */
|
|
99
|
+
async function exportPdfLibreOffice(pptxPath, workDir) {
|
|
100
|
+
const soffice = sofficePath();
|
|
101
|
+
if (!soffice) throw new Error("LibreOffice (soffice) is not installed.");
|
|
102
|
+
const dir = fs.mkdtempSync(path.join(workDir, "libreoffice-"));
|
|
103
|
+
try {
|
|
104
|
+
const inFile = path.join(dir, "deck.pptx");
|
|
105
|
+
fs.copyFileSync(pptxPath, inFile);
|
|
106
|
+
await run(soffice, ["--headless", "--norestore", `-env:UserInstallation=${pathToFileURL(path.join(dir, "profile")).href}`,
|
|
107
|
+
"--convert-to", 'pdf:impress_pdf_Export:{"ExportHiddenSlides":{"type":"boolean","value":"true"}}',
|
|
108
|
+
"--outdir", dir, inFile], 240000);
|
|
109
|
+
const out = path.join(dir, "deck.pdf");
|
|
110
|
+
if (!fs.existsSync(out)) throw new Error("LibreOffice wrote no PDF.");
|
|
111
|
+
const pdf = path.join(workDir, "deck.pdf");
|
|
112
|
+
fs.copyFileSync(out, pdf);
|
|
113
|
+
return { pdf, app: "LibreOffice" };
|
|
114
|
+
} finally {
|
|
115
|
+
fs.rm(dir, { recursive: true, force: true }, () => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Renders pages or regions (see pdf-render.jxa.js for the jobs). Returns the page count. Boxes are in
|
|
120
|
+
slide px of a page slideWidth wide; with slideWidth = the page width in points, they are in PDF points. */
|
|
86
121
|
export async function renderJobs(pdf, jobs, workDir, { slideWidth = 1280 } = {}) {
|
|
122
|
+
if (!mac()) return renderPoppler(pdf, jobs, slideWidth);
|
|
87
123
|
return +(await runJxa({ pdf, slideWidth, jobs }, workDir));
|
|
88
124
|
}
|
|
89
125
|
|
|
90
|
-
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (
|
|
126
|
+
/* Page count and page sizes in points: { pages, sizes: [[w, h], ...] } (PDFKit or Poppler, no other app) */
|
|
91
127
|
export async function pdfInfo(pdf, workDir) {
|
|
128
|
+
if (!mac()) return popplerInfo(pdf);
|
|
92
129
|
return JSON.parse(await runJxa({ pdf, info: true }, workDir));
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
async function popplerInfo(pdf, exact = false) {
|
|
133
|
+
const pages = +(/^Pages:\s+(\d+)/m.exec(await run(tool("pdfinfo"), [pdf])) || [])[1];
|
|
134
|
+
if (!pages) throw new Error("pdfinfo could not read the PDF.");
|
|
135
|
+
const out = await run(tool("pdfinfo"), ["-f", "1", "-l", String(pages), pdf]);
|
|
136
|
+
const sizes = [...out.matchAll(/^Page\s+(\d+)\s+size:\s+([\d.]+) x ([\d.]+)/gm)]
|
|
137
|
+
.sort((a, b) => a[1] - b[1]).map((m) => (exact ? [+m[2], +m[3]] : [Math.round(+m[2]), Math.round(+m[3])]));
|
|
138
|
+
return { pages, sizes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* The same jobs as pdf-render.jxa.js, with pdftoppm: whole pages scaled to a width, or a box of the page
|
|
142
|
+
(in slide px) at job.scale output px per slide px. White background, as PDFKit's. */
|
|
143
|
+
async function renderPoppler(pdf, jobs, slideWidth) {
|
|
144
|
+
const info = await popplerInfo(pdf, true);
|
|
145
|
+
for (const job of jobs) {
|
|
146
|
+
const size = info.sizes[job.page - 1];
|
|
147
|
+
if (!size) continue;
|
|
148
|
+
const [pw, ph] = size;
|
|
149
|
+
const args = ["-f", String(job.page), "-l", String(job.page), "-png", "-singlefile"];
|
|
150
|
+
if (job.box) {
|
|
151
|
+
const [x, y, bw, bh] = job.box, s = job.scale || 2, f = pw / (slideWidth || 1280); // PDF points per slide px
|
|
152
|
+
args.push("-r", String((s / f) * 72), "-x", String(Math.round(x * s)), "-y", String(Math.round(y * s)),
|
|
153
|
+
"-W", String(Math.max(1, Math.round(bw * s))), "-H", String(Math.max(1, Math.round(bh * s))));
|
|
154
|
+
} else {
|
|
155
|
+
const scale = (job.width || 1280) / pw;
|
|
156
|
+
args.push("-scale-to-x", String(Math.round(pw * scale)), "-scale-to-y", String(Math.round(ph * scale)));
|
|
157
|
+
}
|
|
158
|
+
const prefix = job.out.replace(/\.png$/i, ""); // pdftoppm adds .png
|
|
159
|
+
await run(tool("pdftoppm"), [...args, pdf, prefix]);
|
|
160
|
+
}
|
|
161
|
+
return info.pages;
|
|
162
|
+
}
|
|
163
|
+
|
|
95
164
|
async function runJxa(spec, workDir) {
|
|
96
165
|
const file = path.join(workDir, `jobs-${crypto.randomBytes(4).toString("hex")}.json`);
|
|
97
166
|
fs.writeFileSync(file, JSON.stringify(spec));
|
package/lib/system-prompt.md
CHANGED
|
@@ -12,7 +12,9 @@ Each user message may start with an <editor-context> block:
|
|
|
12
12
|
- sometimes the path of a screenshot of the slide with the pin markers drawn on it.
|
|
13
13
|
|
|
14
14
|
## Files
|
|
15
|
-
-
|
|
15
|
+
- {{DECK}}: the slides. theme.css: the styles. Edit these. You may also edit or create other files in the
|
|
16
|
+
deck folder when a request needs it (a script such as deck.js, a style sheet, the deck's notes), but not
|
|
17
|
+
vendor/ (third-party libraries). The user can undo everything a turn changed in one step.
|
|
16
18
|
- brand.css is LOCKED (logos, footer, brand colours). Never edit it. If a request needs a brand
|
|
17
19
|
change, say so instead. Elements marked brand-locked are positioned by brand.css.
|
|
18
20
|
- Follow the deck's CLAUDE.md (at the end of these instructions) and README.md for the template's
|
|
@@ -37,7 +39,7 @@ in the editor. Do not delete hidden slides, and do not unhide them unless asked.
|
|
|
37
39
|
|
|
38
40
|
## Attached files
|
|
39
41
|
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
|
|
42
|
+
`figures/` folder; <editor-context> lists their paths (relative to {{DECK}}), size and, for audio,
|
|
41
43
|
the duration and a generated waveform SVG. If a file was dropped onto a slide, the drop position is
|
|
42
44
|
given too. Put them on slides with the deck's existing patterns: a figure like the other `figure.fig`
|
|
43
45
|
blocks, audio like the deck's audio demo (audio element, play button, waveform). Read an image
|
|
@@ -84,7 +86,7 @@ If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build
|
|
|
84
86
|
|
|
85
87
|
## Hand edits
|
|
86
88
|
The user can also select, move and resize elements in the editor. Those changes are written as
|
|
87
|
-
inline styles on the element in
|
|
89
|
+
inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width`/`height` (and
|
|
88
90
|
`max-width`/`max-height: none`) for sizes. You get a note about each one. Treat them as intended;
|
|
89
91
|
keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
|
|
90
92
|
The user can also edit text in place (double-click); you get a note with the new text. Keep their
|
package/lib/waveform.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
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
|
|
2
|
+
WAV is read directly; other formats are converted to WAV first, with afconvert on macOS or ffmpeg on
|
|
3
|
+
Linux, when available. */
|
|
3
4
|
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
6
|
+
import { findExecutable } from "./capabilities.mjs";
|
|
5
7
|
import fs from "node:fs";
|
|
6
8
|
import os from "node:os";
|
|
7
9
|
import path from "node:path";
|
|
@@ -52,12 +54,16 @@ function svgOf(wav) {
|
|
|
52
54
|
/* Returns the SVG text, or null if the audio cannot be read */
|
|
53
55
|
export async function waveformSvg(data, ext) {
|
|
54
56
|
let wav = /^wav(e)?$/i.test(ext) ? readWav(data) : null;
|
|
55
|
-
|
|
57
|
+
// other formats: decoded to 16-bit WAV by afconvert (macOS) or ffmpeg (Linux, when installed)
|
|
58
|
+
const ffmpeg = process.platform !== "darwin" && findExecutable("ffmpeg").path;
|
|
59
|
+
if (!wav && (process.platform === "darwin" || ffmpeg)) {
|
|
56
60
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pindeck-wave-"));
|
|
57
61
|
try {
|
|
58
62
|
const src = path.join(dir, `in.${ext || "audio"}`), out = path.join(dir, "out.wav");
|
|
59
63
|
fs.writeFileSync(src, data);
|
|
60
|
-
|
|
64
|
+
const [cmd, args] = ffmpeg ? [ffmpeg, ["-v", "error", "-y", "-i", src, "-f", "wav", "-acodec", "pcm_s16le", out]]
|
|
65
|
+
: ["afconvert", ["-f", "WAVE", "-d", "LEI16", src, out]];
|
|
66
|
+
await new Promise((resolve, reject) => execFile(cmd, args, { timeout: 60000 },
|
|
61
67
|
(err) => (err ? reject(err) : resolve())));
|
|
62
68
|
wav = readWav(fs.readFileSync(out));
|
|
63
69
|
} catch { wav = null; }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "PinDeck:
|
|
3
|
+
"version": "2.10.0",
|
|
4
|
+
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux (beta) through an SSH tunnel. Pin elements, describe the change, and Claude Code edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
7
7
|
"presentation",
|
|
@@ -35,7 +35,10 @@
|
|
|
35
35
|
"start": "node server.mjs",
|
|
36
36
|
"test": "node --test"
|
|
37
37
|
},
|
|
38
|
-
"os": [
|
|
38
|
+
"os": [
|
|
39
|
+
"darwin",
|
|
40
|
+
"linux"
|
|
41
|
+
],
|
|
39
42
|
"engines": {
|
|
40
43
|
"node": ">=20"
|
|
41
44
|
}
|