@liebstoeckel/thumbnails 0.3.5

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/src/capture.ts ADDED
@@ -0,0 +1,319 @@
1
+ import { existsSync } from "node:fs";
2
+ import { chromium, type Page } from "playwright-core";
3
+ import {
4
+ CAPTURE_EVENT,
5
+ CAPTURE_FLAG,
6
+ CAPTURE_READY,
7
+ PRINT_FLAG,
8
+ PRINT_READY,
9
+ PRINT_SELECT_EVENT,
10
+ SLIDE_COUNT,
11
+ } from "@liebstoeckel/engine/build/capture-protocol";
12
+ import type { ThumbnailManifest } from "@liebstoeckel/engine/build/thumbnails";
13
+
14
+ export type ThumbnailFormat = "webp" | "jpeg" | "png";
15
+
16
+ /** Thumbnail capture options, the slide driver's options (ADR 0042) plus the
17
+ * image-encoding policy specific to the thumbnail sink. Default width 640 ×
18
+ * scale 2 = 1280×720, the native authoring canvas, so the overview is never
19
+ * upscaled even on large/hi-dpi screens. Lower `width` to shrink a big deck. */
20
+ export interface CaptureOptions extends RenderDriveOptions {
21
+ /** output image format (default "webp", ~half a JPEG, alpha, no extra deps) */
22
+ format?: ThumbnailFormat;
23
+ /** lossy quality 0-100 (ignored for png) */
24
+ quality?: number;
25
+ }
26
+
27
+ // Bun's built-in image codec (Bun.Image), not yet in @types/bun, so typed here.
28
+ // Lets us transcode the browser's PNG screenshot to WebP natively (no `sharp`).
29
+ interface BunImageChain {
30
+ webp(o?: { quality?: number }): BunImageChain;
31
+ jpeg(o?: { quality?: number }): BunImageChain;
32
+ png(): BunImageChain;
33
+ dataurl(): Promise<string>;
34
+ }
35
+ type BunImageCtor = new (data: Uint8Array | ArrayBuffer) => BunImageChain;
36
+ const BunImage = (Bun as unknown as { Image: BunImageCtor }).Image;
37
+
38
+ /** Transcode a PNG screenshot to a `data:` URI in the requested format. */
39
+ function encodeDataUri(png: Uint8Array, format: ThumbnailFormat, quality: number): Promise<string> {
40
+ const img = new BunImage(png);
41
+ if (format === "png") return img.png().dataurl();
42
+ if (format === "jpeg") return img.jpeg({ quality }).dataurl();
43
+ return img.webp({ quality }).dataurl();
44
+ }
45
+
46
+ // Container-friendly flags. The full Chromium (not chrome-headless-shell, which
47
+ // SIGSEGVs in some sandboxes) plus --single-process/--no-zygote launches cleanly
48
+ // without a GPU or a user namespace.
49
+ const DEFAULT_ARGS = [
50
+ "--no-sandbox",
51
+ "--disable-gpu",
52
+ "--disable-dev-shm-usage",
53
+ "--single-process",
54
+ "--no-zygote",
55
+ ];
56
+
57
+ /** Resolve a Chromium binary: explicit → $LIEBSTOECKEL_CHROMIUM → Playwright's. */
58
+ export function resolveChromium(opts: CaptureOptions = {}): string {
59
+ let candidate = opts.executablePath ?? process.env.LIEBSTOECKEL_CHROMIUM;
60
+ if (!candidate) {
61
+ try {
62
+ candidate = chromium.executablePath();
63
+ } catch {
64
+ candidate = undefined;
65
+ }
66
+ }
67
+ // executablePath() returns a computed path even when the browser isn't
68
+ // installed, verify the binary actually exists so hasChromium() stays honest
69
+ // (otherwise capture is attempted where no browser exists, e.g. CI).
70
+ if (candidate && existsSync(candidate)) return candidate;
71
+ throw new Error(
72
+ "No Chromium found for thumbnail capture. Run `bunx playwright install chromium`, " +
73
+ "or set LIEBSTOECKEL_CHROMIUM to a Chrome/Chromium binary.",
74
+ );
75
+ }
76
+
77
+ /** Whether a Chromium is available for capture (cheap, resolves a path, no launch). */
78
+ export function hasChromium(opts: CaptureOptions = {}): boolean {
79
+ try {
80
+ resolveChromium(opts);
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /** Decide whether to capture thumbnails: on by default, opt out with
88
+ * `LIEBSTOECKEL_NO_THUMBS`, and skipped (not failed) when no Chromium is available.
89
+ * Pure, `env`/`chromium` are injectable for tests. */
90
+ export function thumbnailsEnabled(
91
+ env: Record<string, string | undefined> = process.env,
92
+ chromium = hasChromium(),
93
+ ): { enabled: boolean; reason?: string } {
94
+ if (env.LIEBSTOECKEL_NO_THUMBS) return { enabled: false, reason: "LIEBSTOECKEL_NO_THUMBS is set" };
95
+ if (!chromium) {
96
+ return { enabled: false, reason: "no Chromium (run `bunx playwright install chromium` or set LIEBSTOECKEL_CHROMIUM)" };
97
+ }
98
+ return { enabled: true };
99
+ }
100
+
101
+ /** Inject a static-mode flag as a classic (non-deferred) inline script so it runs
102
+ * before the deck's deferred module bundle boots → Present renders the matching
103
+ * static view (CaptureView / PrintView) instead of the live deck. */
104
+ function injectFlag(html: string, global: string, value: unknown): string {
105
+ const tag = `<script>window.${global}=${JSON.stringify(value)};</script>`;
106
+ const head = html.match(/<head[^>]*>/i);
107
+ if (head) return html.replace(head[0], head[0] + tag);
108
+ const body = html.match(/<body[^>]*>/i);
109
+ if (body) return html.replace(body[0], body[0] + tag);
110
+ return tag + html;
111
+ }
112
+
113
+ const injectCaptureFlag = (html: string): string => injectFlag(html, CAPTURE_FLAG, { index: 0 });
114
+
115
+ /** Options for the sink-agnostic slide driver (ADR 0042). The viewport/scale and
116
+ * the per-slide wait policy live here; what the rendered frame *becomes* (a
117
+ * data-URI thumbnail, a PNG file, a PDF page) is the caller's `onFrame`. */
118
+ export interface RenderDriveOptions {
119
+ /** viewport width in CSS px (default 640). */
120
+ width?: number;
121
+ /** viewport height in CSS px (default 16:9 of width). */
122
+ height?: number;
123
+ /** device scale factor, renders at this multiple for crisp output (default 2). */
124
+ scale?: number;
125
+ /** explicit Chromium/Chrome binary; else $LIEBSTOECKEL_CHROMIUM, else Playwright's */
126
+ executablePath?: string;
127
+ /** override the launch flags (defaults are container-friendly) */
128
+ launchArgs?: string[];
129
+ /** extra settle time after a slide reports ready (late chart/font paints) */
130
+ settleMs?: number;
131
+ /** per-step timeout */
132
+ timeoutMs?: number;
133
+ /** 0-based slide indices to render, in order (default: every slide). Indices
134
+ * outside `[0, count)` are skipped. The capture protocol can jump to any
135
+ * index, so a subset is just a shorter list (ADR 0042 / 0043). */
136
+ indices?: number[];
137
+ /** Resolve the index list once the deck's slide count is known, for specs that
138
+ * are open-ended (e.g. "from slide 3 to the end"). Overrides `indices`. */
139
+ selectIndices?(count: number): number[];
140
+ /** progress callback: (nth-rendered, total-to-render). */
141
+ onSlide?(index: number, total: number): void;
142
+ }
143
+
144
+ export interface RenderDriveResult {
145
+ /** total slides the deck reported (not necessarily how many were rendered). */
146
+ count: number;
147
+ /** intrinsic pixel size of each frame (`width*scale` × `height*scale`). */
148
+ w: number;
149
+ h: number;
150
+ }
151
+
152
+ /**
153
+ * The one headless drive loop (ADR 0042): launch a browser, load a built deck in
154
+ * capture mode, wait for fonts + the slide-count handshake, then step through the
155
+ * requested slide indices, calling `onFrame(index, page)` once each slide has
156
+ * painted and settled. Sink-agnostic: the callback decides what a frame becomes.
157
+ * Both `captureThumbnails` and `exportDeck` ride on this. The deck must render
158
+ * `Present`/`CaptureView`. **Loud**, throws if no Chromium / never enters capture.
159
+ */
160
+ export async function renderDeckSlides(
161
+ html: string,
162
+ opts: RenderDriveOptions,
163
+ onFrame: (index: number, page: Page) => Promise<void>,
164
+ ): Promise<RenderDriveResult> {
165
+ const width = opts.width ?? 640;
166
+ const height = opts.height ?? Math.round((width * 9) / 16);
167
+ const scale = opts.scale ?? 2;
168
+ const settleMs = opts.settleMs ?? 250;
169
+ const timeout = opts.timeoutMs ?? 15000;
170
+
171
+ const browser = await chromium.launch({
172
+ headless: true,
173
+ executablePath: resolveChromium(opts),
174
+ args: opts.launchArgs ?? DEFAULT_ARGS,
175
+ });
176
+ try {
177
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: scale });
178
+ await page.setContent(injectCaptureFlag(html), { waitUntil: "load", timeout });
179
+ // fonts affect layout/metrics; wait once before stepping through slides
180
+ await page.evaluate(() => (document as unknown as { fonts?: { ready?: Promise<unknown> } }).fonts?.ready);
181
+ try {
182
+ await page.waitForFunction((key) => (window as unknown as Record<string, unknown>)[key] != null, SLIDE_COUNT, { timeout });
183
+ } catch {
184
+ throw new Error("deck never entered capture mode, ensure it renders <Present> (no __LIEBSTOECKEL_SLIDE_COUNT__)");
185
+ }
186
+ const count = (await page.evaluate((key) => (window as unknown as Record<string, unknown>)[key], SLIDE_COUNT)) as number;
187
+
188
+ // Resolve which slides to render: a count-aware resolver (open-ended specs)
189
+ // wins, else an explicit list, else every slide, always clamped to real slides.
190
+ const requested = opts.selectIndices
191
+ ? opts.selectIndices(count)
192
+ : (opts.indices ?? Array.from({ length: count }, (_, i) => i));
193
+ const indices = requested.filter((i) => Number.isInteger(i) && i >= 0 && i < count);
194
+
195
+ for (let n = 0; n < indices.length; n++) {
196
+ const i = indices[n]!;
197
+ opts.onSlide?.(n, indices.length);
198
+ // tell CaptureView to render slide i; it flips CAPTURE_READY to i when painted
199
+ await page.evaluate(
200
+ ([evt, idx]) => window.dispatchEvent(new CustomEvent(evt as string, { detail: idx })),
201
+ [CAPTURE_EVENT, i] as const,
202
+ );
203
+ await page.waitForFunction(
204
+ ([key, idx]) => (window as unknown as Record<string, unknown>)[key as string] === idx,
205
+ [CAPTURE_READY, i] as const,
206
+ { timeout },
207
+ );
208
+ if (settleMs > 0) await page.waitForTimeout(settleMs);
209
+ await onFrame(i, page);
210
+ }
211
+ return { count, w: Math.round(width * scale), h: Math.round(height * scale) };
212
+ } finally {
213
+ await browser.close();
214
+ }
215
+ }
216
+
217
+ /** Render a built single-file deck in a headless browser and screenshot each slide
218
+ * as a data-URI (WebP by default, via Bun.Image). Returns a thumbnails manifest
219
+ * (embed it with `embedThumbnails`). The deck must use `Present`/`CaptureView`. */
220
+ export async function captureThumbnails(html: string, opts: CaptureOptions = {}): Promise<ThumbnailManifest> {
221
+ const format = opts.format ?? "webp";
222
+ const quality = opts.quality ?? 80;
223
+
224
+ const thumbs: Record<number, string> = {};
225
+ const { w, h } = await renderDeckSlides(html, opts, async (i, page) => {
226
+ // PNG (lossless) from the browser → transcode natively to the target format
227
+ const png = await page.screenshot({ type: "png" });
228
+ thumbs[i] = await encodeDataUri(png, format, quality);
229
+ });
230
+ return { v: 1, w, h, thumbs };
231
+ }
232
+
233
+ /** Options for the vector-PDF driver. The logical page is the authoring canvas
234
+ * (STAGE_W×STAGE_H, 1280×720), selectable text, vector output, no raster. */
235
+ export interface PrintDriveOptions {
236
+ /** logical page width in CSS px (default 1280, the authoring canvas). */
237
+ pageWidth?: number;
238
+ /** logical page height in CSS px (default 16:9 of pageWidth). */
239
+ pageHeight?: number;
240
+ /** explicit Chromium/Chrome binary; else $LIEBSTOECKEL_CHROMIUM, else Playwright's */
241
+ executablePath?: string;
242
+ /** override the launch flags (defaults are container-friendly) */
243
+ launchArgs?: string[];
244
+ /** settle time after the print selection paints (lets entrance motion finish).
245
+ * More generous than capture's per-slide settle, every slide animates at once. */
246
+ settleMs?: number;
247
+ /** per-step timeout */
248
+ timeoutMs?: number;
249
+ /** resolve the 0-based slide indices once the count is known (open-ended specs). */
250
+ selectIndices?(count: number): number[];
251
+ }
252
+
253
+ export interface PrintDriveResult {
254
+ /** the produced PDF bytes. */
255
+ pdf: Uint8Array;
256
+ /** total slides the deck reported. */
257
+ count: number;
258
+ /** number of slides laid out (pages in the PDF). */
259
+ pages: number;
260
+ }
261
+
262
+ /**
263
+ * Render a built deck through `PrintView` and produce a **single, text-preserving**
264
+ * PDF (ADR 0043): every selected slide is stacked one-per-page in the DOM, so one
265
+ * `page.pdf()` yields a multi-page vector PDF with selectable text. `emulateMedia`
266
+ * keeps the deck's *screen* styles (not print CSS). **Loud**, throws if no Chromium.
267
+ */
268
+ export async function printDeckPdf(html: string, opts: PrintDriveOptions = {}): Promise<PrintDriveResult> {
269
+ const pageWidth = opts.pageWidth ?? 1280;
270
+ const pageHeight = opts.pageHeight ?? Math.round((pageWidth * 9) / 16);
271
+ const settleMs = opts.settleMs ?? 700;
272
+ const timeout = opts.timeoutMs ?? 30000;
273
+
274
+ const browser = await chromium.launch({
275
+ headless: true,
276
+ executablePath: resolveChromium(opts),
277
+ args: opts.launchArgs ?? DEFAULT_ARGS,
278
+ });
279
+ try {
280
+ const page = await browser.newPage({ viewport: { width: pageWidth, height: pageHeight } });
281
+ // print with the deck's screen styling, not print-media CSS
282
+ await page.emulateMedia({ media: "screen" });
283
+ await page.setContent(injectFlag(html, PRINT_FLAG, {}), { waitUntil: "load", timeout });
284
+ await page.evaluate(() => (document as unknown as { fonts?: { ready?: Promise<unknown> } }).fonts?.ready);
285
+ try {
286
+ await page.waitForFunction((key) => (window as unknown as Record<string, unknown>)[key] != null, SLIDE_COUNT, { timeout });
287
+ } catch {
288
+ throw new Error("deck never entered print mode, ensure it renders <Present> (no __LIEBSTOECKEL_SLIDE_COUNT__)");
289
+ }
290
+ const count = (await page.evaluate((key) => (window as unknown as Record<string, unknown>)[key], SLIDE_COUNT)) as number;
291
+
292
+ const requested = opts.selectIndices ? opts.selectIndices(count) : Array.from({ length: count }, (_, i) => i);
293
+ const indices = requested.filter((i) => Number.isInteger(i) && i >= 0 && i < count);
294
+
295
+ // Hand PrintView the selection + a token it echoes into PRINT_READY once painted.
296
+ const token = 1;
297
+ await page.evaluate(
298
+ ([evt, payload]) => window.dispatchEvent(new CustomEvent(evt as string, { detail: payload })),
299
+ [PRINT_SELECT_EVENT, { indices, token }] as const,
300
+ );
301
+ await page.waitForFunction(
302
+ ([key, tok]) => (window as unknown as Record<string, unknown>)[key as string] === tok,
303
+ [PRINT_READY, token] as const,
304
+ { timeout },
305
+ );
306
+ if (settleMs > 0) await page.waitForTimeout(settleMs);
307
+
308
+ const pdf = await page.pdf({
309
+ width: `${pageWidth}px`,
310
+ height: `${pageHeight}px`,
311
+ printBackground: true,
312
+ margin: { top: "0", right: "0", bottom: "0", left: "0" },
313
+ preferCSSPageSize: false,
314
+ });
315
+ return { pdf: new Uint8Array(pdf), count, pages: indices.length };
316
+ } finally {
317
+ await browser.close();
318
+ }
319
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bun
2
+ import { defineCommand, runMain } from "citty";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { mkdtempSync, statSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { addThumbnailsToFile, exportDeck } from "./index";
7
+ import type { ExportFormat, ThumbnailFormat } from "./index";
8
+
9
+ /** Parse a numeric flag value; undefined when absent or non-numeric (mirrors the
10
+ * previous CLI's lenient handling). */
11
+ function num(v: string | undefined): number | undefined {
12
+ const n = v == null ? NaN : Number(v);
13
+ return Number.isFinite(n) ? n : undefined;
14
+ }
15
+
16
+ /** Coerce a thumbnail format string, ignoring anything unrecognized. */
17
+ function thumbFormat(v: string | undefined): ThumbnailFormat | undefined {
18
+ return v === "webp" || v === "jpeg" || v === "png" ? v : undefined;
19
+ }
20
+
21
+ export const thumbsCommand = defineCommand({
22
+ meta: {
23
+ name: "thumbs",
24
+ description: "(re)generate thumbnails for a built deck",
25
+ },
26
+ args: {
27
+ deck: {
28
+ type: "positional",
29
+ required: false,
30
+ description: "built deck .html",
31
+ valueHint: "deck.html",
32
+ },
33
+ format: { type: "string", description: "image format", valueHint: "webp|jpeg|png" },
34
+ width: { type: "string", description: "thumbnail width in px", valueHint: "640" },
35
+ quality: { type: "string", description: "image quality", valueHint: "80" },
36
+ scale: { type: "string", description: "device scale factor", valueHint: "2" },
37
+ },
38
+ async run({ args }) {
39
+ const file = args.deck;
40
+ if (!file) {
41
+ console.error("error: missing <deck.html>, the built deck to thumbnail");
42
+ process.exit(1);
43
+ }
44
+ const abs = resolve(file);
45
+ try {
46
+ if (!statSync(abs).isFile()) throw new Error("not a file");
47
+ } catch {
48
+ console.error(`cannot read deck: ${file}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ process.stderr.write(`▶ capturing thumbnails for ${file}\n`);
53
+ try {
54
+ const manifest = await addThumbnailsToFile(abs, {
55
+ width: num(args.width),
56
+ quality: num(args.quality),
57
+ scale: num(args.scale),
58
+ format: thumbFormat(args.format),
59
+ onSlide: (i, n) => process.stderr.write(`\r slide ${i + 1}/${n} `),
60
+ });
61
+ process.stderr.write("\n");
62
+ const n = Object.keys(manifest.thumbs).length;
63
+ console.log(`✓ embedded ${n} thumbnail${n === 1 ? "" : "s"} (${manifest.w}×${manifest.h}) into ${file}`);
64
+ } catch (e) {
65
+ // Mirror `export`'s clean handling: a missing Chromium (the common case) should be a
66
+ // one-line actionable error, not an uncaught stack trace that leaks internal paths.
67
+ process.stderr.write("\n");
68
+ console.error(`✕ thumbnails failed: ${(e as Error).message}`);
69
+ process.exit(1);
70
+ }
71
+ },
72
+ });
73
+
74
+ /** Resolve the export format: an explicit `--format` wins, else infer from the
75
+ * output extension, else PNG. */
76
+ function inferFormat(explicit: string | undefined, out: string | undefined): ExportFormat {
77
+ if (explicit === "png" || explicit === "pdf") return explicit;
78
+ if (out && /\.pdf$/i.test(out)) return "pdf";
79
+ if (out && /\.png$/i.test(out)) return "png";
80
+ return "png";
81
+ }
82
+
83
+ /** A human-friendly base name for output files: a deck's `index.html` borrows its
84
+ * directory's name, skipping a build dir (`dist`/`build`/`out`) so a deck built to
85
+ * `foo/dist/index.html` is named "foo"; anything else uses its own filename stem. */
86
+ function deckBaseName(target: string): string {
87
+ const stem = basename(target).replace(/\.html?$/i, "");
88
+ if (stem !== "index") return stem || "deck";
89
+ let dir = resolve(target, "..");
90
+ if (/^(dist|build|out)$/i.test(basename(dir))) dir = resolve(dir, "..");
91
+ return basename(dir) || "deck";
92
+ }
93
+
94
+ /** Resolve the export input to a built deck HTML string. A `.html` file is read
95
+ * directly; a deck source directory is bundled to a temp dir first (thumbnails
96
+ * skipped, export does its own rendering), so `export <dir>` works in one shot. */
97
+ async function resolveDeckHtml(target: string): Promise<{ html: string; base: string }> {
98
+ const abs = resolve(target);
99
+ let isFile = false;
100
+ try {
101
+ isFile = statSync(abs).isFile();
102
+ } catch {
103
+ /* not a file */
104
+ }
105
+
106
+ if (isFile && /\.html?$/i.test(abs)) {
107
+ return { html: await Bun.file(abs).text(), base: deckBaseName(abs) };
108
+ }
109
+
110
+ // treat as a deck source directory → bundle it to a throwaway dir
111
+ const { bundleDeck } = await import("@liebstoeckel/engine/build");
112
+ const outdir = mkdtempSync(join(tmpdir(), "lst-export-"));
113
+ const prev = process.cwd();
114
+ process.chdir(abs);
115
+ try {
116
+ await bundleDeck({ entry: "./index.html", outdir, inlinePackage: false });
117
+ } finally {
118
+ process.chdir(prev);
119
+ }
120
+ return { html: await Bun.file(join(outdir, "index.html")).text(), base: basename(abs) };
121
+ }
122
+
123
+ export const exportCommand = defineCommand({
124
+ meta: {
125
+ name: "export",
126
+ description: "export slides to PNG or PDF",
127
+ },
128
+ args: {
129
+ deck: {
130
+ type: "positional",
131
+ required: false,
132
+ description: "deck .html or deck source dir (default: cwd)",
133
+ valueHint: "deck.html|deck-dir",
134
+ },
135
+ dir: { type: "string", description: "deck directory (alternative to the positional)", valueHint: "deck" },
136
+ format: { type: "string", description: "output format", valueHint: "png|pdf" },
137
+ slides: { type: "string", description: "slide selection, e.g. 1,3,5-7", valueHint: "1,3,5-7" },
138
+ out: {
139
+ type: "string",
140
+ alias: "o",
141
+ description: "PNG: output directory (one file per slide); PDF: the .pdf file",
142
+ valueHint: "path",
143
+ },
144
+ scale: { type: "string", description: "device scale factor", valueHint: "2" },
145
+ width: { type: "string", description: "render width in px", valueHint: "1280" },
146
+ quality: { type: "string", description: "image quality", valueHint: "92" },
147
+ raster: { type: "boolean", description: "PDF: rasterize pages instead of vector (selectable) text" },
148
+ },
149
+ async run({ args }) {
150
+ // Deck targeting (ADR 0050): a leading positional, else --dir, else cwd.
151
+ const target = args.deck ?? args.dir ?? ".";
152
+
153
+ const out = args.out;
154
+ const format = inferFormat(args.format, out);
155
+ // PNG writes one file per slide into a directory; a `.png`-looking `-o` is almost
156
+ // always a mistake (you'd get a directory literally named "foo.png"), warn (ticket 0030).
157
+ if (format === "png" && out && /\.png$/i.test(out)) {
158
+ process.stderr.write(
159
+ `⚠ --format png writes one file per slide into a DIRECTORY; "-o ${out}" will be a directory (created if needed), not a single PNG.\n`,
160
+ );
161
+ }
162
+ const slides = args.slides;
163
+ const width = num(args.width) ?? 1280;
164
+ const scale = num(args.scale);
165
+ const quality = num(args.quality);
166
+ // PDF text mode: vector (selectable text) by default; --raster forces image pages.
167
+ const pdfMode: "vector" | "raster" = args.raster ? "raster" : "vector";
168
+
169
+ let html: string;
170
+ let base: string;
171
+ try {
172
+ ({ html, base } = await resolveDeckHtml(target));
173
+ } catch (e) {
174
+ console.error(`✕ cannot prepare deck: ${(e as Error).message}`);
175
+ process.exit(1);
176
+ return;
177
+ }
178
+
179
+ const label = format === "pdf" ? `PDF (${pdfMode})` : "PNG";
180
+ process.stderr.write(`▶ exporting ${base} → ${label}\n`);
181
+ try {
182
+ const { written, pages, count } = await exportDeck(html, {
183
+ format,
184
+ slides,
185
+ width,
186
+ scale,
187
+ quality,
188
+ pdfMode,
189
+ baseName: base,
190
+ outDir: format === "png" ? out : undefined,
191
+ outFile: format === "pdf" ? out : undefined,
192
+ onSlide: (i, n) => process.stderr.write(`\r slide ${i + 1}/${n} `),
193
+ });
194
+ process.stderr.write("\n");
195
+ if (written.length === 0) {
196
+ console.error(`✕ no slides matched${slides ? ` "${slides}"` : ""} (deck has ${count} slide${count === 1 ? "" : "s"})`);
197
+ process.exit(1);
198
+ }
199
+ if (format === "pdf") {
200
+ console.log(`✓ exported ${pages} slide${pages === 1 ? "" : "s"} → ${written[0]}`);
201
+ } else {
202
+ console.log(`✓ exported ${pages} PNG${pages === 1 ? "" : "s"}:`);
203
+ for (const f of written) console.log(` ${f}`);
204
+ }
205
+ } catch (e) {
206
+ console.error(`✕ export failed: ${(e as Error).message}`);
207
+ process.exit(1);
208
+ }
209
+ },
210
+ });
211
+
212
+ if (import.meta.main) void runMain(thumbsCommand);