@2018kguo/recto 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/assets.d.ts +11 -6
  2. package/dist/assets.js +123 -42
  3. package/dist/document.d.ts +48 -2
  4. package/dist/document.js +109 -4
  5. package/dist/engine.d.ts +17 -9
  6. package/dist/engine.js +223 -45
  7. package/dist/errors.d.ts +4 -2
  8. package/dist/errors.js +4 -1
  9. package/dist/index.d.ts +12 -1
  10. package/dist/index.js +13 -0
  11. package/dist/pool-worker.d.ts +14 -0
  12. package/dist/pool-worker.js +65 -0
  13. package/dist/pool.d.ts +36 -0
  14. package/dist/pool.js +287 -0
  15. package/dist/ranged.d.ts +10 -0
  16. package/dist/ranged.js +38 -11
  17. package/package.json +2 -1
  18. package/wasm/FoxitDingbats.pfb +0 -0
  19. package/wasm/FoxitFixed.pfb +0 -0
  20. package/wasm/FoxitFixedBold.pfb +0 -0
  21. package/wasm/FoxitFixedBoldItalic.pfb +0 -0
  22. package/wasm/FoxitFixedItalic.pfb +0 -0
  23. package/wasm/FoxitSans.pfb +0 -0
  24. package/wasm/FoxitSansBold.pfb +0 -0
  25. package/wasm/FoxitSansBoldItalic.pfb +0 -0
  26. package/wasm/FoxitSansItalic.pfb +0 -0
  27. package/wasm/FoxitSerif.pfb +0 -0
  28. package/wasm/FoxitSerifBold.pfb +0 -0
  29. package/wasm/FoxitSerifBoldItalic.pfb +0 -0
  30. package/wasm/FoxitSerifItalic.pfb +0 -0
  31. package/wasm/FoxitSymbol.pfb +0 -0
  32. package/wasm/THIRD_PARTY_NOTICES +2 -158
  33. package/wasm/cmaps.deflate +0 -0
  34. package/wasm/pins.d.mts +1 -1
  35. package/wasm/pins.mjs +19 -3
  36. package/wasm/recto-more.wasm +0 -0
  37. package/wasm/recto-rare.wasm +0 -0
  38. package/wasm/recto-render.wasm +0 -0
  39. package/wasm/recto.gen.d.mts +2 -1
  40. package/wasm/recto.gen.mjs +118 -12
  41. package/wasm/recto.types.d.mts +46 -1
  42. package/wasm/recto.wasm +0 -0
  43. package/wasm/cmaps.brotli +0 -0
  44. package/wasm/fonts.bin +0 -0
package/dist/assets.d.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { PINS } from "../wasm/pins.mjs";
2
2
  export type AssetName = keyof typeof PINS;
3
+ export type WasmName = "recto.wasm" | "recto-render.wasm" | "recto-more.wasm" | "recto-rare.wasm";
4
+ /** The standard fonts, in the order the wasm asks for them (`font_index` in crates/recto/src/assets.rs). */
5
+ export declare const STANDARD_FONTS: readonly ["FoxitFixed.pfb", "FoxitFixedBold.pfb", "FoxitFixedBoldItalic.pfb", "FoxitFixedItalic.pfb", "FoxitSans.pfb", "FoxitSansBold.pfb", "FoxitSansBoldItalic.pfb", "FoxitSansItalic.pfb", "FoxitSerif.pfb", "FoxitSerifBold.pfb", "FoxitSerifBoldItalic.pfb", "FoxitSerifItalic.pfb", "FoxitSymbol.pfb", "FoxitDingbats.pfb"];
3
6
  /** Whether an asset can be read at the moment the engine asks for it. */
4
7
  export declare function canReadSync(): boolean;
5
8
  /** `assetBase` as a directory URL, or null for the package's own files. */
6
9
  export declare function assetBaseUrl(base: string | URL | undefined): URL | null;
