@2018kguo/recto 0.1.0 → 0.5.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 +144 -2
- package/dist/document.js +163 -14
- package/dist/engine.d.ts +24 -9
- package/dist/engine.js +236 -49
- 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 +15 -0
- package/dist/pool-worker.js +65 -0
- package/dist/pool.d.ts +42 -0
- package/dist/pool.js +295 -0
- package/dist/ranged.d.ts +44 -9
- package/dist/ranged.js +571 -86
- 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 +213 -19
- package/wasm/recto.types.d.mts +83 -4
- 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,154 @@
|
|
|
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, maxDecodedBytes, 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
|
});
|
|
140
|
+
if (maxDecodedBytes !== undefined) {
|
|
141
|
+
glue.setMaxDecodedBytes(maxDecodedBytes);
|
|
142
|
+
}
|
|
60
143
|
return instance;
|
|
61
144
|
}
|
|
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) {
|
|
145
|
+
function bytesOf(input) {
|
|
68
146
|
if (ArrayBuffer.isView(input)) {
|
|
69
|
-
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
147
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
70
148
|
}
|
|
71
149
|
// Not instanceof: an ArrayBuffer from another realm (a test runner's sandbox) is just as good.
|
|
72
150
|
if (Object.prototype.toString.call(input) === "[object ArrayBuffer]") {
|
|
73
|
-
return new Uint8Array(input)
|
|
151
|
+
return new Uint8Array(input);
|
|
74
152
|
}
|
|
75
153
|
throw new TypeError("expected the PDF's bytes as a Uint8Array, Buffer or ArrayBuffer");
|
|
76
154
|
}
|
|
@@ -85,48 +163,122 @@ class RectoEngine {
|
|
|
85
163
|
#starting = null;
|
|
86
164
|
#base;
|
|
87
165
|
#recycleAboveBytes;
|
|
88
|
-
|
|
166
|
+
#maxDecodedBytes;
|
|
167
|
+
/** Past this, an instance with a document open takes no other. */
|
|
168
|
+
#idleAboveBytes;
|
|
169
|
+
/** Whether an instance that grew goes once nothing has been open on it for IDLE_MS: the default. */
|
|
170
|
+
#dropWhenIdle;
|
|
171
|
+
#idle;
|
|
172
|
+
/** Across every instance: a document that moved, or stayed on an old one, still counts. */
|
|
173
|
+
#documents;
|
|
174
|
+
constructor(instance, base, recycleAboveBytes, maxDecodedBytes) {
|
|
175
|
+
this.#maxDecodedBytes = maxDecodedBytes;
|
|
89
176
|
this.#instance = instance;
|
|
90
177
|
this.#generation = instance.generation;
|
|
91
178
|
this.#base = base;
|
|
92
|
-
this.#recycleAboveBytes = recycleAboveBytes;
|
|
179
|
+
this.#recycleAboveBytes = recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES;
|
|
180
|
+
this.#idleAboveBytes = recycleAboveBytes ?? IDLE_RECYCLE_ABOVE_BYTES;
|
|
181
|
+
this.#dropWhenIdle = recycleAboveBytes === undefined;
|
|
182
|
+
this.#documents = instance.documents;
|
|
93
183
|
this.#watch(instance);
|
|
94
184
|
}
|
|
95
185
|
/** Memory only comes back with the instance, so don't keep one past the mark with nothing on it. */
|
|
96
186
|
#watch(instance) {
|
|
97
187
|
instance.onClose = () => {
|
|
98
|
-
if (this.#instance
|
|
99
|
-
|
|
100
|
-
|
|
188
|
+
if (this.#instance !== instance || instance.openDocuments > 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
192
|
+
if (bytes > this.#recycleAboveBytes) {
|
|
101
193
|
this.#instance = null;
|
|
102
194
|
}
|
|
195
|
+
else if (this.#dropWhenIdle ? bytes > instance.startBytes : bytes > this.#idleAboveBytes) {
|
|
196
|
+
clearTimeout(this.#idle);
|
|
197
|
+
this.#idle = setTimeout(() => {
|
|
198
|
+
// Node's fired timer keeps its callback, and with it the instance.
|
|
199
|
+
this.#idle = undefined;
|
|
200
|
+
if (this.#instance === instance && instance.openDocuments === 0) {
|
|
201
|
+
this.#instance = null;
|
|
202
|
+
}
|
|
203
|
+
}, IDLE_MS);
|
|
204
|
+
// Not a reason for Node to stay running.
|
|
205
|
+
this.#idle.unref?.();
|
|
206
|
+
}
|
|
103
207
|
};
|
|
104
208
|
}
|
|
209
|
+
/** Whether a new document can go on this instance. */
|
|
210
|
+
#usable(instance) {
|
|
211
|
+
if (!instance || instance.dead || instance.abandoned) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
215
|
+
return bytes <= this.#idleAboveBytes || (bytes <= this.#recycleAboveBytes && instance.openDocuments === 0);
|
|
216
|
+
}
|
|
217
|
+
#ready() {
|
|
218
|
+
return this.#usable(this.#instance) ? this.#instance : null;
|
|
219
|
+
}
|
|
105
220
|
async #live() {
|
|
106
221
|
const current = this.#instance;
|
|
107
|
-
if (
|
|
222
|
+
if (this.#usable(current)) {
|
|
108
223
|
return current;
|
|
109
224
|
}
|
|
110
|
-
this.#starting ??=
|
|
225
|
+
this.#starting ??= compile("recto.wasm", this.#base)
|
|
226
|
+
.then((module) => instantiate(this.#generation + 1, this.#base, module, this.#maxDecodedBytes, this.#documents))
|
|
227
|
+
.finally(() => {
|
|
111
228
|
this.#starting = null;
|
|
112
229
|
});
|
|
113
230
|
const started = await this.#starting;
|
|
114
|
-
this.#
|
|
115
|
-
this.#instance = started;
|
|
116
|
-
this.#watch(started);
|
|
231
|
+
this.#adopt(started);
|
|
117
232
|
return started;
|
|
118
233
|
}
|
|
234
|
+
#adopt(instance) {
|
|
235
|
+
this.#generation = instance.generation;
|
|
236
|
+
this.#instance = instance;
|
|
237
|
+
this.#watch(instance);
|
|
238
|
+
}
|
|
239
|
+
/** An instance for a document to reopen on, its own having been abandoned. Synchronous: every call is. */
|
|
240
|
+
#another() {
|
|
241
|
+
const current = this.#instance;
|
|
242
|
+
if (this.#usable(current)) {
|
|
243
|
+
return current;
|
|
244
|
+
}
|
|
245
|
+
// Compiled already: it's what the abandoned instance ran.
|
|
246
|
+
const instance = instantiate(this.#generation + 1, this.#base, compiled.get(moduleKey("recto.wasm", this.#base)), this.#maxDecodedBytes, this.#documents);
|
|
247
|
+
this.#adopt(instance);
|
|
248
|
+
return instance;
|
|
249
|
+
}
|
|
119
250
|
// Async only so a bad argument rejects; everything up to the copy runs synchronously.
|
|
120
251
|
async open(input, options = {}) {
|
|
121
|
-
|
|
252
|
+
// With an instance ready, the bytes go into its memory before the first await, and that's
|
|
253
|
+
// the copy the promise needs: no second one of what may be a large file.
|
|
254
|
+
const ready = this.#ready();
|
|
255
|
+
const bytes = bytesOf(input);
|
|
256
|
+
if (ready) {
|
|
257
|
+
throwIfAborted(options.signal);
|
|
258
|
+
return this.#openOn(ready, bytes, options);
|
|
259
|
+
}
|
|
260
|
+
return await this.openOwned(bytes.slice(), options);
|
|
122
261
|
}
|
|
123
262
|
/** `bytes` is this call's own: getting an instance can take an await. */
|
|
124
263
|
async openOwned(bytes, options) {
|
|
125
264
|
throwIfAborted(options.signal);
|
|
126
|
-
|
|
265
|
+
return this.#openOn(await this.#live(), bytes, options);
|
|
266
|
+
}
|
|
267
|
+
#openOn(instance, bytes, options) {
|
|
268
|
+
const password = options.password ?? null;
|
|
127
269
|
let doc;
|
|
128
270
|
try {
|
|
129
|
-
|
|
271
|
+
// Where the file is, read now: an abandoned instance may run nothing.
|
|
272
|
+
let opened = new instance.glue.Document(bytes, password);
|
|
273
|
+
let pointer = opened.filePointer();
|
|
274
|
+
const length = opened.fileLength();
|
|
275
|
+
doc = new RectoDocument(instance, opened, (_, oldInstance) => {
|
|
276
|
+
const next = this.#another();
|
|
277
|
+
// The file goes straight from the old heap to the new one.
|
|
278
|
+
opened = new next.glue.Document(new Uint8Array(oldInstance.memory.buffer, pointer, length), password);
|
|
279
|
+
pointer = opened.filePointer();
|
|
280
|
+
return { instance: next, doc: opened };
|
|
281
|
+
});
|
|
130
282
|
}
|
|
131
283
|
catch (error) {
|
|
132
284
|
throw engineFailure(instance, error);
|
|
@@ -143,14 +295,22 @@ class RectoEngine {
|
|
|
143
295
|
}
|
|
144
296
|
const owned = ownedSource(source);
|
|
145
297
|
const instance = await this.#live();
|
|
146
|
-
const loader = startRangeLoader(owned, options);
|
|
147
|
-
const read = (offset, length) => {
|
|
298
|
+
const loader = await startRangeLoader(owned, options);
|
|
299
|
+
const read = (offset, length, ahead) => {
|
|
148
300
|
const out = new Uint8Array(length);
|
|
149
|
-
return loader.read(offset, out) ? out : undefined;
|
|
301
|
+
return loader.read(offset, out, ahead) ? out : undefined;
|
|
150
302
|
};
|
|
303
|
+
const password = options.password ?? null;
|
|
151
304
|
let doc;
|
|
152
305
|
try {
|
|
153
|
-
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read,
|
|
306
|
+
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password), () => {
|
|
307
|
+
// Nothing is fetched twice: the loader keeps what it read.
|
|
308
|
+
const next = this.#another();
|
|
309
|
+
return {
|
|
310
|
+
instance: next,
|
|
311
|
+
doc: next.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password),
|
|
312
|
+
};
|
|
313
|
+
}, () => loader.failure);
|
|
154
314
|
}
|
|
155
315
|
catch (error) {
|
|
156
316
|
if (loader.failure && !(error instanceof WebAssembly.RuntimeError)) {
|
|
@@ -168,7 +328,7 @@ class RectoEngine {
|
|
|
168
328
|
return {
|
|
169
329
|
heapBytes: this.#instance?.memory.buffer.byteLength ?? 0,
|
|
170
330
|
generation: this.#generation,
|
|
171
|
-
openDocuments: this.#
|
|
331
|
+
openDocuments: this.#documents.open,
|
|
172
332
|
};
|
|
173
333
|
}
|
|
174
334
|
}
|
|
@@ -177,12 +337,32 @@ export async function createPdfEngine(options = {}) {
|
|
|
177
337
|
}
|
|
178
338
|
async function createEngine(options) {
|
|
179
339
|
const base = assetBaseUrl(options.assetBase);
|
|
180
|
-
|
|
181
|
-
|
|
340
|
+
const canRead = canReadSync();
|
|
341
|
+
for (const asset of options.preloadAssets ?? []) {
|
|
342
|
+
if (asset !== "cmaps.brotli" && asset !== "fonts.bin" && !Object.hasOwn(PINS, asset)) {
|
|
343
|
+
const accepted = [...Object.keys(PINS), "cmaps.brotli", "fonts.bin"].join(", ");
|
|
344
|
+
throw new TypeError(`preloadAssets does not know ${JSON.stringify(asset)}. It takes: ${accepted}`);
|
|
345
|
+
}
|
|
182
346
|
}
|
|
183
|
-
|
|
347
|
+
// cmaps.brotli is the old name of cmaps.deflate, and fonts.bin of all the fonts together.
|
|
348
|
+
const names = (options.preloadAssets ?? (canRead ? [] : ALL_ASSETS)).flatMap((asset) => asset === "cmaps.brotli" ? "cmaps.deflate" : asset === "fonts.bin" ? STANDARD_FONTS : asset);
|
|
349
|
+
const isPart = (name) => PARTS.includes(name);
|
|
350
|
+
const files = names.filter((name) => name !== "recto.wasm" && !isPart(name));
|
|
351
|
+
// With no way to read a part from inside a call, all of them now, always: nothing listed can take
|
|
352
|
+
// away the ability to draw.
|
|
353
|
+
const [module] = await Promise.all([
|
|
354
|
+
compile("recto.wasm", base),
|
|
355
|
+
...(canRead ? names.filter(isPart) : PARTS).map((part) => compile(part, base)),
|
|
356
|
+
preloadAssets(base, files),
|
|
357
|
+
]);
|
|
358
|
+
const maxDecodedBytes = options.maxDecodedBytes;
|
|
359
|
+
if (maxDecodedBytes !== undefined && !(typeof maxDecodedBytes === "number" && maxDecodedBytes >= 0)) {
|
|
360
|
+
throw new RangeError("maxDecodedBytes must be a number of bytes, 0 or more");
|
|
361
|
+
}
|
|
362
|
+
return new RectoEngine(instantiate(0, base, module, maxDecodedBytes), base, options.recycleAboveBytes, maxDecodedBytes);
|
|
184
363
|
}
|
|
185
364
|
let shared = null;
|
|
365
|
+
let sharedReady = null;
|
|
186
366
|
/**
|
|
187
367
|
* Opens a PDF on this thread's shared engine:
|
|
188
368
|
*
|
|
@@ -190,10 +370,17 @@ let shared = null;
|
|
|
190
370
|
* doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
191
371
|
*/
|
|
192
372
|
export async function openPdf(bytes, options = {}) {
|
|
193
|
-
|
|
373
|
+
if (sharedReady) {
|
|
374
|
+
return sharedReady.open(bytes, options);
|
|
375
|
+
}
|
|
376
|
+
// Getting the engine takes an await: the buffer has to be the caller's again before it.
|
|
377
|
+
const owned = bytesOf(bytes).slice();
|
|
194
378
|
if (!shared) {
|
|
195
379
|
const starting = createEngine({});
|
|
196
380
|
shared = starting;
|
|
381
|
+
starting.then((engine) => {
|
|
382
|
+
sharedReady = engine;
|
|
383
|
+
}, () => { });
|
|
197
384
|
// A failure is not kept: the next call tries again.
|
|
198
385
|
starting.catch(() => {
|
|
199
386
|
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 PdfDestination, type PdfDocument, type PdfFont, type PdfLink, type PdfLinkTarget, type PdfOutlineItem, type PdfPageInfo, type PdfPageObjects, type PdfPageText, type PdfPathSegments, type PdfRegionBitmap, 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,15 @@
|
|
|
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
|
+
maxDecodedBytes: number | undefined;
|
|
15
|
+
};
|
|
@@ -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, maxDecodedBytes } = message;
|
|
36
|
+
try {
|
|
37
|
+
engine = await createPdfEngine({ ...(assetBase ? { assetBase } : {}), ...(maxDecodedBytes === undefined ? {} : { maxDecodedBytes }) });
|
|
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,42 @@
|
|
|
1
|
+
import type { PdfAnnotation, PdfBitmap, PdfBox, PdfLink, PdfOutlineItem, PdfPageInfo, PdfPageObjects, PdfPageText, PdfRegionBitmap, 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
|
+
/** As for `createPdfEngine`, on every thread. */
|
|
8
|
+
maxDecodedBytes?: number;
|
|
9
|
+
};
|
|
10
|
+
export type PdfPoolOpenOptions = {
|
|
11
|
+
password?: string;
|
|
12
|
+
};
|
|
13
|
+
/** A `PdfDocument` whose calls run on the pool's threads, each a promise. */
|
|
14
|
+
export interface PdfPoolDocument {
|
|
15
|
+
readonly pageCount: number;
|
|
16
|
+
pageInfo(index: number): Promise<PdfPageInfo>;
|
|
17
|
+
text(index: number): Promise<PdfPageText>;
|
|
18
|
+
objects(index: number): Promise<PdfPageObjects>;
|
|
19
|
+
annotations(index: number): Promise<PdfAnnotation[]>;
|
|
20
|
+
links(index: number): Promise<PdfLink[]>;
|
|
21
|
+
outline(): Promise<PdfOutlineItem[]>;
|
|
22
|
+
/** Takes no `signal`: a call that's running can't be stopped. */
|
|
23
|
+
render(index: number, options: PdfRenderOptions & {
|
|
24
|
+
region: PdfBox;
|
|
25
|
+
}): Promise<PdfRegionBitmap>;
|
|
26
|
+
render(index: number, options: PdfRenderOptions): Promise<PdfBitmap>;
|
|
27
|
+
/** Frees it on every thread, and fails its calls still waiting with `closed`. */
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface PdfPool {
|
|
32
|
+
open(bytes: Uint8Array | ArrayBuffer, options?: PdfPoolOpenOptions): Promise<PdfPoolDocument>;
|
|
33
|
+
/** Threads started so far, and calls waiting for one. */
|
|
34
|
+
stats(): {
|
|
35
|
+
threads: number;
|
|
36
|
+
queued: number;
|
|
37
|
+
};
|
|
38
|
+
/** Stops every thread now: anything unfinished fails with `closed`. */
|
|
39
|
+
close(): void;
|
|
40
|
+
[Symbol.dispose](): void;
|
|
41
|
+
}
|
|
42
|
+
export declare function startPool(options: PdfPoolOptions): PdfPool;
|