fullbleed 0.2.0 → 0.3.1

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/dist/build.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "packageVersion": "0.2.0",
3
- "engineVersion": "2.5.8",
4
- "engineSource": "https://crates.io/crates/fullbleed/2.5.8",
2
+ "packageVersion": "0.3.1",
3
+ "engineVersion": "2.5.10",
4
+ "engineSource": "https://crates.io/crates/fullbleed/2.5.10",
5
5
  "target": "wasm32-wasip1",
6
6
  "memoryMaximumBytes": 536870912,
7
7
  "fonts": [
@@ -13,7 +13,7 @@
13
13
  "dependencies": [
14
14
  {
15
15
  "name": "fullbleed",
16
- "version": "2.5.8",
16
+ "version": "2.5.10",
17
17
  "source": "registry+https://github.com/rust-lang/crates.io-index",
18
18
  "license": "MIT"
19
19
  },
@@ -44,12 +44,12 @@
44
44
  ],
45
45
  "files": {
46
46
  "dist/engine.wasm": {
47
- "bytes": 5069661,
48
- "sha256": "2f41ac7c2d85f7724166ea0d466cc55b6d6f5ca0014014d667f54f7ac93577cb"
47
+ "bytes": 5075752,
48
+ "sha256": "4fcbecb954f6a5a64ea840795e74d719cc045d5ba35d5ffa730fcb46b3ce8b3c"
49
49
  },
50
50
  "dist/THIRD_PARTY_NOTICES.txt": {
51
- "bytes": 57660,
52
- "sha256": "5475f50666d3af5df89048cc1ddc98d153cd813019afbc213be20e4f3838911f"
51
+ "bytes": 57662,
52
+ "sha256": "d41ecdf3735cc3eb030949311ba830a503f339ddb0108fb0b2e452186bab354f"
53
53
  },
54
54
  "assets/fonts/BebasNeue-OFL.txt": {
55
55
  "bytes": 4337,
@@ -78,6 +78,18 @@
78
78
  "assets/fonts/Inter-Variable.ttf": {
79
79
  "bytes": 876576,
80
80
  "sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031"
81
+ },
82
+ "dist/browser/client.js": {
83
+ "bytes": 12043,
84
+ "sha256": "60ea647aa3818c61a313f6a3c8ab8f269667fddc6ab85465d30c61408da0b765"
85
+ },
86
+ "dist/browser/worker.js": {
87
+ "bytes": 52109,
88
+ "sha256": "32734ecd472fe4c329869ccf58078e8dbb0269621f5b37b8a72116daace65a99"
89
+ },
90
+ "dist/browser/asset-manifest.json": {
91
+ "bytes": 2399,
92
+ "sha256": "d7f39b2163eb1a979eb393c9559e18494f63db1033c00f2c0e17700e4edf4521"
81
93
  }
82
94
  }
83
95
  }
package/dist/engine.wasm CHANGED
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fullbleed",
3
- "version": "0.2.0",
4
- "description": "Generate PDFs from HTML and CSS in Node.js with the Fullbleed Rust engine, bundled fonts, and optional PNG previews.",
3
+ "version": "0.3.1",
4
+ "description": "Generate PDFs from HTML and CSS in Node.js or a browser worker with the Fullbleed Rust engine, bundled fonts, and optional PNG previews.",
5
5
  "type": "module",
6
6
  "main": "./src/index.cjs",
7
7
  "types": "./src/index.d.ts",
@@ -15,6 +15,10 @@
15
15
  "types": "./dist/index.d.cts",
16
16
  "default": "./src/index.cjs"
17
17
  }
18
+ },
19
+ "./browser": {
20
+ "types": "./src/browser.d.ts",
21
+ "default": "./dist/browser/client.js"
18
22
  }
19
23
  },