7
- /** The wasm's bytes, for compiling. Asynchronous where it can be. */
8
- export declare function loadWasm(base: URL | null): Promise<Uint8Array<ArrayBuffer>>;
9
- /** Fetches assets ahead of time, for a thread that can't read them when they're needed. */
10
+ /** A hash to use where there is no native one to call synchronously. A live instance's is quicker than this file's. */
11
+ export type SyncHash = (bytes: Uint8Array) => string | null;
12
+ /** The wasm, verified and then compiled. */
13
+ export declare function compileWasm(name: WasmName, base: URL | null): Promise<WebAssembly.Module>;
14
+ /** Fetches and verifies assets ahead of time; a thread that can't read them when needed has to. */
10
15
  export declare function preloadAssets(base: URL | null, names: AssetName[]): Promise<void>;
11
16
  /** An asset, read synchronously. Throws a PdfError rather than returning nothing. */
12
- export declare function loadAssetSync(name: AssetName, base: URL | null): Uint8Array;
13
- /** Entry `index` of fonts.bin: u32 count, u32 offsets[count + 1], bytes (little-endian). */
14
- export declare function fontFromBundle(bundle: Uint8Array, index: number): Uint8Array;
17
+ export declare function loadAssetSync(name: AssetName, base: URL | null, hash?: SyncHash): Uint8Array;
18
+ /** A file, read now and hashed before it's returned, and not kept: a part is compiled once. */
19
+ export declare function readCheckedSync(name: AssetName, base: URL | null, hash?: SyncHash): Uint8Array;
package/dist/assets.js CHANGED
@@ -8,21 +8,72 @@
8
8
  // are written out as `new URL("literal", import.meta.url)` because that is the form
9
9
  // bundlers recognise and rewrite, emitting the file.
10
10
  //
11
- // Each file is pinned by SHA-256: the engine reads its tables as trusted, and a dev server's
12
- // HTML fallback page must not read as a font. pins.mjs is written by the build.
11
+ // Each file is pinned by SHA-256 and hashed here, every byte, before it's used: the engine reads
12
+ // its tables as trusted, and a dev server's HTML fallback page must not read as a font. Nothing is
13
+ // left to `fetch`'s `integrity`, which a wrapper around `fetch` can drop, and which buffers the
14
+ // body anyway. pins.mjs is written by the build.
13
15
  import { PINS } from "../wasm/pins.mjs";
14
16
  import { PdfError } from "./errors.js";
15
17
  import { sha256Hex } from "./sha256.js";
18
+ /** The standard fonts, in the order the wasm asks for them (`font_index` in crates/recto/src/assets.rs). */
19
+ export const STANDARD_FONTS = [
20
+ "FoxitFixed.pfb",
21
+ "FoxitFixedBold.pfb",
22
+ "FoxitFixedBoldItalic.pfb",
23
+ "FoxitFixedItalic.pfb",
24
+ "FoxitSans.pfb",
25
+ "FoxitSansBold.pfb",
26
+ "FoxitSansBoldItalic.pfb",
27
+ "FoxitSansItalic.pfb",
28
+ "FoxitSerif.pfb",
29
+ "FoxitSerifBold.pfb",
30
+ "FoxitSerifBoldItalic.pfb",
31
+ "FoxitSerifItalic.pfb",
32
+ "FoxitSymbol.pfb",
33
+ "FoxitDingbats.pfb",
34
+ ];
16
35
  function packagedUrl(name) {
17
36
  switch (name) {
18
37
  case "recto.wasm":
19
38
  return new URL("../wasm/recto.wasm", import.meta.url);
20
- case "fonts.bin":
21
- return new URL("../wasm/fonts.bin", import.meta.url);
39
+ case "recto-render.wasm":
40
+ return new URL("../wasm/recto-render.wasm", import.meta.url);
41
+ case "recto-more.wasm":
42
+ return new URL("../wasm/recto-more.wasm", import.meta.url);
43
+ case "recto-rare.wasm":
44
+ return new URL("../wasm/recto-rare.wasm", import.meta.url);
45
+ case "FoxitFixed.pfb":
46
+ return new URL("../wasm/FoxitFixed.pfb", import.meta.url);
47
+ case "FoxitFixedBold.pfb":
48
+ return new URL("../wasm/FoxitFixedBold.pfb", import.meta.url);
49
+ case "FoxitFixedBoldItalic.pfb":
50
+ return new URL("../wasm/FoxitFixedBoldItalic.pfb", import.meta.url);
51
+ case "FoxitFixedItalic.pfb":
52
+ return new URL("../wasm/FoxitFixedItalic.pfb", import.meta.url);
53
+ case "FoxitSans.pfb":
54
+ return new URL("../wasm/FoxitSans.pfb", import.meta.url);
55
+ case "FoxitSansBold.pfb":
56
+ return new URL("../wasm/FoxitSansBold.pfb", import.meta.url);
57
+ case "FoxitSansBoldItalic.pfb":
58
+ return new URL("../wasm/FoxitSansBoldItalic.pfb", import.meta.url);
59
+ case "FoxitSansItalic.pfb":
60
+ return new URL("../wasm/FoxitSansItalic.pfb", import.meta.url);
61
+ case "FoxitSerif.pfb":
62
+ return new URL("../wasm/FoxitSerif.pfb", import.meta.url);
63
+ case "FoxitSerifBold.pfb":
64
+ return new URL("../wasm/FoxitSerifBold.pfb", import.meta.url);
65
+ case "FoxitSerifBoldItalic.pfb":
66
+ return new URL("../wasm/FoxitSerifBoldItalic.pfb", import.meta.url);
67
+ case "FoxitSerifItalic.pfb":
68
+ return new URL("../wasm/FoxitSerifItalic.pfb", import.meta.url);
69
+ case "FoxitSymbol.pfb":
70
+ return new URL("../wasm/FoxitSymbol.pfb", import.meta.url);
71
+ case "FoxitDingbats.pfb":
72
+ return new URL("../wasm/FoxitDingbats.pfb", import.meta.url);
22
73
  case "cjk-fallback.ttf":
23
74
  return new URL("../wasm/cjk-fallback.ttf", import.meta.url);
24
- case "cmaps.brotli":
25
- return new URL("../wasm/cmaps.brotli", import.meta.url);
75
+ case "cmaps.deflate":
76
+ return new URL("../wasm/cmaps.deflate", import.meta.url);
26
77
  }
27
78
  }
