@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/pool.js ADDED
@@ -0,0 +1,295 @@
1
+ // Pages read and drawn across worker threads, from one call per page. Each thread has its own
2
+ // engine and, the first time it's given a page of a document, its own copy of the file; a thread
3
+ // starts only when there's work and none is free, so calls made one at a time stay on one thread.
4
+ // No special headers: SharedArrayBuffer only saves a copy, where it's allowed.
5
+ //
6
+ // No call waits for ever on a thread that's gone: a thread reports a failed start, its death fails its
7
+ // call, and one that has said nothing by START_TIMEOUT_MS counts as dead. A call that's running can't be
8
+ // stopped, only the page budgets end it, or `close()`.
9
+ import { PdfError } from "./errors.js";
10
+ /**
11
+ * The pool's copy of a file. Shared memory where it's allowed (Node, or a page with COOP and COEP), so that
12
+ * telling each thread about the file doesn't copy it again before the thread copies it into its wasm.
13
+ */
14
+ function privateCopy(view) {
15
+ if (typeof SharedArrayBuffer !== "function") {
16
+ return view.slice();
17
+ }
18
+ const copy = new Uint8Array(new SharedArrayBuffer(view.byteLength));
19
+ copy.set(view);
20
+ return copy;
21
+ }
22
+ /** Longer than a thread's slowest fetch of its wasm (ASSET_TIMEOUT_MS), and its compile. */
23
+ const START_TIMEOUT_MS = 90_000;
24
+ const MAX_THREADS = 64;
25
+ function defaultThreads() {
26
+ const cores = globalThis.process?.getBuiltinModule?.("node:os")?.availableParallelism() ?? navigator.hardwareConcurrency ?? 2;
27
+ return Math.max(1, Math.min(8, cores - 1));
28
+ }
29
+ function failureOf(failure) {
30
+ if (failure.code) {
31
+ return new PdfError(failure.code, failure.message);
32
+ }
33
+ // A bad argument stays what it is on a single thread.
34
+ const type = failure.name === "RangeError" ? RangeError : failure.name === "TypeError" ? TypeError : Error;
35
+ return new type(failure.message);
36
+ }
37
+ const closedError = () => new PdfError("closed", "this document or pool is closed");
38
+ class Pool {
39
+ #limit;
40
+ #ceiling;
41
+ #assetBase;
42
+ #maxDecodedBytes;
43
+ #threads = [];
44
+ /** Started and not yet ready. */
45
+ #startingThreads = new Set();
46
+ #queue = [];
47
+ #nextFile = 0;
48
+ #nextId = 0;
49
+ #closed = false;
50
+ constructor(limit, assetBase, maxDecodedBytes) {
51
+ this.#limit = limit;
52
+ this.#ceiling = limit;
53
+ this.#assetBase = assetBase;
54
+ this.#maxDecodedBytes = maxDecodedBytes;
55
+ }
56
+ #spawn() {
57
+ const thread = { worker: null, running: null };
58
+ this.#startingThreads.add(thread);
59
+ let dead = false;
60
+ let waiting;
61
+ const onMessage = (message) => {
62
+ if (this.#closed) {
63
+ void thread.worker.terminate();
64
+ return;
65
+ }
66
+ if (message.startFailed) {
67
+ onDeath(message.failure.message, failureOf(message.failure));
68
+ return;
69
+ }
70
+ if (message.ready) {
71
+ clearTimeout(waiting);
72
+ this.#startingThreads.delete(thread);
73
+ this.#threads.push(thread);
74
+ // Idle, a thread doesn't keep a Node process alive; a call under way does.
75
+ thread.worker.unref?.();
76
+ }
77
+ else {
78
+ const task = thread.running;
79
+ thread.running = null;
80
+ thread.worker.unref?.();
81
+ // It'll need the file again: this was the call that was to open it.
82
+ if (!message.opened) {
83
+ task.file.on.delete(thread);
84
+ }
85
+ if (message.failure) {
86
+ task.reject(failureOf(message.failure));
87
+ }
88
+ else {
89
+ task.resolve(message.result);
90
+ }
91
+ }
92
+ this.#pump();
93
+ };
94
+ // In Node a failure is an `error` and then an `exit`.
95
+ const onDeath = (reason, typed) => {
96
+ if (dead || this.#closed) {
97
+ return;
98
+ }
99
+ dead = true;
100
+ clearTimeout(waiting);
101
+ void thread.worker?.terminate();
102
+ const failure = typed ?? new PdfError("crash", `a pool thread stopped: ${String(reason)}`, reason);
103
+ if (this.#startingThreads.delete(thread)) {
104
+ // It couldn't start, so another wouldn't either: run on what's left, or fail what's waiting.
105
+ this.#limit = this.#threads.length + this.#startingThreads.size;
106
+ if (this.#limit === 0) {
107
+ // The next call tries again: what stopped it, a missing file say, may be back by then.
108
+ this.#limit = this.#ceiling;
109
+ for (const task of this.#queue.splice(0)) {
110
+ task.reject(failure);
111
+ }
112
+ }
113
+ }
114
+ else {
115
+ this.#threads.splice(this.#threads.indexOf(thread), 1);
116
+ }
117
+ thread.running?.file.on.delete(thread);
118
+ thread.running?.reject(failure);
119
+ thread.running = null;
120
+ this.#pump();
121
+ };
122
+ waiting = setTimeout(() => onDeath(`it hadn't started after ${START_TIMEOUT_MS} ms`), START_TIMEOUT_MS);
123
+ waiting.unref?.();
124
+ try {
125
+ const threads = globalThis.process?.getBuiltinModule?.("node:worker_threads");
126
+ if (threads) {
127
+ const worker = new threads.Worker(new URL("./pool-worker.js", import.meta.url), { execArgv: [] });
128
+ worker.on("message", onMessage);
129
+ worker.on("messageerror", onDeath);
130
+ worker.on("error", onDeath);
131
+ worker.on("exit", onDeath);
132
+ thread.worker = worker;
133
+ }
134
+ else {
135
+ // Not `new URL("…", import.meta.url)`: a bundler would emit the file as it stands for that.
136
+ const here = import.meta.url;
137
+ const worker = new URL(here).origin === location.origin
138
+ ? // Written out in full: this is the form bundlers recognise, and they emit the worker for it.
139
+ new Worker(new URL("./pool-worker.js", import.meta.url), { type: "module" })
140
+ : // A worker's script has to be of this page's origin, so from a CDN it's one line that imports the
141
+ // real one, which the CDN has to allow with CORS, as it does the wasm.
142
+ // ponytail: the blob's URL lives as long as the page; one per thread started.
143
+ new Worker(URL.createObjectURL(new Blob([`import ${JSON.stringify(new URL("./pool-worker.js", here).href)};`], { type: "text/javascript" })), { type: "module" });
144
+ worker.onmessage = ({ data }) => onMessage(data);
145
+ worker.onmessageerror = () => onDeath("a message from it could not be read");
146
+ worker.onerror = (event) => {
147
+ event.preventDefault();
148
+ onDeath(event.message || "its script did not load");
149
+ };
150
+ thread.worker = worker;
151
+ }
152
+ const start = { assetBase: this.#assetBase, maxDecodedBytes: this.#maxDecodedBytes };
153
+ thread.worker.postMessage(start);
154
+ }
155
+ catch (error) {
156
+ onDeath(error);
157
+ }
158
+ }
159
+ /** Gives each free thread work, the oldest call on a document it has if there is one. */
160
+ #pump() {
161
+ for (const thread of this.#threads) {
162
+ if (thread.running || this.#queue.length === 0) {
163
+ continue;
164
+ }
165
+ let at = this.#queue.findIndex((task) => task.file.on.has(thread));
166
+ if (at < 0) {
167
+ // Not a copy of the file for a call that a thread already holding it will take next.
168
+ const free = this.#threads.filter((other) => !other.running);
169
+ at = this.#queue.findIndex((task) => !free.some((other) => task.file.on.has(other)));
170
+ }
171
+ if (at < 0) {
172
+ continue;
173
+ }
174
+ const [task] = this.#queue.splice(at, 1);
175
+ thread.running = task;
176
+ thread.worker.ref?.();
177
+ const { file } = task;
178
+ const had = file.on.has(thread);
179
+ const open = had ? {} : { open: { bytes: file.bytes, password: file.password } };
180
+ file.on.add(thread);
181
+ try {
182
+ thread.worker.postMessage({ ...task.request, id: this.#nextId++, ...open });
183
+ }
184
+ catch (error) {
185
+ // An argument that can't cross. Nothing reached the thread.
186
+ thread.running = null;
187
+ thread.worker.unref?.();
188
+ if (!had) {
189
+ file.on.delete(thread);
190
+ }
191
+ task.reject(error);
192
+ // Nothing will come back from the thread to take the next call.
193
+ this.#pump();
194
+ return;
195
+ }
196
+ }
197
+ const wanted = Math.min(this.#limit - this.#threads.length, this.#queue.length) - this.#startingThreads.size;
198
+ for (let i = 0; i < wanted; i++) {
199
+ this.#spawn();
200
+ }
201
+ }
202
+ #call(file, method, args) {
203
+ if (file.closed || this.#closed) {
204
+ return Promise.reject(closedError());
205
+ }
206
+ return new Promise((resolve, reject) => {
207
+ this.#queue.push({ request: { doc: file.id, method, args }, file, resolve, reject });
208
+ this.#pump();
209
+ });
210
+ }
211
+ async open(input, options = {}) {
212
+ const view = ArrayBuffer.isView(input) ? input : new Uint8Array(input);
213
+ const file = {
214
+ id: this.#nextFile++,
215
+ bytes: privateCopy(view),
216
+ password: options.password,
217
+ closed: false,
218
+ on: new Set(),
219
+ };
220
+ const call = (method, ...args) => this.#call(file, method, args);
221
+ const pageCount = (await call("pageCount"));
222
+ const close = async () => {
223
+ if (file.closed) {
224
+ return;
225
+ }
226
+ file.closed = true;
227
+ this.#queue = this.#queue.filter((task) => {
228
+ if (task.file === file) {
229
+ task.reject(closedError());
230
+ }
231
+ return task.file !== file;
232
+ });
233
+ // Each thread frees it when it gets there, past any call of it under way, which still answers.
234
+ for (const thread of file.on) {
235
+ thread.worker.postMessage({ id: this.#nextId++, doc: file.id, method: "close", args: [] });
236
+ }
237
+ file.on.clear();
238
+ };
239
+ return {
240
+ pageCount,
241
+ pageInfo: (index) => call("pageInfo", index),
242
+ text: (index) => call("text", index),
243
+ objects: (index) => call("objects", index),
244
+ annotations: (index) => call("annotations", index),
245
+ links: (index) => call("links", index),
246
+ outline: () => call("outline"),
247
+ render: ((index, renderOptions) => {
248
+ if (renderOptions?.signal) {
249
+ return Promise.reject(new TypeError("a pool's render takes no signal: a call that's running can't be stopped. pool.close() ends all of them."));
250
+ }
251
+ const { width, maxHeight, region } = renderOptions ?? {};
252
+ return call("render", index, region === undefined ? { width, maxHeight } : { width, maxHeight, region });
253
+ }),
254
+ close,
255
+ [Symbol.asyncDispose]: close,
256
+ };
257
+ }
258
+ stats() {
259
+ return { threads: this.#threads.length, queued: this.#queue.length };
260
+ }
261
+ close() {
262
+ if (this.#closed) {
263
+ return;
264
+ }
265
+ this.#closed = true;
266
+ for (const thread of [...this.#threads, ...this.#startingThreads]) {
267
+ thread.running?.reject(closedError());
268
+ void thread.worker?.terminate();
269
+ }
270
+ for (const task of this.#queue.splice(0)) {
271
+ task.reject(closedError());
272
+ }
273
+ }
274
+ [Symbol.dispose]() {
275
+ this.close();
276
+ }
277
+ }
278
+ export function startPool(options) {
279
+ const threads = options.threads ?? defaultThreads();
280
+ if (!(Number.isInteger(threads) && threads >= 1 && threads <= MAX_THREADS)) {
281
+ throw new RangeError(`threads must be a whole number from 1 to ${MAX_THREADS}, not ${threads}`);
282
+ }
283
+ if (!globalThis.process?.getBuiltinModule?.("node:worker_threads") && typeof Worker === "undefined") {
284
+ throw new PdfError("wrong-thread", "a pool needs worker threads, which this thread can't start");
285
+ }
286
+ // A thread's location isn't this one's, so a relative URL has to be resolved here.
287
+ const assetBase = options.assetBase !== undefined && typeof location !== "undefined"
288
+ ? new URL(options.assetBase, location.href).href
289
+ : options.assetBase;
290
+ const maxDecodedBytes = options.maxDecodedBytes;
291
+ if (maxDecodedBytes !== undefined && !(typeof maxDecodedBytes === "number" && maxDecodedBytes >= 0)) {
292
+ throw new RangeError("maxDecodedBytes must be a number of bytes, 0 or more");
293
+ }
294
+ return new Pool(threads, assetBase, maxDecodedBytes);
295
+ }
package/dist/ranged.d.ts CHANGED
@@ -18,6 +18,14 @@ export type PdfRangedOpenOptions = {
18
18
  signal?: AbortSignal;
19
19
  /** How long one fetch round may block the thread (code "timeout"). Default 30 s. */
20
20
  timeoutMs?: number;
21
+ /**
22
+ * Wall time for the whole read, from the first fetch on, in place of counting rounds (code "timeout").
23
+ * A fetch in flight is cut when it runs out, and none starts after; that bounds the network waits, not
24
+ * the parsing between them. What's already fetched reads until a read fails, after which every call
25
+ * on the document throws that failure: reopen to go on. In WebKit a response that keeps trickling is
26
+ * not cut. Default: no limit.
27
+ */
28
+ totalTimeoutMs?: number;
21
29
  };
22
30
  export type PdfRangedStats = {
23
31
  /** Bytes received from the source, across every request. */
@@ -35,18 +43,36 @@ export type PdfRangedStats = {
35
43
  export type PdfRangedDocument = PdfDocument & {
36
44
  stats(): PdfRangedStats;
37
45
  };
46
+ /**
47
+ * Bytes `start..end`. With `yieldTail`, read no further than that many bytes before the file's
48
+ * end, where a suffix asked for beside it starts, so no byte comes twice.
49
+ */
38
50
  type Range = {
39
51
  start: number;
40
52
  end: number;
53
+ yieldTail?: number;
54
+ };
55
+ /** The file's last `suffix` bytes (`bytes=-n`), asked for before its size is known. */
56
+ type Suffix = {
57
+ suffix: number;
41
58
  };
59
+ type Request = Range | Suffix;
60
+ /** `received`: the bytes read off the response, `bytes` and any read past it. */
42
61
  type Answer = {
43
62
  start: number;
44
63
  bytes: Uint8Array;
45
64
  length: number;
46
65
  etag: string | null;
66
+ status: number;
67
+ received: number;
47
68
  };
48
- /** Fetches `ranges`; `limit` is how many bytes may still arrive. */
49
- type Fetch = (ranges: Range[], timeoutMs: number, limit: number) => Answer[];
69
+ /**
70
+ * Fetches `requests` in parallel; `limit` is how many bytes may still arrive. A range that
71
+ * fails throws. A suffix that isn't answered as one (a server that ignores or refuses it)
72
+ * gives null, and isn't counted: the caller asks for the tail by its offset instead.
73
+ */
74
+ type Fetch = (requests: Request[], timeoutMs: number, limit: number) => (Answer | null)[];
75
+ type FetchAsync = (requests: Request[], timeoutMs: number, limit: number) => Promise<(Answer | null)[]>;
50
76
  /** Fetched chunks of one file and the policy for fetching more. */
51
77
  declare class RangeLoader {
52
78
  #private;
@@ -58,28 +84,37 @@ declare class RangeLoader {
58
84
  chunkBytes: number;
59
85
  maxBytes: number;
60
86
  timeoutMs: number;
87
+ totalTimeoutMs: number;
61
88
  signal: AbortSignal | undefined;
62
89
  });
63
90
  stats(): PdfRangedStats;
64
91
  /** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
65
92
  fetchChunks(firstChunk: number, endChunk: number): void;
93
+ /**
94
+ * The first round: the head and, in parallel, the tail as a suffix, then the last
95
+ * cross-reference section if the tail didn't hold it. A file the head covers needs no more;
96
+ * a server that doesn't answer the suffix gets the tail by offset in a round of its own.
97
+ */
98
+ begin(fetchAsync: FetchAsync | null): Promise<void>;
66
99
  /**
67
100
  * The `startxref` offset in the fetched last chunk, if it names one. Null for a linearized
68
101
  * file, whose trailer points at the first-page section near the head.
69
102
  */
70
103
  startxref(): number | null;
71
104
  /**
72
- * Copies [offset, offset + out.length) into `out`, fetching what is missing first. On a
73
- * failed fetch it records the failure and fails the read; the call that made it then
74
- * throws the failure.
105
+ * Copies [offset, offset + out.length) into `out`, fetching what is missing first, and if it
106
+ * fetches, `ahead` bytes past it too, as far as the budget allows: the engine's guess at what
107
+ * it reads next, which then costs no round trip of its own. On a failed fetch it records the
108
+ * failure and fails the read; the call that made it then throws the failure.
75
109
  */
76
- read(offset: number, out: Uint8Array): boolean;
110
+ read(offset: number, out: Uint8Array, ahead?: number): boolean;
77
111
  }
78
112
  /**
79
- * Starts a ranged read of `source`: the head, then (for a larger file) the tail and its last
80
- * cross-reference section. Blocks while it fetches.
113
+ * Starts a ranged read of `source`: the head and tail, then the last cross-reference section
114
+ * if the tail didn't hold it. Blocks while it fetches, except in a browser, where these
115
+ * rounds are `fetch` calls.
81
116
  */
82
- export declare function startRangeLoader(source: string | Uint8Array, options?: PdfRangedOpenOptions): RangeLoader;
117
+ export declare function startRangeLoader(source: string | Uint8Array, options?: PdfRangedOpenOptions): Promise<RangeLoader>;
83
118
  /**
84
119
  * The caller's source in a form that stays valid across an await: bytes copied, a URL as an
85
120
  * absolute http(s) string. Runs before anything asynchronous.