@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.
Files changed (44) hide show
  1. package/dist/assets.d.ts +11 -6
  2. package/dist/assets.js +123 -42
  3. package/dist/document.d.ts +144 -2
  4. package/dist/document.js +163 -14
  5. package/dist/engine.d.ts +24 -9
  6. package/dist/engine.js +236 -49
  7. package/dist/errors.d.ts +4 -2
  8. package/dist/errors.js +4 -1
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.js +13 -0
  11. package/dist/pool-worker.d.ts +15 -0
  12. package/dist/pool-worker.js +65 -0
  13. package/dist/pool.d.ts +42 -0
  14. package/dist/pool.js +295 -0
  15. package/dist/ranged.d.ts +44 -9
  16. package/dist/ranged.js +571 -86
  17. package/package.json +2 -1
  18. package/wasm/FoxitDingbats.pfb +0 -0
  19. package/wasm/FoxitFixed.pfb +0 -0
  20. package/wasm/FoxitFixedBold.pfb +0 -0
  21. package/wasm/FoxitFixedBoldItalic.pfb +0 -0
  22. package/wasm/FoxitFixedItalic.pfb +0 -0
  23. package/wasm/FoxitSans.pfb +0 -0
  24. package/wasm/FoxitSansBold.pfb +0 -0
  25. package/wasm/FoxitSansBoldItalic.pfb +0 -0
  26. package/wasm/FoxitSansItalic.pfb +0 -0
  27. package/wasm/FoxitSerif.pfb +0 -0
  28. package/wasm/FoxitSerifBold.pfb +0 -0
  29. package/wasm/FoxitSerifBoldItalic.pfb +0 -0
  30. package/wasm/FoxitSerifItalic.pfb +0 -0
  31. package/wasm/FoxitSymbol.pfb +0 -0
  32. package/wasm/THIRD_PARTY_NOTICES +2 -158
  33. package/wasm/cmaps.deflate +0 -0
  34. package/wasm/pins.d.mts +1 -1
  35. package/wasm/pins.mjs +19 -3
  36. package/wasm/recto-more.wasm +0 -0
  37. package/wasm/recto-rare.wasm +0 -0
  38. package/wasm/recto-render.wasm +0 -0
  39. package/wasm/recto.gen.d.mts +2 -1
  40. package/wasm/recto.gen.mjs +213 -19
  41. package/wasm/recto.types.d.mts +83 -4
  42. package/wasm/recto.wasm +0 -0
  43. package/wasm/cmaps.brotli +0 -0
  44. 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 as soon as its last document closes; documents still open on an instance keep it.
4
- import { createGlue } from "../wasm/recto.gen.mjs";
5
- import { assetBaseUrl, canReadSync, fontFromBundle, loadAssetSync, loadWasm, preloadAssets, } from "./assets.js";
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
- /** The wasm, verified and compiled once per thread, per location. */
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
- function compile(base) {
15
- const key = base?.href ?? "";
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 = loadWasm(base).then((bytes) => WebAssembly.compile(bytes).catch((error) => {
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
- async function instantiate(generation, base) {
27
- const module = await compile(base);
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 retire the
43
- // instance so the next open tries again.
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.brotli", base);
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 fontFromBundle(loadAssetSync("fonts.bin", base), index);
132
+ return loadAssetSync(STANDARD_FONTS[index], base, hashOn(instance));
53
133
  }
54
134
  catch (error) {
55
- instance.dead = true;
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).slice();
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).slice();
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
- constructor(instance, base, recycleAboveBytes) {
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 === instance &&
99
- instance.openDocuments === 0 &&
100
- instance.memory.buffer.byteLength > this.#recycleAboveBytes) {
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 (current && !current.dead && current.memory.buffer.byteLength <= this.#recycleAboveBytes) {
222
+ if (this.#usable(current)) {
108
223
  return current;
109
224
  }
110
- this.#starting ??= instantiate(this.#generation + 1, this.#base).finally(() => {
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.#generation = started.generation;
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
- return await this.openOwned(ownedBytes(input), options);
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
- const instance = await this.#live();
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
- doc = new RectoDocument(instance, new instance.glue.Document(bytes, options.password ?? null));
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, options.password ?? null), () => loader.failure);
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.#instance?.openDocuments ?? 0,
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
- if (!canReadSync()) {
181
- await preloadAssets(base, options.preloadAssets ?? ["fonts.bin", "cmaps.brotli", "cjk-fallback.ttf"]);
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
- return new RectoEngine(await instantiate(0, base), base, options.recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES);
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
- const owned = ownedBytes(bytes);
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
- constructor(code: PdfErrorCode, message: string, cause?: unknown);
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
- constructor(code, message, cause) {
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;