@2018kguo/recto 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +25 -2
  3. package/dist/assets.d.ts +19 -0
  4. package/dist/assets.js +250 -0
  5. package/dist/document.d.ts +307 -0
  6. package/dist/document.js +405 -0
  7. package/dist/engine.d.ts +62 -0
  8. package/dist/engine.js +383 -0
  9. package/dist/errors.d.ts +36 -0
  10. package/dist/errors.js +14 -0
  11. package/dist/index.d.ts +18 -0
  12. package/dist/index.js +23 -0
  13. package/dist/page-measures.d.ts +34 -0
  14. package/dist/page-measures.js +166 -0
  15. package/dist/pool-worker.d.ts +14 -0
  16. package/dist/pool-worker.js +65 -0
  17. package/dist/pool.d.ts +36 -0
  18. package/dist/pool.js +287 -0
  19. package/dist/ranged.d.ts +98 -0
  20. package/dist/ranged.js +497 -0
  21. package/dist/reading-order.d.ts +14 -0
  22. package/dist/reading-order.js +173 -0
  23. package/dist/sha256.d.ts +1 -0
  24. package/dist/sha256.js +78 -0
  25. package/dist/text-items.d.ts +49 -0
  26. package/dist/text-items.js +272 -0
  27. package/package.json +48 -4
  28. package/wasm/FoxitDingbats.pfb +0 -0
  29. package/wasm/FoxitFixed.pfb +0 -0
  30. package/wasm/FoxitFixedBold.pfb +0 -0
  31. package/wasm/FoxitFixedBoldItalic.pfb +0 -0
  32. package/wasm/FoxitFixedItalic.pfb +0 -0
  33. package/wasm/FoxitSans.pfb +0 -0
  34. package/wasm/FoxitSansBold.pfb +0 -0
  35. package/wasm/FoxitSansBoldItalic.pfb +0 -0
  36. package/wasm/FoxitSansItalic.pfb +0 -0
  37. package/wasm/FoxitSerif.pfb +0 -0
  38. package/wasm/FoxitSerifBold.pfb +0 -0
  39. package/wasm/FoxitSerifBoldItalic.pfb +0 -0
  40. package/wasm/FoxitSerifItalic.pfb +0 -0
  41. package/wasm/FoxitSymbol.pfb +0 -0
  42. package/wasm/THIRD_PARTY_NOTICES +2362 -0
  43. package/wasm/cjk-fallback.ttf +0 -0
  44. package/wasm/cmaps.deflate +0 -0
  45. package/wasm/pins.d.mts +1 -0
  46. package/wasm/pins.mjs +23 -0
  47. package/wasm/recto-more.wasm +0 -0
  48. package/wasm/recto-rare.wasm +0 -0
  49. package/wasm/recto-render.wasm +0 -0
  50. package/wasm/recto.gen.d.mts +3 -0
  51. package/wasm/recto.gen.mjs +1203 -0
  52. package/wasm/recto.types.d.mts +295 -0
  53. package/wasm/recto.wasm +0 -0
