@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.
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 +48 -2
  4. package/dist/document.js +109 -4
  5. package/dist/engine.d.ts +17 -9
  6. package/dist/engine.js +223 -45
  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 +14 -0
  12. package/dist/pool-worker.js +65 -0
  13. package/dist/pool.d.ts +36 -0
  14. package/dist/pool.js +287 -0
  15. package/dist/ranged.d.ts +10 -0
  16. package/dist/ranged.js +38 -11
  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 +118 -12
  41. package/wasm/recto.types.d.mts +46 -1
  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,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 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, 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
  });
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).slice();
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).slice();
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 === instance &&
99
- instance.openDocuments === 0 &&
100
- instance.memory.buffer.byteLength > this.#recycleAboveBytes) {
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 (current && !current.dead && current.memory.buffer.byteLength <= this.#recycleAboveBytes) {
217
+ if (this.#usable(current)) {
108
218
  return current;
109
219
  }
110
- this.#starting ??= instantiate(this.#generation + 1, this.#base).finally(() => {
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.#generation = started.generation;
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
- return await this.openOwned(ownedBytes(input), options);
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
- const instance = await this.#live();
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
- doc = new RectoDocument(instance, new instance.glue.Document(bytes, options.password ?? null));
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, options.password ?? null), () => loader.failure);
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.#instance?.openDocuments ?? 0,
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
- if (!canReadSync()) {
181
- await preloadAssets(base, options.preloadAssets ?? ["fonts.bin", "cmaps.brotli", "cjk-fallback.ttf"]);
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
- return new RectoEngine(await instantiate(0, base), base, options.recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES);
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
- const owned = ownedBytes(bytes);
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
- 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 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;