@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/export.ts ADDED
@@ -0,0 +1,256 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { printDeckPdf, renderDeckSlides, type RenderDriveOptions } from "./capture";
4
+
5
+ /**
6
+ * Static export of a built deck to PNG files or a single PDF (ADR 0043), riding on
7
+ * the shared slide driver (ADR 0042). PNGs come straight off the page; the PDF is
8
+ * one slide per page, each a JPEG drawn full-bleed, composed without a PDF library.
9
+ *
10
+ * `parseSlideRange` and `pdfFromJpegPages` are pure and unit-tested; `exportDeck`
11
+ * is the orchestrator that drives the browser and writes files.
12
+ */
13
+
14
+ export type ExportFormat = "png" | "pdf";
15
+
16
+ // ── slide range grammar (pure) ───────────────────────────────────────────────
17
+
18
+ /**
19
+ * Parse a human, 1-based, inclusive slide spec into ordered, de-duped **0-based**
20
+ * indices. Grammar (comma-separated parts):
21
+ * "3" → slide 3
22
+ * "2-5" → slides 2,3,4,5
23
+ * "3-" → slide 3 through the end
24
+ * "-4" → slide 1 through 4
25
+ * "1,3,5-7"→ mixed
26
+ * An empty / whitespace-only spec means "every slide". Out-of-range or malformed
27
+ * parts throw, export should refuse a spec it can't honor rather than silently
28
+ * drop slides.
29
+ */
30
+ export function parseSlideRange(spec: string | undefined, count: number): number[] {
31
+ if (count <= 0) return [];
32
+ const all = Array.from({ length: count }, (_, i) => i);
33
+ if (spec == null || spec.trim() === "") return all;
34
+
35
+ const out = new Set<number>();
36
+ for (const raw of spec.split(",")) {
37
+ const part = raw.trim();
38
+ if (part === "") continue;
39
+ const m = part.match(/^(\d+)?\s*-\s*(\d+)?$|^(\d+)$/);
40
+ if (!m) throw new Error(`bad slide spec "${part}" (use e.g. "3", "2-5", "1,3,5-7", "3-", "-4")`);
41
+
42
+ if (m[3] != null) {
43
+ // single slide
44
+ const n = Number(m[3]);
45
+ assertInRange(n, count, part);
46
+ out.add(n - 1);
47
+ } else {
48
+ // range; open-ended on either side
49
+ const from = m[1] != null ? Number(m[1]) : 1;
50
+ const to = m[2] != null ? Number(m[2]) : count;
51
+ assertInRange(from, count, part);
52
+ assertInRange(to, count, part);
53
+ if (from > to) throw new Error(`bad slide range "${part}" (${from} > ${to})`);
54
+ for (let n = from; n <= to; n++) out.add(n - 1);
55
+ }
56
+ }
57
+ return [...out].sort((a, b) => a - b);
58
+ }
59
+
60
+ function assertInRange(n: number, count: number, part: string): void {
61
+ if (!Number.isInteger(n) || n < 1 || n > count) {
62
+ throw new Error(`slide ${n} out of range in "${part}" (deck has ${count} slide${count === 1 ? "" : "s"})`);
63
+ }
64
+ }
65
+
66
+ // ── PDF composer (pure, dependency-free) ─────────────────────────────────────
67
+
68
+ export interface JpegPage {
69
+ jpeg: Uint8Array;
70
+ /** intrinsic pixel dimensions of the JPEG. */
71
+ w: number;
72
+ h: number;
73
+ }
74
+
75
+ /**
76
+ * Compose JPEG images into a minimal PDF, one image per page, drawn full-bleed.
77
+ * Each page's `MediaBox` is the logical `pageW`×`pageH` (points); the hi-res JPEG
78
+ * is scaled into it, so the on-page resolution rides the capture scale factor.
79
+ * Hand-rolled (one `DCTDecode` XObject per page) to avoid a PDF dependency.
80
+ */
81
+ export function pdfFromJpegPages(pages: JpegPage[], pageW: number, pageH: number): Uint8Array {
82
+ const enc = new TextEncoder();
83
+ const chunks: Uint8Array[] = [];
84
+ let offset = 0;
85
+ const push = (data: Uint8Array | string): void => {
86
+ const b = typeof data === "string" ? enc.encode(data) : data;
87
+ chunks.push(b);
88
+ offset += b.length;
89
+ };
90
+
91
+ // obj 1 = Catalog, obj 2 = Pages, then 3 objects per page (Page, Contents, Image).
92
+ const total = 2 + pages.length * 3;
93
+ const offsets = new Array<number>(total + 1).fill(0);
94
+ const obj = (n: number, body: Uint8Array | string): void => {
95
+ offsets[n] = offset;
96
+ push(`${n} 0 obj\n`);
97
+ push(body);
98
+ push("\nendobj\n");
99
+ };
100
+
101
+ push("%PDF-1.4\n");
102
+ // binary marker so tools treat the file as binary
103
+ push(new Uint8Array([0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a]));
104
+
105
+ const pageObjNum = (i: number) => 3 + i * 3;
106
+ const kids = pages.map((_, i) => `${pageObjNum(i)} 0 R`).join(" ");
107
+
108
+ obj(1, "<< /Type /Catalog /Pages 2 0 R >>");
109
+ obj(2, `<< /Type /Pages /Kids [${kids}] /Count ${pages.length} >>`);
110
+
111
+ pages.forEach((pg, i) => {
112
+ const pageN = pageObjNum(i);
113
+ const contentN = pageN + 1;
114
+ const imageN = pageN + 2;
115
+
116
+ obj(
117
+ pageN,
118
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageW} ${pageH}] ` +
119
+ `/Resources << /XObject << /Im0 ${imageN} 0 R >> >> /Contents ${contentN} 0 R >>`,
120
+ );
121
+
122
+ const content = `q ${pageW} 0 0 ${pageH} 0 0 cm /Im0 Do Q`;
123
+ obj(contentN, `<< /Length ${enc.encode(content).length} >>\nstream\n${content}\nendstream`);
124
+
125
+ // image XObject, body is a dict header, the raw JPEG bytes, then the stream tail
126
+ offsets[imageN] = offset;
127
+ push(`${imageN} 0 obj\n`);
128
+ push(
129
+ `<< /Type /XObject /Subtype /Image /Width ${pg.w} /Height ${pg.h} ` +
130
+ `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${pg.jpeg.length} >>\nstream\n`,
131
+ );
132
+ push(pg.jpeg);
133
+ push("\nendstream\nendobj\n");
134
+ });
135
+
136
+ // cross-reference table
137
+ const xrefOffset = offset;
138
+ push(`xref\n0 ${total + 1}\n`);
139
+ push("0000000000 65535 f\r\n");
140
+ for (let n = 1; n <= total; n++) {
141
+ push(`${String(offsets[n]).padStart(10, "0")} 00000 n\r\n`);
142
+ }
143
+ push(`trailer\n<< /Size ${total + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`);
144
+
145
+ // concat
146
+ const totalLen = chunks.reduce((s, c) => s + c.length, 0);
147
+ const out = new Uint8Array(totalLen);
148
+ let p = 0;
149
+ for (const c of chunks) {
150
+ out.set(c, p);
151
+ p += c.length;
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // ── orchestrator ─────────────────────────────────────────────────────────────
157
+
158
+ export interface ExportOptions extends Omit<RenderDriveOptions, "onSlide"> {
159
+ format: ExportFormat;
160
+ /** 1-based slide spec (e.g. "3", "2-5", "1,3,5-7", "3-", "-4"); default all.
161
+ * Resolved against the real slide count, so open-ended ranges work. */
162
+ slides?: string;
163
+ /** PNG: directory the per-slide files are written into. Defaults to cwd. */
164
+ outDir?: string;
165
+ /** PDF: file path to write. Defaults to `<baseName>.pdf` in cwd. */
166
+ outFile?: string;
167
+ /** Base name for default output filenames (e.g. the deck name). */
168
+ baseName?: string;
169
+ /** JPEG quality for PDF pages, 0-100 (default 92). Raster PDF only. */
170
+ quality?: number;
171
+ /** PDF rendering mode (default "vector"):
172
+ * • "vector", one `page.pdf()` over a stacked print view → **selectable text**,
173
+ * vector graphics, smallest files. The default and recommended PDF.
174
+ * • "raster", one full-bleed JPEG per page → no text layer, but pixel-exact
175
+ * fidelity for slides with effects that don't reproduce under print. */
176
+ pdfMode?: "vector" | "raster";
177
+ /** progress callback: (nth-rendered, total-to-render). */
178
+ onSlide?(index: number, total: number): void;
179
+ }
180
+
181
+ export interface ExportResult {
182
+ /** absolute (or as-given) paths written. */
183
+ written: string[];
184
+ /** number of slides actually rendered (pages in the PDF / PNG files). */
185
+ pages: number;
186
+ /** total slides the deck has. */
187
+ count: number;
188
+ }
189
+
190
+ /** Export a built single-file deck to PNG files or a PDF (ADR 0043). **Loud**, * throws if no Chromium is available (export is explicit, unlike thumbnails). */
191
+ export async function exportDeck(html: string, opts: ExportOptions): Promise<ExportResult> {
192
+ const base = opts.baseName ?? "deck";
193
+ const selectIndices =
194
+ opts.slides != null && opts.slides.trim() !== ""
195
+ ? (count: number) => parseSlideRange(opts.slides, count)
196
+ : opts.selectIndices;
197
+
198
+ // Vector PDF (default): one page.pdf() over a stacked print view → selectable text.
199
+ if (opts.format === "pdf" && opts.pdfMode !== "raster") {
200
+ const { pdf, count, pages } = await printDeckPdf(html, {
201
+ pageWidth: opts.width,
202
+ pageHeight: opts.height,
203
+ executablePath: opts.executablePath,
204
+ launchArgs: opts.launchArgs,
205
+ timeoutMs: opts.timeoutMs,
206
+ selectIndices,
207
+ });
208
+ const outFile = opts.outFile ?? `${base}.pdf`;
209
+ await mkdir(dirname(outFile), { recursive: true });
210
+ await Bun.write(outFile, pdf);
211
+ return { written: [outFile], pages, count };
212
+ }
213
+
214
+ // One render pass into memory. We only learn the deck's true slide count after
215
+ // the driver returns, so collect frames first, then name + write with the final
216
+ // pad width, no rename dance.
217
+ const isPdf = opts.format === "pdf";
218
+ const quality = opts.quality ?? 92;
219
+ const frames: { index: number; bytes: Uint8Array }[] = [];
220
+ const driveOpts: RenderDriveOptions = { ...opts, selectIndices };
221
+ const drive = await renderDeckSlides(html, driveOpts, async (i, page) => {
222
+ const bytes = isPdf
223
+ ? await page.screenshot({ type: "jpeg", quality })
224
+ : await page.screenshot({ type: "png" });
225
+ frames.push({ index: i, bytes });
226
+ });
227
+ frames.sort((a, b) => a.index - b.index);
228
+
229
+ // Pad slide numbers to the width of the deck's total count (min 2): slide-03 etc.
230
+ const padW = Math.max(2, String(drive.count).length);
231
+ const written: string[] = [];
232
+
233
+ if (!isPdf) {
234
+ const outDir = opts.outDir ?? ".";
235
+ await mkdir(outDir, { recursive: true });
236
+ for (const f of frames) {
237
+ const file = join(outDir, `${base}-slide-${String(f.index + 1).padStart(padW, "0")}.png`);
238
+ await Bun.write(file, f.bytes);
239
+ written.push(file);
240
+ }
241
+ return { written, pages: frames.length, count: drive.count };
242
+ }
243
+
244
+ const pageW = opts.width ?? 1280;
245
+ const pageH = opts.height ?? Math.round((pageW * 9) / 16);
246
+ const pdf = pdfFromJpegPages(
247
+ frames.map((f) => ({ jpeg: f.bytes, w: drive.w, h: drive.h })),
248
+ pageW,
249
+ pageH,
250
+ );
251
+ const outFile = opts.outFile ?? `${base}.pdf`;
252
+ await mkdir(dirname(outFile), { recursive: true });
253
+ await Bun.write(outFile, pdf);
254
+ written.push(outFile);
255
+ return { written, pages: frames.length, count: drive.count };
256
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { captureThumbnails, thumbnailsEnabled, type CaptureOptions } from "./capture";
2
+ import { embedThumbnails, type ThumbnailManifest } from "@liebstoeckel/engine/build/thumbnails";
3
+
4
+ export {
5
+ captureThumbnails,
6
+ renderDeckSlides,
7
+ printDeckPdf,
8
+ resolveChromium,
9
+ hasChromium,
10
+ thumbnailsEnabled,
11
+ type CaptureOptions,
12
+ type RenderDriveOptions,
13
+ type RenderDriveResult,
14
+ type PrintDriveOptions,
15
+ type PrintDriveResult,
16
+ type ThumbnailFormat,
17
+ } from "./capture";
18
+ export {
19
+ exportDeck,
20
+ parseSlideRange,
21
+ pdfFromJpegPages,
22
+ type ExportFormat,
23
+ type ExportOptions,
24
+ type ExportResult,
25
+ type JpegPage,
26
+ } from "./export";
27
+ export {
28
+ embedThumbnails,
29
+ extractThumbnails,
30
+ stripThumbnails,
31
+ type ThumbnailManifest,
32
+ } from "@liebstoeckel/engine/build/thumbnails";
33
+
34
+ export interface WithThumbnailsResult {
35
+ /** the deck HTML, with thumbnails embedded, or unchanged if skipped */
36
+ html: string;
37
+ /** the captured manifest, or null when skipped */
38
+ manifest: ThumbnailManifest | null;
39
+ /** set when capture was skipped; the human-readable reason */
40
+ skipped?: string;
41
+ }
42
+
43
+ /** The canonical, in-memory "add thumbnails to a deck" step: gate → capture →
44
+ * embed, **graceful and never-fatal**. Returns the deck unchanged (with a
45
+ * `skipped` reason) when thumbnails are off (`LIEBSTOECKEL_NO_THUMBS`), no Chromium
46
+ * is available, or capture throws. This is the one place that policy lives, the
47
+ * build (`buildDeck`) and the live server both route through it.
48
+ *
49
+ * For an explicit, *loud* capture (fail if no Chromium), use `captureThumbnails` /
50
+ * `addThumbnailsToFile` instead, e.g. the `thumbs` CLI. */
51
+ export async function withThumbnails(html: string, opts: CaptureOptions = {}): Promise<WithThumbnailsResult> {
52
+ const gate = thumbnailsEnabled();
53
+ if (!gate.enabled) return { html, manifest: null, skipped: gate.reason };
54
+ try {
55
+ const manifest = await captureThumbnails(html, opts);
56
+ return { html: embedThumbnails(html, manifest), manifest };
57
+ } catch (err) {
58
+ return { html, manifest: null, skipped: (err as Error).message };
59
+ }
60
+ }
61
+
62
+ /** Capture thumbnails for a built deck file and embed them back into it (in place).
63
+ * Re-running is idempotent (the prior block is stripped). **Loud**: throws if no
64
+ * Chromium, for the explicit `thumbs` command. Returns the manifest. */
65
+ export async function addThumbnailsToFile(path: string, opts: CaptureOptions = {}): Promise<ThumbnailManifest> {
66
+ const html = await Bun.file(path).text();
67
+ const manifest = await captureThumbnails(html, opts);
68
+ await Bun.write(path, embedThumbnails(html, manifest));
69
+ return manifest;
70
+ }