@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 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
- let url = null;
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 (name) => {
200
- let url = null;
201
- let bytes;
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
- loaded.set(url.href, bytes);
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
- let url = null;
221
- try {
222
- url = assetUrl(name, base);
223
- const cached = loaded.get(url.href);
224
- if (cached) {
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
- catch (error) {
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
- let url = null;
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
- bytes = nodeFs() ? readFromDisk(url) : readSync(url);
245
- }
246
- catch (error) {
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
  }
@@ -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
- if (this.#closed) {
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
- if (this.#closed) {
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
- if (this.#closed) {
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
- if (loader.failure && !(error instanceof WebAssembly.RuntimeError)) {
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 Fetch = (requests: Request[], timeoutMs: number, limit: number) => (Answer | null)[];
75
- type FetchAsync = (requests: Request[], timeoutMs: number, limit: number) => Promise<(Answer | null)[]>;
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, passesTotal) => this.#timed(passesTotal, () => this.#fetch(runs, timeoutMs, limit))));
90
+ this.#absorb(this.#round(runs, (limit, timeoutMs) => this.#fetch(runs, timeoutMs, limit)));
91
91
  }
92
92
  }
93
- /** The same, through `fetchAsync` when there is one: before the parse, a browser need not block. */
94
- async #fetchChunksAsync(firstChunk, endChunk, fetchAsync) {
95
- await this.#fetchRunsAsync(this.#missing(firstChunk, endChunk), fetchAsync);
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
- const requests = [head, { suffix: tail }];
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.#fetchRunsAsync([...this.#missing(0, Math.min(HEAD_CHUNKS, totalChunks)), ...this.#missing(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks)], fetchAsync);
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.#fetchChunksAsync(Math.floor(xrefAt / this.chunkBytes), totalChunks, fetchAsync);
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
- return fetch(this.#maxBytes - this.#bytesFetched, passesTotal ? Math.ceil(left) : this.#timeoutMs, passesTotal);
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
- return fetch();
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
- if (passesTotal && error.code === "timeout") {
202
- throw new PdfError("timeout", this.#passedTotal(), error);
203
- }
204
- throw error;
186
+ throw said(error);
205
187
  }
206
188
  }
207
- async #timedAsync(passesTotal, fetch) {
208
- return await fetch.catch((error) => {
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 = new TextDecoder("latin1").decode(last.subarray(-1024));
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.fetchChunks(...this.#window(chunk, last, lastAhead));
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
- await loader.begin(asyncFetcherFor(source));
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
- return new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
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 new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})`, undefined, status);
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(a, b) {
660
- const out = new Uint8Array(a.byteLength + b.byteLength);
661
- out.set(a);
662
- out.set(b, a.byteLength);
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
- if (parts.length === 1) {
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 && !reply.overLimit
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) {
@@ -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;
@@ -54,11 +54,13 @@ function streamOrder(page) {
54
54
  return { order, breaks, length };
55
55
  }
56
56
  /**
57
- * A char's quarter turn in the viewer frame: the page's clockwise /Rotate
58
- * less the char's own counter-clockwise angle, snapped to 0, 90, 180, 270.
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 - (angle * 180) / Math.PI;
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
- const byLine = Array.from({ length: n }, (_, k) => k).toSorted((p, q) => (turnOf[p] ?? 0) - (turnOf[q] ?? 0) || (across[p] ?? 0) - (across[q] ?? 0));
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++) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2018kguo/recto",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": {
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": "6ea5dcbefb46130b65af4e3e8abd4c36204ecff4beaa089960c5d24e58dd6345",
4
- "recto-render.wasm": "903b95817bb9c23769db27cedf3a29a7b5d3dcff0c65fbe28c0d0191943d59b7",
5
- "recto-more.wasm": "e612392af0129e4e2f9083605f56c1e39b6eca0e2c5fef6ee39b72849b384f9b",
6
- "recto-rare.wasm": "da54f130b42290ff9ef8c0ba45a0f9f49545f038f3bcfa19c5ec75ac2e9f4e6e",
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",
Binary file
Binary file
Binary file
@@ -916,7 +916,7 @@ function sha256(bytes) {
916
916
  function __wbg_get_imports() {
917
917
  const import0 = {
918
918
  __proto__: null,
919
- __wbg_Error_ac777cef8e847b72: function(arg0, arg1) {
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
- __wbg_set_0fd79ff56b8778a4: function() { return handleError(function (arg0, arg1, arg2) {
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