package/dist/ranged.js ADDED
@@ -0,0 +1,497 @@
1
+ // Ranged open: the engine parses a buffer of the file's whole length, and asks for each
2
+ // chunk the first time the parser reads it. The engine's calls are synchronous, so a fetch
3
+ // has to finish before it returns: in Node it runs on a small fetcher thread while this
4
+ // thread waits on a SharedArrayBuffer (Atomics.wait), and in a Web Worker it is a synchronous
5
+ // XMLHttpRequest. The engine therefore parses the document once and sees exactly the bytes
6
+ // a whole-file open sees: no reopen, no retry, no zero-filled reads on the success path.
7
+ //
8
+ // The thread that reads a ranged document blocks while a fetch is in flight (bounded by
9
+ // `timeoutMs`), so run it on a worker thread of its own, never on a server's main loop.
10
+ //
11
+ // Fetched bytes are kept as fixed-size chunks indexed by chunk number, so a read costs
12
+ // O(chunks it spans), whatever the number of fetches so far. The first fetch reads the head
13
+ // (and learns the size); a file it does not cover gets its tail next, since an ordinary
14
+ // file's cross-reference table is at its end. A miss that continues the previous fetch (a
15
+ // long xref table or page run read front to back) doubles the read-ahead, so a sequential
16
+ // scan of n chunks costs O(log n) round trips.
17
+ import { PdfError } from "./errors.js";
18
+ const DEFAULT_CHUNK_BYTES = 64 * 1024;
19
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
20
+ const DEFAULT_TIMEOUT_MS = 30_000;
21
+ const MAX_READ_AHEAD_BYTES = 8 * 1024 * 1024;
22
+ /**
23
+ * The first read. It covers a small file whole in one request, and a linearized file's
24
+ * first-page cross-reference section.
25
+ */
26
+ const HEAD_CHUNKS = 4;
27
+ /**
28
+ * A ranged open reserves the file's length of linear memory: fresh pages, committed only where
29
+ * fetched, so memory use follows the bytes fetched and not the length (measured in
30
+ * docs/how-it-works.md). wasm32 has 4 GiB of addresses, and this leaves 512 MiB for everything else.
31
+ * The parser's zero-copy objects are slices of that one buffer, and its offsets are 32-bit there.
32
+ */
33
+ const MAX_FILE_BYTES = 3584 * 1024 * 1024;
34
+ /** Fetched chunks of one file and the policy for fetching more. */
35
+ class RangeLoader {
36
+ length = 0;
37
+ chunkBytes;
38
+ /** Fetched chunks by chunk number (a flat array, sized once the length is known). */
39
+ #chunks = [];
40
+ /** Set once a fetch failed; every later read fails and every call throws it. */
41
+ failure = null;
42
+ #bytesFetched = 0;
43
+ #requests = 0;
44
+ #rounds = 0;
45
+ #reads = 0;
46
+ #readBytes = 0;
47
+ #lastFetchEnd = -1;
48
+ #readAhead;
49
+ #etag = null;
50
+ #fetch;
51
+ #maxBytes;
52
+ #timeoutMs;
53
+ #totalTimeoutMs;
54
+ #deadline;
55
+ #signal;
56
+ constructor(fetch, options) {
57
+ this.#fetch = fetch;
58
+ this.chunkBytes = options.chunkBytes;
59
+ this.#readAhead = options.chunkBytes;
60
+ this.#maxBytes = options.maxBytes;
61
+ this.#timeoutMs = options.timeoutMs;
62
+ this.#totalTimeoutMs = options.totalTimeoutMs;
63
+ this.#deadline = performance.now() + options.totalTimeoutMs;
64
+ this.#signal = options.signal;
65
+ }
66
+ stats() {
67
+ return {
68
+ bytesFetched: this.#bytesFetched,
69
+ requests: this.#requests,
70
+ rounds: this.#rounds,
71
+ reads: this.#reads,
72
+ readBytes: this.#readBytes,
73
+ fileBytes: this.length,
74
+ };
75
+ }
76
+ /** Fetches chunks `firstChunk..endChunk`, keeping only those not held yet. */
77
+ fetchChunks(firstChunk, endChunk) {
78
+ const runs = [];
79
+ let wanted = 0;
80
+ for (let chunk = firstChunk; chunk < endChunk; chunk++) {
81
+ if (this.#chunks[chunk]) {
82
+ continue;
83
+ }
84
+ const start = chunk * this.chunkBytes;
85
+ const end = this.length > 0
86
+ ? Math.min(this.length, start + this.chunkBytes)
87
+ : start + this.chunkBytes;
88
+ const last = runs.at(-1);
89
+ if (last && last.end === start) {
90
+ last.end = end;
91
+ }
92
+ else {
93
+ runs.push({ start, end });
94
+ }
95
+ wanted += end - start;
96
+ }
97
+ if (runs.length === 0) {
98
+ return;
99
+ }
100
+ if (this.#signal?.aborted) {
101
+ throw new PdfError("aborted", "the signal was aborted", this.#signal.reason);
102
+ }
103
+ // Before the length is known the head's size is a guess, and the file may be far smaller;
104
+ // the fetch itself stops at the budget.
105
+ if (this.length > 0 && this.#bytesFetched + wanted > this.#maxBytes) {
106
+ throw tooLarge(this.#maxBytes);
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
+ }
113
+ this.#rounds += 1;
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
+ }
125
+ for (const answer of answers) {
126
+ this.#requests += 1;
127
+ this.#bytesFetched += answer.bytes.byteLength;
128
+ if (this.length > 0 && answer.length !== this.length) {
129
+ throw new PdfError("fetch-failed", `the file changed size during the ranged read (${this.length} bytes, now ${answer.length})`, undefined, answer.status);
130
+ }
131
+ // Before anything is sized from it: the total is whatever the server said.
132
+ if (answer.length > MAX_FILE_BYTES) {
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`);
134
+ }
135
+ if (this.#etag && answer.etag && answer.etag !== this.#etag) {
136
+ throw new PdfError("fetch-failed", "the file was replaced during the ranged read (its ETag changed)", undefined, answer.status);
137
+ }
138
+ this.#etag ??= answer.etag;
139
+ this.length = answer.length;
140
+ this.#store(answer);
141
+ }
142
+ }
143
+ #store(answer) {
144
+ // Answers start on a chunk boundary (we asked for one). Views, not copies.
145
+ const { bytes } = answer;
146
+ if (this.#chunks.length === 0) {
147
+ this.#chunks = Array.from({
148
+ length: Math.ceil(answer.length / this.chunkBytes),
149
+ });
150
+ }
151
+ for (let at = 0; at < bytes.byteLength; at += this.chunkBytes) {
152
+ const chunk = (answer.start + at) / this.chunkBytes;
153
+ this.#chunks[chunk] ??= bytes.subarray(at, Math.min(bytes.byteLength, at + this.chunkBytes));
154
+ }
155
+ }
156
+ /**
157
+ * The `startxref` offset in the fetched last chunk, if it names one. Null for a linearized
158
+ * file, whose trailer points at the first-page section near the head.
159
+ */
160
+ startxref() {
161
+ const first = this.#chunks[0];
162
+ const last = this.#chunks.at(-1);
163
+ if (!first ||
164
+ !last ||
165
+ new TextDecoder("latin1").decode(first.subarray(0, 1024)).includes("/Linearized")) {
166
+ return null;
167
+ }
168
+ const tail = new TextDecoder("latin1").decode(last.subarray(-1024));
169
+ const match = /startxref\s+(\d+)\s+%%EOF\s*$/u.exec(tail);
170
+ const offset = match ? Number(match[1]) : Number.NaN;
171
+ return offset >= 0 && offset < this.length ? offset : null;
172
+ }
173
+ /** The chunk window to fetch for a miss at `chunk`, with read-ahead. */
174
+ #window(chunk, lastNeeded) {
175
+ const start = chunk * this.chunkBytes;
176
+ const sequential = this.#lastFetchEnd >= 0 &&
177
+ start >= this.#lastFetchEnd - this.chunkBytes &&
178
+ start <= this.#lastFetchEnd + this.chunkBytes;
179
+ this.#readAhead = sequential
180
+ ? Math.min(MAX_READ_AHEAD_BYTES, this.#readAhead * 2)
181
+ : this.chunkBytes;
182
+ const totalChunks = Math.ceil(this.length / this.chunkBytes);
183
+ const end = Math.min(totalChunks, Math.max(lastNeeded + 1, chunk + this.#readAhead / this.chunkBytes));
184
+ this.#lastFetchEnd = end * this.chunkBytes;
185
+ return [chunk, end];
186
+ }
187
+ /**
188
+ * Copies [offset, offset + out.length) into `out`, fetching what is missing first. On a
189
+ * failed fetch it records the failure and fails the read; the call that made it then
190
+ * throws the failure.
191
+ */
192
+ read(offset, out) {
193
+ this.#reads += 1;
194
+ this.#readBytes += out.byteLength;
195
+ const end = offset + out.byteLength;
196
+ if (this.failure || end > this.length || offset < 0) {
197
+ return false;
198
+ }
199
+ if (out.byteLength === 0) {
200
+ return true;
201
+ }
202
+ const first = Math.floor(offset / this.chunkBytes);
203
+ const last = Math.floor((end - 1) / this.chunkBytes);
204
+ for (let chunk = first; chunk <= last; chunk++) {
205
+ if (!this.#chunks[chunk]) {
206
+ try {
207
+ this.fetchChunks(...this.#window(chunk, last));
208
+ }
209
+ catch (error) {
210
+ this.failure =
211
+ error instanceof PdfError
212
+ ? error
213
+ : new PdfError("fetch-failed", String(error), error);
214
+ return false;
215
+ }
216
+ break;
217
+ }
218
+ }
219
+ let at = offset;
220
+ for (let chunk = first; chunk <= last; chunk++) {
221
+ const bytes = this.#chunks[chunk];
222
+ if (!bytes) {
223
+ return false;
224
+ }
225
+ const from = at - chunk * this.chunkBytes;
226
+ const take = Math.min(bytes.byteLength - from, end - at);
227
+ out.set(bytes.subarray(from, from + take), at - offset);
228
+ at += take;
229
+ }
230
+ return true;
231
+ }
232
+ }
233
+ /**
234
+ * Starts a ranged read of `source`: the head, then (for a larger file) the tail and its last
235
+ * cross-reference section. Blocks while it fetches.
236
+ */
237
+ export function startRangeLoader(source, options = {}) {
238
+ const positive = (name, value) => {
239
+ if (value !== undefined && !(typeof value === "number" && value > 0)) {
240
+ throw new RangeError(`${name} must be above 0 (got ${value})`);
241
+ }
242
+ };
243
+ positive("chunkBytes", options.chunkBytes);
244
+ positive("maxBytes", options.maxBytes);
245
+ positive("timeoutMs", options.timeoutMs);
246
+ positive("totalTimeoutMs", options.totalTimeoutMs);
247
+ if (options.chunkBytes !== undefined && !Number.isSafeInteger(options.chunkBytes)) {
248
+ throw new RangeError(`chunkBytes must be a whole number (got ${options.chunkBytes})`);
249
+ }
250
+ if (options.signal?.aborted) {
251
+ throw new PdfError("aborted", "the signal was aborted", options.signal.reason);
252
+ }
253
+ const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;
254
+ const loader = new RangeLoader(fetcherFor(source), {
255
+ chunkBytes,
256
+ maxBytes: options.maxBytes ?? DEFAULT_MAX_BYTES,
257
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
258
+ totalTimeoutMs: options.totalTimeoutMs ?? Number.POSITIVE_INFINITY,
259
+ signal: options.signal,
260
+ });
261
+ loader.fetchChunks(0, HEAD_CHUNKS);
262
+ if (loader.length <= 0) {
263
+ throw new PdfError("invalid-file", "the file is empty");
264
+ }
265
+ // A file the head covered is read whole; any other one gets its tail.
266
+ const totalChunks = Math.ceil(loader.length / chunkBytes);
267
+ if (totalChunks > HEAD_CHUNKS) {
268
+ loader.fetchChunks(Math.max(HEAD_CHUNKS, totalChunks - 1), totalChunks);
269
+ // The tail names the last cross-reference section (`startxref`). Fetch all of it in one
270
+ // round, rather than letting the parser discover it a chunk at a time.
271
+ const xrefAt = loader.startxref();
272
+ if (xrefAt !== null && loader.length - xrefAt <= MAX_READ_AHEAD_BYTES) {
273
+ loader.fetchChunks(Math.floor(xrefAt / chunkBytes), totalChunks);
274
+ }
275
+ }
276
+ return loader;
277
+ }
278
+ /**
279
+ * The caller's source in a form that stays valid across an await: bytes copied, a URL as an
280
+ * absolute http(s) string. Runs before anything asynchronous.
281
+ */
282
+ export function ownedSource(source) {
283
+ if (ArrayBuffer.isView(source)) {
284
+ return new Uint8Array(source.buffer, source.byteOffset, source.byteLength).slice();
285
+ }
286
+ if (typeof source !== "string" && !(source instanceof URL)) {
287
+ throw new TypeError("expected an http(s) URL or the PDF's bytes as a Uint8Array");
288
+ }
289
+ const base = globalThis.location?.href;
290
+ const url = URL.parse(source, base);
291
+ if (url?.protocol !== "http:" && url?.protocol !== "https:") {
292
+ // Not the URL itself: it may hold a credential.
293
+ throw new TypeError("expected an absolute http(s) URL; use open() for a file on disk");
294
+ }
295
+ return url.href;
296
+ }
297
+ function fetcherFor(source) {
298
+ if (typeof source !== "string") {
299
+ const bytes = source;
300
+ return (ranges) => ranges.map(({ start, end }) => ({
301
+ start,
302
+ bytes: bytes.subarray(start, Math.min(end, bytes.byteLength)),
303
+ length: bytes.byteLength,
304
+ etag: null,
305
+ status: 206,
306
+ }));
307
+ }
308
+ const url = source;
309
+ // Node's fetch cannot run under a blocked thread; in a browser worker a synchronous
310
+ // XMLHttpRequest needs no second thread.
311
+ return globalThis.process?.getBuiltinModule?.("node:worker_threads")
312
+ ? (ranges, timeoutMs, limit) => fetchOverThread(url, ranges, timeoutMs, limit)
313
+ : (ranges, timeoutMs, limit) => fetchOverXhr(url, ranges, timeoutMs, limit);
314
+ }
315
+ function tooLarge(maxBytes) {
316
+ return new PdfError("too-large", `the ranged read would pass ${maxBytes} bytes (maxBytes); raise it or fetch the file whole`);
317
+ }
318
+ /**
319
+ * A server's answer as chunks to store, or a clear failure. Messages never carry the URL: a
320
+ * presigned query is a credential.
321
+ */
322
+ function answerOf(range, status, contentRange, etag, bytes, limit) {
323
+ if (status === 200) {
324
+ // The server ignored Range and sent the whole file: use it, while the budget allows.
325
+ if (bytes.byteLength > limit) {
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`);
327
+ }
328
+ return { start: 0, bytes, length: bytes.byteLength, etag, status };
329
+ }
330
+ if (status !== 206) {
331
+ throw new PdfError("fetch-failed", `the ranged read failed (HTTP ${status})`, undefined, status);
332
+ }
333
+ const match = /^bytes (\d+)-\d+\/(\d+)$/u.exec(contentRange?.trim() ?? "");
334
+ if (!match) {
335
+ throw new PdfError("fetch-failed", "the server's 206 answer has no usable Content-Range (with a total length). " +
336
+ "A cross-origin server must also send Access-Control-Expose-Headers: Content-Range.", undefined, status);
337
+ }
338
+ const start = Number(match[1]);
339
+ const length = Number(match[2]);
340
+ // A short or shifted answer (a proxy capping ranges) would store a partial chunk.
341
+ if (start !== range.start || bytes.byteLength !== Math.min(range.end, length) - range.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);
343
+ }
344
+ return { start, bytes, length, etag, status };
345
+ }
346
+ const NO_ANSWER = "the ranged read failed: the network, or a cross-origin server that does not allow it";
347
+ function fetchOverXhr(url, ranges, timeoutMs, limit) {
348
+ const deadline = performance.now() + timeoutMs;
349
+ return ranges.map((range) => {
350
+ const xhr = new XMLHttpRequest();
351
+ xhr.open("GET", url, false);
352
+ xhr.responseType = "arraybuffer";
353
+ xhr.timeout = Math.max(1, deadline - performance.now());
354
+ xhr.setRequestHeader("Range", `bytes=${range.start}-${range.end - 1}`);
355
+ try {
356
+ xhr.send();
357
+ }
358
+ catch (error) {
359
+ const timedOut = error.name === "TimeoutError";
360
+ throw new PdfError(timedOut ? "timeout" : "fetch-failed", timedOut
361
+ ? `the ranged read timed out after ${timeoutMs} ms`
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);
367
+ }
368
+ return answerOf(range, xhr.status, xhr.getResponseHeader("Content-Range"), xhr.getResponseHeader("ETag"), new Uint8Array(xhr.response), limit);
369
+ });
370
+ }
371
+ // The fetcher thread: plain JS (eval), so it needs no loader or bundler entry. It answers
372
+ // each request on the port, then bumps `flag` so the waiting thread wakes. The body is read
373
+ // up to `limit`, for a server that answers a small range with a huge whole file.
374
+ const FETCHER_SOURCE = `
375
+ const { workerData } = require("node:worker_threads");
376
+ const { port, flag } = workerData;
377
+ const controllers = new Map();
378
+ async function get(m, signal) {
379
+ const response = await fetch(m.url, {
380
+ headers: { Range: "bytes=" + m.start + "-" + (m.end - 1), "Accept-Encoding": "identity" },
381
+ signal,
382
+ });
383
+ const reply = {
384
+ status: response.status,
385
+ contentRange: response.headers.get("content-range"),
386
+ etag: response.headers.get("etag"),
387
+ };
388
+ if (!response.ok) {
389
+ if (response.body) await response.body.cancel();
390
+ return { ...reply, bytes: new ArrayBuffer(0) };
391
+ }
392
+ const parts = [];
393
+ let size = 0;
394
+ for await (const part of response.body ?? []) {
395
+ size += part.byteLength;
396
+ if (size > m.limit) return { ...reply, overLimit: true, bytes: new ArrayBuffer(0) };
397
+ parts.push(part);
398
+ }
399
+ const { buffer, byteOffset, byteLength } = Buffer.concat(parts);
400
+ return { ...reply, bytes: buffer.slice(byteOffset, byteOffset + byteLength) };
401
+ }
402
+ port.on("message", async (m) => {
403
+ if (m.abort) { const c = controllers.get(m.id); if (c) c.abort(); return; }
404
+ const controller = new AbortController();
405
+ controllers.set(m.id, controller);
406
+ let reply;
407
+ try {
408
+ reply = await get(m, controller.signal);
409
+ // Done reading, so a request cut short at the limit stops downloading too.
410
+ controller.abort();
411
+ } catch (error) {
412
+ // A code, never the message: fetch quotes the URL in some of them.
413
+ reply = { failed: (error && error.cause && error.cause.code) || (error && error.name) || "error" };
414
+ }
415
+ controllers.delete(m.id);
416
+ reply.id = m.id;
417
+ port.postMessage(reply, reply.bytes ? [reply.bytes] : []);
418
+ Atomics.add(flag, 0, 1);
419
+ Atomics.notify(flag, 0);
420
+ });
421
+ `;
422
+ let fetcher = null;
423
+ function workerThreads() {
424
+ return globalThis.process.getBuiltinModule("node:worker_threads");
425
+ }
426
+ function startFetcher() {
427
+ const { MessageChannel, Worker } = workerThreads();
428
+ const flag = new Int32Array(new SharedArrayBuffer(4));
429
+ const { port1, port2 } = new MessageChannel();
430
+ const worker = new Worker(FETCHER_SOURCE, {
431
+ eval: true,
432
+ name: "recto-range-fetcher",
433
+ workerData: { port: port2, flag },
434
+ transferList: [port2],
435
+ });
436
+ worker.unref();
437
+ port1.unref();
438
+ worker.on("exit", () => {
439
+ fetcher = null;
440
+ });
441
+ return { port: port1, flag, nextId: 0 };
442
+ }
443
+ /** Fetches `ranges` in parallel on the fetcher thread; blocks until all answer. */
444
+ function fetchOverThread(url, ranges, timeoutMs, limit) {
445
+ const current = (fetcher ??= startFetcher());
446
+ const deadline = performance.now() + timeoutMs;
447
+ const pending = new Map();
448
+ for (const range of ranges) {
449
+ const id = current.nextId++;
450
+ pending.set(id, range);
451
+ current.port.postMessage({ id, url, start: range.start, end: range.end, limit });
452
+ }
453
+ const answers = [];
454
+ let failure = null;
455
+ let seen = Atomics.load(current.flag, 0);
456
+ while (pending.size > 0) {
457
+ for (;;) {
458
+ const received = workerThreads().receiveMessageOnPort(current.port);
459
+ if (!received) {
460
+ break;
461
+ }
462
+ const reply = received.message;
463
+ const range = pending.get(reply.id);
464
+ if (!range) {
465
+ continue; // a reply to a request that already timed out
466
+ }
467
+ pending.delete(reply.id);
468
+ try {
469
+ if (reply.failed !== undefined) {
470
+ throw new PdfError("fetch-failed", `the ranged read failed: ${reply.failed}`);
471
+ }
472
+ if (reply.overLimit) {
473
+ throw tooLarge(limit);
474
+ }
475
+ answers.push(answerOf(range, reply.status, reply.contentRange, reply.etag, new Uint8Array(reply.bytes), limit));
476
+ }
477
+ catch (error) {
478
+ failure ??= error;
479
+ }
480
+ }
481
+ if (pending.size === 0) {
482
+ break;
483
+ }
484
+ const remaining = deadline - performance.now();
485
+ if (remaining <= 0 || Atomics.wait(current.flag, 0, seen, remaining) === "timed-out") {
486
+ for (const id of pending.keys()) {
487
+ current.port.postMessage({ id, abort: true });
488
+ }
489
+ throw new PdfError("timeout", `the ranged read timed out after ${timeoutMs} ms. This thread is blocked while it waits, so a server running on the same thread can never answer; run the read on a worker thread.`);
490
+ }
491
+ seen = Atomics.load(current.flag, 0);
492
+ }
493
+ if (failure) {
494
+ throw failure;
495
+ }
496
+ return answers;
497
+ }
@@ -0,0 +1,14 @@
1
+ import { type PdfPageInfo, type PdfPageText } from "./document.ts";
2
+ export declare const BREAK_NONE = 0;
3
+ export declare const BREAK_SPACE = 1;
4
+ export declare const BREAK_LINE = 2;
5
+ export type ReadingOrder = {
6
+ /** Char indices in reading order: real chars only (no generated chars, no CR/LF). */
7
+ order: Uint32Array<ArrayBuffer>;
8
+ /** BREAK_NONE | BREAK_SPACE | BREAK_LINE before `order[k]`. */
9
+ breaks: Uint8Array<ArrayBuffer>;
10
+ length: number;
11
+ };
12
+ /** Break hints without a visible glyph, even when the font supplies bounds. */
13
+ export declare function isInvisibleCodepoint(code: number): boolean;
14
+ export declare function readingOrder(page: Pick<PdfPageText, "count" | "codepoints" | "flags" | "boxes" | "angles">, info: Pick<PdfPageInfo, "width" | "height" | "rotation">): ReadingOrder;
@@ -0,0 +1,173 @@
1
+ // The order a page's chars are read in, and where the word and line breaks
2
+ // fall, as flat arrays over the page's char columns.
3
+ //
4
+ // On an upright page the order is the content stream's, and generated chars
5
+ // (a space at a gap, CR LF at a new line) mark the breaks. On a page displayed
6
+ // with /Rotate 90, 180 or 270, content order can split words and lines along
7
+ // the wrong axis and reverse runs on a 180 page. There the chars are laid out again in the frame
8
+ // the text reads in: each char's box is turned back by the page rotation
9
+ // less its own angle, clustered into lines across that frame, sorted along
10
+ // it, and a space marked where a gap is wider than a word gap.
11
+ //
12
+ // Data-oriented: the plan is two typed columns (`order`, `breaks`) over
13
+ // char indices; the reflow sorts an index array, never char objects.
14
+ import { CHAR_GENERATED } from "./document.js";
15
+ export const BREAK_NONE = 0;
16
+ export const BREAK_SPACE = 1;
17
+ export const BREAK_LINE = 2;
18
+ const LF = 0x0a;
19
+ const CR = 0x0d;
20
+ const SPACE = 0x20;
21
+ /** Break hints without a visible glyph, even when the font supplies bounds. */
22
+ export function isInvisibleCodepoint(code) {
23
+ return (code === 0xad || code === 0x20_0b || code === 0x20_60 || code === 0xfe_ff);
24
+ }
25
+ /** A gap wider than this share of the font height is a word break (pdf.js's TRACKING_SPACE_FACTOR band starts at 0.102). */
26
+ const WORD_GAP_RATIO = 0.15;
27
+ /** Chars whose centres are within this share of their height across the reading axis share a line. */
28
+ const LINE_TOLERANCE_RATIO = 0.5;
29
+ /** Content order, with its generated chars turned into breaks. */
30
+ function streamOrder(page) {
31
+ const { count, codepoints, flags } = page;
32
+ const order = new Uint32Array(count);
33
+ const breaks = new Uint8Array(count);
34
+ let length = 0;
35
+ let pending = BREAK_NONE;
36
+ for (let i = 0; i < count; i++) {
37
+ const code = codepoints[i] ?? 0;
38
+ if (isInvisibleCodepoint(code)) {
39
+ continue;
40
+ }
41
+ if ((flags[i] ?? 0) & CHAR_GENERATED) {
42
+ pending = Math.max(pending, code === LF ? BREAK_LINE : code === SPACE ? BREAK_SPACE : BREAK_NONE);
43
+ continue;
44
+ }
45
+ if (code === CR || code === LF) {
46
+ pending = BREAK_LINE;
47
+ continue;
48
+ }
49
+ order[length] = i;
50
+ breaks[length] = length === 0 ? BREAK_NONE : pending;
51
+ pending = BREAK_NONE;
52
+ length += 1;
53
+ }
54
+ return { order, breaks, length };
55
+ }
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.
59
+ */
60
+ function viewerTurn(rotation, angle) {
61
+ const degrees = rotation - (angle * 180) / Math.PI;
62
+ return ((Math.round(degrees / 90) % 4) + 4) % 4;
63
+ }
64
+ /**
65
+ * Reflow for a rotated page. Each char's box centre and extent go into the
66
+ * frame its text reads in (x along the line, y down the page), then lines
67
+ * are clustered by y and read by x.
68
+ */
69
+ function reflowedOrder(page, info) {
70
+ const { count, codepoints, flags, boxes, angles } = page;
71
+ // Real, visible chars and their reading-frame geometry, as columns.
72
+ const ids = new Uint32Array(count);
73
+ const along = new Float64Array(count);
74
+ const across = new Float64Array(count);
75
+ const start = new Float64Array(count);
76
+ const end = new Float64Array(count);
77
+ const size = new Float64Array(count);
78
+ const turnOf = new Uint8Array(count);
79
+ const generatedBreaks = new Uint32Array(count + 1);
80
+ let n = 0;
81
+ for (let i = 0; i < count; i++) {
82
+ const code = codepoints[i] ?? 0;
83
+ const generated = ((flags[i] ?? 0) & CHAR_GENERATED) !== 0;
84
+ generatedBreaks[i + 1] =
85
+ (generatedBreaks[i] ?? 0) +
86
+ (generated && (code === SPACE || code === CR || code === LF) ? 1 : 0);
87
+ if (generated || isInvisibleCodepoint(code) || code === CR || code === LF) {
88
+ continue;
89
+ }
90
+ const x = boxes[4 * i] ?? 0;
91
+ const y = boxes[4 * i + 1] ?? 0;
92
+ const w = boxes[4 * i + 2] ?? 0;
93
+ const h = boxes[4 * i + 3] ?? 0;
94
+ const turn = viewerTurn(info.rotation, angles[i] ?? 0);
95
+ // (along, across) of the box's two corners in the reading frame.
96
+ let a0;
97
+ let a1;
98
+ let c0;
99
+ let c1;
100
+ if (turn === 0) {
101
+ [a0, a1, c0, c1] = [x, x + w, y, y + h];
102
+ }
103
+ else if (turn === 1) {
104
+ // Reads down the viewer frame; later lines further left.
105
+ [a0, a1, c0, c1] = [y, y + h, info.width - (x + w), info.width - x];
106
+ }
107
+ else if (turn === 2) {
108
+ [a0, a1, c0, c1] = [
109
+ info.width - (x + w),
110
+ info.width - x,
111
+ info.height - (y + h),
112
+ info.height - y,
113
+ ];
114
+ }
115
+ else {
116
+ [a0, a1, c0, c1] = [info.height - (y + h), info.height - y, x, x + w];
117
+ }
118
+ ids[n] = i;
119
+ turnOf[n] = turn;
120
+ start[n] = a0;
121
+ end[n] = a1;
122
+ along[n] = (a0 + a1) / 2;
123
+ across[n] = (c0 + c1) / 2;
124
+ size[n] = Math.max(c1 - c0, 0.01);
125
+ n += 1;
126
+ }
127
+ // Lines: sort by turn, then across; a new line starts where the centre
128
+ // 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
+ const lineOf = new Uint32Array(n);
132
+ let line = 0;
133
+ for (let k = 1; k < byLine.length; k++) {
134
+ const p = byLine[k - 1] ?? 0;
135
+ const q = byLine[k] ?? 0;
136
+ if (turnOf[p] !== turnOf[q] ||
137
+ (across[q] ?? 0) - (across[p] ?? 0) >
138
+ LINE_TOLERANCE_RATIO * Math.max(size[p] ?? 0, size[q] ?? 0)) {
139
+ line += 1;
140
+ }
141
+ lineOf[q] = line;
142
+ }
143
+ const reading = byLine.toSorted((p, q) => (lineOf[p] ?? 0) - (lineOf[q] ?? 0) || (ids[p] ?? 0) - (ids[q] ?? 0));
144
+ const order = new Uint32Array(n);
145
+ const breaks = new Uint8Array(n);
146
+ for (let k = 0; k < reading.length; k++) {
147
+ const q = reading[k] ?? 0;
148
+ order[k] = ids[q] ?? 0;
149
+ if (k === 0) {
150
+ continue;
151
+ }
152
+ const p = reading[k - 1] ?? 0;
153
+ if (lineOf[p] !== lineOf[q]) {
154
+ breaks[k] = BREAK_LINE;
155
+ }
156
+ else {
157
+ const first = Math.min(ids[p] ?? 0, ids[q] ?? 0);
158
+ const last = Math.max(ids[p] ?? 0, ids[q] ?? 0);
159
+ const hasBreak = (generatedBreaks[last] ?? 0) > (generatedBreaks[first + 1] ?? 0);
160
+ const gap = Math.max((start[q] ?? 0) - (end[p] ?? 0), (start[p] ?? 0) - (end[q] ?? 0));
161
+ // Native rotation can generate a break inside a word. Conversely,
162
+ // loose CJK boxes can have a gap without a word boundary at all.
163
+ if (hasBreak &&
164
+ gap > WORD_GAP_RATIO * Math.max(size[p] ?? 0, size[q] ?? 0)) {
165
+ breaks[k] = BREAK_SPACE;
166
+ }
167
+ }
168
+ }
169
+ return { order, breaks, length: n };
170
+ }
171
+ export function readingOrder(page, info) {
172
+ return info.rotation === 0 ? streamOrder(page) : reflowedOrder(page, info);
173
+ }
@@ -0,0 +1 @@
1
+ export declare function sha256Hex(bytes: Uint8Array): string;