pindeck 2.9.0 → 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 +123 -20
- package/cli.mjs +2 -1
- package/lib/capabilities.mjs +13 -7
- package/lib/deck-files.mjs +38 -0
- package/lib/eids.mjs +101 -8
- package/lib/frameworks.mjs +59 -0
- package/lib/instructions.mjs +74 -0
- package/lib/pptx-render.mjs +78 -9
- package/lib/system-prompt.md +5 -3
- package/lib/waveform.mjs +9 -3
- package/package.json +1 -1
- package/public/bridge.js +248 -69
- package/public/editor.js +17 -9
- package/public/index.html +1 -1
- package/server.mjs +207 -61
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
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",
|