@slothpdf/render 0.4.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 ADDED
@@ -0,0 +1,242 @@
1
+ # @slothpdf/render
2
+
3
+ Fast PDF generation from templates. **16,000+ PDFs per second.** No headless browser.
4
+
5
+ Built on a native engine written in Zig — no Puppeteer, no Chrome, no runtime overhead. Constant memory.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @slothpdf/render
11
+ ```
12
+
13
+ > Requires [Bun](https://bun.sh) v1.1+. The correct native binary is installed automatically.
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { render } from "@slothpdf/render";
19
+
20
+ const pdf = render(`
21
+ <Page size="A4" margin="20mm">
22
+ <Box class="text-2xl font-bold mb-4">Hello, {name}!</Box>
23
+ <Box class="text-sm text-gray-600">{description}</Box>
24
+ </Page>
25
+ `, { name: "World", description: "Generated with SlothPDF" });
26
+
27
+ await Bun.write("hello.pdf", pdf);
28
+ ```
29
+
30
+ ## Batch
31
+
32
+ Pass an array and `render` yields one PDF per row:
33
+
34
+ ```ts
35
+ for (const { index, buffer } of render(template, invoices)) {
36
+ await Bun.write(`invoices/${index}.pdf`, buffer);
37
+ }
38
+ ```
39
+
40
+ ## Merge
41
+
42
+ All rows into a single multi-page PDF:
43
+
44
+ ```ts
45
+ await Bun.write("report.pdf", render(template, rows, { merge: true }));
46
+ ```
47
+
48
+ ## ZIP
49
+
50
+ All rows as a ZIP archive:
51
+
52
+ ```ts
53
+ await Bun.write("invoices.zip", render(template, rows, { zip: true }));
54
+ ```
55
+
56
+ ## Encryption
57
+
58
+ AES-256 password protection:
59
+
60
+ ```ts
61
+ await Bun.write("secure.pdf", render(template, data, { password: "secret" }));
62
+ ```
63
+
64
+ Works with all modes — single, merge, zip, compress.
65
+
66
+ ## Init
67
+
68
+ Preload fonts and images at startup. Skips assets already loaded.
69
+
70
+ ```ts
71
+ import { init, render } from "@slothpdf/render";
72
+
73
+ init({
74
+ fonts: {
75
+ Inter: { regular: "fonts/Inter-Regular.ttf", bold: "fonts/Inter-Bold.ttf" },
76
+ Mono: "fonts/JetBrainsMono-Regular.ttf",
77
+ },
78
+ images: {
79
+ logo: "assets/logo.png",
80
+ },
81
+ });
82
+ ```
83
+
84
+ ## Custom fonts
85
+
86
+ ```ts
87
+ import { loadFont } from "@slothpdf/render";
88
+
89
+ loadFont("Inter", await Bun.file("Inter-Regular.ttf").bytes());
90
+ loadFont("Inter", await Bun.file("Inter-Bold.ttf").bytes(), "bold");
91
+ ```
92
+
93
+ ```
94
+ <Page size="A4" margin="20mm" font="Inter">
95
+ <Box class="font-bold">This renders in Inter Bold</Box>
96
+ </Page>
97
+ ```
98
+
99
+ ## Images
100
+
101
+ ```ts
102
+ import { loadImage } from "@slothpdf/render";
103
+
104
+ loadImage("logo", await Bun.file("logo.png").bytes());
105
+ ```
106
+
107
+ ```
108
+ <Image src="logo" class="w-32" />
109
+ ```
110
+
111
+ ## QR codes
112
+
113
+ ```
114
+ <QrCode src="https://example.com/pay/inv-042" class="h-[120]" />
115
+ ```
116
+
117
+ ## Page headers & footers
118
+
119
+ ```
120
+ <Page size="A4" margin="20mm">
121
+ <PageHeader class="flex-row border-b border-gray-200 pb-3">
122
+ <Box class="text-lg font-bold">Acme Corp</Box>
123
+ <Box class="text-right text-sm">Invoice #2026-042</Box>
124
+ </PageHeader>
125
+
126
+ <!-- page content -->
127
+
128
+ <PageFooter class="flex-row border-t border-gray-200 pt-2 text-xs text-gray-400">
129
+ <Box>hello@acme.com</Box>
130
+ <Box class="text-right">Page {page}</Box>
131
+ </PageFooter>
132
+ </Page>
133
+ ```
134
+
135
+ ## Template syntax
136
+
137
+ Templates use an HTML-like markup with utility classes. If you've built a web page, the syntax will feel familiar.
138
+
139
+ **Elements:** `Page`, `Box`, `Text`, `Image`, `QrCode`, `Line`, `Columns`, `Column`, `PageHeader`, `PageFooter`
140
+
141
+ **Layout:** `flex-row`, `flex-col`, `justify-between`, `items-center`, `gap-4`
142
+
143
+ **Sizing:** `w-1/2`, `w-full`, `h-16`
144
+
145
+ **Spacing:** `p-4`, `px-6`, `mt-2`, `mb-8`
146
+
147
+ **Typography:** `text-sm`, `text-2xl`, `font-bold`, `text-gray-500`, `text-center`
148
+
149
+ **Data:** `{field}`, `{nested.field}`, `each="arrayField"`, `when="condition"`
150
+
151
+ ```
152
+ <Box each="items" class="flex-row py-2 border-b border-gray-100">
153
+ <Box class="w-2/3 text-sm">{name}</Box>
154
+ <Box class="w-1/3 text-sm text-right">{price}</Box>
155
+ </Box>
156
+
157
+ <Box when="discount" class="text-green-600">
158
+ Discount applied: {discount}
159
+ </Box>
160
+ ```
161
+
162
+ ## CLI
163
+
164
+ ```bash
165
+ slothpdf render template.sloth data.json -o output.pdf
166
+ slothpdf batch template.sloth rows.json -o invoices/
167
+ slothpdf render template.sloth --font "Inter=Inter-Regular.ttf" --compress -o out.pdf
168
+ ```
169
+
170
+ ## API reference
171
+
172
+ ### `render(template, data?, options?)`
173
+
174
+ The only function you need. Behavior depends on what you pass:
175
+
176
+ | Call | Returns |
177
+ |------|---------|
178
+ | `render(template, object)` | `Buffer` — single PDF |
179
+ | `render(template, array)` | `Generator<{ index, buffer }>` — one PDF per row |
180
+ | `render(template, array, { merge: true })` | `Buffer` — one multi-page PDF |
181
+ | `render(template, array, { zip: true })` | `Buffer` — ZIP archive |
182
+
183
+ **Options:**
184
+ - **compress** `boolean` — FlateDecode compression (smaller files)
185
+ - **password** `string` — AES-256 encryption
186
+ - **merge** `boolean` — combine all rows into one PDF
187
+ - **zip** `boolean` — package all PDFs into a ZIP
188
+
189
+ All options are additive — `{ compress: true, password: "secret", merge: true }` works.
190
+
191
+ ### `init(options)`
192
+
193
+ Preload fonts and images from file paths. Skips assets already loaded.
194
+
195
+ ### `loadFont(name, ttf, variant?)`
196
+
197
+ Register a TrueType font. Variant: `"regular"` | `"bold"` | `"italic"`.
198
+
199
+ ### `loadImage(key, data)` · `hasImage(key)` · `clearImages()`
200
+
201
+ Register, check, or clear images for `<Image src="key" />`.
202
+
203
+ ### `hasFont(name)`
204
+
205
+ Check if a font family is loaded. Returns `boolean`.
206
+
207
+ ## Performance
208
+
209
+ Benchmarked on Apple M4, single-threaded, 1000 unique invoices with 3–10 line items:
210
+
211
+ | Mode | Latency | Throughput |
212
+ |------|---------|------------|
213
+ | No encryption | 0.057ms | **17,600 /sec** |
214
+ | AES-256 encrypted | 0.067ms | **14,900 /sec** |
215
+
216
+ Memory is constant after warmup — 50,000 renders with no growth.
217
+
218
+ Compared to other tools:
219
+
220
+ | Tool | ~Speed |
221
+ |------|--------|
222
+ | **SlothPDF** | **17,600 /sec** |
223
+ | jsPDF | 7,750 /sec |
224
+ | Chromium/Puppeteer | ~18 /sec |
225
+ | wkhtmltopdf | ~8 /sec |
226
+
227
+ ## Platforms
228
+
229
+ | Platform | Package |
230
+ |----------|---------|
231
+ | macOS arm64 | `@slothpdf/darwin-arm64` |
232
+ | Linux x64 | `@slothpdf/linux-x64` |
233
+
234
+ Set `SLOTHPDF_LIB` to use a custom binary path.
235
+
236
+ ## Playground
237
+
238
+ Build and preview templates at [slothpdf.jsoto.cloud/editor](https://slothpdf.jsoto.cloud/editor).
239
+
240
+ ## License
241
+
242
+ MIT
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@slothpdf/render",
3
+ "version": "0.4.0",
4
+ "description": "Fast PDF generation from templates. 16,000+ PDFs/sec. No headless browser.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "bin": {
12
+ "slothpdf": "src/cli.ts"
13
+ },
14
+ "files": ["src", "README.md"],
15
+ "keywords": [
16
+ "pdf", "pdf-generation", "template", "render", "batch",
17
+ "invoice", "receipt", "report", "html-to-pdf", "fast",
18
+ "zig", "native", "bun"
19
+ ],
20
+ "author": "SlothPDF",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/voidzer0-dev/slothpdf-render"
25
+ },
26
+ "homepage": "https://slothpdf.jsoto.cloud",
27
+ "bugs": {
28
+ "url": "https://github.com/voidzer0-dev/slothpdf-render/issues"
29
+ },
30
+ "engines": {
31
+ "bun": ">=1.1.0"
32
+ },
33
+ "optionalDependencies": {
34
+ "@slothpdf/darwin-arm64": "0.4.0",
35
+ "@slothpdf/linux-x64": "0.4.0"
36
+ }
37
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * SlothPDF CLI
4
+ *
5
+ * Usage:
6
+ * slothpdf render template.sloth [data.json] [-o output.pdf]
7
+ * slothpdf batch template.sloth data.json [-o outdir/]
8
+ *
9
+ * data.json for batch: array of objects, one PDF per element.
10
+ */
11
+
12
+ import { render, batch, loadFont } from "./index";
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
14
+ import { join, basename, extname } from "path";
15
+
16
+ const args = process.argv.slice(2);
17
+ const cmd = args[0];
18
+
19
+ function usage(): never {
20
+ console.log(`
21
+ slothpdf — fast PDF generation from templates
22
+
23
+ Usage:
24
+ slothpdf render template.sloth [data.json] [-o output.pdf]
25
+ slothpdf batch template.sloth data.json [-o outdir/] [--name field]
26
+
27
+ Options:
28
+ -o Output path (file for render, directory for batch)
29
+ --name JSON field to use for filenames in batch mode
30
+ --font Load a TTF font: --font "Name=path.ttf" or --font "Name:bold=path.ttf"
31
+ --compress Enable FlateDecode compression
32
+ `);
33
+ process.exit(1);
34
+ }
35
+
36
+ if (!cmd || cmd === "--help" || cmd === "-h") usage();
37
+
38
+ // Parse flags
39
+ function flag(name: string): string | undefined {
40
+ const i = args.indexOf(name);
41
+ if (i === -1) return undefined;
42
+ return args[i + 1];
43
+ }
44
+ function hasFlag(name: string): boolean {
45
+ return args.includes(name);
46
+ }
47
+
48
+ // Load fonts from --font flags
49
+ for (let i = 0; i < args.length; i++) {
50
+ if (args[i] === "--font" && args[i + 1]) {
51
+ const spec = args[i + 1];
52
+ const [nameSpec, path] = spec.split("=");
53
+ if (!path) { console.error(`Invalid --font: ${spec}. Use "Name=path.ttf"`); process.exit(1); }
54
+ const [name, variant] = nameSpec.split(":");
55
+ const ttf = readFileSync(path);
56
+ loadFont(name, ttf, (variant as "regular" | "bold" | "italic") ?? "regular");
57
+ i++;
58
+ }
59
+ }
60
+
61
+ const compress = hasFlag("--compress");
62
+
63
+ if (cmd === "render") {
64
+ const templatePath = args[1];
65
+ if (!templatePath) usage();
66
+ const template = readFileSync(templatePath, "utf-8");
67
+
68
+ const dataPath = args.find((a, i) => i > 1 && !a.startsWith("-") && a !== flag("-o"));
69
+ const data = dataPath ? JSON.parse(readFileSync(dataPath, "utf-8")) : undefined;
70
+
71
+ const out = flag("-o") ?? "output.pdf";
72
+ const t0 = performance.now();
73
+ const pdf = render(template, data, { compress });
74
+ const elapsed = performance.now() - t0;
75
+
76
+ writeFileSync(out, pdf);
77
+ console.log(`${out} (${(pdf.length / 1024).toFixed(1)}KB) in ${elapsed.toFixed(1)}ms`);
78
+
79
+ } else if (cmd === "batch") {
80
+ const templatePath = args[1];
81
+ const dataPath = args[2];
82
+ if (!templatePath || !dataPath) usage();
83
+
84
+ const template = readFileSync(templatePath, "utf-8");
85
+ const rows: Record<string, unknown>[] = JSON.parse(readFileSync(dataPath, "utf-8"));
86
+ if (!Array.isArray(rows)) { console.error("Data must be a JSON array"); process.exit(1); }
87
+
88
+ const outDir = flag("-o") ?? "out";
89
+ const nameField = flag("--name");
90
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
91
+
92
+ const t0 = performance.now();
93
+ let totalBytes = 0;
94
+ for (const { index, buffer } of batch(template, rows, { compress })) {
95
+ const name = nameField && rows[index][nameField]
96
+ ? String(rows[index][nameField])
97
+ : String(index + 1).padStart(String(rows.length).length, "0");
98
+ writeFileSync(join(outDir, `${name}.pdf`), buffer);
99
+ totalBytes += buffer.length;
100
+ }
101
+ const elapsed = performance.now() - t0;
102
+
103
+ console.log(`${rows.length} PDFs → ${outDir}/ (${(totalBytes / 1024 / 1024).toFixed(1)}MB) in ${elapsed.toFixed(0)}ms | ${(elapsed / rows.length).toFixed(2)}ms/pdf`);
104
+
105
+ } else {
106
+ console.error(`Unknown command: ${cmd}`);
107
+ usage();
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * @slothpdf/render — Fast PDF generation from templates.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import { render, loadFont } from "@slothpdf/render";
7
+ *
8
+ * // Single PDF
9
+ * const pdf = render(template, { name: "Acme" });
10
+ *
11
+ * // Batch — one PDF per row
12
+ * for (const { index, buffer } of render(template, rows)) {
13
+ * fs.writeFileSync(`out/${index}.pdf`, buffer);
14
+ * }
15
+ *
16
+ * // Merge — all rows into one multi-page PDF
17
+ * const merged = render(template, rows, { merge: true });
18
+ *
19
+ * // ZIP — all rows as a ZIP archive
20
+ * const zip = render(template, rows, { zip: true });
21
+ * ```
22
+ */
23
+
24
+ import { lib, ptr, toArrayBuffer } from "./native";
25
+
26
+ /** Copy bytes from a Zig-owned pointer into a new Buffer.
27
+ * The copy must complete before the Zig memory is freed. */
28
+ function copyFromPtr(p: any, len: number): Buffer {
29
+ const view = toArrayBuffer(p, 0, len)!;
30
+ const copy = Buffer.alloc(len);
31
+ copy.set(new Uint8Array(view));
32
+ return copy;
33
+ }
34
+
35
+ // ── Types ────────────────────────────────────────────────────────────
36
+
37
+ export interface RenderOptions {
38
+ compress?: boolean;
39
+ password?: string;
40
+ merge?: boolean;
41
+ zip?: boolean;
42
+ }
43
+
44
+ export interface BatchItem {
45
+ index: number;
46
+ buffer: Buffer;
47
+ }
48
+
49
+ // ── Render ───────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Render PDFs from a template and data.
53
+ *
54
+ * - Object → single PDF (Buffer)
55
+ * - Array → generator yielding one PDF per row
56
+ * - Array + `{ merge: true }` → single multi-page PDF (Buffer)
57
+ * - Array + `{ zip: true }` → ZIP archive of all PDFs (Buffer)
58
+ */
59
+ export function render(
60
+ template: string,
61
+ data: Record<string, unknown>[],
62
+ options: RenderOptions & { merge: true },
63
+ ): Buffer;
64
+ export function render(
65
+ template: string,
66
+ data: Record<string, unknown>[],
67
+ options: RenderOptions & { zip: true },
68
+ ): Buffer;
69
+ export function render(
70
+ template: string,
71
+ data: Record<string, unknown>[],
72
+ options?: RenderOptions,
73
+ ): Generator<BatchItem>;
74
+ export function render(
75
+ template: string,
76
+ data?: Record<string, unknown>,
77
+ options?: RenderOptions,
78
+ ): Buffer;
79
+ export function render(
80
+ template: string,
81
+ data?: Record<string, unknown> | Record<string, unknown>[],
82
+ options?: RenderOptions,
83
+ ): Buffer | Generator<BatchItem> {
84
+ if (Array.isArray(data)) {
85
+ if (options?.merge) return _renderMerge(template, data, options);
86
+ if (options?.zip) return _renderZip(template, data, options);
87
+ return _renderBatch(template, data, options);
88
+ }
89
+ return _renderOne(template, data, options);
90
+ }
91
+
92
+ // ── Single render ────────────────────────────────────────────────────
93
+
94
+ function _renderOne(
95
+ template: string,
96
+ data?: Record<string, unknown>,
97
+ options?: RenderOptions,
98
+ ): Buffer {
99
+ const l = lib();
100
+ const templateBuf = Buffer.from(template);
101
+ const jsonBuf = data ? Buffer.from(JSON.stringify(data)) : null;
102
+ const pwBuf = options?.password ? Buffer.from(options.password) : null;
103
+ const flags = options?.compress ? 1 : 0;
104
+
105
+ const result = l.symbols.slothpdf_render_pdf(
106
+ ptr(templateBuf), templateBuf.length,
107
+ jsonBuf ? ptr(jsonBuf) : null, jsonBuf?.length ?? 0,
108
+ null, 0,
109
+ pwBuf ? ptr(pwBuf) : null, pwBuf?.length ?? 0,
110
+ flags,
111
+ );
112
+
113
+ if (!result) throw new Error("SlothPDF render failed");
114
+
115
+ const p = l.symbols.slothpdf_result_ptr(result);
116
+ const len = Number(l.symbols.slothpdf_result_len(result));
117
+ const pdf = copyFromPtr(p!, len);
118
+ l.symbols.slothpdf_result_free(result);
119
+
120
+ return pdf;
121
+ }
122
+
123
+ // ── Batch render (generator) ─────────────────────────────────────────
124
+
125
+ function* _renderBatch(
126
+ template: string,
127
+ rows: Record<string, unknown>[],
128
+ options?: RenderOptions,
129
+ ): Generator<BatchItem> {
130
+ for (let i = 0; i < rows.length; i++) {
131
+ yield { index: i, buffer: _renderOne(template, rows[i], options) };
132
+ }
133
+ }
134
+
135
+ // ── Merge render ─────────────────────────────────────────────────────
136
+
137
+ function _renderMerge(
138
+ template: string,
139
+ rows: Record<string, unknown>[],
140
+ options?: RenderOptions,
141
+ ): Buffer {
142
+ const l = lib();
143
+ const templateBuf = Buffer.from(template);
144
+ const packed = packRows(rows);
145
+ const pwBuf = options?.password ? Buffer.from(options.password) : null;
146
+ const flags = (options?.compress ? 1 : 0) | 2; // bit 1 = merge mode
147
+
148
+ const result = l.symbols.slothpdf_render_pdf(
149
+ ptr(templateBuf), templateBuf.length,
150
+ ptr(packed), packed.length,
151
+ null, 0,
152
+ pwBuf ? ptr(pwBuf) : null, pwBuf?.length ?? 0,
153
+ flags,
154
+ );
155
+
156
+ if (!result) throw new Error("SlothPDF merge failed");
157
+
158
+ const p = l.symbols.slothpdf_result_ptr(result);
159
+ const len = Number(l.symbols.slothpdf_result_len(result));
160
+ const pdf = copyFromPtr(p!, len);
161
+ l.symbols.slothpdf_result_free(result);
162
+
163
+ return pdf;
164
+ }
165
+
166
+ // ── ZIP render ───────────────────────────────────────────────────────
167
+
168
+ function _renderZip(
169
+ template: string,
170
+ rows: Record<string, unknown>[],
171
+ options?: RenderOptions,
172
+ ): Buffer {
173
+ // Render each PDF individually (supports all options), then ZIP
174
+ const pdfs: Buffer[] = [];
175
+ for (let i = 0; i < rows.length; i++) {
176
+ pdfs.push(_renderOne(template, rows[i], options));
177
+ }
178
+
179
+ const pad = String(rows.length).length;
180
+ const entries = pdfs.map((buf, i) => ({
181
+ name: `${String(i + 1).padStart(pad, "0")}.pdf`,
182
+ data: buf,
183
+ }));
184
+
185
+ return buildZip(entries);
186
+ }
187
+
188
+ function buildZip(entries: { name: string; data: Buffer }[]): Buffer {
189
+ const parts: Buffer[] = [];
190
+ const centralDir: Buffer[] = [];
191
+ let offset = 0;
192
+
193
+ for (const { name, data } of entries) {
194
+ const nameBytes = Buffer.from(name);
195
+ const crc = crc32(data);
196
+
197
+ // Local file header
198
+ const local = Buffer.alloc(30 + nameBytes.length);
199
+ local.writeUInt32LE(0x04034b50, 0);
200
+ local.writeUInt16LE(20, 4);
201
+ local.writeUInt32LE(crc, 14);
202
+ local.writeUInt32LE(data.length, 18);
203
+ local.writeUInt32LE(data.length, 22);
204
+ local.writeUInt16LE(nameBytes.length, 26);
205
+ nameBytes.copy(local, 30);
206
+ parts.push(local, data);
207
+
208
+ // Central directory entry
209
+ const cd = Buffer.alloc(46 + nameBytes.length);
210
+ cd.writeUInt32LE(0x02014b50, 0);
211
+ cd.writeUInt16LE(20, 4);
212
+ cd.writeUInt16LE(20, 6);
213
+ cd.writeUInt32LE(crc, 16);
214
+ cd.writeUInt32LE(data.length, 20);
215
+ cd.writeUInt32LE(data.length, 24);
216
+ cd.writeUInt16LE(nameBytes.length, 28);
217
+ cd.writeUInt32LE(offset, 42);
218
+ nameBytes.copy(cd, 46);
219
+ centralDir.push(cd);
220
+
221
+ offset += local.length + data.length;
222
+ }
223
+
224
+ let cdSize = 0;
225
+ for (const cd of centralDir) cdSize += cd.length;
226
+
227
+ const eocd = Buffer.alloc(22);
228
+ eocd.writeUInt32LE(0x06054b50, 0);
229
+ eocd.writeUInt16LE(entries.length, 8);
230
+ eocd.writeUInt16LE(entries.length, 10);
231
+ eocd.writeUInt32LE(cdSize, 12);
232
+ eocd.writeUInt32LE(offset, 16);
233
+
234
+ return Buffer.concat([...parts, ...centralDir, eocd]);
235
+ }
236
+
237
+ function crc32(buf: Buffer): number {
238
+ let crc = 0xFFFFFFFF;
239
+ for (let i = 0; i < buf.length; i++) {
240
+ crc ^= buf[i];
241
+ for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
242
+ }
243
+ return (crc ^ 0xFFFFFFFF) >>> 0;
244
+ }
245
+
246
+ // ── Internal helpers ─────────────────────────────────────────────────
247
+
248
+ function packRows(rows: Record<string, unknown>[]): Buffer {
249
+ const jsonStrings = rows.map(r => JSON.stringify(r));
250
+ const totalLen = 4 + jsonStrings.reduce((s, j) => s + 4 + Buffer.byteLength(j), 0);
251
+ const buf = Buffer.alloc(totalLen);
252
+ buf.writeUInt32LE(rows.length, 0);
253
+ let offset = 4;
254
+ for (const j of jsonStrings) {
255
+ const len = Buffer.byteLength(j);
256
+ buf.writeUInt32LE(len, offset); offset += 4;
257
+ buf.write(j, offset); offset += len;
258
+ }
259
+ return buf;
260
+ }
261
+
262
+ // ── Fonts ────────────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * Register a TrueType font for use in templates.
266
+ */
267
+ export function loadFont(
268
+ name: string,
269
+ ttf: Buffer,
270
+ variant: "regular" | "bold" | "italic" = "regular",
271
+ ): void {
272
+ const l = lib();
273
+ const nameBuf = Buffer.from(name);
274
+ const v = variant === "bold" ? 1 : variant === "italic" ? 2 : 0;
275
+ l.symbols.slothpdf_load_font(ptr(nameBuf), nameBuf.length, ptr(ttf), ttf.length, v);
276
+ }
277
+
278
+ /**
279
+ * Check if a font family is loaded.
280
+ */
281
+ export function hasFont(name: string): boolean {
282
+ const l = lib();
283
+ const buf = Buffer.from(name);
284
+ return l.symbols.slothpdf_has_font(ptr(buf), buf.length) === 1;
285
+ }
286
+
287
+ // ── Images ───────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Register an image by key for use in templates via `src="{key}"`.
291
+ */
292
+ export function loadImage(key: string, data: Buffer): void {
293
+ const l = lib();
294
+ const keyBuf = Buffer.from(key);
295
+ l.symbols.slothpdf_register_image(ptr(keyBuf), keyBuf.length, ptr(data), data.length);
296
+ }
297
+
298
+ /**
299
+ * Check if an image is registered.
300
+ */
301
+ export function hasImage(key: string): boolean {
302
+ const l = lib();
303
+ const buf = Buffer.from(key);
304
+ return l.symbols.slothpdf_has_image(ptr(buf), buf.length) === 1;
305
+ }
306
+
307
+ /**
308
+ * Clear all registered images.
309
+ */
310
+ export function clearImages(): void {
311
+ lib().symbols.slothpdf_clear_images();
312
+ }
313
+
314
+ // ── Init ─────────────────────────────────────────────────────────────
315
+
316
+ export interface FontConfig {
317
+ regular?: string;
318
+ bold?: string;
319
+ italic?: string;
320
+ }
321
+
322
+ export interface InitOptions {
323
+ fonts?: Record<string, FontConfig | string>;
324
+ images?: Record<string, string>;
325
+ }
326
+
327
+ /**
328
+ * Preload fonts and images at startup. Skips assets already loaded.
329
+ *
330
+ * @example
331
+ * ```ts
332
+ * init({
333
+ * fonts: {
334
+ * Inter: { regular: "fonts/Inter-Regular.ttf", bold: "fonts/Inter-Bold.ttf" },
335
+ * Mono: "fonts/JetBrainsMono-Regular.ttf", // shorthand for { regular: ... }
336
+ * },
337
+ * images: {
338
+ * logo: "assets/logo.png",
339
+ * },
340
+ * });
341
+ * ```
342
+ */
343
+ export function init(options: InitOptions): void {
344
+ const { readFileSync } = require("fs");
345
+
346
+ if (options.fonts) {
347
+ for (const [name, config] of Object.entries(options.fonts)) {
348
+ const variants = typeof config === "string" ? { regular: config } : config;
349
+ for (const [variant, path] of Object.entries(variants)) {
350
+ if (!path) continue;
351
+ if (!hasFont(name)) {
352
+ loadFont(name, readFileSync(path), variant as "regular" | "bold" | "italic");
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ if (options.images) {
359
+ for (const [key, path] of Object.entries(options.images)) {
360
+ if (!hasImage(key)) {
361
+ loadImage(key, readFileSync(path));
362
+ }
363
+ }
364
+ }
365
+ }
package/src/native.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Native FFI bridge to libslothpdf.
3
+ * Internal module — not exported to users.
4
+ */
5
+ import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi";
6
+ import { join } from "path";
7
+ import { existsSync } from "fs";
8
+
9
+ // ── Binary resolution ────────────────────────────────────────────────
10
+
11
+ const LIB_EXT = process.platform === "darwin" ? "dylib" : "so";
12
+
13
+ function findLibrary(): string {
14
+ // 1. Explicit env override
15
+ if (process.env.SLOTHPDF_LIB) return process.env.SLOTHPDF_LIB;
16
+
17
+ // 2. Platform-specific optional dependency (@slothpdf/darwin-arm64, etc.)
18
+ const platform = process.platform === "darwin" ? "darwin" : "linux";
19
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
20
+ const pkgName = `@slothpdf/${platform}-${arch}`;
21
+ try {
22
+ const pkgPath = require.resolve(`${pkgName}/libslothpdf.${LIB_EXT}`);
23
+ if (existsSync(pkgPath)) return pkgPath;
24
+ } catch {}
25
+
26
+ // 3. Monorepo development path
27
+ const devPath = join(import.meta.dir, `../../../engine/zig-out/lib/libslothpdf.${LIB_EXT}`);
28
+ if (existsSync(devPath)) return devPath;
29
+
30
+ throw new Error(
31
+ `SlothPDF native binary not found. Install the platform package:\n bun add ${pkgName}\n\nOr set SLOTHPDF_LIB to the path of libslothpdf.${LIB_EXT}`
32
+ );
33
+ }
34
+
35
+ // ── FFI symbols ──────────────────────────────────────────────────────
36
+
37
+ const SYMBOLS = {
38
+ // Single render
39
+ slothpdf_render_pdf: {
40
+ args: [FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.u32],
41
+ returns: FFIType.ptr,
42
+ },
43
+ // Result accessors
44
+ slothpdf_result_ptr: { args: [FFIType.ptr], returns: FFIType.ptr },
45
+ slothpdf_result_len: { args: [FFIType.ptr], returns: FFIType.u64 },
46
+ slothpdf_result_free: { args: [FFIType.ptr], returns: FFIType.void },
47
+ // Streaming batch
48
+ slothpdf_stream_begin: { args: [FFIType.ptr, FFIType.u64, FFIType.u32], returns: FFIType.ptr },
49
+ slothpdf_stream_next: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.bool },
50
+ slothpdf_stream_pdf_ptr: { args: [FFIType.ptr], returns: FFIType.ptr },
51
+ slothpdf_stream_pdf_len: { args: [FFIType.ptr], returns: FFIType.u64 },
52
+ slothpdf_stream_end: { args: [FFIType.ptr], returns: FFIType.void },
53
+ // Batch + zip
54
+ slothpdf_render_batch: { args: [FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.u32], returns: FFIType.ptr },
55
+ slothpdf_batch_count: { args: [FFIType.ptr], returns: FFIType.u32 },
56
+ slothpdf_batch_free: { args: [FFIType.ptr], returns: FFIType.void },
57
+ slothpdf_batch_to_zip: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.ptr },
58
+ // Fonts & images
59
+ slothpdf_load_font: { args: [FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64, FFIType.u8], returns: FFIType.void },
60
+ slothpdf_has_font: { args: [FFIType.ptr, FFIType.u64], returns: FFIType.u8 },
61
+ slothpdf_register_image: { args: [FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64], returns: FFIType.void },
62
+ slothpdf_has_image: { args: [FFIType.ptr, FFIType.u64], returns: FFIType.u8 },
63
+ slothpdf_clear_images: { args: [], returns: FFIType.void },
64
+ } as const;
65
+
66
+ type Lib = ReturnType<typeof dlopen<typeof SYMBOLS>>;
67
+
68
+ // ── Singleton ────────────────────────────────────────────────────────
69
+
70
+ let _lib: Lib | null = null;
71
+
72
+ export function lib(): Lib {
73
+ if (!_lib) _lib = dlopen(findLibrary(), SYMBOLS);
74
+ return _lib;
75
+ }
76
+
77
+ export { ptr, toArrayBuffer };