@2018kguo/recto 0.1.0 → 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/dist/assets.d.ts +11 -6
- package/dist/assets.js +123 -42
- package/dist/document.d.ts +48 -2
- package/dist/document.js +109 -4
- package/dist/engine.d.ts +17 -9
- package/dist/engine.js +223 -45
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +4 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +13 -0
- package/dist/pool-worker.d.ts +14 -0
- package/dist/pool-worker.js +65 -0
- package/dist/pool.d.ts +36 -0
- package/dist/pool.js +287 -0
- package/dist/ranged.d.ts +10 -0
- package/dist/ranged.js +38 -11
- package/package.json +2 -1
- package/wasm/FoxitDingbats.pfb +0 -0
- package/wasm/FoxitFixed.pfb +0 -0
- package/wasm/FoxitFixedBold.pfb +0 -0
- package/wasm/FoxitFixedBoldItalic.pfb +0 -0
- package/wasm/FoxitFixedItalic.pfb +0 -0
- package/wasm/FoxitSans.pfb +0 -0
- package/wasm/FoxitSansBold.pfb +0 -0
- package/wasm/FoxitSansBoldItalic.pfb +0 -0
- package/wasm/FoxitSansItalic.pfb +0 -0
- package/wasm/FoxitSerif.pfb +0 -0
- package/wasm/FoxitSerifBold.pfb +0 -0
- package/wasm/FoxitSerifBoldItalic.pfb +0 -0
- package/wasm/FoxitSerifItalic.pfb +0 -0
- package/wasm/FoxitSymbol.pfb +0 -0
- package/wasm/THIRD_PARTY_NOTICES +2 -158
- package/wasm/cmaps.deflate +0 -0
- package/wasm/pins.d.mts +1 -1
- package/wasm/pins.mjs +19 -3
- package/wasm/recto-more.wasm +0 -0
- package/wasm/recto-rare.wasm +0 -0
- package/wasm/recto-render.wasm +0 -0
- package/wasm/recto.gen.d.mts +2 -1
- package/wasm/recto.gen.mjs +118 -12
- package/wasm/recto.types.d.mts +46 -1
- package/wasm/recto.wasm +0 -0
- package/wasm/cmaps.brotli +0 -0
- package/wasm/fonts.bin +0 -0
package/dist/engine.js
CHANGED
|
@@ -1,76 +1,151 @@
|
|
|
1
1
|
// The per-thread engine: owns one WebAssembly instance and replaces it when it trapped or
|
|
2
2
|
// its memory grew past the recycle mark (WebAssembly memory never shrinks). Past the mark, the
|
|
3
|
-
// instance goes
|
|
4
|
-
|
|
5
|
-
import {
|
|
3
|
+
// instance goes once nothing has been open on it for a moment, and a document still open on an
|
|
4
|
+
// instance keeps it.
|
|
5
|
+
import { PINS } from "../wasm/pins.mjs";
|
|
6
|
+
import { createGlue, PART_TABLE } from "../wasm/recto.gen.mjs";
|
|
7
|
+
import { assetBaseUrl, canReadSync, STANDARD_FONTS, compileWasm, loadAssetSync, preloadAssets, readCheckedSync, } from "./assets.js";
|
|
6
8
|
import { engineFailure, RectoDocument, } from "./document.js";
|
|
7
9
|
import { PdfError } from "./errors.js";
|
|
8
10
|
import { ownedSource, startRangeLoader, } from "./ranged.js";
|
|
9
11
|
const CJK_FONT_ASSET = 14;
|
|
10
12
|
const CMAPS_ASSET = 15;
|
|
13
|
+
const PARTS = ["recto-render.wasm", "recto-more.wasm", "recto-rare.wasm"];
|
|
14
|
+
const ALL_ASSETS = [...STANDARD_FONTS, "cmaps.deflate", "cjk-fallback.ttf"];
|
|
15
|
+
/**
|
|
16
|
+
* Past this, an open with another document still open gets a new instance. Chosen from
|
|
17
|
+
* bench/results/recycle.json: retiring at each close made a repeated large file slow.
|
|
18
|
+
*/
|
|
19
|
+
const IDLE_RECYCLE_ABOVE_BYTES = 64 * 1024 * 1024;
|
|
11
20
|
const DEFAULT_RECYCLE_ABOVE_BYTES = 512 * 1024 * 1024;
|
|
12
|
-
|
|
21
|
+
const IDLE_MS = 1000;
|
|
22
|
+
/** Each wasm, verified and compiled once per thread, per location. */
|
|
23
|
+
const compiling = new Map();
|
|
13
24
|
const compiled = new Map();
|
|
14
|
-
|
|
15
|
-
|
|
25
|
+
const moduleKey = (name, base) => `${name} ${base?.href ?? ""}`;
|
|
26
|
+
function compile(name, base) {
|
|
27
|
+
const key = moduleKey(name, base);
|
|
28
|
+
let module = compiling.get(key);
|
|
29
|
+
if (!module) {
|
|
30
|
+
module = compileWasm(name, base);
|
|
31
|
+
module.then((done) => compiled.set(key, done), () => compiling.delete(key));
|
|
32
|
+
compiling.set(key, module);
|
|
33
|
+
}
|
|
34
|
+
return module;
|
|
35
|
+
}
|
|
36
|
+
/** A part, from inside a synchronous call. Hashed before it's compiled; this is the only way in but `compile`. */
|
|
37
|
+
function partModule(name, base, hash) {
|
|
38
|
+
const key = moduleKey(name, base);
|
|
16
39
|
let module = compiled.get(key);
|
|
17
40
|
if (!module) {
|
|
18
|
-
module =
|
|
19
|
-
throw new PdfError("missing-asset", `recto could not compile recto.wasm: ${String(error)}. A Content-Security-Policy must allow 'wasm-unsafe-eval'.`, error);
|
|
20
|
-
}));
|
|
21
|
-
module.catch(() => compiled.delete(key));
|
|
41
|
+
module = new WebAssembly.Module(readCheckedSync(name, base, hash));
|
|
22
42
|
compiled.set(key, module);
|
|
23
43
|
}
|
|
24
44
|
return module;
|
|
25
45
|
}
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
/** An instance's own SHA-256, for an asset it needs: faster than JavaScript's in a cold browser. */
|
|
47
|
+
export function hashOn(instance) {
|
|
48
|
+
return (bytes) => {
|
|
49
|
+
// A function the hash itself runs may be in a part, and that part's check can't use this hash.
|
|
50
|
+
if (instance.dead || hashing.has(instance)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
hashing.add(instance);
|
|
54
|
+
try {
|
|
55
|
+
return Array.from(instance.glue.sha256(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
hashing.delete(instance);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const hashing = new WeakSet();
|
|
63
|
+
function instantiate(generation, base, module, documents = { open: 0 }) {
|
|
64
|
+
const parts = new Set();
|
|
65
|
+
// A function of a part that isn't loaded yet: load it, then make the call this was meant to be. A
|
|
66
|
+
// failure unwinds the engine's frames with its state half-done, so no call can follow on this
|
|
67
|
+
// instance. What was open on it reopens on another, at its next call.
|
|
68
|
+
const placeholders = (name) => new Proxy({}, {
|
|
69
|
+
get: (_, slot) => (...args) => {
|
|
70
|
+
try {
|
|
71
|
+
instance.loadPart(name);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
instance.abandoned = true;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const table = exports[PART_TABLE];
|
|
78
|
+
return table.get(Number(slot))(...args);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
28
81
|
// Each instance gets its own glue closure (scripts/package-wasm.mjs), so a retired
|
|
29
82
|
// instance and its memory are collected with it.
|
|
30
|
-
const glue = createGlue(
|
|
83
|
+
const glue = createGlue({
|
|
84
|
+
"part.recto-render": placeholders("recto-render.wasm"),
|
|
85
|
+
"part.recto-more": placeholders("recto-more.wasm"),
|
|
86
|
+
"part.recto-rare": placeholders("recto-rare.wasm"),
|
|
87
|
+
},
|
|
88
|
+
// Something never closed, freed by the collector. Nothing can run on an abandoned instance, which goes
|
|
89
|
+
// whole; and a free that needs a part it can't get has nobody to tell, and mustn't be an uncaught error.
|
|
90
|
+
(free) => {
|
|
91
|
+
if (instance.abandoned || instance.dead) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
free();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
instance.abandoned = true;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
31
101
|
const exports = glue.initSync({ module });
|
|
32
102
|
const instance = {
|
|
33
103
|
glue,
|
|
104
|
+
loadPart(name) {
|
|
105
|
+
if (!parts.has(name)) {
|
|
106
|
+
// Its table entries replace its placeholders.
|
|
107
|
+
new WebAssembly.Instance(partModule(name, base, hashOn(instance)), { primary: exports });
|
|
108
|
+
parts.add(name);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
34
111
|
memory: exports.memory,
|
|
112
|
+
startBytes: exports.memory.buffer.byteLength,
|
|
35
113
|
generation,
|
|
36
114
|
openDocuments: 0,
|
|
115
|
+
documents,
|
|
37
116
|
dead: false,
|
|
117
|
+
abandoned: false,
|
|
38
118
|
failure: undefined,
|
|
39
119
|
};
|
|
40
120
|
// The engine asks for a built-in font (0..13), the CJK fallback or its predefined CMaps
|
|
41
121
|
// the first time a document needs one. It reads a throw as a missing asset, which would
|
|
42
|
-
// be blank text, so keep the error for the call in progress to rethrow, and
|
|
43
|
-
//
|
|
122
|
+
// be blank text, so keep the error for the call in progress to rethrow, and leave the instance
|
|
123
|
+
// so the next call tries again.
|
|
44
124
|
glue.setAssetLoader((index) => {
|
|
45
125
|
try {
|
|
46
126
|
if (index === CMAPS_ASSET) {
|
|
47
|
-
return loadAssetSync("cmaps.
|
|
127
|
+
return loadAssetSync("cmaps.deflate", base, hashOn(instance));
|
|
48
128
|
}
|
|
49
129
|
if (index === CJK_FONT_ASSET) {
|
|
50
|
-
return loadAssetSync("cjk-fallback.ttf", base);
|
|
130
|
+
return loadAssetSync("cjk-fallback.ttf", base, hashOn(instance));
|
|
51
131
|
}
|
|
52
|
-
return
|
|
132
|
+
return loadAssetSync(STANDARD_FONTS[index], base, hashOn(instance));
|
|
53
133
|
}
|
|
54
134
|
catch (error) {
|
|
55
|
-
instance.
|
|
135
|
+
instance.abandoned = true;
|
|
56
136
|
instance.failure = error;
|
|
57
137
|
return;
|
|
58
138
|
}
|
|
59
139
|
});
|
|
60
140
|
return instance;
|
|
61
141
|
}
|
|
62
|
-
|
|
63
|
-
* A copy of the caller's bytes, taken before anything asynchronous, so the buffer is theirs
|
|
64
|
-
* again the moment the call returns its promise. (The engine then copies them into WebAssembly
|
|
65
|
-
* memory, so the file is held twice for the length of the open.)
|
|
66
|
-
*/
|
|
67
|
-
function ownedBytes(input) {
|
|
142
|
+
function bytesOf(input) {
|
|
68
143
|
if (ArrayBuffer.isView(input)) {
|
|
69
|
-
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
144
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
70
145
|
}
|
|
71
146
|
// Not instanceof: an ArrayBuffer from another realm (a test runner's sandbox) is just as good.
|
|
72
147
|
if (Object.prototype.toString.call(input) === "[object ArrayBuffer]") {
|
|
73
|
-
return new Uint8Array(input)
|
|
148
|
+
return new Uint8Array(input);
|
|
74
149
|
}
|
|
75
150
|
throw new TypeError("expected the PDF's bytes as a Uint8Array, Buffer or ArrayBuffer");
|
|
76
151
|
}
|
|
@@ -85,48 +160,120 @@ class RectoEngine {
|
|
|
85
160
|
#starting = null;
|
|
86
161
|
#base;
|
|
87
162
|
#recycleAboveBytes;
|
|
163
|
+
/** Past this, an instance with a document open takes no other. */
|
|
164
|
+
#idleAboveBytes;
|
|
165
|
+
/** Whether an instance that grew goes once nothing has been open on it for IDLE_MS: the default. */
|
|
166
|
+
#dropWhenIdle;
|
|
167
|
+
#idle;
|
|
168
|
+
/** Across every instance: a document that moved, or stayed on an old one, still counts. */
|
|
169
|
+
#documents;
|
|
88
170
|
constructor(instance, base, recycleAboveBytes) {
|
|
89
171
|
this.#instance = instance;
|
|
90
172
|
this.#generation = instance.generation;
|
|
91
173
|
this.#base = base;
|
|
92
|
-
this.#recycleAboveBytes = recycleAboveBytes;
|
|
174
|
+
this.#recycleAboveBytes = recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES;
|
|
175
|
+
this.#idleAboveBytes = recycleAboveBytes ?? IDLE_RECYCLE_ABOVE_BYTES;
|
|
176
|
+
this.#dropWhenIdle = recycleAboveBytes === undefined;
|
|
177
|
+
this.#documents = instance.documents;
|
|
93
178
|
this.#watch(instance);
|
|
94
179
|
}
|
|
95
180
|
/** Memory only comes back with the instance, so don't keep one past the mark with nothing on it. */
|
|
96
181
|
#watch(instance) {
|
|
97
182
|
instance.onClose = () => {
|
|
98
|
-
if (this.#instance
|
|
99
|
-
|
|
100
|
-
|
|
183
|
+
if (this.#instance !== instance || instance.openDocuments > 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
187
|
+
if (bytes > this.#recycleAboveBytes) {
|
|
101
188
|
this.#instance = null;
|
|
102
189
|
}
|
|
190
|
+
else if (this.#dropWhenIdle ? bytes > instance.startBytes : bytes > this.#idleAboveBytes) {
|
|
191
|
+
clearTimeout(this.#idle);
|
|
192
|
+
this.#idle = setTimeout(() => {
|
|
193
|
+
// Node's fired timer keeps its callback, and with it the instance.
|
|
194
|
+
this.#idle = undefined;
|
|
195
|
+
if (this.#instance === instance && instance.openDocuments === 0) {
|
|
196
|
+
this.#instance = null;
|
|
197
|
+
}
|
|
198
|
+
}, IDLE_MS);
|
|
199
|
+
// Not a reason for Node to stay running.
|
|
200
|
+
this.#idle.unref?.();
|
|
201
|
+
}
|
|
103
202
|
};
|
|
104
203
|
}
|
|
204
|
+
/** Whether a new document can go on this instance. */
|
|
205
|
+
#usable(instance) {
|
|
206
|
+
if (!instance || instance.dead || instance.abandoned) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
210
|
+
return bytes <= this.#idleAboveBytes || (bytes <= this.#recycleAboveBytes && instance.openDocuments === 0);
|
|
211
|
+
}
|
|
212
|
+
#ready() {
|
|
213
|
+
return this.#usable(this.#instance) ? this.#instance : null;
|
|
214
|
+
}
|
|
105
215
|
async #live() {
|
|
106
216
|
const current = this.#instance;
|
|
107
|
-
if (
|
|
217
|
+
if (this.#usable(current)) {
|
|
108
218
|
return current;
|
|
109
219
|
}
|
|
110
|
-
this.#starting ??=
|
|
220
|
+
this.#starting ??= compile("recto.wasm", this.#base)
|
|
221
|
+
.then((module) => instantiate(this.#generation + 1, this.#base, module, this.#documents))
|
|
222
|
+
.finally(() => {
|
|
111
223
|
this.#starting = null;
|
|
112
224
|
});
|
|
113
225
|
const started = await this.#starting;
|
|
114
|
-
this.#
|
|
115
|
-
this.#instance = started;
|
|
116
|
-
this.#watch(started);
|
|
226
|
+
this.#adopt(started);
|
|
117
227
|
return started;
|
|
118
228
|
}
|
|
229
|
+
#adopt(instance) {
|
|
230
|
+
this.#generation = instance.generation;
|
|
231
|
+
this.#instance = instance;
|
|
232
|
+
this.#watch(instance);
|
|
233
|
+
}
|
|
234
|
+
/** An instance for a document to reopen on, its own having been abandoned. Synchronous: every call is. */
|
|
235
|
+
#another() {
|
|
236
|
+
const current = this.#instance;
|
|
237
|
+
if (this.#usable(current)) {
|
|
238
|
+
return current;
|
|
239
|
+
}
|
|
240
|
+
// Compiled already: it's what the abandoned instance ran.
|
|
241
|
+
const instance = instantiate(this.#generation + 1, this.#base, compiled.get(moduleKey("recto.wasm", this.#base)), this.#documents);
|
|
242
|
+
this.#adopt(instance);
|
|
243
|
+
return instance;
|
|
244
|
+
}
|
|
119
245
|
// Async only so a bad argument rejects; everything up to the copy runs synchronously.
|
|
120
246
|
async open(input, options = {}) {
|
|
121
|
-
|
|
247
|
+
// With an instance ready, the bytes go into its memory before the first await, and that's
|
|
248
|
+
// the copy the promise needs: no second one of what may be a large file.
|
|
249
|
+
const ready = this.#ready();
|
|
250
|
+
const bytes = bytesOf(input);
|
|
251
|
+
if (ready) {
|
|
252
|
+
throwIfAborted(options.signal);
|
|
253
|
+
return this.#openOn(ready, bytes, options);
|
|
254
|
+
}
|
|
255
|
+
return await this.openOwned(bytes.slice(), options);
|
|
122
256
|
}
|
|
123
257
|
/** `bytes` is this call's own: getting an instance can take an await. */
|
|
124
258
|
async openOwned(bytes, options) {
|
|
125
259
|
throwIfAborted(options.signal);
|
|
126
|
-
|
|
260
|
+
return this.#openOn(await this.#live(), bytes, options);
|
|
261
|
+
}
|
|
262
|
+
#openOn(instance, bytes, options) {
|
|
263
|
+
const password = options.password ?? null;
|
|
127
264
|
let doc;
|
|
128
265
|
try {
|
|
129
|
-
|
|
266
|
+
// Where the file is, read now: an abandoned instance may run nothing.
|
|
267
|
+
let opened = new instance.glue.Document(bytes, password);
|
|
268
|
+
let pointer = opened.filePointer();
|
|
269
|
+
const length = opened.fileLength();
|
|
270
|
+
doc = new RectoDocument(instance, opened, (_, oldInstance) => {
|
|
271
|
+
const next = this.#another();
|
|
272
|
+
// The file goes straight from the old heap to the new one.
|
|
273
|
+
opened = new next.glue.Document(new Uint8Array(oldInstance.memory.buffer, pointer, length), password);
|
|
274
|
+
pointer = opened.filePointer();
|
|
275
|
+
return { instance: next, doc: opened };
|
|
276
|
+
});
|
|
130
277
|
}
|
|
131
278
|
catch (error) {
|
|
132
279
|
throw engineFailure(instance, error);
|
|
@@ -148,9 +295,17 @@ class RectoEngine {
|
|
|
148
295
|
const out = new Uint8Array(length);
|
|
149
296
|
return loader.read(offset, out) ? out : undefined;
|
|
150
297
|
};
|
|
298
|
+
const password = options.password ?? null;
|
|
151
299
|
let doc;
|
|
152
300
|
try {
|
|
153
|
-
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read,
|
|
301
|
+
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password), () => {
|
|
302
|
+
// Nothing is fetched twice: the loader keeps what it read.
|
|
303
|
+
const next = this.#another();
|
|
304
|
+
return {
|
|
305
|
+
instance: next,
|
|
306
|
+
doc: next.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password),
|
|
307
|
+
};
|
|
308
|
+
}, () => loader.failure);
|
|
154
309
|
}
|
|
155
310
|
catch (error) {
|
|
156
311
|
if (loader.failure && !(error instanceof WebAssembly.RuntimeError)) {
|
|
@@ -168,7 +323,7 @@ class RectoEngine {
|
|
|
168
323
|
return {
|
|
169
324
|
heapBytes: this.#instance?.memory.buffer.byteLength ?? 0,
|
|
170
325
|
generation: this.#generation,
|
|
171
|
-
openDocuments: this.#
|
|
326
|
+
openDocuments: this.#documents.open,
|
|
172
327
|
};
|
|
173
328
|
}
|
|
174
329
|
}
|
|
@@ -177,12 +332,28 @@ export async function createPdfEngine(options = {}) {
|
|
|
177
332
|
}
|
|
178
333
|
async function createEngine(options) {
|
|
179
334
|
const base = assetBaseUrl(options.assetBase);
|
|
180
|
-
|
|
181
|
-
|
|
335
|
+
const canRead = canReadSync();
|
|
336
|
+
for (const asset of options.preloadAssets ?? []) {
|
|
337
|
+
if (asset !== "cmaps.brotli" && asset !== "fonts.bin" && !Object.hasOwn(PINS, asset)) {
|
|
338
|
+
const accepted = [...Object.keys(PINS), "cmaps.brotli", "fonts.bin"].join(", ");
|
|
339
|
+
throw new TypeError(`preloadAssets does not know ${JSON.stringify(asset)}. It takes: ${accepted}`);
|
|
340
|
+
}
|
|
182
341
|
}
|
|
183
|
-
|
|
342
|
+
// cmaps.brotli is the old name of cmaps.deflate, and fonts.bin of all the fonts together.
|
|
343
|
+
const names = (options.preloadAssets ?? (canRead ? [] : ALL_ASSETS)).flatMap((asset) => asset === "cmaps.brotli" ? "cmaps.deflate" : asset === "fonts.bin" ? STANDARD_FONTS : asset);
|
|
344
|
+
const isPart = (name) => PARTS.includes(name);
|
|
345
|
+
const files = names.filter((name) => name !== "recto.wasm" && !isPart(name));
|
|
346
|
+
// With no way to read a part from inside a call, all of them now, always: nothing listed can take
|
|
347
|
+
// away the ability to draw.
|
|
348
|
+
const [module] = await Promise.all([
|
|
349
|
+
compile("recto.wasm", base),
|
|
350
|
+
...(canRead ? names.filter(isPart) : PARTS).map((part) => compile(part, base)),
|
|
351
|
+
preloadAssets(base, files),
|
|
352
|
+
]);
|
|
353
|
+
return new RectoEngine(instantiate(0, base, module), base, options.recycleAboveBytes);
|
|
184
354
|
}
|
|
185
355
|
let shared = null;
|
|
356
|
+
let sharedReady = null;
|
|
186
357
|
/**
|
|
187
358
|
* Opens a PDF on this thread's shared engine:
|
|
188
359
|
*
|
|
@@ -190,10 +361,17 @@ let shared = null;
|
|
|
190
361
|
* doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
191
362
|
*/
|
|
192
363
|
export async function openPdf(bytes, options = {}) {
|
|
193
|
-
|
|
364
|
+
if (sharedReady) {
|
|
365
|
+
return sharedReady.open(bytes, options);
|
|
366
|
+
}
|
|
367
|
+
// Getting the engine takes an await: the buffer has to be the caller's again before it.
|
|
368
|
+
const owned = bytesOf(bytes).slice();
|
|
194
369
|
if (!shared) {
|
|
195
370
|
const starting = createEngine({});
|
|
196
371
|
shared = starting;
|
|
372
|
+
starting.then((engine) => {
|
|
373
|
+
sharedReady = engine;
|
|
374
|
+
}, () => { });
|
|
197
375
|
// A failure is not kept: the next call tries again.
|
|
198
376
|
starting.catch(() => {
|
|
199
377
|
if (shared === starting) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type PdfErrorCode =
|
|
|
22
22
|
| "wrong-thread"
|
|
23
23
|
/** A ranged read failed: the network, an HTTP error, or a server's bad Range answer. */
|
|
24
24
|
| "fetch-failed"
|
|
25
|
-
/** A ranged read waited longer than `timeoutMs`. */
|
|
25
|
+
/** A ranged read waited longer than `timeoutMs`, or passed `totalTimeoutMs`. */
|
|
26
26
|
| "timeout"
|
|
27
27
|
/** A ranged read would pass `maxBytes`, or the file is past what a ranged read can hold. */
|
|
28
28
|
| "too-large"
|
|
@@ -30,5 +30,7 @@ export type PdfErrorCode =
|
|
|
30
30
|
| "failed";
|
|
31
31
|
export declare class PdfError extends Error {
|
|
32
32
|
readonly code: PdfErrorCode;
|
|
33
|
-
|
|
33
|
+
/** On `fetch-failed`, the HTTP status of the answer it objects to. Undefined if the server never answered. */
|
|
34
|
+
readonly status: number | undefined;
|
|
35
|
+
constructor(code: PdfErrorCode, message: string, cause?: unknown, status?: number);
|
|
34
36
|
}
|
package/dist/errors.js
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
// plain RangeError or TypeError instead: that is a bug to fix, not a condition to handle.
|
|
4
4
|
export class PdfError extends Error {
|
|
5
5
|
code;
|
|
6
|
-
|
|
6
|
+
/** On `fetch-failed`, the HTTP status of the answer it objects to. Undefined if the server never answered. */
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, message, cause, status) {
|
|
7
9
|
super(message, cause === undefined ? undefined : { cause });
|
|
8
10
|
this.name = "PdfError";
|
|
9
11
|
this.code = code;
|
|
12
|
+
this.status = status;
|
|
10
13
|
}
|
|
11
14
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
export { ANNOT_SUBTYPE, boxAt, CHAR_GENERATED, CHAR_UNMAPPED, charsText, PATH_FILL, PATH_STROKE, type PdfAnnotation, type PdfBitmap, type PdfBox, type PdfDocument, type PdfFont, type PdfOutlineItem, type PdfPageInfo, type PdfPageObjects, type PdfPageText, type PdfPathSegments, type PdfRenderOptions, type PdfTextRuns, type PdfWords, SEGMENT_BEZIER, SEGMENT_CLOSE, SEGMENT_LINE, SEGMENT_MOVE, SEGMENT_RECT, } from "./document.ts";
|
|
1
|
+
export { ANNOT_SUBTYPE, boxAt, CHAR_GENERATED, CHAR_UNMAPPED, charsText, PATH_FILL, PATH_STROKE, type PdfAnnotation, type PdfBitmap, type PdfBox, type PdfDocument, type PdfFont, type PdfOutlineItem, type PdfPageInfo, type PdfPageObjects, type PdfPageText, type PdfPathSegments, type PdfRenderAsyncOptions, type PdfRenderOptions, type PdfTextRuns, type PdfWords, SEGMENT_BEZIER, SEGMENT_CLOSE, SEGMENT_LINE, SEGMENT_MOVE, SEGMENT_RECT, } from "./document.ts";
|
|
2
2
|
export { createPdfEngine, openPdf, type PdfEngine, type PdfEngineOptions, type PdfEngineStats, type PdfOpenOptions, } from "./engine.ts";
|
|
3
3
|
export { PdfError, type PdfErrorCode } from "./errors.ts";
|
|
4
|
+
export type { PdfPool, PdfPoolDocument, PdfPoolOpenOptions, PdfPoolOptions } from "./pool.ts";
|
|
4
5
|
export { type PageBox, rasterMeasures, showsGlyphs, vectorMeasures, } from "./page-measures.ts";
|
|
5
6
|
export type { PdfRangedDocument, PdfRangedOpenOptions, PdfRangedStats, PdfRangeSource, } from "./ranged.ts";
|
|
6
7
|
export { BREAK_LINE, BREAK_NONE, BREAK_SPACE, readingOrder, type ReadingOrder, } from "./reading-order.ts";
|
|
7
8
|
export { annotationPaintedText, annotationShownText, annotationTextItems, NOT_TEXT, type PdfTextItem, textItems, visibleCodepoint, } from "./text-items.ts";
|
|
9
|
+
/**
|
|
10
|
+
* Runs a document's pages across worker threads:
|
|
11
|
+
*
|
|
12
|
+
* using pool = await createPdfPool();
|
|
13
|
+
* await using doc = await pool.open(bytes);
|
|
14
|
+
* const pages = await Promise.all(Array.from({ length: doc.pageCount }, (_, i) => doc.text(i)));
|
|
15
|
+
*
|
|
16
|
+
* Loaded only when called, so a program that doesn't use it doesn't download it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createPdfPool(options?: import("./pool.ts").PdfPoolOptions): Promise<import("./pool.ts").PdfPool>;
|
package/dist/index.js
CHANGED
|
@@ -8,3 +8,16 @@ export { PdfError } from "./errors.js";
|
|
|
8
8
|
export { rasterMeasures, showsGlyphs, vectorMeasures, } from "./page-measures.js";
|
|
9
9
|
export { BREAK_LINE, BREAK_NONE, BREAK_SPACE, readingOrder, } from "./reading-order.js";
|
|
10
10
|
export { annotationPaintedText, annotationShownText, annotationTextItems, NOT_TEXT, textItems, visibleCodepoint, } from "./text-items.js";
|
|
11
|
+
/**
|
|
12
|
+
* Runs a document's pages across worker threads:
|
|
13
|
+
*
|
|
14
|
+
* using pool = await createPdfPool();
|
|
15
|
+
* await using doc = await pool.open(bytes);
|
|
16
|
+
* const pages = await Promise.all(Array.from({ length: doc.pageCount }, (_, i) => doc.text(i)));
|
|
17
|
+
*
|
|
18
|
+
* Loaded only when called, so a program that doesn't use it doesn't download it.
|
|
19
|
+
*/
|
|
20
|
+
export async function createPdfPool(options = {}) {
|
|
21
|
+
const { startPool } = await import("./pool.js");
|
|
22
|
+
return startPool(options);
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PoolRequest = {
|
|
2
|
+
id: number;
|
|
3
|
+
doc: number;
|
|
4
|
+
/** Only until this thread has the document. */
|
|
5
|
+
open?: {
|
|
6
|
+
bytes: Uint8Array;
|
|
7
|
+
password: string | undefined;
|
|
8
|
+
};
|
|
9
|
+
method: string;
|
|
10
|
+
args: unknown[];
|
|
11
|
+
};
|
|
12
|
+
export type PoolStart = {
|
|
13
|
+
assetBase: string | undefined;
|
|
14
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// One thread of a pool: opens what it's sent on demand, and answers each call with its result.
|
|
2
|
+
import { createPdfEngine } from "./engine.js";
|
|
3
|
+
const parent = globalThis.process?.getBuiltinModule?.("node:worker_threads")?.parentPort;
|
|
4
|
+
const port = (parent ?? self);
|
|
5
|
+
const docs = new Map();
|
|
6
|
+
let engine;
|
|
7
|
+
/** Every column in a result, so it moves rather than being copied. */
|
|
8
|
+
function buffersIn(value, found) {
|
|
9
|
+
if (ArrayBuffer.isView(value)) {
|
|
10
|
+
found.add(value.buffer);
|
|
11
|
+
}
|
|
12
|
+
else if (typeof value === "object" && value !== null) {
|
|
13
|
+
for (const inner of Object.values(value)) {
|
|
14
|
+
buffersIn(inner, found);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
async function answer({ doc: id, open, method, args }) {
|
|
19
|
+
let doc = docs.get(id);
|
|
20
|
+
if (!doc) {
|
|
21
|
+
doc = await engine.open(open.bytes, open.password === undefined ? {} : { password: open.password });
|
|
22
|
+
docs.set(id, doc);
|
|
23
|
+
}
|
|
24
|
+
if (method === "pageCount") {
|
|
25
|
+
return doc.pageCount;
|
|
26
|
+
}
|
|
27
|
+
return doc[method](...args);
|
|
28
|
+
}
|
|
29
|
+
function failureOf(error) {
|
|
30
|
+
const { name, code, message } = error;
|
|
31
|
+
return { name, code, message: message ?? String(error) };
|
|
32
|
+
}
|
|
33
|
+
async function handle(message) {
|
|
34
|
+
if (!("id" in message)) {
|
|
35
|
+
const { assetBase } = message;
|
|
36
|
+
try {
|
|
37
|
+
engine = await createPdfEngine(assetBase ? { assetBase } : {});
|
|
38
|
+
port.postMessage({ ready: true }, []);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
port.postMessage({ startFailed: true, failure: failureOf(error) }, []);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (message.method === "close") {
|
|
46
|
+
docs.get(message.doc)?.close();
|
|
47
|
+
docs.delete(message.doc);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const result = await answer(message);
|
|
52
|
+
const buffers = new Set();
|
|
53
|
+
buffersIn(result, buffers);
|
|
54
|
+
port.postMessage({ result, opened: docs.has(message.doc) }, [...buffers]);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
port.postMessage({ failure: failureOf(error), opened: docs.has(message.doc) }, []);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (parent) {
|
|
61
|
+
parent.on("message", (message) => void handle(message));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
self.onmessage = ({ data }) => void handle(data);
|
|
65
|
+
}
|
package/dist/pool.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PdfAnnotation, PdfBitmap, PdfOutlineItem, PdfPageInfo, PdfPageObjects, PdfPageText, PdfRenderOptions } from "./document.ts";
|
|
2
|
+
export type PdfPoolOptions = {
|
|
3
|
+
/** At most this many threads, up to 64. Default: one less than the machine's cores, up to 8. */
|
|
4
|
+
threads?: number;
|
|
5
|
+
/** As for `createPdfEngine`. A string: it crosses to the threads. */
|
|
6
|
+
assetBase?: string;
|
|
7
|
+
};
|
|
8
|
+
export type PdfPoolOpenOptions = {
|
|
9
|
+
password?: string;
|
|
10
|
+
};
|
|
11
|
+
/** A `PdfDocument` whose calls run on the pool's threads, each a promise. */
|
|
12
|
+
export interface PdfPoolDocument {
|
|
13
|
+
readonly pageCount: number;
|
|
14
|
+
pageInfo(index: number): Promise<PdfPageInfo>;
|
|
15
|
+
text(index: number): Promise<PdfPageText>;
|
|
16
|
+
objects(index: number): Promise<PdfPageObjects>;
|
|
17
|
+
annotations(index: number): Promise<PdfAnnotation[]>;
|
|
18
|
+
outline(): Promise<PdfOutlineItem[]>;
|
|
19
|
+
/** Takes no `signal`: a call that's running can't be stopped. */
|
|
20
|
+
render(index: number, options: PdfRenderOptions): Promise<PdfBitmap>;
|
|
21
|
+
/** Frees it on every thread, and fails its calls still waiting with `closed`. */
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface PdfPool {
|
|
26
|
+
open(bytes: Uint8Array | ArrayBuffer, options?: PdfPoolOpenOptions): Promise<PdfPoolDocument>;
|
|
27
|
+
/** Threads started so far, and calls waiting for one. */
|
|
28
|
+
stats(): {
|
|
29
|
+
threads: number;
|
|
30
|
+
queued: number;
|
|
31
|
+
};
|
|
32
|
+
/** Stops every thread now: anything unfinished fails with `closed`. */
|
|
33
|
+
close(): void;
|
|
34
|
+
[Symbol.dispose](): void;
|
|
35
|
+
}
|
|
36
|
+
export declare function startPool(options: PdfPoolOptions): PdfPool;
|