@2018kguo/recto 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets.d.ts +11 -6
- package/dist/assets.js +123 -42
- package/dist/document.d.ts +48 -2
- package/dist/document.js +109 -4
- package/dist/engine.d.ts +17 -9
- package/dist/engine.js +223 -45
- package/dist/errors.d.ts +4 -2
- package/dist/errors.js +4 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +13 -0
- package/dist/pool-worker.d.ts +14 -0
- package/dist/pool-worker.js +65 -0
- package/dist/pool.d.ts +36 -0
- package/dist/pool.js +287 -0
- package/dist/ranged.d.ts +10 -0
- package/dist/ranged.js +38 -11
- package/package.json +2 -1
- package/wasm/FoxitDingbats.pfb +0 -0
- package/wasm/FoxitFixed.pfb +0 -0
- package/wasm/FoxitFixedBold.pfb +0 -0
- package/wasm/FoxitFixedBoldItalic.pfb +0 -0
- package/wasm/FoxitFixedItalic.pfb +0 -0
- package/wasm/FoxitSans.pfb +0 -0
- package/wasm/FoxitSansBold.pfb +0 -0
- package/wasm/FoxitSansBoldItalic.pfb +0 -0
- package/wasm/FoxitSansItalic.pfb +0 -0
- package/wasm/FoxitSerif.pfb +0 -0
- package/wasm/FoxitSerifBold.pfb +0 -0
- package/wasm/FoxitSerifBoldItalic.pfb +0 -0
- package/wasm/FoxitSerifItalic.pfb +0 -0
- package/wasm/FoxitSymbol.pfb +0 -0
- package/wasm/THIRD_PARTY_NOTICES +2 -158
- package/wasm/cmaps.deflate +0 -0
- package/wasm/pins.d.mts +1 -1
- package/wasm/pins.mjs +19 -3
- package/wasm/recto-more.wasm +0 -0
- package/wasm/recto-rare.wasm +0 -0
- package/wasm/recto-render.wasm +0 -0
- package/wasm/recto.gen.d.mts +2 -1
- package/wasm/recto.gen.mjs +118 -12
- package/wasm/recto.types.d.mts +46 -1
- package/wasm/recto.wasm +0 -0
- package/wasm/cmaps.brotli +0 -0
- package/wasm/fonts.bin +0 -0
package/dist/pool.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
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
|
+
#threads = [];
|
|
43
|
+
/** Started and not yet ready. */
|
|
44
|
+
#startingThreads = new Set();
|
|
45
|
+
#queue = [];
|
|
46
|
+
#nextFile = 0;
|
|
47
|
+
#nextId = 0;
|
|
48
|
+
#closed = false;
|
|
49
|
+
constructor(limit, assetBase) {
|
|
50
|
+
this.#limit = limit;
|
|
51
|
+
this.#ceiling = limit;
|
|
52
|
+
this.#assetBase = assetBase;
|
|
53
|
+
}
|
|
54
|
+
#spawn() {
|
|
55
|
+
const thread = { worker: null, running: null };
|
|
56
|
+
this.#startingThreads.add(thread);
|
|
57
|
+
let dead = false;
|
|
58
|
+
let waiting;
|
|
59
|
+
const onMessage = (message) => {
|
|
60
|
+
if (this.#closed) {
|
|
61
|
+
void thread.worker.terminate();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (message.startFailed) {
|
|
65
|
+
onDeath(message.failure.message, failureOf(message.failure));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (message.ready) {
|
|
69
|
+
clearTimeout(waiting);
|
|
70
|
+
this.#startingThreads.delete(thread);
|
|
71
|
+
this.#threads.push(thread);
|
|
72
|
+
// Idle, a thread doesn't keep a Node process alive; a call under way does.
|
|
73
|
+
thread.worker.unref?.();
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const task = thread.running;
|
|
77
|
+
thread.running = null;
|
|
78
|
+
thread.worker.unref?.();
|
|
79
|
+
// It'll need the file again: this was the call that was to open it.
|
|
80
|
+
if (!message.opened) {
|
|
81
|
+
task.file.on.delete(thread);
|
|
82
|
+
}
|
|
83
|
+
if (message.failure) {
|
|
84
|
+
task.reject(failureOf(message.failure));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
task.resolve(message.result);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.#pump();
|
|
91
|
+
};
|
|
92
|
+
// In Node a failure is an `error` and then an `exit`.
|
|
93
|
+
const onDeath = (reason, typed) => {
|
|
94
|
+
if (dead || this.#closed) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
dead = true;
|
|
98
|
+
clearTimeout(waiting);
|
|
99
|
+
void thread.worker?.terminate();
|
|
100
|
+
const failure = typed ?? new PdfError("crash", `a pool thread stopped: ${String(reason)}`, reason);
|
|
101
|
+
if (this.#startingThreads.delete(thread)) {
|
|
102
|
+
// It couldn't start, so another wouldn't either: run on what's left, or fail what's waiting.
|
|
103
|
+
this.#limit = this.#threads.length + this.#startingThreads.size;
|
|
104
|
+
if (this.#limit === 0) {
|
|
105
|
+
// The next call tries again: what stopped it, a missing file say, may be back by then.
|
|
106
|
+
this.#limit = this.#ceiling;
|
|
107
|
+
for (const task of this.#queue.splice(0)) {
|
|
108
|
+
task.reject(failure);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.#threads.splice(this.#threads.indexOf(thread), 1);
|
|
114
|
+
}
|
|
115
|
+
thread.running?.file.on.delete(thread);
|
|
116
|
+
thread.running?.reject(failure);
|
|
117
|
+
thread.running = null;
|
|
118
|
+
this.#pump();
|
|
119
|
+
};
|
|
120
|
+
waiting = setTimeout(() => onDeath(`it hadn't started after ${START_TIMEOUT_MS} ms`), START_TIMEOUT_MS);
|
|
121
|
+
waiting.unref?.();
|
|
122
|
+
try {
|
|
123
|
+
const threads = globalThis.process?.getBuiltinModule?.("node:worker_threads");
|
|
124
|
+
if (threads) {
|
|
125
|
+
const worker = new threads.Worker(new URL("./pool-worker.js", import.meta.url), { execArgv: [] });
|
|
126
|
+
worker.on("message", onMessage);
|
|
127
|
+
worker.on("messageerror", onDeath);
|
|
128
|
+
worker.on("error", onDeath);
|
|
129
|
+
worker.on("exit", onDeath);
|
|
130
|
+
thread.worker = worker;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Not `new URL("…", import.meta.url)`: a bundler would emit the file as it stands for that.
|
|
134
|
+
const here = import.meta.url;
|
|
135
|
+
const worker = new URL(here).origin === location.origin
|
|
136
|
+
? // Written out in full: this is the form bundlers recognise, and they emit the worker for it.
|
|
137
|
+
new Worker(new URL("./pool-worker.js", import.meta.url), { type: "module" })
|
|
138
|
+
: // A worker's script has to be of this page's origin, so from a CDN it's one line that imports the
|
|
139
|
+
// real one, which the CDN has to allow with CORS, as it does the wasm.
|
|
140
|
+
// ponytail: the blob's URL lives as long as the page; one per thread started.
|
|
141
|
+
new Worker(URL.createObjectURL(new Blob([`import ${JSON.stringify(new URL("./pool-worker.js", here).href)};`], { type: "text/javascript" })), { type: "module" });
|
|
142
|
+
worker.onmessage = ({ data }) => onMessage(data);
|
|
143
|
+
worker.onmessageerror = () => onDeath("a message from it could not be read");
|
|
144
|
+
worker.onerror = (event) => {
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
onDeath(event.message || "its script did not load");
|
|
147
|
+
};
|
|
148
|
+
thread.worker = worker;
|
|
149
|
+
}
|
|
150
|
+
const start = { assetBase: this.#assetBase };
|
|
151
|
+
thread.worker.postMessage(start);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
onDeath(error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Gives each free thread work, the oldest call on a document it has if there is one. */
|
|
158
|
+
#pump() {
|
|
159
|
+
for (const thread of this.#threads) {
|
|
160
|
+
if (thread.running || this.#queue.length === 0) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
let at = this.#queue.findIndex((task) => task.file.on.has(thread));
|
|
164
|
+
if (at < 0) {
|
|
165
|
+
// Not a copy of the file for a call that a thread already holding it will take next.
|
|
166
|
+
const free = this.#threads.filter((other) => !other.running);
|
|
167
|
+
at = this.#queue.findIndex((task) => !free.some((other) => task.file.on.has(other)));
|
|
168
|
+
}
|
|
169
|
+
if (at < 0) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const [task] = this.#queue.splice(at, 1);
|
|
173
|
+
thread.running = task;
|
|
174
|
+
thread.worker.ref?.();
|
|
175
|
+
const { file } = task;
|
|
176
|
+
const had = file.on.has(thread);
|
|
177
|
+
const open = had ? {} : { open: { bytes: file.bytes, password: file.password } };
|
|
178
|
+
file.on.add(thread);
|
|
179
|
+
try {
|
|
180
|
+
thread.worker.postMessage({ ...task.request, id: this.#nextId++, ...open });
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// An argument that can't cross. Nothing reached the thread.
|
|
184
|
+
thread.running = null;
|
|
185
|
+
thread.worker.unref?.();
|
|
186
|
+
if (!had) {
|
|
187
|
+
file.on.delete(thread);
|
|
188
|
+
}
|
|
189
|
+
task.reject(error);
|
|
190
|
+
// Nothing will come back from the thread to take the next call.
|
|
191
|
+
this.#pump();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const wanted = Math.min(this.#limit - this.#threads.length, this.#queue.length) - this.#startingThreads.size;
|
|
196
|
+
for (let i = 0; i < wanted; i++) {
|
|
197
|
+
this.#spawn();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
#call(file, method, args) {
|
|
201
|
+
if (file.closed || this.#closed) {
|
|
202
|
+
return Promise.reject(closedError());
|
|
203
|
+
}
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
this.#queue.push({ request: { doc: file.id, method, args }, file, resolve, reject });
|
|
206
|
+
this.#pump();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async open(input, options = {}) {
|
|
210
|
+
const view = ArrayBuffer.isView(input) ? input : new Uint8Array(input);
|
|
211
|
+
const file = {
|
|
212
|
+
id: this.#nextFile++,
|
|
213
|
+
bytes: privateCopy(view),
|
|
214
|
+
password: options.password,
|
|
215
|
+
closed: false,
|
|
216
|
+
on: new Set(),
|
|
217
|
+
};
|
|
218
|
+
const call = (method, ...args) => this.#call(file, method, args);
|
|
219
|
+
const pageCount = (await call("pageCount"));
|
|
220
|
+
const close = async () => {
|
|
221
|
+
if (file.closed) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
file.closed = true;
|
|
225
|
+
this.#queue = this.#queue.filter((task) => {
|
|
226
|
+
if (task.file === file) {
|
|
227
|
+
task.reject(closedError());
|
|
228
|
+
}
|
|
229
|
+
return task.file !== file;
|
|
230
|
+
});
|
|
231
|
+
// Each thread frees it when it gets there, past any call of it under way, which still answers.
|
|
232
|
+
for (const thread of file.on) {
|
|
233
|
+
thread.worker.postMessage({ id: this.#nextId++, doc: file.id, method: "close", args: [] });
|
|
234
|
+
}
|
|
235
|
+
file.on.clear();
|
|
236
|
+
};
|
|
237
|
+
return {
|
|
238
|
+
pageCount,
|
|
239
|
+
pageInfo: (index) => call("pageInfo", index),
|
|
240
|
+
text: (index) => call("text", index),
|
|
241
|
+
objects: (index) => call("objects", index),
|
|
242
|
+
annotations: (index) => call("annotations", index),
|
|
243
|
+
outline: () => call("outline"),
|
|
244
|
+
render: (index, renderOptions) => {
|
|
245
|
+
if (renderOptions?.signal) {
|
|
246
|
+
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."));
|
|
247
|
+
}
|
|
248
|
+
return call("render", index, { width: renderOptions?.width, maxHeight: renderOptions?.maxHeight });
|
|
249
|
+
},
|
|
250
|
+
close,
|
|
251
|
+
[Symbol.asyncDispose]: close,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
stats() {
|
|
255
|
+
return { threads: this.#threads.length, queued: this.#queue.length };
|
|
256
|
+
}
|
|
257
|
+
close() {
|
|
258
|
+
if (this.#closed) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.#closed = true;
|
|
262
|
+
for (const thread of [...this.#threads, ...this.#startingThreads]) {
|
|
263
|
+
thread.running?.reject(closedError());
|
|
264
|
+
void thread.worker?.terminate();
|
|
265
|
+
}
|
|
266
|
+
for (const task of this.#queue.splice(0)) {
|
|
267
|
+
task.reject(closedError());
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
[Symbol.dispose]() {
|
|
271
|
+
this.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export function startPool(options) {
|
|
275
|
+
const threads = options.threads ?? defaultThreads();
|
|
276
|
+
if (!(Number.isInteger(threads) && threads >= 1 && threads <= MAX_THREADS)) {
|
|
277
|
+
throw new RangeError(`threads must be a whole number from 1 to ${MAX_THREADS}, not ${threads}`);
|
|
278
|
+
}
|
|
279
|
+
if (!globalThis.process?.getBuiltinModule?.("node:worker_threads") && typeof Worker === "undefined") {
|
|
280
|
+
throw new PdfError("wrong-thread", "a pool needs worker threads, which this thread can't start");
|
|
281
|
+
}
|
|
282
|
+
// A thread's location isn't this one's, so a relative URL has to be resolved here.
|
|
283
|
+
const assetBase = options.assetBase !== undefined && typeof location !== "undefined"
|
|
284
|
+
? new URL(options.assetBase, location.href).href
|
|
285
|
+
: options.assetBase;
|
|
286
|
+
return new Pool(threads, assetBase);
|
|
287
|
+
}
|
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. */
|
|
@@ -44,6 +52,7 @@ type Answer = {
|
|
|
44
52
|
bytes: Uint8Array;
|
|
45
53
|
length: number;
|
|
46
54
|
etag: string | null;
|
|
55
|
+
status: number;
|
|
47
56
|
};
|
|
48
57
|
/** Fetches `ranges`; `limit` is how many bytes may still arrive. */
|
|
49
58
|
type Fetch = (ranges: Range[], timeoutMs: number, limit: number) => Answer[];
|
|
@@ -58,6 +67,7 @@ declare class RangeLoader {
|
|
|
58
67
|
chunkBytes: number;
|
|
59
68
|
maxBytes: number;
|
|
60
69
|
timeoutMs: number;
|
|
70
|
+
totalTimeoutMs: number;
|
|
61
71
|
signal: AbortSignal | undefined;
|
|
62
72
|
});
|
|
63
73
|
stats(): PdfRangedStats;
|
package/dist/ranged.js
CHANGED
|
@@ -50,6 +50,8 @@ class RangeLoader {
|
|
|
50
50
|
#fetch;
|
|
51
51
|
#maxBytes;
|
|
52
52
|
#timeoutMs;
|
|
53
|
+
#totalTimeoutMs;
|
|
54
|
+
#deadline;
|
|
53
55
|
#signal;
|
|
54
56
|
constructor(fetch, options) {
|
|
55
57
|
this.#fetch = fetch;
|
|
@@ -57,6 +59,8 @@ class RangeLoader {
|
|
|
57
59
|
this.#readAhead = options.chunkBytes;
|
|
58
60
|
this.#maxBytes = options.maxBytes;
|
|
59
61
|
this.#timeoutMs = options.timeoutMs;
|
|
62
|
+
this.#totalTimeoutMs = options.totalTimeoutMs;
|
|
63
|
+
this.#deadline = performance.now() + options.totalTimeoutMs;
|
|
60
64
|
this.#signal = options.signal;
|
|
61
65
|
}
|
|
62
66
|
stats() {
|
|
@@ -101,20 +105,35 @@ class RangeLoader {
|
|
|
101
105
|
if (this.length > 0 && this.#bytesFetched + wanted > this.#maxBytes) {
|
|
102
106
|
throw tooLarge(this.#maxBytes);
|
|
103
107
|
}
|
|
108
|
+
const passedTotal = `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
109
|
+
const left = this.#deadline - performance.now();
|
|
110
|
+
if (left <= 0) {
|
|
111
|
+
throw new PdfError("timeout", passedTotal);
|
|
112
|
+
}
|
|
104
113
|
this.#rounds += 1;
|
|
105
|
-
const
|
|
114
|
+
const passesTotal = left < this.#timeoutMs;
|
|
115
|
+
let answers;
|
|
116
|
+
try {
|
|
117
|
+
answers = this.#fetch(runs, passesTotal ? Math.ceil(left) : this.#timeoutMs, this.#maxBytes - this.#bytesFetched);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (passesTotal && error.code === "timeout") {
|
|
121
|
+
throw new PdfError("timeout", passedTotal, error);
|
|
122
|
+
}
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
106
125
|
for (const answer of answers) {
|
|
107
126
|
this.#requests += 1;
|
|
108
127
|
this.#bytesFetched += answer.bytes.byteLength;
|
|
109
128
|
if (this.length > 0 && answer.length !== this.length) {
|
|
110
|
-
throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})
|
|
129
|
+
throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})`, undefined, answer.status);
|
|
111
130
|
}
|
|
112
131
|
// Before anything is sized from it: the total is whatever the server said.
|
|
113
132
|
if (answer.length > MAX_FILE_BYTES) {
|
|
114
133
|
throw new PdfError("too-large", `a ranged read holds files up to ${MAX_FILE_BYTES} bytes (this one is ${answer.length}); use open() or a smaller file`);
|
|
115
134
|
}
|
|
116
135
|
if (this.#etag && answer.etag && answer.etag !== this.#etag) {
|
|
117
|
-
throw new PdfError("fetch-failed", "the file was replaced during the ranged read (its ETag changed)");
|
|
136
|
+
throw new PdfError("fetch-failed", "the file was replaced during the ranged read (its ETag changed)", undefined, answer.status);
|
|
118
137
|
}
|
|
119
138
|
this.#etag ??= answer.etag;
|
|
120
139
|
this.length = answer.length;
|
|
@@ -217,13 +236,14 @@ class RangeLoader {
|
|
|
217
236
|
*/
|
|
218
237
|
export function startRangeLoader(source, options = {}) {
|
|
219
238
|
const positive = (name, value) => {
|
|
220
|
-
if (value !== undefined && !(value > 0)) {
|
|
239
|
+
if (value !== undefined && !(typeof value === "number" && value > 0)) {
|
|
221
240
|
throw new RangeError(`${name} must be above 0 (got ${value})`);
|
|
222
241
|
}
|
|
223
242
|
};
|
|
224
243
|
positive("chunkBytes", options.chunkBytes);
|
|
225
244
|
positive("maxBytes", options.maxBytes);
|
|
226
245
|
positive("timeoutMs", options.timeoutMs);
|
|
246
|
+
positive("totalTimeoutMs", options.totalTimeoutMs);
|
|
227
247
|
if (options.chunkBytes !== undefined && !Number.isSafeInteger(options.chunkBytes)) {
|
|
228
248
|
throw new RangeError(`chunkBytes must be a whole number (got ${options.chunkBytes})`);
|
|
229
249
|
}
|
|
@@ -235,6 +255,7 @@ export function startRangeLoader(source, options = {}) {
|
|
|
235
255
|
chunkBytes,
|
|
236
256
|
maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
237
257
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
258
|
+
totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
|
|
238
259
|
signal: options.signal,
|
|
239
260
|
});
|
|
240
261
|
loader.fetchChunks(0, HEAD_CHUNKS);
|
|
@@ -281,6 +302,7 @@ function fetcherFor(source) {
|
|
|
281
302
|
bytes: bytes.subarray(start, Math.min(end, bytes.byteLength)),
|
|
282
303
|
length: bytes.byteLength,
|
|
283
304
|
etag: null,
|
|
305
|
+
status: 206,
|
|
284
306
|
}));
|
|
285
307
|
}
|
|
286
308
|
const url = source;
|
|
@@ -303,24 +325,25 @@ function answerOf(range, status, contentRange, etag, bytes, limit) {
|
|
|
303
325
|
if (bytes.byteLength > limit) {
|
|
304
326
|
throw new PdfError("too-large", `the server does not answer Range requests, and the whole file is past the ${limit} bytes left of maxBytes`);
|
|
305
327
|
}
|
|
306
|
-
return { start: 0, bytes, length: bytes.byteLength, etag };
|
|
328
|
+
return { start: 0, bytes, length: bytes.byteLength, etag, status };
|
|
307
329
|
}
|
|
308
330
|
if (status !== 206) {
|
|
309
|
-
throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})
|
|
331
|
+
throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})`, undefined, status);
|
|
310
332
|
}
|
|
311
333
|
const match = /^bytes (\d+)-\d+\/(\d+)$/u.exec(contentRange?.trim() ?? "");
|
|
312
334
|
if (!match) {
|
|
313
335
|
throw new PdfError("fetch-failed", "the server's 206 answer has no usable Content-Range (with a total length). " +
|
|
314
|
-
"A cross-origin server must also send Access-Control-Expose-Headers: Content-Range.");
|
|
336
|
+
"A cross-origin server must also send Access-Control-Expose-Headers: Content-Range.", undefined, status);
|
|
315
337
|
}
|
|
316
338
|
const start = Number(match[1]);
|
|
317
339
|
const length = Number(match[2]);
|
|
318
340
|
// A short or shifted answer (a proxy capping ranges) would store a partial chunk.
|
|
319
341
|
if (start !== range.start || bytes.byteLength !== Math.min(range.end, length) - range.start) {
|
|
320
|
-
throw new PdfError("fetch-failed", `the server answered a different range than asked (asked ${range.start}-${range.end - 1}, got ${bytes.byteLength} bytes from ${start})
|
|
342
|
+
throw new PdfError("fetch-failed", `the server answered a different range than asked (asked ${range.start}-${range.end - 1}, got ${bytes.byteLength} bytes from ${start})`, undefined, status);
|
|
321
343
|
}
|
|
322
|
-
return { start, bytes, length, etag };
|
|
344
|
+
return { start, bytes, length, etag, status };
|
|
323
345
|
}
|
|
346
|
+
const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
|
|
324
347
|
function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
325
348
|
const deadline = performance.now() + timeoutMs;
|
|
326
349
|
return ranges.map((range) => {
|
|
@@ -336,7 +359,11 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
336
359
|
const timedOut = error.name === "TimeoutError";
|
|
337
360
|
throw new PdfError(timedOut ? "timeout" : "fetch-failed", timedOut
|
|
338
361
|
? `the ranged read timed out after ${timeoutMs} ms`
|
|
339
|
-
:
|
|
362
|
+
: NO_ANSWER, error);
|
|
363
|
+
}
|
|
364
|
+
// Firefox reports a refused cross-origin read as status 0, not a throw.
|
|
365
|
+
if (xhr.status === 0) {
|
|
366
|
+
throw new PdfError("fetch-failed", NO_ANSWER);
|
|
340
367
|
}
|
|
341
368
|
return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
|
|
342
369
|
});
|
|
@@ -364,7 +391,7 @@ async function get(m, signal) {
|
|
|
364
391
|
}
|
|
365
392
|
const parts = [];
|
|
366
393
|
let size = 0;
|
|
367
|
-
for await (const part of response.body) {
|
|
394
|
+
for await (const part of response.body ?? []) {
|
|
368
395
|
size += part.byteLength;
|
|
369
396
|
if (size > m.limit) return { ...reply, overLimit: true, bytes: new ArrayBuffer(0) };
|
|
370
397
|
parts.push(part);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@2018kguo/recto",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A PDF engine for JavaScript: text with boxes, page objects, annotations and rendering, in Rust compiled to WebAssembly.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"types": "./dist/index.d.ts",
|
|
27
27
|
"default": "./dist/index.js"
|
|
28
28
|
},
|
|
29
|
+
"./wasm/*": "./wasm/*",
|
|
29
30
|
"./package.json": "./package.json"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|