20
24
  "engines": {
@@ -31,7 +35,7 @@
31
35
  "build": "node tools/build.mjs",
32
36
  "prepack": "node tools/check-build.mjs",
33
37
  "test": "node --test test/*.test.mjs",
34
- "check:types": "tsc -p tsconfig.json",
38
+ "check:types": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json",
35
39
  "verify:native": "node tools/verify-native.mjs",
36
40
  "example": "node examples/invoice.mjs",
37
41
  "verify:pack": "node tools/verify-pack.mjs",
@@ -39,7 +43,11 @@
39
43
  "render:fonts": "node tools/render-font-subsets.mjs",
40
44
  "verify:fonts": "python tools/verify-font-subsets.py",
41
45
  "render:families": "node tools/render-font-families.mjs",
42
- "verify:families": "python tools/verify-font-families.py output/font-families"
46
+ "verify:families": "python tools/verify-font-families.py output/font-families",
47
+ "build:browser": "node tools/build-browser.mjs",
48
+ "verify:browser:prepare": "node tools/prepare-browser-check.mjs",
49
+ "render:inline": "node tools/render-inline-wrapping.mjs",
50
+ "verify:inline": "python tools/verify-inline-wrapping.py"
43
51
  },
44
52
  "dependencies": {
45
53
  "@bjorn3/browser_wasi_shim": "0.4.2"
@@ -67,10 +75,14 @@
67
75
  "access": "public"
68
76
  },
69
77
  "fullbleed": {
70
- "engineVersion": "2.5.8"
78
+ "engineVersion": "2.5.10"
71
79
  },
72
80
  "devDependencies": {
73
81
  "@types/node": "22.20.5",
74
- "typescript": "7.0.2"
82
+ "typescript": "7.0.2",
83
+ "esbuild": "0.28.2"
84
+ },
85
+ "bin": {
86
+ "fullbleed-browser-assets": "src/copy-browser-assets.cjs"
75
87
  }
