@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 +242 -0
- package/package.json +37 -0
- package/src/cli.ts +108 -0
- package/src/index.ts +365 -0
- package/src/native.ts +77 -0
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 };
|