fullbleed 0.1.5 → 0.3.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 +66 -13
- package/dist/browser/asset-manifest.json +67 -0
- package/dist/browser/client.js +225 -0
- package/dist/browser/worker.js +1477 -0
- package/dist/build.json +13 -1
- package/dist/index.d.cts +5 -0
- package/package.json +15 -5
- package/src/browser-worker.js +39 -0
- package/src/browser.d.ts +37 -0
- package/src/browser.js +103 -0
- package/src/copy-browser-assets.cjs +43 -0
- package/src/engine-runner.js +46 -0
- package/src/index.cjs +118 -42
- package/src/index.d.ts +5 -0
- package/src/process-worker.cjs +20 -0
- package/src/render-input.cjs +51 -0
- package/src/worker.js +3 -43
|
@@ -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 {
|
|
3
|
+
import { runEngine } from './engine-runner.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
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)] : []);
|