@2018kguo/recto 0.5.0 → 0.5.2
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.js +32 -52
- package/dist/document.d.ts +4 -4
- package/dist/document.js +5 -16
- package/dist/engine.js +2 -5
- package/dist/ranged.d.ts +9 -4
- package/dist/ranged.js +92 -68
- package/dist/reading-order.d.ts +7 -0
- package/dist/reading-order.js +15 -6
- package/dist/text-items.js +1 -6
- package/package.json +1 -1
- package/wasm/pins.mjs +4 -4
- 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.mjs +2 -2
- package/wasm/recto.wasm +0 -0
package/dist/assets.js
CHANGED
|
@@ -124,6 +124,22 @@ function loadFailure(name, url, reason, cause) {
|
|
|
124
124
|
"somewhere it serves and pass that directory as `assetBase` (with Vite's dev server, add " +
|
|
125
125
|
'optimizeDeps: { exclude: ["@2018kguo/recto"] } instead; a server bundle should keep the package external).', cause);
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* `read` of an asset's URL, sync or async, with any failure but a PdfError reported as that asset's
|
|
129
|
+
* `missing-asset`. Resolving the URL is inside it: under a blob: worker `import.meta.url` is no base.
|
|
130
|
+
*/
|
|
131
|
+
function located(name, base, read) {
|
|
132
|
+
let url = null;
|
|
133
|
+
const failure = (error) => error instanceof PdfError ? error : loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
|
|
134
|
+
try {
|
|
135
|
+
url = assetUrl(name, base);
|
|
136
|
+
const result = read(url);
|
|
137
|
+
return (result instanceof Promise ? result.catch((error) => Promise.reject(failure(error))) : result);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw failure(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
127
143
|
function notThisBuild(name, url, bytes) {
|
|
128
144
|
return loadFailure(name, url, `the ${bytes.byteLength} bytes there are not this build's file (a server's fallback page, or a different version of the package)`);
|
|
129
145
|
}
|
|
@@ -172,19 +188,7 @@ async function fetchBytes(name, url) {
|
|
|
172
188
|
}
|
|
173
189
|
/** The wasm, verified and then compiled. */
|
|
174
190
|
export async function compileWasm(name, base) {
|
|
175
|
-
|
|
176
|
-
let bytes;
|
|
177
|
-
try {
|
|
178
|
-
// Inside the try: under a blob: worker `import.meta.url` is no base for a URL.
|
|
179
|
-
url = assetUrl(name, base);
|
|
180
|
-
bytes = (nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url));
|
|
181
|
-
}
|
|
182
|
-
catch (error) {
|
|
183
|
-
if (error instanceof PdfError) {
|
|
184
|
-
throw error;
|
|
185
|
-
}
|
|
186
|
-
throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
|
|
187
|
-
}
|
|
191
|
+
const bytes = await located(name, base, async (url) => (nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url)));
|
|
188
192
|
return await compiling(name, WebAssembly.compile(bytes));
|
|
189
193
|
}
|
|
190
194
|
function compiling(name, module) {
|
|
@@ -196,55 +200,31 @@ function compiling(name, module) {
|
|
|
196
200
|
const loaded = new Map();
|
|
197
201
|
/** Fetches and verifies assets ahead of time; a thread that can't read them when needed has to. */
|
|
198
202
|
export async function preloadAssets(base, names) {
|
|
199
|
-
await Promise.all(names.map(async (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
try {
|
|
203
|
-
url = assetUrl(name, base);
|
|
204
|
-
if (loaded.has(url.href)) {
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
bytes = nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url);
|
|
208
|
-
}
|
|
209
|
-
catch (error) {
|
|
210
|
-
if (error instanceof PdfError) {
|
|
211
|
-
throw error;
|
|
212
|
-
}
|
|
213
|
-
throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
|
|
203
|
+
await Promise.all(names.map((name) => located(name, base, async (url) => {
|
|
204
|
+
if (!loaded.has(url.href)) {
|
|
205
|
+
loaded.set(url.href, nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url));
|
|
214
206
|
}
|
|
215
|
-
|
|
216
|
-
}));
|
|
207
|
+
})));
|
|
217
208
|
}
|
|
218
209
|
/** An asset, read synchronously. Throws a PdfError rather than returning nothing. */
|
|
219
210
|
export function loadAssetSync(name, base, hash) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return cached;
|
|
211
|
+
return located(name, base, (url) => {
|
|
212
|
+
let bytes = loaded.get(url.href);
|
|
213
|
+
if (!bytes) {
|
|
214
|
+
bytes = readCheckedSync(name, base, hash);
|
|
215
|
+
loaded.set(url.href, bytes);
|
|
226
216
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
|
|
230
|
-
}
|
|
231
|
-
const bytes = readCheckedSync(name, base, hash);
|
|
232
|
-
loaded.set(url.href, bytes);
|
|
233
|
-
return bytes;
|
|
217
|
+
return bytes;
|
|
218
|
+
});
|
|
234
219
|
}
|
|
235
220
|
/** A file, read now and hashed before it's returned, and not kept: a part is compiled once. */
|
|
236
221
|
export function readCheckedSync(name, base, hash) {
|
|
237
|
-
|
|
238
|
-
let bytes;
|
|
239
|
-
try {
|
|
240
|
-
url = assetUrl(name, base);
|
|
222
|
+
const [url, bytes] = located(name, base, (url) => {
|
|
241
223
|
if (!canReadSync()) {
|
|
242
224
|
throw new Error("this thread can only use assets fetched up front, and it was left out of preloadAssets");
|
|
243
225
|
}
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
|
|
248
|
-
}
|
|
226
|
+
return [url, nodeFs() ? readFromDisk(url) : readSync(url)];
|
|
227
|
+
});
|
|
228
|
+
// Outside: a trap in the instance's hash is a crash, not a missing file.
|
|
249
229
|
return checked(name, url, bytes, hash);
|
|
250
230
|
}
|
package/dist/document.d.ts
CHANGED
|
@@ -273,10 +273,6 @@ export declare const ANNOT_SUBTYPE: {
|
|
|
273
273
|
readonly stamp: 13;
|
|
274
274
|
readonly widget: 20;
|
|
275
275
|
};
|
|
276
|
-
/**
|
|
277
|
-
* An open PDF. Every call is synchronous CPU work on this thread. Closing is idempotent, and
|
|
278
|
-
* every other method throws a PdfError with code "closed" afterwards.
|
|
279
|
-
*/
|
|
280
276
|
/** A bookmark. `page` is -1 if it goes nowhere in this file; `url` is set if it links out. */
|
|
281
277
|
export type PdfOutlineItem = {
|
|
282
278
|
title: string;
|
|
@@ -284,6 +280,10 @@ export type PdfOutlineItem = {
|
|
|
284
280
|
url: string | null;
|
|
285
281
|
children: PdfOutlineItem[];
|
|
286
282
|
};
|
|
283
|
+
/**
|
|
284
|
+
* An open PDF. Every call is synchronous CPU work on this thread. Closing is idempotent, and
|
|
285
|
+
* every other method throws a PdfError with code "closed" afterwards.
|
|
286
|
+
*/
|
|
287
287
|
export interface PdfDocument {
|
|
288
288
|
readonly pageCount: number;
|
|
289
289
|
/** The document's bookmarks; empty if it has none. */
|
package/dist/document.js
CHANGED
|
@@ -253,16 +253,14 @@ export class RectoDocument {
|
|
|
253
253
|
instance.openDocuments += 1;
|
|
254
254
|
instance.documents.open += 1;
|
|
255
255
|
}
|
|
256
|
+
/** Before every call: open, the page there (null for a call about the whole document), and the instance able to run it. */
|
|
256
257
|
#check(index) {
|
|
257
258
|
if (this.#closed) {
|
|
258
259
|
throw new PdfError("closed", "this document is closed");
|
|
259
260
|
}
|
|
260
|
-
if (!(Number.isInteger(index) && index >= 0 && index < this.pageCount)) {
|
|
261
|
+
if (index !== null && !(Number.isInteger(index) && index >= 0 && index < this.pageCount)) {
|
|
261
262
|
throw new RangeError(`page ${index} is out of range (0..${this.pageCount - 1})`);
|
|
262
263
|
}
|
|
263
|
-
this.#checkInstance();
|
|
264
|
-
}
|
|
265
|
-
#checkInstance() {
|
|
266
264
|
if (this.#instance.abandoned) {
|
|
267
265
|
this.#moveOffAbandoned();
|
|
268
266
|
}
|
|
@@ -327,25 +325,16 @@ export class RectoDocument {
|
|
|
327
325
|
return this.#call(() => toObjects(this.#doc.objects(index)));
|
|
328
326
|
}
|
|
329
327
|
pageLabels() {
|
|
330
|
-
|
|
331
|
-
throw new PdfError("closed", "this document is closed");
|
|
332
|
-
}
|
|
333
|
-
this.#checkInstance();
|
|
328
|
+
this.#check(null);
|
|
334
329
|
return this.#call(() => this.#doc.pageLabels());
|
|
335
330
|
}
|
|
336
331
|
structureTruncated() {
|
|
337
|
-
|
|
338
|
-
throw new PdfError("closed", "this document is closed");
|
|
339
|
-
}
|
|
340
|
-
this.#checkInstance();
|
|
332
|
+
this.#check(null);
|
|
341
333
|
const [pages, pageLabels, outline] = this.#call(() => this.#doc.structureTruncated());
|
|
342
334
|
return { pages: pages === 1, pageLabels: pageLabels === 1, outline: outline === 1 };
|
|
343
335
|
}
|
|
344
336
|
outline() {
|
|
345
|
-
|
|
346
|
-
throw new PdfError("closed", "this document is closed");
|
|
347
|
-
}
|
|
348
|
-
this.#checkInstance();
|
|
337
|
+
this.#check(null);
|
|
349
338
|
return this.#call(() => {
|
|
350
339
|
const top = [];
|
|
351
340
|
// The open item at each level; an item's parent is the last one a level up.
|
package/dist/engine.js
CHANGED
|
@@ -313,14 +313,11 @@ class RectoEngine {
|
|
|
313
313
|
}, () => loader.failure);
|
|
314
314
|
}
|
|
315
315
|
catch (error) {
|
|
316
|
-
|
|
317
|
-
throw loader.failure;
|
|
318
|
-
}
|
|
319
|
-
throw engineFailure(instance, error);
|
|
316
|
+
throw loader.explain(loader.failure && !(error instanceof WebAssembly.RuntimeError) ? loader.failure : engineFailure(instance, error));
|
|
320
317
|
}
|
|
321
318
|
if (loader.failure) {
|
|
322
319
|
doc.close();
|
|
323
|
-
throw loader.failure;
|
|
320
|
+
throw loader.explain(loader.failure);
|
|
324
321
|
}
|
|
325
322
|
return Object.assign(doc, { stats: () => loader.stats() });
|
|
326
323
|
}
|
package/dist/ranged.d.ts
CHANGED
|
@@ -71,8 +71,9 @@ type Answer = {
|
|
|
71
71
|
* fails throws. A suffix that isn't answered as one (a server that ignores or refuses it)
|
|
72
72
|
* gives null, and isn't counted: the caller asks for the tail by its offset instead.
|
|
73
73
|
*/
|
|
74
|
-
type
|
|
75
|
-
type
|
|
74
|
+
type Answers = (Answer | null)[];
|
|
75
|
+
type Fetch = (requests: Request[], timeoutMs: number, limit: number) => Answers;
|
|
76
|
+
type FetchAsync = (requests: Request[], timeoutMs: number, limit: number) => Promise<Answers>;
|
|
76
77
|
/** Fetched chunks of one file and the policy for fetching more. */
|
|
77
78
|
declare class RangeLoader {
|
|
78
79
|
#private;
|
|
@@ -88,14 +89,18 @@ declare class RangeLoader {
|
|
|
88
89
|
signal: AbortSignal | undefined;
|
|
89
90
|
});
|
|
90
91
|
stats(): PdfRangedStats;
|
|
91
|
-
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
|
|
92
|
-
fetchChunks(firstChunk: number, endChunk: number): void;
|
|
93
92
|
/**
|
|
94
93
|
* The first round: the head and, in parallel, the tail as a suffix, then the last
|
|
95
94
|
* cross-reference section if the tail didn't hold it. A file the head covers needs no more;
|
|
96
95
|
* a server that doesn't answer the suffix gets the tail by offset in a round of its own.
|
|
97
96
|
*/
|
|
98
97
|
begin(fetchAsync: FetchAsync | null): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* `failure`, or when what's held shows the file isn't a PDF (see `notPdfBytes`), that it isn't: an
|
|
100
|
+
* error or sign-in page fails to parse, or runs into `maxBytes`, and neither says why. Only when
|
|
101
|
+
* enough of the first KiB is held to tell.
|
|
102
|
+
*/
|
|
103
|
+
explain(failure: unknown): unknown;
|
|
99
104
|
/**
|
|
100
105
|
* The `startxref` offset in the fetched last chunk, if it names one. Null for a linearized
|
|
101
106
|
* file, whose trailer points at the first-page section near the head.
|
package/dist/ranged.js
CHANGED
|
@@ -83,24 +83,18 @@ class RangeLoader {
|
|
|
83
83
|
fileBytes: this.length,
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
-
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
|
|
87
|
-
fetchChunks(firstChunk, endChunk) {
|
|
86
|
+
/** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet: a read's miss, so it blocks. */
|
|
87
|
+
#fetchChunks(firstChunk, endChunk) {
|
|
88
88
|
const runs = this.#missing(firstChunk, endChunk);
|
|
89
89
|
if (runs.length > 0) {
|
|
90
|
-
this.#absorb(this.#round(runs, (limit, timeoutMs
|
|
90
|
+
this.#absorb(this.#round(runs, (limit, timeoutMs) => this.#fetch(runs, timeoutMs, limit)));
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
/**
|
|
94
|
-
async #
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
async #fetchRunsAsync(runs, fetchAsync) {
|
|
98
|
-
if (runs.length === 0) {
|
|
99
|
-
return;
|
|
93
|
+
/** A round before the parse: through `fetchAsync` when there is one, so a browser need not block. */
|
|
94
|
+
async #fetchAhead(requests, fetchAsync) {
|
|
95
|
+
if (requests.length > 0) {
|
|
96
|
+
this.#absorb(await this.#round(requests, (limit, timeoutMs) => fetchAsync ? fetchAsync(requests, timeoutMs, limit) : this.#fetch(requests, timeoutMs, limit)));
|
|
100
97
|
}
|
|
101
|
-
this.#absorb(await this.#round(runs, (limit, timeoutMs, passesTotal) => fetchAsync
|
|
102
|
-
? this.#timedAsync(passesTotal, fetchAsync(runs, timeoutMs, limit))
|
|
103
|
-
: this.#timed(passesTotal, () => this.#fetch(runs, timeoutMs, limit))));
|
|
104
98
|
}
|
|
105
99
|
/**
|
|
106
100
|
* The first round: the head and, in parallel, the tail as a suffix, then the last
|
|
@@ -111,11 +105,7 @@ class RangeLoader {
|
|
|
111
105
|
const tail = TAIL_CHUNKS * this.chunkBytes;
|
|
112
106
|
// The head stops where the suffix starts, in a file shorter than the two.
|
|
113
107
|
const head = { start: 0, end: HEAD_CHUNKS * this.chunkBytes, yieldTail: tail };
|
|
114
|
-
|
|
115
|
-
const answers = await this.#round(requests, (limit, timeoutMs, passesTotal) => fetchAsync
|
|
116
|
-
? this.#timedAsync(passesTotal, fetchAsync(requests, timeoutMs, limit))
|
|
117
|
-
: this.#timed(passesTotal, () => this.#fetch(requests, timeoutMs, limit)));
|
|
118
|
-
this.#absorb(answers);
|
|
108
|
+
await this.#fetchAhead([head, { suffix: tail }], fetchAsync);
|
|
119
109
|
if (this.length <= 0) {
|
|
120
110
|
throw new PdfError("invalid-file", "the file is empty");
|
|
121
111
|
}
|
|
@@ -124,13 +114,13 @@ class RangeLoader {
|
|
|
124
114
|
}
|
|
125
115
|
// Without the suffix's answer, the head and the last chunk by their offsets, as one round.
|
|
126
116
|
const totalChunks = Math.ceil(this.length / this.chunkBytes);
|
|
127
|
-
await this.#
|
|
117
|
+
await this.#fetchAhead([...this.#missing(0, Math.min(HEAD_CHUNKS, totalChunks)), ...this.#missing(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks)], fetchAsync);
|
|
128
118
|
if (totalChunks > HEAD_CHUNKS) {
|
|
129
119
|
// The tail names the last cross-reference section (`startxref`). Fetch all of it in one
|
|
130
120
|
// round, rather than letting the parser discover it a chunk at a time.
|
|
131
121
|
const xrefAt = this.startxref();
|
|
132
122
|
if (xrefAt !== null && this.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
|
|
133
|
-
await this.#
|
|
123
|
+
await this.#fetchAhead(this.#missing(Math.floor(xrefAt / this.chunkBytes), totalChunks), fetchAsync);
|
|
134
124
|
}
|
|
135
125
|
}
|
|
136
126
|
}
|
|
@@ -169,7 +159,7 @@ class RangeLoader {
|
|
|
169
159
|
}
|
|
170
160
|
/**
|
|
171
161
|
* One round trip for `requests`: the signal, the budget and the deadline checked first, then
|
|
172
|
-
* `fetch` with what's left of each.
|
|
162
|
+
* `fetch` with what's left of each. A timeout that's the deadline's says so.
|
|
173
163
|
*/
|
|
174
164
|
#round(requests, fetch) {
|
|
175
165
|
if (this.#signal?.aborted) {
|
|
@@ -187,27 +177,17 @@ class RangeLoader {
|
|
|
187
177
|
}
|
|
188
178
|
this.#rounds += 1;
|
|
189
179
|
const passesTotal = left < this.#timeoutMs;
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
#passedTotal() {
|
|
193
|
-
return `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
194
|
-
}
|
|
195
|
-
/** A round's timeout, when it's the deadline's, says so. */
|
|
196
|
-
#timed(passesTotal, fetch) {
|
|
180
|
+
const said = (error) => passesTotal && error.code === "timeout" ? new PdfError("timeout", this.#passedTotal(), error) : error;
|
|
197
181
|
try {
|
|
198
|
-
|
|
182
|
+
const answers = fetch(this.#maxBytes - this.#bytesFetched, passesTotal ? Math.ceil(left) : this.#timeoutMs);
|
|
183
|
+
return (answers instanceof Promise ? answers.catch((error) => Promise.reject(said(error))) : answers);
|
|
199
184
|
}
|
|
200
185
|
catch (error) {
|
|
201
|
-
|
|
202
|
-
throw new PdfError("timeout", this.#passedTotal(), error);
|
|
203
|
-
}
|
|
204
|
-
throw error;
|
|
186
|
+
throw said(error);
|
|
205
187
|
}
|
|
206
188
|
}
|
|
207
|
-
|
|
208
|
-
return
|
|
209
|
-
throw passesTotal && error.code === "timeout" ? new PdfError("timeout", this.#passedTotal(), error) : error;
|
|
210
|
-
});
|
|
189
|
+
#passedTotal() {
|
|
190
|
+
return `the ranged read passed its ${this.#totalTimeoutMs} ms (totalTimeoutMs)`;
|
|
211
191
|
}
|
|
212
192
|
/** Checks and keeps a round's answers; a suffix nobody answered is skipped. */
|
|
213
193
|
#absorb(answers) {
|
|
@@ -277,6 +257,27 @@ class RangeLoader {
|
|
|
277
257
|
}
|
|
278
258
|
}
|
|
279
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* `failure`, or when what's held shows the file isn't a PDF (see `notPdfBytes`), that it isn't: an
|
|
262
|
+
* error or sign-in page fails to parse, or runs into `maxBytes`, and neither says why. Only when
|
|
263
|
+
* enough of the first KiB is held to tell.
|
|
264
|
+
*/
|
|
265
|
+
explain(failure) {
|
|
266
|
+
if (!(failure instanceof PdfError && (failure.code === "invalid-file" || overBudget.has(failure)))) {
|
|
267
|
+
return failure;
|
|
268
|
+
}
|
|
269
|
+
// The chunks held from the start on, at least the first KiB's worth when chunks are smaller.
|
|
270
|
+
const parts = [];
|
|
271
|
+
let held = 0;
|
|
272
|
+
for (let chunk = 0; held < Math.max(HEADER_BYTES, this.chunkBytes) && this.#chunks[chunk]; chunk++) {
|
|
273
|
+
parts.push(this.#chunks[chunk]);
|
|
274
|
+
held += this.#chunks[chunk].byteLength;
|
|
275
|
+
}
|
|
276
|
+
const head = parts.length > 0 ? concat(...parts) : this.#pieces.get(0)?.find((piece) => piece.start === 0)?.bytes;
|
|
277
|
+
return head && head.byteLength >= Math.min(HEADER_BYTES, this.length) && notPdfBytes(head, this.#chunks.at(-1))
|
|
278
|
+
? notPdf(failure)
|
|
279
|
+
: failure;
|
|
280
|
+
}
|
|
280
281
|
/**
|
|
281
282
|
* The `startxref` offset in the fetched last chunk, if it names one. Null for a linearized
|
|
282
283
|
* file, whose trailer points at the first-page section near the head.
|
|
@@ -284,12 +285,10 @@ class RangeLoader {
|
|
|
284
285
|
startxref() {
|
|
285
286
|
const first = this.#chunks[0];
|
|
286
287
|
const last = this.#chunks.at(-1);
|
|
287
|
-
if (!first ||
|
|
288
|
-
!last ||
|
|
289
|
-
new TextDecoder("latin1").decode(first.subarray(0, 1024)).includes("/Linearized")) {
|
|
288
|
+
if (!first || !last || latin1(first.subarray(0, HEADER_BYTES)).includes("/Linearized")) {
|
|
290
289
|
return null;
|
|
291
290
|
}
|
|
292
|
-
const tail =
|
|
291
|
+
const tail = latin1(last.subarray(-1024));
|
|
293
292
|
const match = /startxref\s+(\d+)\s+%%EOF\s*$/u.exec(tail);
|
|
294
293
|
const offset = match ? Number(match[1]) : Number.NaN;
|
|
295
294
|
return offset >= 0 && offset < this.length ? offset : null;
|
|
@@ -334,7 +333,7 @@ class RangeLoader {
|
|
|
334
333
|
for (let chunk = first; chunk <= last; chunk++) {
|
|
335
334
|
if (!this.#chunks[chunk]) {
|
|
336
335
|
try {
|
|
337
|
-
this
|
|
336
|
+
this.#fetchChunks(...this.#window(chunk, last, lastAhead));
|
|
338
337
|
}
|
|
339
338
|
catch (error) {
|
|
340
339
|
this.failure =
|
|
@@ -388,7 +387,12 @@ export async function startRangeLoader(source, options = {}) {
|
|
|
388
387
|
totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
|
|
389
388
|
signal: options.signal,
|
|
390
389
|
});
|
|
391
|
-
|
|
390
|
+
try {
|
|
391
|
+
await loader.begin(asyncFetcherFor(source));
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
throw loader.explain(error);
|
|
395
|
+
}
|
|
392
396
|
return loader;
|
|
393
397
|
}
|
|
394
398
|
/**
|
|
@@ -460,8 +464,32 @@ function readsOf(request, contentRange) {
|
|
|
460
464
|
function rangeHeader(request) {
|
|
461
465
|
return "suffix" in request ? `bytes=-${request.suffix}` : `bytes=${request.start}-${request.end - 1}`;
|
|
462
466
|
}
|
|
467
|
+
/** The budget's failures, which bytes that aren't a PDF explain better; not the address space's. */
|
|
468
|
+
const overBudget = new WeakSet();
|
|
463
469
|
function tooLarge(maxBytes) {
|
|
464
|
-
|
|
470
|
+
const error = new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
|
|
471
|
+
overBudget.add(error);
|
|
472
|
+
return error;
|
|
473
|
+
}
|
|
474
|
+
/** How far into a file a reader looks for its `%PDF` header. */
|
|
475
|
+
const HEADER_BYTES = 1024;
|
|
476
|
+
function latin1(bytes) {
|
|
477
|
+
return new TextDecoder("latin1").decode(bytes);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Whether a file's first and last bytes show it isn't a PDF: no `%PDF` where a reader looks for it,
|
|
481
|
+
* and no `endobj` or `startxref` either, which a damaged PDF that lost its header still has.
|
|
482
|
+
*/
|
|
483
|
+
function notPdfBytes(head, tail) {
|
|
484
|
+
const syntax = (bytes) => /endobj|startxref/u.test(latin1(bytes));
|
|
485
|
+
return !latin1(head.subarray(0, HEADER_BYTES)).includes("%PDF") && !syntax(head) && !(tail && syntax(tail));
|
|
486
|
+
}
|
|
487
|
+
function notPdf(cause) {
|
|
488
|
+
return new PdfError("invalid-file", `not a PDF: no %PDF header in its first ${HEADER_BYTES} bytes (an error or sign-in page?)`, cause);
|
|
489
|
+
}
|
|
490
|
+
/** A non-2xx answer: its status says more than anything read from its body would. */
|
|
491
|
+
function httpFailure(status) {
|
|
492
|
+
return new PdfError("fetch-failed", `the server answered HTTP ${status}, not the file`, undefined, status);
|
|
465
493
|
}
|
|
466
494
|
/**
|
|
467
495
|
* A server's answer as chunks to store, or a clear failure. Messages never carry the URL: a
|
|
@@ -471,12 +499,15 @@ function answerOf(range, status, contentRange, etag, bytes, limit, received = by
|
|
|
471
499
|
if (status === 200 && !("suffix" in range)) {
|
|
472
500
|
// The server ignored Range and sent the whole file: use it, while the budget allows.
|
|
473
501
|
if (bytes.byteLength > limit) {
|
|
502
|
+
if (notPdfBytes(bytes.subarray(0, 1 << 16), bytes.subarray(-(1 << 16)))) {
|
|
503
|
+
throw notPdf();
|
|
504
|
+
}
|
|
474
505
|
throw new PdfError("too-large", `the server does not answer Range requests, and the whole file is past the ${limit} bytes left of maxBytes`);
|
|
475
506
|
}
|
|
476
507
|
return { start: 0, bytes, length: bytes.byteLength, etag, status, received };
|
|
477
508
|
}
|
|
478
509
|
if (status !== 206) {
|
|
479
|
-
throw
|
|
510
|
+
throw httpFailure(status);
|
|
480
511
|
}
|
|
481
512
|
const match = /^bytes (\d+)-\d+\/(\d+)$/u.exec(contentRange?.trim() ?? "");
|
|
482
513
|
if (!match) {
|
|
@@ -507,6 +538,7 @@ function suffixAnswerOf(suffix, status, contentRange, etag, bytes, received = by
|
|
|
507
538
|
}
|
|
508
539
|
}
|
|
509
540
|
const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
|
|
541
|
+
/** A read's miss in a browser worker. The rounds with a suffix go through `fetch`, before the parse. */
|
|
510
542
|
function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
511
543
|
const deadline = performance.now() + timeoutMs;
|
|
512
544
|
return ranges.map((range) => {
|
|
@@ -528,9 +560,6 @@ function fetchOverXhr(url, ranges, timeoutMs, limit) {
|
|
|
528
560
|
if (xhr.status === 0) {
|
|
529
561
|
throw new PdfError("fetch-failed", NO_ANSWER);
|
|
530
562
|
}
|
|
531
|
-
if ("suffix" in range) {
|
|
532
|
-
return suffixAnswerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response));
|
|
533
|
-
}
|
|
534
563
|
return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
|
|
535
564
|
});
|
|
536
565
|
}
|
|
@@ -568,6 +597,11 @@ async function fetchOverFetch(url, requests, timeoutMs, limit) {
|
|
|
568
597
|
suffixDone(null);
|
|
569
598
|
return null;
|
|
570
599
|
}
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
// An error page: its status is the answer, and its body isn't worth downloading.
|
|
602
|
+
await response.body?.cancel();
|
|
603
|
+
throw httpFailure(response.status);
|
|
604
|
+
}
|
|
571
605
|
const reader = byteReader(response.body);
|
|
572
606
|
try {
|
|
573
607
|
if (response.status !== 206) {
|
|
@@ -656,10 +690,13 @@ function suffixGrace(took) {
|
|
|
656
690
|
function delay(ms, value) {
|
|
657
691
|
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
|
|
658
692
|
}
|
|
659
|
-
function concat(
|
|
660
|
-
const out = new Uint8Array(
|
|
661
|
-
|
|
662
|
-
|
|
693
|
+
function concat(...parts) {
|
|
694
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0));
|
|
695
|
+
let at = 0;
|
|
696
|
+
for (const part of parts) {
|
|
697
|
+
out.set(part, at);
|
|
698
|
+
at += part.byteLength;
|
|
699
|
+
}
|
|
663
700
|
return out;
|
|
664
701
|
}
|
|
665
702
|
/**
|
|
@@ -711,16 +748,7 @@ function byteReader(body) {
|
|
|
711
748
|
spare = value.subarray(take);
|
|
712
749
|
}
|
|
713
750
|
}
|
|
714
|
-
|
|
715
|
-
return parts[0];
|
|
716
|
-
}
|
|
717
|
-
const out = new Uint8Array(got);
|
|
718
|
-
let at = 0;
|
|
719
|
-
for (const part of parts) {
|
|
720
|
-
out.set(part, at);
|
|
721
|
-
at += part.byteLength;
|
|
722
|
-
}
|
|
723
|
-
return out;
|
|
751
|
+
return parts.length === 1 ? parts[0] : concat(...parts);
|
|
724
752
|
},
|
|
725
753
|
async cancel() {
|
|
726
754
|
await (byob ?? plain)?.cancel().catch(() => undefined);
|
|
@@ -778,10 +806,9 @@ async function get(m, signal) {
|
|
|
778
806
|
};
|
|
779
807
|
try {
|
|
780
808
|
// A 206 is read as far as the range runs (see readsOf), and a byte past to catch a longer one;
|
|
781
|
-
// a whole file, to a byte past the limit.
|
|
809
|
+
// a whole file, to a byte past the limit (answerOf says which).
|
|
782
810
|
if (response.status !== 206) {
|
|
783
811
|
const bytes = await read(m.limit + 1);
|
|
784
|
-
if (received > m.limit) return { ...reply, overLimit: true, bytes: new ArrayBuffer(0), received };
|
|
785
812
|
return { ...reply, bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), received };
|
|
786
813
|
}
|
|
787
814
|
const total = Number((reply.contentRange || "").trim().split("/").pop());
|
|
@@ -904,7 +931,7 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
904
931
|
pending.delete(reply.id);
|
|
905
932
|
if ("suffix" in range) {
|
|
906
933
|
answers[order.get(range)] =
|
|
907
|
-
reply.failed === undefined
|
|
934
|
+
reply.failed === undefined
|
|
908
935
|
? suffixAnswerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), reply.received)
|
|
909
936
|
: null;
|
|
910
937
|
continue;
|
|
@@ -913,9 +940,6 @@ function fetchOverThread(url, ranges, timeoutMs, limit) {
|
|
|
913
940
|
if (reply.failed !== undefined) {
|
|
914
941
|
throw new PdfError("fetch-failed", `the ranged read failed: ${reply.failed}`);
|
|
915
942
|
}
|
|
916
|
-
if (reply.overLimit) {
|
|
917
|
-
throw tooLarge(limit);
|
|
918
|
-
}
|
|
919
943
|
answers[order.get(range)] = answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit, reply.received);
|
|
920
944
|
}
|
|
921
945
|
catch (error) {
|
package/dist/reading-order.d.ts
CHANGED
|
@@ -11,4 +11,11 @@ export type ReadingOrder = {
|
|
|
11
11
|
};
|
|
12
12
|
/** Break hints without a visible glyph, even when the font supplies bounds. */
|
|
13
13
|
export declare function isInvisibleCodepoint(code: number): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Quarter turns clockwise in the viewer frame that a char reads along: the
|
|
16
|
+
* page's clockwise /Rotate plus the char's angle, snapped to 0, 1, 2, 3. A
|
|
17
|
+
* char's angle is `atan2(c, a)`, which grows clockwise on the page (text
|
|
18
|
+
* turned θ counter-clockwise reads 2π - θ), so the two turns add.
|
|
19
|
+
*/
|
|
20
|
+
export declare function viewerTurn(rotation: number, angle: number): number;
|
|
14
21
|
export declare function readingOrder(page: Pick<PdfPageText, "count" | "codepoints" | "flags" | "boxes" | "angles">, info: Pick<PdfPageInfo, "width" | "height" | "rotation">): ReadingOrder;
|
package/dist/reading-order.js
CHANGED
|
@@ -54,11 +54,13 @@ function streamOrder(page) {
|
|
|
54
54
|
return { order, breaks, length };
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* Quarter turns clockwise in the viewer frame that a char reads along: the
|
|
58
|
+
* page's clockwise /Rotate plus the char's angle, snapped to 0, 1, 2, 3. A
|
|
59
|
+
* char's angle is `atan2(c, a)`, which grows clockwise on the page (text
|
|
60
|
+
* turned θ counter-clockwise reads 2π - θ), so the two turns add.
|
|
59
61
|
*/
|
|
60
|
-
function viewerTurn(rotation, angle) {
|
|
61
|
-
const degrees = rotation
|
|
62
|
+
export function viewerTurn(rotation, angle) {
|
|
63
|
+
const degrees = rotation + (angle * 180) / Math.PI;
|
|
62
64
|
return ((Math.round(degrees / 90) % 4) + 4) % 4;
|
|
63
65
|
}
|
|
64
66
|
/**
|
|
@@ -126,8 +128,15 @@ function reflowedOrder(page, info) {
|
|
|
126
128
|
}
|
|
127
129
|
// Lines: sort by turn, then across; a new line starts where the centre
|
|
128
130
|
// moves past the tolerance. Native traversal within a line is independent
|
|
129
|
-
// of the page rotation.
|
|
130
|
-
|
|
131
|
+
// of the page rotation. The turns come in the order the content stream
|
|
132
|
+
// first draws them, as on an upright page: a table's sideways header row
|
|
133
|
+
// drawn before its rows reads before them.
|
|
134
|
+
const turnRank = [n, n, n, n];
|
|
135
|
+
for (let k = n - 1; k >= 0; k--) {
|
|
136
|
+
turnRank[turnOf[k] ?? 0] = k;
|
|
137
|
+
}
|
|
138
|
+
const byLine = Array.from({ length: n }, (_, k) => k).toSorted((p, q) => (turnRank[turnOf[p] ?? 0] ?? 0) - (turnRank[turnOf[q] ?? 0] ?? 0) ||
|
|
139
|
+
(across[p] ?? 0) - (across[q] ?? 0));
|
|
131
140
|
const lineOf = new Uint32Array(n);
|
|
132
141
|
let line = 0;
|
|
133
142
|
for (let k = 1; k < byLine.length; k++) {
|
package/dist/text-items.js
CHANGED
|
@@ -17,18 +17,13 @@
|
|
|
17
17
|
// Data-oriented: one pass over the reading order's index column; the only
|
|
18
18
|
// objects made are the items returned.
|
|
19
19
|
import { ANNOT_SUBTYPE, } from "./document.js";
|
|
20
|
-
import { BREAK_LINE, BREAK_SPACE, isInvisibleCodepoint, readingOrder, } from "./reading-order.js";
|
|
20
|
+
import { BREAK_LINE, BREAK_SPACE, isInvisibleCodepoint, readingOrder, viewerTurn, } from "./reading-order.js";
|
|
21
21
|
const SPACE_IN_FLOW_MAX_FACTOR = 0.6;
|
|
22
22
|
const VERTICAL_SHIFT_RATIO = 0.25;
|
|
23
23
|
/** Share of the em below the baseline in the layout's box rule (1 - GLYPH_ASCENT). */
|
|
24
24
|
const DESCENT_SHARE = 0.2;
|
|
25
25
|
const HYPHEN_MARK = 0x02;
|
|
26
26
|
const HYPHEN = 0x2d;
|
|
27
|
-
/** Quarter turns clockwise in the viewer frame that a char reads along. */
|
|
28
|
-
function viewerTurn(rotation, angle) {
|
|
29
|
-
const degrees = rotation - (angle * 180) / Math.PI;
|
|
30
|
-
return ((Math.round(degrees / 90) % 4) + 4) % 4;
|
|
31
|
-
}
|
|
32
27
|
/** The token direction (counter-clockwise, y up) of a clockwise viewer turn. */
|
|
33
28
|
const DIRECTION_OF_TURN = [0, 270, 180, 90];
|
|
34
29
|
function isSpaceCode(code) {
|
package/package.json
CHANGED
package/wasm/pins.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Generated by scripts/package-wasm.mjs.
|
|
2
2
|
export const PINS = {
|
|
3
|
-
"recto.wasm": "
|
|
4
|
-
"recto-render.wasm": "
|
|
5
|
-
"recto-more.wasm": "
|
|
6
|
-
"recto-rare.wasm": "
|
|
3
|
+
"recto.wasm": "8d19f0dab3a875e6eab985af29053e00abac3a89912781e99ce25d5c1aba276b",
|
|
4
|
+
"recto-render.wasm": "88f970ce7230e634a81109871183df9a9ba65b6383f85cae8f7f53cdc7c2a7e8",
|
|
5
|
+
"recto-more.wasm": "cbd76e2a6faf37fac557151a3fcea32f5db0ad4a6a5b840dac8657ee53d1bf08",
|
|
6
|
+
"recto-rare.wasm": "1fc8c60ac2a2eeb9b625393556ec7bba27a0caa26170caeeaed91d5134470917",
|
|
7
7
|
"FoxitFixed.pfb": "b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b",
|
|
8
8
|
"FoxitFixedBold.pfb": "f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603",
|
|
9
9
|
"FoxitFixedBoldItalic.pfb": "8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d",
|
package/wasm/recto-more.wasm
CHANGED
|
Binary file
|
package/wasm/recto-rare.wasm
CHANGED
|
Binary file
|
package/wasm/recto-render.wasm
CHANGED
|
Binary file
|
package/wasm/recto.gen.mjs
CHANGED
|
@@ -916,7 +916,7 @@ function sha256(bytes) {
|
|
|
916
916
|
function __wbg_get_imports() {
|
|
917
917
|
const import0 = {
|
|
918
918
|
__proto__: null,
|
|
919
|
-
|
|
919
|
+
__wbg_Error_17e7c3613064979b: function(arg0, arg1) {
|
|
920
920
|
const ret = Error(getStringFromWasm0(arg0, arg1));
|
|
921
921
|
return ret;
|
|
922
922
|
},
|
|
@@ -962,7 +962,7 @@ function __wbg_get_imports() {
|
|
|
962
962
|
const ret = arg0.push(arg1);
|
|
963
963
|
return ret;
|
|
964
964
|
},
|
|
965
|
-
|
|
965
|
+
__wbg_set_7c811f3f25847af5: function() { return handleError(function (arg0, arg1, arg2) {
|
|
966
966
|
const ret = Reflect.set(arg0, arg1, arg2);
|
|
967
967
|
return ret;
|
|
968
968
|
}, arguments); },
|
package/wasm/recto.wasm
CHANGED
|
Binary file
|