28
79
  /** A stalled server must not block the thread for ever; the largest asset is 5 MB. */
@@ -71,11 +122,16 @@ function loadFailure(name, url, reason, cause) {
71
122
  return new PdfError("missing-asset", `recto could not load ${name}${url ? ` from ${url.href}` : ""}: ${reason}. It ships in the package's wasm/ ` +
72
123
  "directory. If a bundler or dev server moved the package without its files, copy them " +
73
124
  "somewhere it serves and pass that directory as `assetBase` (with Vite's dev server, add " +
74
- 'optimizeDeps: { exclude: ["@2018kguo/recto"] } instead).', cause);
125
+ 'optimizeDeps: { exclude: ["@2018kguo/recto"] } instead; a server bundle should keep the package external).', cause);
75
126
  }
76
- function checked(name, url, bytes) {
77
- if (sha256Hex(bytes) !== PINS[name]) {
78
- throw 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)`);
127
+ function notThisBuild(name, url, bytes) {
128
+ 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
+ }
130
+ function checked(name, url, bytes, hash) {
131
+ const crypto = globalThis.process?.getBuiltinModule?.("node:crypto");
132
+ const hex = crypto ? crypto.createHash("sha256").update(bytes).digest("hex") : (hash?.(bytes) ?? sha256Hex(bytes));
133
+ if (hex !== PINS[name]) {
134
+ throw notThisBuild(name, url, bytes);
79
135
  }
80
136
  return bytes;
81
137
  }
@@ -93,32 +149,52 @@ function readSync(url) {
93
149
  }
94
150
  return new Uint8Array(xhr.response);
95
151
  }
96
- /** The wasm's bytes, for compiling. Asynchronous where it can be. */
97
- export async function loadWasm(base) {
152
+ /** `crypto.subtle` is only there in a secure context; a plain-http page hashes here. */
153
+ async function hashOf(bytes) {
154
+ const subtle = globalThis.crypto?.subtle;
155
+ if (!subtle) {
156
+ return sha256Hex(bytes);
157
+ }
158
+ const digest = new Uint8Array(await subtle.digest("SHA-256", bytes));
159
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
160
+ }
161
+ /** The only way bytes get here from a network without blocking: whole, and hashed before they're used. */
162
+ async function fetchBytes(name, url) {
163
+ const response = await fetch(url);
164
+ if (!response.ok) {
165
+ throw new Error(`HTTP ${response.status}`);
166
+ }
167
+ const bytes = new Uint8Array(await response.arrayBuffer());
168
+ if ((await hashOf(bytes)) !== PINS[name]) {
169
+ throw notThisBuild(name, url, bytes);
170
+ }
171
+ return bytes;
172
+ }
173
+ /** The wasm, verified and then compiled. */
174
+ export async function compileWasm(name, base) {
98
175
  let url = null;
99
176
  let bytes;
100
177
  try {
101
178
  // Inside the try: under a blob: worker `import.meta.url` is no base for a URL.
102
- url = assetUrl("recto.wasm", base);
103
- if (nodeFs()) {
104
- bytes = readFromDisk(url);
105
- }
106
- else {
107
- const response = await fetch(url);
108
- if (!response.ok) {
109
- throw new Error(`HTTP ${response.status}`);
110
- }
111
- bytes = new Uint8Array(await response.arrayBuffer());
112
- }
179
+ url = assetUrl(name, base);
180
+ bytes = (nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url));
113
181
  }
114
182
  catch (error) {
115
- throw loadFailure("recto.wasm", url, error instanceof Error ? error.message : String(error), error);
183
+ if (error instanceof PdfError) {
184
+ throw error;
185
+ }
186
+ throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
116
187
  }
117
- return checked("recto.wasm", url, bytes);
188
+ return await compiling(name, WebAssembly.compile(bytes));
189
+ }
190
+ function compiling(name, module) {
191
+ return module.catch((error) => {
192
+ throw new PdfError("missing-asset", `recto could not compile ${name}: ${String(error)}. A Content-Security-Policy must allow 'wasm-unsafe-eval'.`, error);
193
+ });
118
194
  }
119
195
  /** Verified assets by URL: hash and read each once per thread. */
120
196
  const loaded = new Map();
121
- /** Fetches assets ahead of time, for a thread that can't read them when they're needed. */
197
+ /** Fetches and verifies assets ahead of time; a thread that can't read them when needed has to. */
122
198
  export async function preloadAssets(base, names) {
123
199
  await Promise.all(names.map(async (name) => {
124
200
  let url = null;
@@ -128,28 +204,40 @@ export async function preloadAssets(base, names) {
128
204
  if (loaded.has(url.href)) {
129
205
  return;
130
206
  }
131
- const response = await fetch(url);
132
- if (!response.ok) {
133
- throw new Error(`HTTP ${response.status}`);
134
- }
135
- bytes = new Uint8Array(await response.arrayBuffer());
207
+ bytes = nodeFs() ? checked(name, url, readFromDisk(url)) : await fetchBytes(name, url);
136
208
  }
137
209
  catch (error) {
210
+ if (error instanceof PdfError) {
211
+ throw error;
212
+ }
138
213
  throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
139
214
  }
140
- loaded.set(url.href, checked(name, url, bytes));
215
+ loaded.set(url.href, bytes);
141
216
  }));
142
217
  }
143
218
  /** An asset, read synchronously. Throws a PdfError rather than returning nothing. */
144
- export function loadAssetSync(name, base) {
219
+ export function loadAssetSync(name, base, hash) {
145
220
  let url = null;
146
- let bytes;
147
221
  try {
148
222
  url = assetUrl(name, base);
149
223
  const cached = loaded.get(url.href);
150
224
  if (cached) {
151
225
  return cached;
152
226
  }
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;
234
+ }
235
+ /** A file, read now and hashed before it's returned, and not kept: a part is compiled once. */
236
+ export function readCheckedSync(name, base, hash) {
237
+ let url = null;
238
+ let bytes;
239
+ try {
240
+ url = assetUrl(name, base);
153
241
  if (!canReadSync()) {
154
242
  throw new Error("this thread can only use assets fetched up front, and it was left out of preloadAssets");
155
243
  }
@@ -158,12 +246,5 @@ export function loadAssetSync(name, base) {
158
246
  catch (error) {
159
247
  throw loadFailure(name, url, error instanceof Error ? error.message : String(error), error);
160
248
  }
161
- loaded.set(url.href, checked(name, url, bytes));
162
- return bytes;
163
- }
164
- /** Entry `index` of fonts.bin: u32 count, u32 offsets[count + 1], bytes (little-endian). */
165
- export function fontFromBundle(bundle, index) {
166
- const view = new DataView(bundle.buffer, bundle.byteOffset, bundle.byteLength);
167
- const base = 4 + (view.getUint32(0, true) + 1) * 4;
168
- return bundle.subarray(base + view.getUint32(4 + index * 4, true), base + view.getUint32(8 + index * 4, true));
249
+ return checked(name, url, bytes, hash);
169
250
  }
@@ -171,6 +171,12 @@ export type PdfRenderOptions = {
171
171
  /** Default 4096. May be Infinity, to fit the width alone. A render is capped at 16,384 px a side and 64 Mpx. */
172
172
  maxHeight?: number;
173
173
  };
174
+ export type PdfRenderAsyncOptions = PdfRenderOptions & {
175
+ /** Stops the render at the next pause: `aborted`, with the document still usable. */
176
+ signal?: AbortSignal;
177
+ /** About how long it runs before giving the thread back. Default 8. */
178
+ sliceMs?: number;
179
+ };
174
180
  export type PdfBitmap = {
175
181
  width: number;
176
182
  height: number;
@@ -204,6 +210,18 @@ export interface PdfDocument {
204
210
  readonly pageCount: number;
205
211
  /** The document's bookmarks; empty if it has none. */
206
212
  outline(): PdfOutlineItem[];
213
+ /** What each page is called, one string per page ("iv", "A-5"), or null if the file doesn't say. */
214
+ pageLabels(): string[] | null;
215
+ /**
216
+ * Which of the document's trees were cut short: `pages` means `pageCount` may be short, by a limit or
217
+ * because a damaged tree left no pages (an honestly empty document says false). `pageLabels` and `outline`
218
+ * mean those passed a limit, and may be wrong or incomplete. A page's own limits are `truncated` on its results.
219
+ */
220
+ structureTruncated(): {
221
+ pages: boolean;
222
+ pageLabels: boolean;
223
+ outline: boolean;
224
+ };
207
225
  pageInfo(index: number): PdfPageInfo;
208
226
  text(index: number): PdfPageText;
209
227
  /** `text`, `objects` and `annotations` from one interpretation of the page. */
@@ -220,16 +238,34 @@ export interface PdfDocument {
220
238
  objects(index: number): PdfPageObjects;
221
239
  annotations(index: number): PdfAnnotation[];
222
240
  render(index: number, options: PdfRenderOptions): PdfBitmap;
241
+ /**
242
+ * The same pixels as `render`, giving the thread back between slices, and stoppable with a
243
+ * signal. The last step, rasterizing, is one piece: about a third of a heavy page's time.
244
+ */
245
+ renderAsync(index: number, options: PdfRenderAsyncOptions): Promise<PdfBitmap>;
223
246
  close(): void;
224
247
  [Symbol.dispose](): void;
225
248
  }
226
249
  export type Instance = {
227
250
  glue: ReturnType<typeof createGlue>;
251
+ /** Loads a part of the wasm, hashed first, or throws `missing-asset` having changed nothing. */
252
+ loadPart(name: "recto-render.wasm" | "recto-more.wasm" | "recto-rare.wasm"): void;
228
253
  memory: WebAssembly.Memory;
254
+ /** Its memory when it was made: past this, something used it, and dropping it gives memory back. */
255
+ startBytes: number;
229
256
  generation: number;
230
257
  openDocuments: number;
231
- /** Set once the instance trapped or an asset failed; nothing may call it again. */
258
+ /** The engine's count of documents open, on any of its instances. */
259
+ documents: {
260
+ open: number;
261
+ };
262
+ /** Set once the instance trapped; nothing may call it again. */
232
263
  dead: boolean;
264
+ /**
265
+ * Set once a part or asset failed to load inside a call. Nothing more runs on it, but a document's
266
+ * file is still whole there, so it reopens on another instance at its next call.
267
+ */
268
+ abandoned: boolean;
233
269
  /** An asset's load failure, rethrown by the call that needed the asset. */
234
270
  failure: PdfError | undefined;
235
271
  /** Called when a document on this instance closes. */
@@ -240,10 +276,19 @@ export declare function engineFailure(instance: Instance, error: unknown): unkno
240
276
  export declare class RectoDocument implements PdfDocument {
241
277
  #private;
242
278
  readonly pageCount: number;
243
- constructor(instance: Instance, doc: Glue.Document, fetchFailure?: () => PdfError | null);
279
+ constructor(instance: Instance, doc: Glue.Document, reopen: (doc: Glue.Document, instance: Instance) => {
280
+ instance: Instance;
281
+ doc: Glue.Document;
282
+ }, fetchFailure?: () => PdfError | null);
244
283
  pageInfo(index: number): PdfPageInfo;
245
284
  text(index: number): PdfPageText;
246
285
  objects(index: number): PdfPageObjects;
286
+ pageLabels(): string[] | null;
287
+ structureTruncated(): {
288
+ pages: boolean;
289
+ pageLabels: boolean;
290
+ outline: boolean;
291
+ };
247
292
  outline(): PdfOutlineItem[];
248
293
  annotations(index: number): PdfAnnotation[];
249
294
  scan(index: number): {
@@ -256,6 +301,7 @@ export declare class RectoDocument implements PdfDocument {
256
301
  annotations: PdfAnnotation[];
257
302
  };
258
303
  render(index: number, options: PdfRenderOptions): PdfBitmap;
304
+ renderAsync(index: number, options: PdfRenderAsyncOptions): Promise<PdfBitmap>;
259
305
  close(): void;
260
306
  [Symbol.dispose](): void;
261
307
  }
package/dist/document.js CHANGED
@@ -167,19 +167,35 @@ function toAnnotations(a) {
167
167
  return out;
168
168
  });
169
169
  }
170
+ const DEFAULT_SLICE_MS = 8;
171
+ /** Back to the event loop, without the 4 ms a nested timer waits in a browser. */
172
+ function nextTask() {
173
+ return new Promise((resolve) => {
174
+ const { port1, port2 } = new MessageChannel();
175
+ port1.onmessage = () => {
176
+ port1.close();
177
+ resolve();
178
+ };
179
+ port2.postMessage(0);
180
+ });
181
+ }
170
182
  export class RectoDocument {
171
183
  pageCount;
172
184
  #closed = false;
173
185
  #doc;
174
186
  #instance;
187
+ /** The same file on another instance. */
188
+ #reopen;
175
189
  /** A ranged read's failure, thrown by the call whose fetch it failed. */
176
190
  #fetchFailure;
177
- constructor(instance, doc, fetchFailure = () => null) {
191
+ constructor(instance, doc, reopen, fetchFailure = () => null) {
178
192
  this.#instance = instance;
179
193
  this.#doc = doc;
194
+ this.#reopen = reopen;
180
195
  this.#fetchFailure = fetchFailure;
181
196
  this.pageCount = doc.pageCount;
182
197
  instance.openDocuments += 1;
198
+ instance.documents.open += 1;
183
199
  }
184
200
  #check(index) {
185
201
  if (this.#closed) {
@@ -188,8 +204,11 @@ export class RectoDocument {
188
204
  if (!(Number.isInteger(index) && index >= 0 && index < this.pageCount)) {
189
205
  throw new RangeError(`page ${index} is out of range (0..${this.pageCount - 1})`);
190
206
  }
191
- if (this.#instance.failure) {
192
- throw this.#instance.failure;
207
+ this.#checkInstance();
208
+ }
209
+ #checkInstance() {
210
+ if (this.#instance.abandoned) {
211
+ this.#moveOffAbandoned();
193
212
  }
194
213
  if (this.#instance.dead) {
195
214
  throw new PdfError("crash", "the engine crashed earlier on this thread; open the document again");
@@ -215,6 +234,26 @@ export class RectoDocument {
215
234
  }
216
235
  return result;
217
236
  }
237
+ /** The call that failed told the caller; this one is a fresh start. */
238
+ #moveOffAbandoned() {
239
+ const old = this.#instance;
240
+ let moved;
241
+ try {
242
+ moved = this.#reopen(this.#doc, old);
243
+ }
244
+ catch (error) {
245
+ throw engineFailure(old, error);
246
+ }
247
+ old.openDocuments -= 1;
248
+ old.onClose?.();
249
+ this.#instance = moved.instance;
250
+ this.#doc = moved.doc;
251
+ moved.instance.openDocuments += 1;
252
+ }
253
+ /** Loaded ahead of the call, so that failing to get it leaves nothing half-done. */
254
+ #needsRender() {
255
+ this.#call(() => this.#instance.loadPart("recto-render.wasm"));
256
+ }
218
257
  pageInfo(index) {
219
258
  this.#check(index);
220
259
  return this.#call(() => {
@@ -228,12 +267,29 @@ export class RectoDocument {
228
267
  }
229
268
  objects(index) {
230
269
  this.#check(index);
270
+ this.#needsRender();
231
271
  return this.#call(() => toObjects(this.#doc.objects(index)));
232
272
  }
273
+ pageLabels() {
274
+ if (this.#closed) {
275
+ throw new PdfError("closed", "this document is closed");
276
+ }
277
+ this.#checkInstance();
278
+ return this.#call(() => this.#doc.pageLabels());
279
+ }
280
+ structureTruncated() {
281
+ if (this.#closed) {
282
+ throw new PdfError("closed", "this document is closed");
283
+ }
284
+ this.#checkInstance();
285
+ const [pages, pageLabels, outline] = this.#call(() => this.#doc.structureTruncated());
286
+ return { pages: pages === 1, pageLabels: pageLabels === 1, outline: outline === 1 };
287
+ }
233
288
  outline() {
234
289
  if (this.#closed) {
235
290
  throw new PdfError("closed", "this document is closed");
236
291
  }
292
+ this.#checkInstance();
237
293
  return this.#call(() => {
238
294
  const top = [];
239
295
  // The open item at each level; an item's parent is the last one a level up.
@@ -252,6 +308,7 @@ export class RectoDocument {
252
308
  }
253
309
  scan(index) {
254
310
  this.#check(index);
311
+ this.#needsRender();
255
312
  return this.#call(() => consume(this.#doc.scan(index), (scan) => ({
256
313
  text: toText(scan.takeText()),
257
314
  objects: toObjects(scan.takeObjects()),
@@ -272,6 +329,7 @@ export class RectoDocument {
272
329
  !(maxHeight > 0)) {
273
330
  throw new RangeError(`render width must be in 1..${MAX_DIMENSION} and maxHeight above 0`);
274
331
  }
332
+ this.#needsRender();
275
333
  return this.#call(() => consume(this.#doc.render(index, options.width, maxHeight), (page) => ({
276
334
  width: page.width,
277
335
  height: page.height,
@@ -279,13 +337,60 @@ export class RectoDocument {
279
337
  rgba: page.takeRgba(),
280
338
  })));
281
339
  }
340
+ async renderAsync(index, options) {
341
+ this.#check(index);
342
+ const maxHeight = options.maxHeight ?? DEFAULT_MAX_HEIGHT;
343
+ if (!(options.width > 0 && options.width <= MAX_DIMENSION) ||
344
+ !(maxHeight > 0)) {
345
+ throw new RangeError(`render width must be in 1..${MAX_DIMENSION} and maxHeight above 0`);
346
+ }
347
+ this.#needsRender();
348
+ const instance = this.#instance;
349
+ const sliceMs = options.sliceMs ?? DEFAULT_SLICE_MS;
350
+ const task = this.#call(() => this.#doc.renderTask(index, options.width, maxHeight));
351
+ const stopIfAsked = () => {
352
+ if (options.signal?.aborted) {
353
+ throw new PdfError("aborted", "the signal was aborted", options.signal.reason);
354
+ }
355
+ if (this.#closed) {
356
+ throw new PdfError("closed", "this document is closed");
357
+ }
358
+ if (instance.dead) {
359
+ throw new PdfError("crash", "the engine crashed while this render was paused; open the document again");
360
+ }
361
+ if (instance.abandoned) {
362
+ throw instance.failure ?? new PdfError("missing-asset", "a file the engine needed failed to load while this render was paused; render again");
363
+ }
364
+ };
365
+ try {
366
+ stopIfAsked();
367
+ while (!this.#call(() => task.step(sliceMs))) {
368
+ await nextTask();
369
+ stopIfAsked();
370
+ }
371
+ await nextTask();
372
+ stopIfAsked();
373
+ return this.#call(() => consume(task.finish(), (page) => ({
374
+ width: page.width,
375
+ height: page.height,
376
+ truncated: page.truncated,
377
+ rgba: page.takeRgba(),
378
+ })));
379
+ }
380
+ finally {
381
+ if (!instance.dead && !instance.abandoned) {
382
+ task.free();
383
+ }
384
+ }
385
+ }
282
386
  close() {
283
387
  if (this.#closed) {
284
388
  return;
285
389
  }
286
390
  this.#closed = true;
287
391
  this.#instance.openDocuments -= 1;
288
- if (!this.#instance.dead) {
392
+ this.#instance.documents.open -= 1;
393
+ if (!this.#instance.dead && !this.#instance.abandoned) {
289
394
  this.#doc.free();
290
395
  }
291
396
  this.#instance.onClose?.();
package/dist/engine.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type AssetName } from "./assets.ts";
2
- import { type PdfDocument } from "./document.ts";
1
+ import { type AssetName, type SyncHash } from "./assets.ts";
2
+ import { type Instance, type PdfDocument } from "./document.ts";
3
3
  import { type PdfRangedDocument, type PdfRangedOpenOptions, type PdfRangeSource } from "./ranged.ts";
4
4
  export type PdfEngineOptions = {
5
5
  /**
@@ -8,16 +8,22 @@ export type PdfEngineOptions = {
8
8
  * path or file: URL in Node. Defaults to the package's own wasm/ directory.
9
9
  */
10
10
  assetBase?: string | URL;
11
- /** Past this much linear memory, the instance is dropped when its last document closes, or
12
- * replaced at the next open if one is still open. Default 512 MiB. */
11
+ /**
12
+ * Past this much linear memory, the instance is dropped when its last document closes, or replaced
13
+ * at the next open if one is still open. By default it's dropped a second after the last close, once
14
+ * anything has used it, and an open before then reuses it; past 512 MiB, at the close; and past 64 MiB
15
+ * with a document still open, the next open gets a new one.
16
+ */
13
17
  recycleAboveBytes?: number;
14
18
  /**
15
- * Where a thread can't read a file synchronously (a page's main thread, a service worker), the
16
- * assets are fetched while the engine is created: by default all of them, 5.6 MB. Name fewer to
17
- * fetch less, and a document that needs one you left out fails with `missing-asset`. Ignored
18
- * where assets can be read when needed.
19
+ * Files to fetch and verify while the engine is created, so that a missing one fails here and no later
20
+ * call waits for it. `recto-render.wasm` saves the first `render` that wait; all three parts check the
21
+ * whole engine at startup. The default is none, except where a thread can't read a file when it's needed
22
+ * (a page's main thread, a service worker): there it's all of them, about 9.6 MB. Name fewer to fetch less, and
23
+ * a call that needs a font or CMaps you left out fails with `missing-asset`. The parts are fetched there
24
+ * whatever you name, so it can always draw.
19
25
  */
20
- preloadAssets?: Exclude<AssetName, "recto.wasm">[];
26
+ preloadAssets?: (AssetName | "cmaps.brotli" | "fonts.bin")[];
21
27
  };
22
28
  export type PdfOpenOptions = {
23
29
  password?: string;
@@ -44,6 +50,8 @@ export interface PdfEngine {
44
50
  openRanged(source: PdfRangeSource, options?: PdfRangedOpenOptions): Promise<PdfRangedDocument>;
45
51
  stats(): PdfEngineStats;
46
52
  }
53
+ /** An instance's own SHA-256, for an asset it needs: faster than JavaScript's in a cold browser. */
54
+ export declare function hashOn(instance: Pick<Instance, "dead" | "glue">): SyncHash;
47
55
  export declare function createPdfEngine(options?: PdfEngineOptions): Promise<PdfEngine>;
48
56
  /**
49
57
  * Opens a PDF on this thread's shared engine: