pindeck 2.9.0 → 2.11.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 CHANGED
@@ -21,8 +21,8 @@ checked the layout; the next request, still being typed, refers to the new pins
21
21
  * **Edit by hand, too.** Move, resize and nudge elements with snapping, edit text in place (math stays
22
22
  TeX), write speaker notes, add, hide, move and delete slides. One undo history covers your edits and
23
23
  Claude's.
24
- * **Stay on brand.** `brand.css` is locked: Claude edits `index.html` and `theme.css` only, and checks
25
- its own work with PinDeck's layout command.
24
+ * **Stay on brand.** `brand.css` is locked: Claude edits the deck, its styles and other files of the deck
25
+ folder, never `brand.css` or `vendor/`, and checks its own work with PinDeck's layout command.
26
26
  * **Bring material in.** Attach figures, audio and PDFs to a message, or import slides from PowerPoint
27
27
  into the deck's own layouts.
28
28
  * **Get it out.** Present from the browser, or export a handout or presentation PDF.
@@ -45,17 +45,21 @@ node server.mjs ocean-example # or from source, with the example deck in this
45
45
  Then open the login link PinDeck prints (`PinDeck: http://127.0.0.1:5180/login?code=...`). It works
46
46
  once and logs this browser in; for a new one, run `pindeck login` (or `node server.mjs login`). Requirements: Node 20 or later and the `claude` CLI, signed in
47
47
  with your normal Claude account. Chrome (or Chromium, Edge, Brave) is optional and is used for
48
- screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import.
49
-
50
- PinDeck adds element ids to the deck's `index.html` when it opens it (see below), so try it on a copy
51
- of a deck first. Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
52
- `~/.pindeck/config.json`), `--help`. The deck folder defaults to the current folder and must contain
53
- `index.html`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
48
+ screenshots and PDF export; PowerPoint or Keynote (macOS) for pictures in the PowerPoint import; on
49
+ Linux, Poppler, LibreOffice and ffmpeg (see [Running on a Linux server](#running-on-a-linux-server-beta)).
50
+
51
+ The deck is a file (`pindeck path/to/talk.html`) or a folder, which opens its `index.html`; without
52
+ one, the current folder. Several decks can share a folder, each with its own chat and undo history;
53
+ they share the folder's `theme.css` and `brand.css`, and Claude edits only the deck that is open.
54
+ PinDeck adds element ids to the deck file when it opens it (see below), so try it on a copy of a deck
55
+ first. Options: `--port 5180`, `--model <name>` (for this run only; otherwise the model in
56
+ `~/.pindeck/config.json`), `--help`. Runtime files (chat history, screenshots, backups) are kept in `~/.pindeck/` (change with
54
57
  `PINDECK_HOME`), not in the deck. `ocean-example/` is a complete example deck to start from.
55
58
 
56
59
  ## Preparing a deck
57
60
 
58
- PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`. Two
61
+ PinDeck works with reveal.js decks whose slides are `<section>` elements inside `.slides`, and with other
62
+ decks that name their slides (see [Decks made without reveal.js](#decks-made-without-revealjs)). Two
59
63
  optional `<meta>` tags in the deck's `<head>` tell it about the design:
60
64
 
61
65
  ```html
@@ -70,6 +74,40 @@ numbers will jump where a slide is hidden.
70
74
  Without them PinDeck runs no footer check and locks only `.slide-foot` and `.logo`. A file called
71
75
  `brand.css` in the deck folder is locked for Claude. See `ocean-example/` for a complete deck.
72
76
 
77
+ ### Decks made without reveal.js
78
+
79
+ A deck with a script of its own, or made with a framework whose slides are elements of their own
80
+ (Shower, deck.js, a hand-written deck), can be edited too once it names its slides in its `<head>`:
81
+
82
+ ```html
83
+ <meta name="pindeck:slides" content="main.deck > section.slide"> <!-- a CSS selector for the slides -->
84
+ <meta name="pindeck:size" content="1440x1080"> <!-- their size in px -->
85
+ ```
86
+
87
+ The selector may use tag names, `#id`, `.class`, `[attr]` and `[attr="value"]`, joined by spaces or `>`,
88
+ and a list of them separated by commas. In the editor, PinDeck shows the slides itself, one at a time and
89
+ scaled to the window, and switches off the deck's own scaling, slide keys and inert or hidden marks while
90
+ you edit; that changes nothing in the deck file. Everything in [Working with it](#working-with-it) works as
91
+ with a reveal.js deck, with these differences:
92
+
93
+ * Build steps (`.fragment`) belong to reveal.js: other decks show every element at once.
94
+ * Hidden slides (`data-visibility="hidden"`) are left out of PinDeck's PDF exports; whether presenting
95
+ skips them is up to the deck's own script.
96
+ * Speaker notes are an `<aside class="notes">` in the slide, as in reveal.js. PinDeck hides them in the
97
+ editor; the deck's CSS should hide them too (`aside.notes { display: none }`).
98
+ * **Present** opens the deck at `#<slide id>`, or `#<number>` (counting from 1) for a slide without an id.
99
+ Present may open on the first slide if the deck does not go to slides by their address.
100
+ * **+ New slide** models a new slide on the deck's own (see [Making your own template](#making-your-own-template)):
101
+ a content slide gets placeholder bullets in a `.slide-body`; without one it has only its title. A new or
102
+ duplicated slide does not take the original's `id`.
103
+
104
+ Both tags are needed: PinDeck opens the slides at the given size for screenshots and PDF pages. A missing
105
+ size, or a selector that finds no slides or that PinDeck cannot read, is reported before anything changes.
106
+ While PinDeck runs, it reads the tags again for every screenshot, PDF and Claude session; the editor reads
107
+ them when the slide reloads. If a tag stops working meanwhile, the editor shows the same message and Claude
108
+ is told. A Claude session already running keeps the slide selector it started with; **New chat** starts one
109
+ with the new selector.
110
+
73
111
  ## Making your own template
74
112
 
75
113
  PinDeck has no template generator; a template is simply a deck folder. Start from a copy of the example:
@@ -91,6 +129,19 @@ cp -R ocean-example my-template
91
129
  example's `CLAUDE.md` does; PinDeck tells Claude the right path wherever the deck is. An older
92
130
  `node ../cli.mjs` in a deck's `CLAUDE.md` is replaced with that command.
93
131
 
132
+ **Instructions for Claude.** PinDeck gives Claude the deck folder's own `CLAUDE.md` and `AGENTS.md`.
133
+ It does not load `CLAUDE.md` files from parent folders, as Claude Code does in a terminal: those are
134
+ usually written for coding (run the tests, commit), which PinDeck's Claude cannot do; they would change
135
+ with where the deck happens to sit; and on a shared server a parent folder may be writable by others.
136
+ To share rules between decks, say so in the deck's own file, in one of two ways:
137
+
138
+ - **Import:** `Follow @../shared/STYLE.md for wording.` includes that file's text. Paths are relative to
139
+ the file they are in (or absolute, or `~/...`); imported files may import others, up to 5 levels,
140
+ each file once, 200 KB in all. Only text files are imported; an e-mail address, an `@name` that is
141
+ not a file, and anything in code are left as written.
142
+ - **Pointer:** `The style rules are in ../shared/STYLE.md; read them first.` Claude reads the file when
143
+ it needs it.
144
+
94
145
  **New slide** and the PowerPoint import model new slides on the deck's own: keep a slide with class
95
146
  `title-slide`, one with `section-slide`, and a content slide with `.slide-head` (`h2`, optionally a
96
147
  `.kicker` or `.sub`) and `.slide-body`. Imported tables take the class of the deck's first table,
@@ -113,6 +164,18 @@ plain players.
113
164
  chat header's menu is written to this file, so it always shows what PinDeck uses. Edit it while
114
165
  PinDeck is stopped, or simply use the menu.
115
166
 
167
+ ### Files outside the deck folder
168
+
169
+ A deck can use files from a folder around it, such as a style sheet or figures shared by several
170
+ talks: start PinDeck with `--asset-root <folder>` (repeatable), or list the folders in config.json as
171
+ `"assetRoots": ["~/talks"]`. When the folder contains the deck, PinDeck serves the deck from inside
172
+ it, so relative paths such as `../shared/style.css` work, in the editor, in screenshots and in PDF
173
+ exports. Claude may read those files but does not edit them.
174
+
175
+ Symlinks: PinDeck serves a file only if, with symlinks followed, it lies inside the deck folder or an
176
+ asset root. A symlink in the deck that leads anywhere else is refused (403). To use a symlinked
177
+ library folder, add the folder it points to as an asset root; it does not have to contain the deck.
178
+
116
179
  ## Running on a Linux server (beta)
117
180
 
118
181
  PinDeck can run on a Linux machine where the deck lives, with the editor in your Mac's browser. The
@@ -146,10 +209,19 @@ you), never in a page or on a command line.
146
209
 
147
210
  What works on Linux in this beta: editing by hand, speaker notes, slides, pins, the chat with Claude,
148
211
  Undo/Redo, live reload when files change on the server, layout checks, attaching figures, audio and
149
- PDFs, `git-setup`, and, with Chrome or Chromium installed on the server, screenshots and PDF export
150
- (`PINDECK_LINUX_CHROME=0` turns these off). What does not, with the reason shown in the editor (hover
151
- over "Server: Linux (beta)"): pictures of PDF pages (`pdf-figure`, needs macOS PDFKit) and the
152
- PowerPoint import. Claude is told which commands are not available. Chromium from snap may not be
212
+ PDFs, `git-setup` and the PowerPoint import. Some features use programs on the server, and are on when
213
+ they are installed (on Debian or Ubuntu: `sudo apt install poppler-utils libreoffice-impress ffmpeg`):
214
+
215
+ | Feature | Needs |
216
+ |---|---|
217
+ | Screenshots, layout checks without an open editor, PDF export | Chrome or Chromium (`PINDECK_LINUX_CHROME=0` turns these off) |
218
+ | Pictures of PDF pages (`pdf-figure`) | Poppler (`pdftoppm`, `pdfinfo`) |
219
+ | Pictures of PowerPoint slides in the import (thumbnails, diagrams) | LibreOffice (`soffice`) and Poppler |
220
+ | Waveforms of audio other than WAV in the import | ffmpeg |
221
+
222
+ Without them the editor says what is missing (hover over "Server: Linux (beta)"), and Claude is told
223
+ which commands are not available. LibreOffice may draw some slides a little differently from PowerPoint;
224
+ the pictures are references and figures, the slides themselves are rebuilt from the file. Chromium from snap may not be
153
225
  allowed to use hidden folders in your home folder such as `~/.pindeck`; if screenshots fail with it, set
154
226
  `PINDECK_HOME` to a folder that is not hidden, or install Chrome or Chromium from a package.
155
227
 
@@ -181,7 +253,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
181
253
  | Undo, redo | `⌘Z` / `⇧⌘Z` or the toolbar arrows. One history for your hand edits and Claude's changes. |
182
254
  | Pin elements | Press `P` (or the Pin button), click elements; each click gets a number. Click a marker to remove it, `Esc` to stop. |
183
255
  | Ask Claude | Write in the sidebar and refer to pins by number: "1: align with 2, make 3 smaller". The selected element is sent too, so "make this smaller" works. `Enter` sends, `Shift+Enter` adds a new line. |
184
- | Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. |
256
+ | Visual questions | **+** in the chat, then **Screenshot**: the current slide with its pin markers is attached when you send. The chat shows it, and the screenshots Claude reads, as a small picture; click it to see it larger. |
185
257
  | Review | Each answer ends with the changed files, a **Diff** button and an **Undo** button (most recent change). |
186
258
  | Stop | **Stop** interrupts Claude; the conversation continues. **New chat** starts over. |
187
259
  | New slide | **+ New slide** above the slide list: a **content slide** (title and bullets), a **section divider**, or a **duplicate** of the current slide. It is added after the current slide, modelled on the deck's own slides, and its title opens for typing. |
@@ -191,7 +263,7 @@ show and commit as usual. Clones without the filter configured simply see the fi
191
263
  | Hide a slide | The eye on a slide in the list, or `H`. A hidden slide stays in the deck and in PinDeck (greyed out, with a banner) but is skipped when presenting and in PDFs; **Export ▾** can include hidden slides. Show it the same way. |
192
264
  | Model and effort | Click **Claude ▾** in the chat header: Opus or Sonnet, and the effort (how long Claude thinks: low to max). The conversation continues after a change. The choice is saved in `~/.pindeck/config.json` (default: Sonnet, medium). |
193
265
  | Attach figures and audio | **+** in the chat, then **File…**, drag and drop onto the chat or a slide, or paste an image. Files are saved in `<deck>/figures/` straight away; audio also gets a waveform SVG. Then ask Claude, e.g. "put this figure on a new slide after 4". Removing an attachment before sending deletes the file again. |
194
- | Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck`); Claude reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
266
+ | Attach a PDF | The same way: a figure or table to take over, or a paper or report with more details. PDFs stay outside the deck (in `~/.pindeck`); Claude reads them, rebuilds tables as HTML, and renders a figure (a page or a cropped part of one) into `<deck>/figures/` as PNG with `cli.mjs pdf-figure` (macOS, or Linux with Poppler). E.g. "put Fig. 3 of this paper on slide 7" or "add the results of Table 2 to slide 9". |
195
267
  | Export PDF | **Export ▾** in the toolbar. **Handout PDF**: one page per slide, build steps shown complete. **Presentation PDF**: one page per click, so a slide with two build steps gives three pages. Files go to `<deck>/export/`. Needs Chrome or Chromium. |
196
268
  | Navigate | Slide list, `←` `→`, or the toolbar. **Present** opens the deck in a new tab. |
197
269
 
@@ -269,12 +341,15 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
269
341
  * **One Claude Code process.** The server starts `claude -p` in stream-json mode once and keeps it
270
342
  running, so there is no start-up delay per message. It runs in the deck folder under your Claude
271
343
  login, with `--append-system-prompt` from `lib/system-prompt.md` and the deck's `CLAUDE.md`.
272
- * **Permissions.** Claude may read files, edit `index.html` and `theme.css`, and run the editor's
273
- CLI. Nothing else, and in particular no edits to `brand.css`. Claude Code loads no settings files
344
+ * **Permissions.** Claude may read files, edit and create files in the deck folder, and run the editor's
345
+ CLI. Nothing else: no other commands, and no edits to `brand.css`, `vendor/`, `.git/` or, for a named
346
+ deck file, the other decks in its folder. Claude Code loads no settings files
274
347
  and no MCP servers, so no allow rule in your own, the deck's or a parent folder's settings widens this. If `brand.css` changes during a
275
348
  turn anyway, the server restores it and says so in the chat.
276
- * **Undo.** The server keeps a copy of the editable files from before each change (a Claude turn
277
- or a hand edit). Undo and redo walk that history and tell Claude on the next message. They first check
349
+ * **Undo.** The server keeps a copy of every file a change touched, from before and after it (a Claude
350
+ turn, which may change, create or delete several files of the deck, or a hand edit). Files over 2 MB
351
+ are not copied: the chat says when a turn changed one, and Undo leaves it as it is. Hidden files,
352
+ `vendor/` and `export/` are not part of undo. Undo and redo walk that history and tell Claude on the next message. They first check
278
353
  that the files are still as that change left them; if something else changed them since, they do
279
354
  nothing and say which file differs, rather than overwrite that work.
280
355
  * **Saving.** Every file PinDeck writes into the deck is written to a temporary file next to it and then
@@ -293,7 +368,7 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
293
368
  | `lib/pptx-import.mjs` | Turns PowerPoint slides into slides of the deck: figures, tables, build steps, audio, layout, HTML in the deck's style. |
294
369
  | `lib/waveform.mjs` | Waveform SVGs for imported audio. |
295
370
  | `lib/omml.mjs` | PowerPoint equations (Office Math) to TeX. |
296
- | `lib/pptx-render.mjs`, `lib/pdf-render.jxa.js` | Slide pictures: PowerPoint or Keynote saves a PDF, macOS PDFKit renders pages and regions of it. |
371
+ | `lib/pptx-render.mjs`, `lib/pdf-render.jxa.js` | Slide and PDF pictures: PowerPoint or Keynote (macOS) or LibreOffice (Linux) saves a PDF; macOS PDFKit or Poppler renders pages and regions of it. |
297
372
  | `lib/pdf.mjs` | PDF export: reveal.js print layout in headless Chrome, then print to PDF. |
298
373
  | `lib/eids.mjs` | Adds the `data-eid` attributes by editing the HTML as text. |
299
374
  | `lib/claude.mjs` | The long-running Claude Code process. |
@@ -306,16 +381,48 @@ Without PowerPoint or Keynote (or on other systems than macOS) only the text and
306
381
 
307
382
  ## Current Limitations
308
383
 
384
+ * reveal.js decks, and decks that name their slides with `<meta name="pindeck:slides">` (see [Decks made
385
+ without reveal.js](#decks-made-without-revealjs)). Any other deck made with another framework (Marp, Slidev,
386
+ impress.js, remark, Spectacle, Shower, deck.js, bespoke.js) is recognised and refused with its name before
387
+ PinDeck changes anything.
309
388
  * Text inside inline SVG diagrams cannot be edited in place; pin it and ask Claude.
310
389
  * Parts of an inline SVG move with the whole SVG; inline text (bold, spans) moves with its block.
311
390
  * No indenting or outdenting of bullets with `Tab` yet, and `Backspace` at the start of a bullet does not merge it with the one above; ask Claude for those.
312
391
  * Moves are offsets (`translate`), so the space an element came from stays reserved. Ask Claude to turn hand edits into proper CSS when a layout should really change.
313
392
  * The undo history lives on the server and starts fresh when PinDeck restarts.
314
393
  * One writer at a time: PinDeck is not made for editing the same deck from two editors or tools at once.
315
- * Linux (beta): no PDF page pictures or PowerPoint import yet; tested hosts are listed in the release notes.
394
+ * Linux (beta): pictures of PDF pages and PowerPoint slides, and waveforms of non-WAV audio, need
395
+ Poppler, LibreOffice and ffmpeg on the server (see [Running on a Linux server](#running-on-a-linux-server-beta)).
316
396
 
317
397
  ## Versions
318
398
 
399
+ * **2.11**: screenshots in the chat. A screenshot sent with a message, and one Claude reads, shows as
400
+ a small picture that opens larger with a click (Esc or a click closes it). PinDeck keeps the 40
401
+ newest screenshots of a deck; an older one says "no longer kept". Chats from before 2.11 keep their
402
+ plain label.
403
+ * **2.10**: decks with their own file names and shared files, decks made without reveal.js, and Claude
404
+ working on the whole deck.
405
+ - A deck file by name: `pindeck path/to/talk.html` (a folder still opens its `index.html`). Several
406
+ decks can share a folder, each with its own chat and undo history.
407
+ - Claude may edit and create other files of the deck (scripts, style sheets, notes), never
408
+ `brand.css`, `vendor/` or other decks in the folder. One Undo takes back everything a turn changed,
409
+ created or deleted; files over 2 MB are not copied, and the chat says so.
410
+ - Files outside the deck folder: `--asset-root <folder>` (or `"assetRoots"` in config.json) serves
411
+ the deck from inside that folder, so paths such as `../shared/style.css` work in the editor,
412
+ screenshots and PDF exports. A symlink that leads out of the deck and its asset roots is refused.
413
+ - Shared instructions: the deck's `CLAUDE.md` and `AGENTS.md` may import other files with `@path`.
414
+ `CLAUDE.md` files in parent folders are still not loaded, on purpose (see the README).
415
+ - Decks made without reveal.js (a script of their own, Shower, deck.js, a hand-written deck): a deck
416
+ that names its slides with `<meta name="pindeck:slides">` (a CSS selector) and their size with
417
+ `<meta name="pindeck:size">` is edited like a reveal.js deck, with screenshots at the slide size and
418
+ one PDF page per slide (contributed by Zeyu Xu). Decks made with other slide frameworks (Marp,
419
+ Slidev, impress.js, remark and others) without these tags are recognised and refused with their
420
+ name, before PinDeck changes anything; the message says how to add the tags.
421
+ - Screenshots fill the picture: headless Chrome lays the page out shorter than its window, which
422
+ left the slide letterboxed in some Chrome versions (reported by Zeyu Xu).
423
+ - Linux: pictures of PDF pages with Poppler, the PowerPoint import with slide pictures from
424
+ LibreOffice, and waveforms of non-WAV audio with ffmpeg, each when installed.
425
+
319
426
  * **2.9**: Linux as a beta, and safer editing.
320
427
  - Linux (beta): PinDeck runs as a headless server, used from a Mac's browser through an SSH tunnel,
321
428
  with Claude Code running on the server. The tunnel may use another local port than the server.
package/cli.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  from the top left), as PNG: without a name in the screenshots
14
14
  folder to look at, with one saved into the deck
15
15
 
16
- node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed)
16
+ node cli.mjs git-setup [deck] keep PinDeck's data-eid ids out of git (no server needed; a folder or deck file)
17
17
 
18
18
  <slide> is a slide id (data-eid), a slide number (1-based) or "current". */
19
19
 
@@ -32,6 +32,7 @@ const arg = rest[0];
32
32
  if (cmd === "git-setup") {
33
33
  const FILTER = `sed 's/ data-eid="[^"]*"//g'`;
34
34
  let deck = path.resolve(arg || ".");
35
+ try { if (!fs.statSync(deck).isDirectory()) deck = path.dirname(deck); } catch {} // a deck file: its folder
35
36
  try { deck = fs.realpathSync(deck); } catch {} // git reports the real path; a symlinked path would not match
36
37
  const git = (...a) => execFileSync("git", ["-C", deck, ...a], { encoding: "utf8" }).trim();
37
38
  let root;
@@ -1,8 +1,9 @@
1
1
  /* What this server can do on this machine, decided once on the backend and used everywhere: the editor
2
2
  (status), the API (refusals before any side effect), the CLI (its error text) and Claude's system prompt.
3
3
 
4
- macOS: everything, as before. Linux (beta): editing, the chat and live reload. Features that need macOS
5
- (PDFKit, PowerPoint/Keynote, afconvert) are off with a reason. Chrome features (screenshots, headless
4
+ macOS: everything, as before. Linux (beta): editing, the chat, live reload and the PowerPoint import;
5
+ pictures of PDF pages with Poppler, pictures of PowerPoint slides with LibreOffice and Poppler, and
6
+ waveforms of non-WAV audio with ffmpeg, each when installed, else off with a reason. Chrome features (screenshots, headless
6
7
  layout, PDF export) work on Linux as on macOS when Chrome or Chromium is found; PINDECK_LINUX_CHROME=0
7
8
  turns them off. Each entry is { available, reason }: reason says why not, in plain words. */
8
9
 
@@ -71,11 +72,16 @@ export function detectCapabilities({ platform = process.platform, env = process.
71
72
  caps.headlessLayout = chromeCap;
72
73
  caps.pdfExport = chromeCap;
73
74
 
74
- caps.pdfPages = mac ? on() : off("Rendering PDF pages to pictures needs macOS (PDFKit). Attached PDFs can still be read.");
75
- caps.pptxImport = mac ? on() : off("PowerPoint import is not available on Linux in this beta.");
76
- caps.pptxPictures = mac && OFFICE_APPS.some((a) => fs.existsSync(a)) ? on()
77
- : off(mac ? "Pictures of PowerPoint slides need PowerPoint or Keynote." : "Pictures of PowerPoint slides need macOS with PowerPoint or Keynote.");
78
- caps.audioConvert = mac ? on() : off("Waveforms for audio other than WAV need macOS (afconvert).");
75
+ // Linux: Poppler renders PDF pages, LibreOffice exports PowerPoint slides to PDF, ffmpeg decodes audio
76
+ const has = (name) => !!findExecutable(name, { env }).path;
77
+ const poppler = has("pdftoppm") && has("pdfinfo"), soffice = has("soffice") || has("libreoffice");
78
+ caps.pdfPages = mac || poppler ? on()
79
+ : off("Pictures of PDF pages need Poppler on the server (pdftoppm and pdfinfo; install poppler-utils). Attached PDFs can still be read.");
80
+ caps.pptxImport = on(); // the import itself reads the .pptx file: no other program needed
81
+ caps.pptxPictures = mac ? (OFFICE_APPS.some((a) => fs.existsSync(a)) ? on() : off("Pictures of PowerPoint slides need PowerPoint or Keynote."))
82
+ : soffice && poppler ? on()
83
+ : off("Pictures of PowerPoint slides need LibreOffice (soffice) and Poppler (pdftoppm, pdfinfo) on the server.");
84
+ caps.audioConvert = mac || has("ffmpeg") ? on() : off("Waveforms for audio other than WAV need ffmpeg on the server.");
79
85
  return caps;
80
86
  }
81
87
 
@@ -54,3 +54,41 @@ export function revision(dir, files) {
54
54
  }
55
55
  return h.digest("hex").slice(0, 16);
56
56
  }
57
+
58
+ /* The deck's files for undo: every file a turn may change, with its content (bigger files: only size and
59
+ time, too large to keep a copy of). Hidden files and folders (.git), node_modules, and the vendor/ and
60
+ export/ folders are left out; so are symlinks, which point elsewhere. */
61
+ const SKIP_TOP = new Set(["vendor", "export", "node_modules"]);
62
+ export const KEEP_BYTES = 2 * 1024 * 1024;
63
+ export function scanFiles(dir, maxBytes = KEEP_BYTES) {
64
+ const files = new Map();
65
+ const walk = (rel) => {
66
+ let entries = [];
67
+ try { entries = fs.readdirSync(path.join(dir, rel), { withFileTypes: true }); } catch { return; }
68
+ for (const e of entries) {
69
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
70
+ const r = rel ? `${rel}/${e.name}` : e.name;
71
+ if (e.isDirectory()) { if (rel || !SKIP_TOP.has(e.name)) walk(r); continue; }
72
+ if (!e.isFile()) continue;
73
+ try {
74
+ const st = fs.statSync(path.join(dir, r));
75
+ files.set(r, { size: st.size, mtimeMs: st.mtimeMs, data: st.size <= maxBytes ? fs.readFileSync(path.join(dir, r)) : null });
76
+ } catch {}
77
+ }
78
+ };
79
+ walk("");
80
+ return files;
81
+ }
82
+
83
+ /* What changed between two scans: { rel: { before, after } } with the content on each side (null: the
84
+ file did not exist), and the files that changed but are too large to keep a copy of. */
85
+ export function scanChanges(before, after) {
86
+ const changed = {}, tooLarge = [];
87
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
88
+ const b = before.get(rel), a = after.get(rel);
89
+ if (b && a && b.size === a.size && (b.data && a.data ? b.data.equals(a.data) : b.mtimeMs === a.mtimeMs)) continue;
90
+ if ((b && !b.data) || (a && !a.data)) { tooLarge.push(rel); continue; }
91
+ changed[rel] = { before: b ? b.data : null, after: a ? a.data : null };
92
+ }
93
+ return { changed, tooLarge };
94
+ }
package/lib/eids.mjs CHANGED
@@ -145,8 +145,97 @@ function find(node, pred) {
145
145
  return hit;
146
146
  }
147
147
 
148
- /* Slides are leaf <section>s inside .slides; vertical-stack containers are not slides. */
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.name === "section") continue; // nested slide, handled on its own
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 slides = findSlides(parse(html));
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 slides = findSlides(parse(html));
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
+ }