76
88
  }
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { runEngine } from './engine-runner.js';
3
+
4
+ const version = __FULLBLEED_PACKAGE_VERSION__;
5
+ const engineVersion = __FULLBLEED_ENGINE_VERSION__;
6
+ const files = __FULLBLEED_RUNTIME_FILES__;
7
+ const fontNames = __FULLBLEED_FONT_NAMES__;
8
+ const hex = bytes => Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, '0')).join('');
9
+
10
+ async function load(name) {
11
+ const url = new URL(name, self.location.href);
12
+ url.searchParams.set('v', version);
13
+ const response = await fetch(url, { credentials: 'omit', redirect: 'error' });
14
+ if (!response.ok) throw new Error(`Could not load ${name} (HTTP ${response.status}).`);
15
+ const bytes = new Uint8Array(await response.arrayBuffer());
16
+ if (bytes.length !== files[name].bytes || hex(await crypto.subtle.digest('SHA-256', bytes)) !== files[name].sha256) {
17
+ throw new Error(`Bundled asset failed verification: ${name}.`);
18
+ }
19
+ return bytes;
20
+ }
21
+
22
+ self.addEventListener('message', async ({ data }) => {
23
+ let result;
24
+ try {
25
+ if (data?.type !== 'render') throw new Error('Invalid render request.');
26
+ const [wasm, ...fontBytes] = await Promise.all([load('engine.wasm'), ...fontNames.map(name => load('fonts/' + name))]);
27
+ const module = await WebAssembly.compile(wasm);
28
+ if (!WebAssembly.Module.imports(module).every(item => item.module === 'wasi_snapshot_preview1' && !item.name.startsWith('sock_'))) {
29
+ throw new Error('Unexpected WebAssembly host imports.');
30
+ }
31
+ result = await runEngine({ ...data.job, module,
32
+ fonts: [...fontNames.map((name, i) => ({ name: 'fonts/' + name, data: fontBytes[i] })), ...data.job.fonts] });
33
+ } catch (cause) {
34
+ result = { ok: false, code: 'ENGINE_LOAD_FAILED', message: 'Could not load the bundled Fullbleed engine: ' + cause.message };
35
+ }
36
+ self.postMessage({ type: 'result', engineVersion, ...result }, result.ok ? [result.pdf.buffer, ...result.previews.map(page => page.buffer)] : []);
37
+ self.close();
38
+ }, { once: true });
39
+ self.postMessage({ type: 'ready', protocol: 1, version, engineVersion });
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export interface BrowserRendererOptions {
3
+ /** Same-origin directory URL ending in /, containing the copied browser assets. */
4
+ assetBaseUrl: string | URL;
5
+ }
6
+ export interface BrowserRenderOptions {
7
+ html: string;
8
+ css?: string;
9
+ /** Additional TrueType font bytes; use the font family names in CSS. */
10
+ fonts?: readonly Uint8Array[];
11
+ /** Relative names referenced as assets/name.svg in document HTML/CSS. */
12
+ assets?: Readonly<Record<string, Uint8Array>>;
13
+ /** 0 disables previews (default); otherwise 36–300 DPI. */
14
+ previewDpi?: number;
15
+ /** Includes worker startup and asset loading. Default: 30000 ms. */
16
+ timeoutMs?: number;
17
+ maxPages?: number;
18
+ allowMissingGlyphs?: boolean;
19
+ signal?: AbortSignal;
20
+ }
21
+ export interface BrowserRenderResult {
22
+ pdf: Uint8Array<ArrayBuffer>;
23
+ previews: Uint8Array<ArrayBuffer>[];
24
+ pages: number;
25
+ missingGlyphs: number;
26
+ engineVersion: string;
27
+ }
28
+ export interface BrowserRenderer {
29
+ renderPdf(options: BrowserRenderOptions): Promise<BrowserRenderResult>;
30
+ }
31
+ export class FullbleedError extends Error {
32
+ readonly code: string;
33
+ constructor(code: string, message: string, options?: ErrorOptions);
34
+ }
35
+ export function createRenderer(options: BrowserRendererOptions): BrowserRenderer;
36
+ export const version: string;
37
+ export const engineVersion: string;
package/src/browser.js ADDED
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import input from './render-input.cjs';
3
+
4
+ export const version = __FULLBLEED_PACKAGE_VERSION__;
5
+ export const engineVersion = __FULLBLEED_ENGINE_VERSION__;
6
+ export const FullbleedError = input.FullbleedError;
7
+ const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); };
8
+
9
+ /** Create a browser renderer using the assets copied to your site's static directory. */
10
+ export function createRenderer(options) {
11
+ if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an assetBaseUrl pointing to your hosted Fullbleed assets.');
12
+ for (const key of Object.keys(options)) if (key !== 'assetBaseUrl') invalid(`Unknown renderer option: ${key}`);
13
+ if (typeof globalThis.Worker !== 'function' || !globalThis.location || !globalThis.crypto?.subtle || !globalThis.WebAssembly) {
14
+ throw new FullbleedError('UNSUPPORTED_BROWSER', 'Use a browser with Web Workers, WebAssembly and Web Crypto over HTTPS or localhost.');
15
+ }
16
+ if (typeof options.assetBaseUrl !== 'string' && !(options.assetBaseUrl instanceof URL)) invalid('assetBaseUrl must be a URL or URL string.');
17
+ let base;
18
+ try { base = new URL(options.assetBaseUrl, globalThis.location.href); }
19
+ catch { invalid('assetBaseUrl is not a valid URL.'); }
20
+ if (!['http:', 'https:'].includes(base.protocol) || base.origin !== globalThis.location.origin || base.username || base.password || base.search || base.hash || !base.pathname.endsWith('/')) {
21
+ invalid('assetBaseUrl must be a same-origin HTTP(S) directory URL ending in /, without credentials, a query or a fragment.');
22
+ }
23
+ const entry = new URL('worker.js', base);
24
+ entry.searchParams.set('v', version);
25
+
26
+ async function renderPdf(options) {
27
+ const start = Date.now();
28
+ const job = input.normalize(options, { browser: true });
29
+ const aborted = () => new FullbleedError('ABORTED', 'PDF rendering was aborted.');
30
+ if (job.signal?.aborted) throw aborted();
31
+ const remaining = job.timeoutMs - (Date.now() - start);
32
+ if (remaining <= 0) throw new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
33
+ return new Promise((resolve, reject) => {
34
+ let worker, timer, settled = false, ready = false;
35
+ const finish = (error, result) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ clearTimeout(timer);
39
+ job.signal?.removeEventListener('abort', abort);
40
+ // A caller may synchronously abort while a Worker constructor is being
41
+ // instrumented. Let its handle be assigned before requesting termination.
42
+ queueMicrotask(() => {
43
+ try { worker?.terminate(); }
44
+ catch (cause) { reject(new FullbleedError('WORKER_FAILED', 'Could not stop the render worker.', { cause })); return; }
45
+ if (error) reject(error); else resolve(result);
46
+ });
47
+ };
48
+ const abort = () => finish(aborted());
49
+ timer = setTimeout(() => finish(new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`)), remaining);
50
+ job.signal?.addEventListener('abort', abort, { once: true });
51
+ if (job.signal?.aborted) { abort(); return; }
52
+ try {
53
+ worker = new Worker(entry.href, { type: 'module', name: 'Fullbleed PDF renderer', credentials: 'omit' });
54
+ worker.addEventListener('error', event => {
55
+ event.preventDefault();
56
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker could not run. Check the copied assets and the site content security policy.'));
57
+ });
58
+ worker.addEventListener('messageerror', () => finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an unreadable response.')));
59
+ worker.addEventListener('message', ({ data }) => {
60
+ if (settled) return;
61
+ if (!ready) {
62
+ if (data?.type !== 'ready' || data.protocol !== 1 || data.version !== version || data.engineVersion !== engineVersion) {
63
+ finish(new FullbleedError('VERSION_MISMATCH', 'The hosted browser assets do not match this package. Copy the assets again and deploy them together.'));
64
+ return;
65
+ }
66
+ ready = true;
67
+ try {
68
+ worker.postMessage({ type: 'render', job: {
69
+ html: job.html, css: 'body { font-family: Inter; }\n' + job.css,
70
+ fonts: job.customFonts, assets: job.assetFiles, previewDpi: job.previewDpi,
71
+ maxPages: job.maxPages, allowMissingGlyphs: job.allowMissingGlyphs,
72
+ } });
73
+ } catch (cause) { finish(new FullbleedError('WORKER_FAILED', 'Could not send the document to the rendering worker.', { cause })); }
74
+ return;
75
+ }
76
+ if (data?.type !== 'result' || typeof data.ok !== 'boolean') {
77
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid response.'));
78
+ return;
79
+ }
80
+ if (!data.ok) {
81
+ finish(typeof data.code === 'string' && typeof data.message === 'string'
82
+ ? new FullbleedError(data.code, data.message)
83
+ : new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid error.'));
84
+ return;
85
+ }
86
+ if (!(data.pdf instanceof Uint8Array) || new TextDecoder().decode(data.pdf.subarray(0, 5)) !== '%PDF-'
87
+ || !Number.isSafeInteger(data.pages) || data.pages < 1 || data.pages > job.maxPages
88
+ || !Array.isArray(data.previews) || data.previews.length !== (job.previewDpi ? data.pages : 0)
89
+ || !data.previews.every(bytes => bytes instanceof Uint8Array && [137, 80, 78, 71, 13, 10, 26, 10].every((value, i) => bytes[i] === value))
90
+ || !Number.isSafeInteger(data.missingGlyphs) || data.missingGlyphs < 0 || data.engineVersion !== engineVersion) {
91
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid result.'));
92
+ return;
93
+ }
94
+ finish(null, { pdf: data.pdf, previews: data.previews, pages: data.pages, missingGlyphs: data.missingGlyphs, engineVersion });
95
+ });
96
+ if (job.signal?.aborted) abort();
97
+ } catch (cause) {
98
+ finish(new FullbleedError('WORKER_FAILED', 'Could not start the rendering worker.', { cause }));
99
+ }
100
+ });
101
+ }
102
+ return Object.freeze({ renderPdf });
103
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ 'use strict';
4
+ const { readFile, writeFile, mkdir } = require('node:fs/promises');
5
+ const { resolve, join, dirname, relative, isAbsolute } = require('node:path');
6
+ const { createHash } = require('node:crypto');
7
+
8
+ async function main() {
9
+ const args = process.argv.slice(2);
10
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
11
+ console.log('Usage: fullbleed-browser-assets OUTPUT_DIRECTORY\nCopies the browser client, worker, engine, fonts and license notices. Existing runtime files in the named directory are replaced.');
12
+ return;
13
+ }
14
+ if (args.length !== 1 || args[0].startsWith('-') || !args[0].trim()) throw new Error('Pass one output directory, for example: fullbleed-browser-assets public/fullbleed');
15
+ const root = resolve(__dirname, '..');
16
+ const destination = resolve(args[0]);
17
+ const inside = relative(root, destination);
18
+ if (!inside || (!inside.startsWith('..') && !isAbsolute(inside))) throw new Error('Choose an output directory outside the installed Fullbleed package.');
19
+ const manifest = JSON.parse(await readFile(join(root, 'dist/browser/asset-manifest.json'), 'utf8'));
20
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
21
+ if (manifest.schema !== 'fullbleed.browser-assets.v1' || manifest.packageVersion !== pkg.version || manifest.engineVersion !== pkg.fullbleed.engineVersion) throw new Error('The browser build does not match the installed package.');
22
+ const files = [];
23
+ for (const [name, record] of Object.entries(manifest.files)) {
24
+ for (const path of [name, record.source]) {
25
+ if (typeof path !== 'string' || /[\\:\0]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('The browser asset manifest contains an invalid path.');
26
+ }
27
+ const data = await readFile(join(root, record.source));
28
+ if (data.length !== record.bytes || createHash('sha256').update(data).digest('hex') !== record.sha256) throw new Error('The installed browser asset failed verification: ' + name);
29
+ files.push({ name, data, bytes: record.bytes, sha256: record.sha256 });
30
+ }
31
+ // Verify the complete installed set before writing any output files.
32
+ for (const file of files) {
33
+ const path = join(destination, file.name);
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, file.data);
36
+ }
37
+ await writeFile(join(destination, 'build.json'), JSON.stringify({ schema: manifest.schema,
38
+ packageVersion: pkg.version, engineVersion: manifest.engineVersion,
39
+ files: Object.fromEntries(files.map(({ name, bytes, sha256 }) => [name, { bytes, sha256 }])) }, null, 2) + '\n');
40
+ console.log(JSON.stringify({ ok: true, directory: destination, packageVersion: pkg.version,
41
+ engineVersion: manifest.engineVersion, files: files.length + 1, bytes: files.reduce((sum, file) => sum + file.bytes, 0) }));
42
+ }
43
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { WASI, File, OpenFile, Directory, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim';
3
+
4
+ export async function runEngine(job) {
5
+ try {
6
+ const root = new PreopenDirectory('.', new Map());
7
+ const put = (path, data) => {
8
+ const parts = path.split('/');
9
+ let directory = root.dir;
10
+ for (const part of parts.slice(0, -1)) {
11
+ if (!directory.contents.has(part)) directory.contents.set(part, new Directory(new Map()));
12
+ directory = directory.contents.get(part);
13
+ }
14
+ directory.contents.set(parts.at(-1), new File(data));
15
+ };
16
+ const encoder = new TextEncoder();
17
+ put('input.html', encoder.encode(job.html));
18
+ put('style.css', encoder.encode(job.css));
19
+ for (const file of [...job.fonts, ...job.assets]) put(file.name, file.data);
20
+ let log = '';
21
+ const collect = line => { if (log.length < 4000) log += String(line).slice(0, 4000 - log.length) + '\n'; };
22
+ const wasi = new WASI(['fullbleed', String(job.maxPages), String(job.previewDpi), job.allowMissingGlyphs ? 'allow' : 'reject', ...job.fonts.map(f => f.name)], [], [
23
+ new OpenFile(new File([])), ConsoleStdout.lineBuffered(collect), ConsoleStdout.lineBuffered(collect), root,
24
+ ], { debug: false });
25
+ const instance = await WebAssembly.instantiate(job.module, { wasi_snapshot_preview1: wasi.wasiImport });
26
+ const status = wasi.start(instance);
27
+ if (status !== 0) {
28
+ const code = ['PAGE_LIMIT', 'MISSING_GLYPHS', 'PREVIEW_FAILED'].find(c => log.includes(c + ':')) ?? 'RENDER_FAILED';
29
+ return { ok: false, code, message: log.trim() || `The engine exited with code ${status}.` };
30
+ } else {
31
+ const read = name => {
32
+ const file = root.dir.contents.get(name);
33
+ if (!(file instanceof File)) throw new Error(`Missing engine output: ${name}`);
34
+ return Uint8Array.from(file.data);
35
+ };
36
+ const report = JSON.parse(new TextDecoder().decode(read('result.json')));
37
+ const pdf = read('document.pdf');
38
+ if (new TextDecoder().decode(pdf.slice(0, 5)) !== '%PDF-') throw new Error('Engine output is not a PDF.');
39
+ const previews = job.previewDpi ? Array.from({ length: report.pages }, (_, i) => read(`preview-${i + 1}.png`)) : [];
40
+ return { ok: true, pdf, previews, ...report };
41
+ }
42
+ } catch (cause) {
43
+ const code = cause instanceof WebAssembly.RuntimeError || cause instanceof RangeError ? 'ENGINE_LIMIT' : 'RENDER_FAILED';
44
+ return { ok: false, code, message: cause.message || String(cause) };
45
+ }
46
+ }
package/src/index.cjs CHANGED
@@ -8,17 +8,8 @@ const { fork } = require('node:child_process');
8
8
  const { pathToFileURL } = require('node:url');
9
9
  const pkg = require('../package.json');
10
10
 
11
- class FullbleedError extends Error {
12
- constructor(code, message, options) {
13
- super(message, options);
14
- this.name = 'FullbleedError';
15
- this.code = code;
16
- }
17
- }
18
- const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); };
11
+ const { FullbleedError, normalize } = require('./render-input.cjs');
19
12
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
20
- const sourceLimit = 4_000_000;
21
- const assetLimit = 64 * 1024 * 1024;
22
13
  const root = join(__dirname, '..');
23
14
  let runtimePromise;
24
15
 
@@ -44,41 +35,6 @@ function runtime() {
44
35
  return runtimePromise;
45
36
  }
46
37
 
47
- function normalize(options) {
48
- if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an options object with an html string.');
49
- const supported = new Set(['html', 'css', 'fonts', 'assets', 'previewDpi', 'timeoutMs', 'maxPages', 'allowMissingGlyphs', 'signal', 'isolation']);
50
- for (const key of Object.keys(options)) if (!supported.has(key)) invalid(`Unknown option: ${key}`);
51
- const { html, css = '', fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = 1000, allowMissingGlyphs = false, signal, isolation = 'worker' } = options;
52
- if (typeof html !== 'string' || !html.trim()) invalid('html must be a nonempty string.');
53
- if (typeof css !== 'string') invalid('css must be a string.');
54
- if (Buffer.byteLength(html) + Buffer.byteLength(css) > sourceLimit) invalid('HTML and CSS together must not exceed 4,000,000 UTF-8 bytes.');
55
- if (!Number.isInteger(previewDpi) || (previewDpi !== 0 && (previewDpi < 36 || previewDpi > 300))) invalid('previewDpi must be 0 (disabled) or an integer from 36 to 300.');
56
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) invalid('timeoutMs must be a positive integer no greater than 2147483647.');
57
- if (!Number.isSafeInteger(maxPages) || maxPages < 1) invalid('maxPages must be a positive integer.');
58
- if (typeof allowMissingGlyphs !== 'boolean') invalid('allowMissingGlyphs must be a boolean.');
59
- if (signal !== undefined && !(signal instanceof AbortSignal)) invalid('signal must be an AbortSignal.');
60
- if (isolation !== 'worker' && isolation !== 'process') invalid('isolation must be worker or process.');
61
- if (!Array.isArray(fonts)) invalid('fonts must be an array of font byte arrays.');
62
- if (!assets || typeof assets !== 'object' || Array.isArray(assets)) invalid('assets must map relative names to byte arrays.');
63
- let assetBytes = 0;
64
- function bytes(value, label) {
65
- if (!(value instanceof Uint8Array) || !value.byteLength) invalid(`${label} must be a nonempty Uint8Array or Buffer.`);
66
- assetBytes += value.byteLength;
67
- if (assetBytes > assetLimit) invalid('Custom fonts and assets together must not exceed 64 MiB.');
68
- return Uint8Array.from(value); // Snapshot caller-owned buffers before any await.
69
- }
70
- const customFonts = fonts.map((font, i) => ({ name: `fonts/custom-${i}.ttf`, data: bytes(font, `fonts[${i}]`) }));
71
- const names = Object.keys(assets);
72
- const fileNames = new Set(names);
73
- const assetFiles = names.map(name => {
74
- const segments = name.split('/');
75
- if (!name || /[\\:\0]/.test(name) || segments.some(s => !s || s === '.' || s === '..')) invalid(`Invalid asset path: ${name}`);
76
- for (let i = 1; i < segments.length; i++) if (fileNames.has(segments.slice(0, i).join('/'))) invalid(`Asset is both a file and a directory: ${name}`);
77
- return { name: 'assets/' + name, data: bytes(assets[name], `assets[${name}]`) };
78
- });
79
- return { html, css, customFonts, assetFiles, previewDpi, timeoutMs, maxPages, allowMissingGlyphs, signal, isolation };
80
- }
81
-
82
38
  function renderProcess(job, remaining) {
83
39
  return new Promise((resolve, reject) => {
84
40
  let child, timer, closed = false, failure, reply, exited, disconnected = false;
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: MIT
2
+ 'use strict';
3
+
4
+ class FullbleedError extends Error {
5
+ constructor(code, message, options) {
6
+ super(message, options);
7
+ this.name = 'FullbleedError';
8
+ this.code = code;
9
+ }
10
+ }
11
+ const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); };
12
+ const sourceLimit = 4_000_000;
13
+ const assetLimit = 64 * 1024 * 1024;
14
+
15
+ function normalize(options, { browser = false } = {}) {
16
+ if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an options object with an html string.');
17
+ const supported = new Set(['html', 'css', 'fonts', 'assets', 'previewDpi', 'timeoutMs', 'maxPages', 'allowMissingGlyphs', 'signal']);
18
+ if (!browser) supported.add('isolation');
19
+ for (const key of Object.keys(options)) if (!supported.has(key)) invalid(`Unknown option: ${key}`);
20
+ const { html, css = '', fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = 1000, allowMissingGlyphs = false, signal, isolation = 'worker' } = options;
21
+ if (typeof html !== 'string' || !html.trim()) invalid('html must be a nonempty string.');
22
+ if (typeof css !== 'string') invalid('css must be a string.');
23
+ if (new TextEncoder().encode(html).byteLength + new TextEncoder().encode(css).byteLength > sourceLimit) invalid('HTML and CSS together must not exceed 4,000,000 UTF-8 bytes.');
24
+ if (!Number.isInteger(previewDpi) || (previewDpi !== 0 && (previewDpi < 36 || previewDpi > 300))) invalid('previewDpi must be 0 (disabled) or an integer from 36 to 300.');
25
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) invalid('timeoutMs must be a positive integer no greater than 2147483647.');
26
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1) invalid('maxPages must be a positive integer.');
27
+ if (typeof allowMissingGlyphs !== 'boolean') invalid('allowMissingGlyphs must be a boolean.');
28
+ if (signal !== undefined && !(signal instanceof AbortSignal)) invalid('signal must be an AbortSignal.');
29
+ if (isolation !== 'worker' && isolation !== 'process') invalid('isolation must be worker or process.');
30
+ if (!Array.isArray(fonts)) invalid('fonts must be an array of font byte arrays.');
31
+ if (!assets || typeof assets !== 'object' || Array.isArray(assets)) invalid('assets must map relative names to byte arrays.');
32
+ let assetBytes = 0;
33
+ function bytes(value, label) {
34
+ if (!(value instanceof Uint8Array) || !value.byteLength) invalid(`${label} must be a nonempty Uint8Array or Buffer.`);
35
+ assetBytes += value.byteLength;
36
+ if (assetBytes > assetLimit) invalid('Custom fonts and assets together must not exceed 64 MiB.');
37
+ return Uint8Array.from(value); // Snapshot caller-owned buffers before any await.
38
+ }
39
+ const customFonts = fonts.map((font, i) => ({ name: `fonts/custom-${i}.ttf`, data: bytes(font, `fonts[${i}]`) }));
40
+ const names = Object.keys(assets);
41
+ const fileNames = new Set(names);
42
+ const assetFiles = names.map(name => {
43
+ const segments = name.split('/');
44
+ if (!name || /[\\:\0]/.test(name) || segments.some(s => !s || s === '.' || s === '..')) invalid(`Invalid asset path: ${name}`);
45
+ for (let i = 1; i < segments.length; i++) if (fileNames.has(segments.slice(0, i).join('/'))) invalid(`Asset is both a file and a directory: ${name}`);
46
+ return { name: 'assets/' + name, data: bytes(assets[name], `assets[${name}]`) };
47
+ });
48
+ return { html, css, customFonts, assetFiles, previewDpi, timeoutMs, maxPages, allowMissingGlyphs, signal, isolation };
49
+ }
50
+
51
+ module.exports = { FullbleedError, normalize };
package/src/worker.js CHANGED
@@ -1,46 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import { parentPort, workerData } from 'node:worker_threads';
3
- import { WASI, File, OpenFile, Directory, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim';
3
+ import { runEngine } from './engine-runner.js';
4
4
 
5
- try {
6
- const job = workerData;
7
- const root = new PreopenDirectory('.', new Map());
8
- const put = (path, data) => {
9
- const parts = path.split('/');
10
- let directory = root.dir;
11
- for (const part of parts.slice(0, -1)) {
12
- if (!directory.contents.has(part)) directory.contents.set(part, new Directory(new Map()));
13
- directory = directory.contents.get(part);
14
- }
15
- directory.contents.set(parts.at(-1), new File(data));
16
- };
17
- const encoder = new TextEncoder();
18
- put('input.html', encoder.encode(job.html));
19
- put('style.css', encoder.encode(job.css));
20
- for (const file of [...job.fonts, ...job.assets]) put(file.name, file.data);
21
- let log = '';
22
- const collect = line => { if (log.length < 4000) log += String(line).slice(0, 4000 - log.length) + '\n'; };
23
- const wasi = new WASI(['fullbleed', String(job.maxPages), String(job.previewDpi), job.allowMissingGlyphs ? 'allow' : 'reject', ...job.fonts.map(f => f.name)], [], [
24
- new OpenFile(new File([])), ConsoleStdout.lineBuffered(collect), ConsoleStdout.lineBuffered(collect), root,
25
- ], { debug: false });
26
- const instance = await WebAssembly.instantiate(job.module, { wasi_snapshot_preview1: wasi.wasiImport });
27
- const status = wasi.start(instance);
28
- if (status !== 0) {
29
- const code = ['PAGE_LIMIT', 'MISSING_GLYPHS', 'PREVIEW_FAILED'].find(c => log.includes(c + ':')) ?? 'RENDER_FAILED';
30
- parentPort.postMessage({ ok: false, code, message: log.trim() || `The engine exited with code ${status}.` });
31
- } else {
32
- const read = name => {
33
- const file = root.dir.contents.get(name);
34
- if (!(file instanceof File)) throw new Error(`Missing engine output: ${name}`);
35
- return Uint8Array.from(file.data);
36
- };
37
- const report = JSON.parse(new TextDecoder().decode(read('result.json')));
38
- const pdf = read('document.pdf');
39
- if (new TextDecoder().decode(pdf.slice(0, 5)) !== '%PDF-') throw new Error('Engine output is not a PDF.');
40
- const previews = job.previewDpi ? Array.from({ length: report.pages }, (_, i) => read(`preview-${i + 1}.png`)) : [];
41
- parentPort.postMessage({ ok: true, pdf, previews, ...report }, [pdf.buffer, ...previews.map(p => p.buffer)]);
42
- }
43
- } catch (cause) {
44
- const code = cause instanceof WebAssembly.RuntimeError || cause instanceof RangeError ? 'ENGINE_LIMIT' : 'RENDER_FAILED';
45
- parentPort.postMessage({ ok: false, code, message: cause.message || String(cause) });
46
- }
5
+ const result = await runEngine(workerData);
6
+ parentPort.postMessage(result, result.ok ? [result.pdf.buffer, ...result.previews.map(page => page.buffer)] : []);