@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.
- package/LICENSE +26 -0
- package/README.md +25 -2
- package/dist/assets.d.ts +19 -0
- package/dist/assets.js +250 -0
- package/dist/document.d.ts +307 -0
- package/dist/document.js +405 -0
- package/dist/engine.d.ts +62 -0
- package/dist/engine.js +383 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +23 -0
- package/dist/page-measures.d.ts +34 -0
- package/dist/page-measures.js +166 -0
- package/dist/pool-worker.d.ts +14 -0
- package/dist/pool-worker.js +65 -0
- package/dist/pool.d.ts +36 -0
- package/dist/pool.js +287 -0
- package/dist/ranged.d.ts +98 -0
- package/dist/ranged.js +497 -0
- package/dist/reading-order.d.ts +14 -0
- package/dist/reading-order.js +173 -0
- package/dist/sha256.d.ts +1 -0
- package/dist/sha256.js +78 -0
- package/dist/text-items.d.ts +49 -0
- package/dist/text-items.js +272 -0
- package/package.json +48 -4
- package/wasm/FoxitDingbats.pfb +0 -0
- package/wasm/FoxitFixed.pfb +0 -0
- package/wasm/FoxitFixedBold.pfb +0 -0
- package/wasm/FoxitFixedBoldItalic.pfb +0 -0
- package/wasm/FoxitFixedItalic.pfb +0 -0
- package/wasm/FoxitSans.pfb +0 -0
- package/wasm/FoxitSansBold.pfb +0 -0
- package/wasm/FoxitSansBoldItalic.pfb +0 -0
- package/wasm/FoxitSansItalic.pfb +0 -0
- package/wasm/FoxitSerif.pfb +0 -0
- package/wasm/FoxitSerifBold.pfb +0 -0
- package/wasm/FoxitSerifBoldItalic.pfb +0 -0
- package/wasm/FoxitSerifItalic.pfb +0 -0
- package/wasm/FoxitSymbol.pfb +0 -0
- package/wasm/THIRD_PARTY_NOTICES +2362 -0
- package/wasm/cjk-fallback.ttf +0 -0
- package/wasm/cmaps.deflate +0 -0
- package/wasm/pins.d.mts +1 -0
- package/wasm/pins.mjs +23 -0
- package/wasm/recto-more.wasm +0 -0
- package/wasm/recto-rare.wasm +0 -0
- package/wasm/recto-render.wasm +0 -0
- package/wasm/recto.gen.d.mts +3 -0
- package/wasm/recto.gen.mjs +1203 -0
- package/wasm/recto.types.d.mts +295 -0
- package/wasm/recto.wasm +0 -0
package/dist/engine.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// The per-thread engine: owns one WebAssembly instance and replaces it when it trapped or
|
|
2
|
+
// its memory grew past the recycle mark (WebAssembly memory never shrinks). Past the mark, the
|
|
3
|
+
// instance goes once nothing has been open on it for a moment, and a document still open on an
|
|
4
|
+
// instance keeps it.
|
|
5
|
+
import { PINS } from "../wasm/pins.mjs";
|
|
6
|
+
import { createGlue, PART_TABLE } from "../wasm/recto.gen.mjs";
|
|
7
|
+
import { assetBaseUrl, canReadSync, STANDARD_FONTS, compileWasm, loadAssetSync, preloadAssets, readCheckedSync, } from "./assets.js";
|
|
8
|
+
import { engineFailure, RectoDocument, } from "./document.js";
|
|
9
|
+
import { PdfError } from "./errors.js";
|
|
10
|
+
import { ownedSource, startRangeLoader, } from "./ranged.js";
|
|
11
|
+
const CJK_FONT_ASSET = 14;
|
|
12
|
+
const CMAPS_ASSET = 15;
|
|
13
|
+
const PARTS = ["recto-render.wasm", "recto-more.wasm", "recto-rare.wasm"];
|
|
14
|
+
const ALL_ASSETS = [...STANDARD_FONTS, "cmaps.deflate", "cjk-fallback.ttf"];
|
|
15
|
+
/**
|
|
16
|
+
* Past this, an open with another document still open gets a new instance. Chosen from
|
|
17
|
+
* bench/results/recycle.json: retiring at each close made a repeated large file slow.
|
|
18
|
+
*/
|
|
19
|
+
const IDLE_RECYCLE_ABOVE_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const DEFAULT_RECYCLE_ABOVE_BYTES = 512 * 1024 * 1024;
|
|
21
|
+
const IDLE_MS = 1000;
|
|
22
|
+
/** Each wasm, verified and compiled once per thread, per location. */
|
|
23
|
+
const compiling = new Map();
|
|
24
|
+
const compiled = new Map();
|
|
25
|
+
const moduleKey = (name, base) => `${name} ${base?.href ?? ""}`;
|
|
26
|
+
function compile(name, base) {
|
|
27
|
+
const key = moduleKey(name, base);
|
|
28
|
+
let module = compiling.get(key);
|
|
29
|
+
if (!module) {
|
|
30
|
+
module = compileWasm(name, base);
|
|
31
|
+
module.then((done) => compiled.set(key, done), () => compiling.delete(key));
|
|
32
|
+
compiling.set(key, module);
|
|
33
|
+
}
|
|
34
|
+
return module;
|
|
35
|
+
}
|
|
36
|
+
/** A part, from inside a synchronous call. Hashed before it's compiled; this is the only way in but `compile`. */
|
|
37
|
+
function partModule(name, base, hash) {
|
|
38
|
+
const key = moduleKey(name, base);
|
|
39
|
+
let module = compiled.get(key);
|
|
40
|
+
if (!module) {
|
|
41
|
+
module = new WebAssembly.Module(readCheckedSync(name, base, hash));
|
|
42
|
+
compiled.set(key, module);
|
|
43
|
+
}
|
|
44
|
+
return module;
|
|
45
|
+
}
|
|
46
|
+
/** An instance's own SHA-256, for an asset it needs: faster than JavaScript's in a cold browser. */
|
|
47
|
+
export function hashOn(instance) {
|
|
48
|
+
return (bytes) => {
|
|
49
|
+
// A function the hash itself runs may be in a part, and that part's check can't use this hash.
|
|
50
|
+
if (instance.dead || hashing.has(instance)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
hashing.add(instance);
|
|
54
|
+
try {
|
|
55
|
+
return Array.from(instance.glue.sha256(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
hashing.delete(instance);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const hashing = new WeakSet();
|
|
63
|
+
function instantiate(generation, base, module, documents = { open: 0 }) {
|
|
64
|
+
const parts = new Set();
|
|
65
|
+
// A function of a part that isn't loaded yet: load it, then make the call this was meant to be. A
|
|
66
|
+
// failure unwinds the engine's frames with its state half-done, so no call can follow on this
|
|
67
|
+
// instance. What was open on it reopens on another, at its next call.
|
|
68
|
+
const placeholders = (name) => new Proxy({}, {
|
|
69
|
+
get: (_, slot) => (...args) => {
|
|
70
|
+
try {
|
|
71
|
+
instance.loadPart(name);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
instance.abandoned = true;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const table = exports[PART_TABLE];
|
|
78
|
+
return table.get(Number(slot))(...args);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
// Each instance gets its own glue closure (scripts/package-wasm.mjs), so a retired
|
|
82
|
+
// instance and its memory are collected with it.
|
|
83
|
+
const glue = createGlue({
|
|
84
|
+
"part.recto-render": placeholders("recto-render.wasm"),
|
|
85
|
+
"part.recto-more": placeholders("recto-more.wasm"),
|
|
86
|
+
"part.recto-rare": placeholders("recto-rare.wasm"),
|
|
87
|
+
},
|
|
88
|
+
// Something never closed, freed by the collector. Nothing can run on an abandoned instance, which goes
|
|
89
|
+
// whole; and a free that needs a part it can't get has nobody to tell, and mustn't be an uncaught error.
|
|
90
|
+
(free) => {
|
|
91
|
+
if (instance.abandoned || instance.dead) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
free();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
instance.abandoned = true;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
const exports = glue.initSync({ module });
|
|
102
|
+
const instance = {
|
|
103
|
+
glue,
|
|
104
|
+
loadPart(name) {
|
|
105
|
+
if (!parts.has(name)) {
|
|
106
|
+
// Its table entries replace its placeholders.
|
|
107
|
+
new WebAssembly.Instance(partModule(name, base, hashOn(instance)), { primary: exports });
|
|
108
|
+
parts.add(name);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
memory: exports.memory,
|
|
112
|
+
startBytes: exports.memory.buffer.byteLength,
|
|
113
|
+
generation,
|
|
114
|
+
openDocuments: 0,
|
|
115
|
+
documents,
|
|
116
|
+
dead: false,
|
|
117
|
+
abandoned: false,
|
|
118
|
+
failure: undefined,
|
|
119
|
+
};
|
|
120
|
+
// The engine asks for a built-in font (0..13), the CJK fallback or its predefined CMaps
|
|
121
|
+
// the first time a document needs one. It reads a throw as a missing asset, which would
|
|
122
|
+
// be blank text, so keep the error for the call in progress to rethrow, and leave the instance
|
|
123
|
+
// so the next call tries again.
|
|
124
|
+
glue.setAssetLoader((index) => {
|
|
125
|
+
try {
|
|
126
|
+
if (index === CMAPS_ASSET) {
|
|
127
|
+
return loadAssetSync("cmaps.deflate", base, hashOn(instance));
|
|
128
|
+
}
|
|
129
|
+
if (index === CJK_FONT_ASSET) {
|
|
130
|
+
return loadAssetSync("cjk-fallback.ttf", base, hashOn(instance));
|
|
131
|
+
}
|
|
132
|
+
return loadAssetSync(STANDARD_FONTS[index], base, hashOn(instance));
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
instance.abandoned = true;
|
|
136
|
+
instance.failure = error;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return instance;
|
|
141
|
+
}
|
|
142
|
+
function bytesOf(input) {
|
|
143
|
+
if (ArrayBuffer.isView(input)) {
|
|
144
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
145
|
+
}
|
|
146
|
+
// Not instanceof: an ArrayBuffer from another realm (a test runner's sandbox) is just as good.
|
|
147
|
+
if (Object.prototype.toString.call(input) === "[object ArrayBuffer]") {
|
|
148
|
+
return new Uint8Array(input);
|
|
149
|
+
}
|
|
150
|
+
throw new TypeError("expected the PDF's bytes as a Uint8Array, Buffer or ArrayBuffer");
|
|
151
|
+
}
|
|
152
|
+
function throwIfAborted(signal) {
|
|
153
|
+
if (signal?.aborted) {
|
|
154
|
+
throw new PdfError("aborted", "the signal was aborted", signal.reason);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
class RectoEngine {
|
|
158
|
+
#instance;
|
|
159
|
+
#generation;
|
|
160
|
+
#starting = null;
|
|
161
|
+
#base;
|
|
162
|
+
#recycleAboveBytes;
|
|
163
|
+
/** Past this, an instance with a document open takes no other. */
|
|
164
|
+
#idleAboveBytes;
|
|
165
|
+
/** Whether an instance that grew goes once nothing has been open on it for IDLE_MS: the default. */
|
|
166
|
+
#dropWhenIdle;
|
|
167
|
+
#idle;
|
|
168
|
+
/** Across every instance: a document that moved, or stayed on an old one, still counts. */
|
|
169
|
+
#documents;
|
|
170
|
+
constructor(instance, base, recycleAboveBytes) {
|
|
171
|
+
this.#instance = instance;
|
|
172
|
+
this.#generation = instance.generation;
|
|
173
|
+
this.#base = base;
|
|
174
|
+
this.#recycleAboveBytes = recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES;
|
|
175
|
+
this.#idleAboveBytes = recycleAboveBytes ?? IDLE_RECYCLE_ABOVE_BYTES;
|
|
176
|
+
this.#dropWhenIdle = recycleAboveBytes === undefined;
|
|
177
|
+
this.#documents = instance.documents;
|
|
178
|
+
this.#watch(instance);
|
|
179
|
+
}
|
|
180
|
+
/** Memory only comes back with the instance, so don't keep one past the mark with nothing on it. */
|
|
181
|
+
#watch(instance) {
|
|
182
|
+
instance.onClose = () => {
|
|
183
|
+
if (this.#instance !== instance || instance.openDocuments > 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
187
|
+
if (bytes > this.#recycleAboveBytes) {
|
|
188
|
+
this.#instance = null;
|
|
189
|
+
}
|
|
190
|
+
else if (this.#dropWhenIdle ? bytes > instance.startBytes : bytes > this.#idleAboveBytes) {
|
|
191
|
+
clearTimeout(this.#idle);
|
|
192
|
+
this.#idle = setTimeout(() => {
|
|
193
|
+
// Node's fired timer keeps its callback, and with it the instance.
|
|
194
|
+
this.#idle = undefined;
|
|
195
|
+
if (this.#instance === instance && instance.openDocuments === 0) {
|
|
196
|
+
this.#instance = null;
|
|
197
|
+
}
|
|
198
|
+
}, IDLE_MS);
|
|
199
|
+
// Not a reason for Node to stay running.
|
|
200
|
+
this.#idle.unref?.();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Whether a new document can go on this instance. */
|
|
205
|
+
#usable(instance) {
|
|
206
|
+
if (!instance || instance.dead || instance.abandoned) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const bytes = instance.memory.buffer.byteLength;
|
|
210
|
+
return bytes <= this.#idleAboveBytes || (bytes <= this.#recycleAboveBytes && instance.openDocuments === 0);
|
|
211
|
+
}
|
|
212
|
+
#ready() {
|
|
213
|
+
return this.#usable(this.#instance) ? this.#instance : null;
|
|
214
|
+
}
|
|
215
|
+
async #live() {
|
|
216
|
+
const current = this.#instance;
|
|
217
|
+
if (this.#usable(current)) {
|
|
218
|
+
return current;
|
|
219
|
+
}
|
|
220
|
+
this.#starting ??= compile("recto.wasm", this.#base)
|
|
221
|
+
.then((module) => instantiate(this.#generation + 1, this.#base, module, this.#documents))
|
|
222
|
+
.finally(() => {
|
|
223
|
+
this.#starting = null;
|
|
224
|
+
});
|
|
225
|
+
const started = await this.#starting;
|
|
226
|
+
this.#adopt(started);
|
|
227
|
+
return started;
|
|
228
|
+
}
|
|
229
|
+
#adopt(instance) {
|
|
230
|
+
this.#generation = instance.generation;
|
|
231
|
+
this.#instance = instance;
|
|
232
|
+
this.#watch(instance);
|
|
233
|
+
}
|
|
234
|
+
/** An instance for a document to reopen on, its own having been abandoned. Synchronous: every call is. */
|
|
235
|
+
#another() {
|
|
236
|
+
const current = this.#instance;
|
|
237
|
+
if (this.#usable(current)) {
|
|
238
|
+
return current;
|
|
239
|
+
}
|
|
240
|
+
// Compiled already: it's what the abandoned instance ran.
|
|
241
|
+
const instance = instantiate(this.#generation + 1, this.#base, compiled.get(moduleKey("recto.wasm", this.#base)), this.#documents);
|
|
242
|
+
this.#adopt(instance);
|
|
243
|
+
return instance;
|
|
244
|
+
}
|
|
245
|
+
// Async only so a bad argument rejects; everything up to the copy runs synchronously.
|
|
246
|
+
async open(input, options = {}) {
|
|
247
|
+
// With an instance ready, the bytes go into its memory before the first await, and that's
|
|
248
|
+
// the copy the promise needs: no second one of what may be a large file.
|
|
249
|
+
const ready = this.#ready();
|
|
250
|
+
const bytes = bytesOf(input);
|
|
251
|
+
if (ready) {
|
|
252
|
+
throwIfAborted(options.signal);
|
|
253
|
+
return this.#openOn(ready, bytes, options);
|
|
254
|
+
}
|
|
255
|
+
return await this.openOwned(bytes.slice(), options);
|
|
256
|
+
}
|
|
257
|
+
/** `bytes` is this call's own: getting an instance can take an await. */
|
|
258
|
+
async openOwned(bytes, options) {
|
|
259
|
+
throwIfAborted(options.signal);
|
|
260
|
+
return this.#openOn(await this.#live(), bytes, options);
|
|
261
|
+
}
|
|
262
|
+
#openOn(instance, bytes, options) {
|
|
263
|
+
const password = options.password ?? null;
|
|
264
|
+
let doc;
|
|
265
|
+
try {
|
|
266
|
+
// Where the file is, read now: an abandoned instance may run nothing.
|
|
267
|
+
let opened = new instance.glue.Document(bytes, password);
|
|
268
|
+
let pointer = opened.filePointer();
|
|
269
|
+
const length = opened.fileLength();
|
|
270
|
+
doc = new RectoDocument(instance, opened, (_, oldInstance) => {
|
|
271
|
+
const next = this.#another();
|
|
272
|
+
// The file goes straight from the old heap to the new one.
|
|
273
|
+
opened = new next.glue.Document(new Uint8Array(oldInstance.memory.buffer, pointer, length), password);
|
|
274
|
+
pointer = opened.filePointer();
|
|
275
|
+
return { instance: next, doc: opened };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
throw engineFailure(instance, error);
|
|
280
|
+
}
|
|
281
|
+
if (instance.failure) {
|
|
282
|
+
throw instance.failure;
|
|
283
|
+
}
|
|
284
|
+
return doc;
|
|
285
|
+
}
|
|
286
|
+
async openRanged(source, options = {}) {
|
|
287
|
+
if (!canReadSync()) {
|
|
288
|
+
throw new PdfError("wrong-thread", "a ranged read blocks its thread on the network, which this thread can't do. Open it in a " +
|
|
289
|
+
'dedicated Web Worker: new Worker(new URL("./pdf-worker.js", import.meta.url), { type: "module" }).');
|
|
290
|
+
}
|
|
291
|
+
const owned = ownedSource(source);
|
|
292
|
+
const instance = await this.#live();
|
|
293
|
+
const loader = startRangeLoader(owned, options);
|
|
294
|
+
const read = (offset, length) => {
|
|
295
|
+
const out = new Uint8Array(length);
|
|
296
|
+
return loader.read(offset, out) ? out : undefined;
|
|
297
|
+
};
|
|
298
|
+
const password = options.password ?? null;
|
|
299
|
+
let doc;
|
|
300
|
+
try {
|
|
301
|
+
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password), () => {
|
|
302
|
+
// Nothing is fetched twice: the loader keeps what it read.
|
|
303
|
+
const next = this.#another();
|
|
304
|
+
return {
|
|
305
|
+
instance: next,
|
|
306
|
+
doc: next.glue.Document.openRanged(loader.length, loader.chunkBytes, read, password),
|
|
307
|
+
};
|
|
308
|
+
}, () => loader.failure);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (loader.failure && !(error instanceof WebAssembly.RuntimeError)) {
|
|
312
|
+
throw loader.failure;
|
|
313
|
+
}
|
|
314
|
+
throw engineFailure(instance, error);
|
|
315
|
+
}
|
|
316
|
+
if (loader.failure) {
|
|
317
|
+
doc.close();
|
|
318
|
+
throw loader.failure;
|
|
319
|
+
}
|
|
320
|
+
return Object.assign(doc, { stats: () => loader.stats() });
|
|
321
|
+
}
|
|
322
|
+
stats() {
|
|
323
|
+
return {
|
|
324
|
+
heapBytes: this.#instance?.memory.buffer.byteLength ?? 0,
|
|
325
|
+
generation: this.#generation,
|
|
326
|
+
openDocuments: this.#documents.open,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export async function createPdfEngine(options = {}) {
|
|
331
|
+
return await createEngine(options);
|
|
332
|
+
}
|
|
333
|
+
async function createEngine(options) {
|
|
334
|
+
const base = assetBaseUrl(options.assetBase);
|
|
335
|
+
const canRead = canReadSync();
|
|
336
|
+
for (const asset of options.preloadAssets ?? []) {
|
|
337
|
+
if (asset !== "cmaps.brotli" && asset !== "fonts.bin" && !Object.hasOwn(PINS, asset)) {
|
|
338
|
+
const accepted = [...Object.keys(PINS), "cmaps.brotli", "fonts.bin"].join(", ");
|
|
339
|
+
throw new TypeError(`preloadAssets does not know ${JSON.stringify(asset)}. It takes: ${accepted}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// cmaps.brotli is the old name of cmaps.deflate, and fonts.bin of all the fonts together.
|
|
343
|
+
const names = (options.preloadAssets ?? (canRead ? [] : ALL_ASSETS)).flatMap((asset) => asset === "cmaps.brotli" ? "cmaps.deflate" : asset === "fonts.bin" ? STANDARD_FONTS : asset);
|
|
344
|
+
const isPart = (name) => PARTS.includes(name);
|
|
345
|
+
const files = names.filter((name) => name !== "recto.wasm" && !isPart(name));
|
|
346
|
+
// With no way to read a part from inside a call, all of them now, always: nothing listed can take
|
|
347
|
+
// away the ability to draw.
|
|
348
|
+
const [module] = await Promise.all([
|
|
349
|
+
compile("recto.wasm", base),
|
|
350
|
+
...(canRead ? names.filter(isPart) : PARTS).map((part) => compile(part, base)),
|
|
351
|
+
preloadAssets(base, files),
|
|
352
|
+
]);
|
|
353
|
+
return new RectoEngine(instantiate(0, base, module), base, options.recycleAboveBytes);
|
|
354
|
+
}
|
|
355
|
+
let shared = null;
|
|
356
|
+
let sharedReady = null;
|
|
357
|
+
/**
|
|
358
|
+
* Opens a PDF on this thread's shared engine:
|
|
359
|
+
*
|
|
360
|
+
* await using doc = await openPdf(bytes);
|
|
361
|
+
* doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
362
|
+
*/
|
|
363
|
+
export async function openPdf(bytes, options = {}) {
|
|
364
|
+
if (sharedReady) {
|
|
365
|
+
return sharedReady.open(bytes, options);
|
|
366
|
+
}
|
|
367
|
+
// Getting the engine takes an await: the buffer has to be the caller's again before it.
|
|
368
|
+
const owned = bytesOf(bytes).slice();
|
|
369
|
+
if (!shared) {
|
|
370
|
+
const starting = createEngine({});
|
|
371
|
+
shared = starting;
|
|
372
|
+
starting.then((engine) => {
|
|
373
|
+
sharedReady = engine;
|
|
374
|
+
}, () => { });
|
|
375
|
+
// A failure is not kept: the next call tries again.
|
|
376
|
+
starting.catch(() => {
|
|
377
|
+
if (shared === starting) {
|
|
378
|
+
shared = null;
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return await (await shared).openOwned(owned, options);
|
|
383
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type PdfErrorCode =
|
|
2
|
+
/** Not a PDF the engine can open: garbage, truncated, a broken xref. */
|
|
3
|
+
"invalid-file"
|
|
4
|
+
/** The file is encrypted and no password was given. */
|
|
5
|
+
| "password-required"
|
|
6
|
+
/** The file is encrypted and the password given does not open it. */
|
|
7
|
+
| "wrong-password"
|
|
8
|
+
/** A valid file the engine cannot read: a security handler other than Standard (e.g. DRM). */
|
|
9
|
+
| "unsupported"
|
|
10
|
+
/** The `signal` passed to the call was aborted. */
|
|
11
|
+
| "aborted"
|
|
12
|
+
/**
|
|
13
|
+
* The WebAssembly instance trapped. The process is fine: documents open on that instance
|
|
14
|
+
* fail with this code, and the next open gets a fresh instance.
|
|
15
|
+
*/
|
|
16
|
+
| "crash"
|
|
17
|
+
/** The document was closed. */
|
|
18
|
+
| "closed"
|
|
19
|
+
/** The wasm or a font or CMap asset is missing, unreadable, or not this build's. */
|
|
20
|
+
| "missing-asset"
|
|
21
|
+
/** Called somewhere it cannot run: a page's main thread, a service worker. */
|
|
22
|
+
| "wrong-thread"
|
|
23
|
+
/** A ranged read failed: the network, an HTTP error, or a server's bad Range answer. */
|
|
24
|
+
| "fetch-failed"
|
|
25
|
+
/** A ranged read waited longer than `timeoutMs`, or passed `totalTimeoutMs`. */
|
|
26
|
+
| "timeout"
|
|
27
|
+
/** A ranged read would pass `maxBytes`, or the file is past what a ranged read can hold. */
|
|
28
|
+
| "too-large"
|
|
29
|
+
/** Any other refusal: a page with no area, a render too large to allocate. */
|
|
30
|
+
| "failed";
|
|
31
|
+
export declare class PdfError extends Error {
|
|
32
|
+
readonly code: PdfErrorCode;
|
|
33
|
+
/** On `fetch-failed`, the HTTP status of the answer it objects to. Undefined if the server never answered. */
|
|
34
|
+
readonly status: number | undefined;
|
|
35
|
+
constructor(code: PdfErrorCode, message: string, cause?: unknown, status?: number);
|
|
36
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Every failure the engine reports is a PdfError with a `code`, so a caller can branch
|
|
2
|
+
// without matching strings. A bad argument (a page out of range, a render size of 0) is a
|
|
3
|
+
// plain RangeError or TypeError instead: that is a bug to fix, not a condition to handle.
|
|
4
|
+
export class PdfError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
/** On `fetch-failed`, the HTTP status of the answer it objects to. Undefined if the server never answered. */
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, message, cause, status) {
|
|
9
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
10
|
+
this.name = "PdfError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { ANNOT_SUBTYPE, boxAt, CHAR_GENERATED, CHAR_UNMAPPED, charsText, PATH_FILL, PATH_STROKE, type PdfAnnotation, type PdfBitmap, type PdfBox, type PdfDocument, type PdfFont, type PdfOutlineItem, type PdfPageInfo, type PdfPageObjects, type PdfPageText, type PdfPathSegments, type PdfRenderAsyncOptions, type PdfRenderOptions, type PdfTextRuns, type PdfWords, SEGMENT_BEZIER, SEGMENT_CLOSE, SEGMENT_LINE, SEGMENT_MOVE, SEGMENT_RECT, } from "./document.ts";
|
|
2
|
+
export { createPdfEngine, openPdf, type PdfEngine, type PdfEngineOptions, type PdfEngineStats, type PdfOpenOptions, } from "./engine.ts";
|
|
3
|
+
export { PdfError, type PdfErrorCode } from "./errors.ts";
|
|
4
|
+
export type { PdfPool, PdfPoolDocument, PdfPoolOpenOptions, PdfPoolOptions } from "./pool.ts";
|
|
5
|
+
export { type PageBox, rasterMeasures, showsGlyphs, vectorMeasures, } from "./page-measures.ts";
|
|
6
|
+
export type { PdfRangedDocument, PdfRangedOpenOptions, PdfRangedStats, PdfRangeSource, } from "./ranged.ts";
|
|
7
|
+
export { BREAK_LINE, BREAK_NONE, BREAK_SPACE, readingOrder, type ReadingOrder, } from "./reading-order.ts";
|
|
8
|
+
export { annotationPaintedText, annotationShownText, annotationTextItems, NOT_TEXT, type PdfTextItem, textItems, visibleCodepoint, } from "./text-items.ts";
|
|
9
|
+
/**
|
|
10
|
+
* Runs a document's pages across worker threads:
|
|
11
|
+
*
|
|
12
|
+
* using pool = await createPdfPool();
|
|
13
|
+
* await using doc = await pool.open(bytes);
|
|
14
|
+
* const pages = await Promise.all(Array.from({ length: doc.pageCount }, (_, i) => doc.text(i)));
|
|
15
|
+
*
|
|
16
|
+
* Loaded only when called, so a program that doesn't use it doesn't download it.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createPdfPool(options?: import("./pool.ts").PdfPoolOptions): Promise<import("./pool.ts").PdfPool>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A PDF engine: Rust compiled to WebAssembly behind a small, synchronous API.
|
|
2
|
+
//
|
|
3
|
+
// await using doc = await openPdf(bytes);
|
|
4
|
+
// doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
5
|
+
export { ANNOT_SUBTYPE, boxAt, CHAR_GENERATED, CHAR_UNMAPPED, charsText, PATH_FILL, PATH_STROKE, SEGMENT_BEZIER, SEGMENT_CLOSE, SEGMENT_LINE, SEGMENT_MOVE, SEGMENT_RECT, } from "./document.js";
|
|
6
|
+
export { createPdfEngine, openPdf, } from "./engine.js";
|
|
7
|
+
export { PdfError } from "./errors.js";
|
|
8
|
+
export { rasterMeasures, showsGlyphs, vectorMeasures, } from "./page-measures.js";
|
|
9
|
+
export { BREAK_LINE, BREAK_NONE, BREAK_SPACE, readingOrder, } from "./reading-order.js";
|
|
10
|
+
export { annotationPaintedText, annotationShownText, annotationTextItems, NOT_TEXT, textItems, visibleCodepoint, } from "./text-items.js";
|
|
11
|
+
/**
|
|
12
|
+
* Runs a document's pages across worker threads:
|
|
13
|
+
*
|
|
14
|
+
* using pool = await createPdfPool();
|
|
15
|
+
* await using doc = await pool.open(bytes);
|
|
16
|
+
* const pages = await Promise.all(Array.from({ length: doc.pageCount }, (_, i) => doc.text(i)));
|
|
17
|
+
*
|
|
18
|
+
* Loaded only when called, so a program that doesn't use it doesn't download it.
|
|
19
|
+
*/
|
|
20
|
+
export async function createPdfPool(options = {}) {
|
|
21
|
+
const { startPool } = await import("./pool.js");
|
|
22
|
+
return startPool(options);
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type PdfPageObjects } from "./document.ts";
|
|
2
|
+
/** left/top/right/bottom, top < bottom (viewer frame). */
|
|
3
|
+
export type PageBox = {
|
|
4
|
+
left: number;
|
|
5
|
+
top: number;
|
|
6
|
+
right: number;
|
|
7
|
+
bottom: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The raster measures over a page's image objects: the largest image's
|
|
11
|
+
* share of the page and the summed share (overlaps count twice).
|
|
12
|
+
*/
|
|
13
|
+
export declare function rasterMeasures(images: PdfPageObjects["images"], pageArea: number): {
|
|
14
|
+
largestImageRatio: number;
|
|
15
|
+
rasterCoverageRatio: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Over a page's painted path objects: the line segments that move in both
|
|
19
|
+
* x and y by more than 0.01 plus curves (a cubic is three Bezier points,
|
|
20
|
+
* counted once), the union box of the paths that have any, and the
|
|
21
|
+
* rectangles of filled paths (four-sided axis-aligned subpaths, implicitly
|
|
22
|
+
* closed by fill; `re` is stored that way, so m/l/l/l rectangles count too).
|
|
23
|
+
*/
|
|
24
|
+
export declare function vectorMeasures(objects: Pick<PdfPageObjects, "paths" | "segments">): {
|
|
25
|
+
chartSegmentCount: number;
|
|
26
|
+
chartSegmentBounds: PageBox | null;
|
|
27
|
+
filledRectangles: PageBox[];
|
|
28
|
+
};
|
|
29
|
+
/** Whether a page's text layer shows glyphs (non-space or unmapped chars). */
|
|
30
|
+
export declare function showsGlyphs(text: {
|
|
31
|
+
count: number;
|
|
32
|
+
codepoints: Uint32Array;
|
|
33
|
+
flags: Uint8Array;
|
|
34
|
+
}): boolean;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// What a page paints, measured over its page objects and chars: raster
|
|
2
|
+
// coverage, vector chart segments and filled rectangles, and whether the
|
|
3
|
+
// text layer shows glyphs. A caller applies its own thresholds to these. Boxes are in the viewer frame.
|
|
4
|
+
import { CHAR_GENERATED, CHAR_UNMAPPED, PATH_FILL, SEGMENT_BEZIER, SEGMENT_LINE, SEGMENT_MOVE, SEGMENT_RECT, } from "./document.js";
|
|
5
|
+
/** pdf.js's lineTo test: a chart segment moves in both axes by more than this. */
|
|
6
|
+
const CHART_SEGMENT_MIN_DELTA = 0.01;
|
|
7
|
+
const AXIS_TOLERANCE = 0.01;
|
|
8
|
+
/**
|
|
9
|
+
* The raster measures over a page's image objects: the largest image's
|
|
10
|
+
* share of the page and the summed share (overlaps count twice).
|
|
11
|
+
*/
|
|
12
|
+
export function rasterMeasures(images, pageArea) {
|
|
13
|
+
let largest = 0;
|
|
14
|
+
let total = 0;
|
|
15
|
+
for (let k = 0; k < images.count; k++) {
|
|
16
|
+
const area = Math.max(0, images.boxes[4 * k + 2] ?? 0) *
|
|
17
|
+
Math.max(0, images.boxes[4 * k + 3] ?? 0);
|
|
18
|
+
largest = Math.max(largest, area);
|
|
19
|
+
total += area;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
largestImageRatio: pageArea > 0 ? largest / pageArea : 0,
|
|
23
|
+
rasterCoverageRatio: pageArea > 0 ? total / pageArea : 0,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** The axis-aligned box of a filled four-sided subpath, or null. */
|
|
27
|
+
function rectangleOf(points, kinds, from, to) {
|
|
28
|
+
// MOVE then three or four LINEs (the fourth returning to the start).
|
|
29
|
+
const lines = to - from - 1;
|
|
30
|
+
if (lines !== 3 && lines !== 4) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
for (let s = from + 1; s < to; s++) {
|
|
34
|
+
if (((kinds[s] ?? 0) & 3) !== SEGMENT_LINE) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const sx = points[2 * from] ?? 0;
|
|
39
|
+
const sy = points[2 * from + 1] ?? 0;
|
|
40
|
+
if (lines === 4 &&
|
|
41
|
+
(Math.abs((points[2 * (to - 1)] ?? 0) - sx) > AXIS_TOLERANCE ||
|
|
42
|
+
Math.abs((points[2 * (to - 1) + 1] ?? 0) - sy) > AXIS_TOLERANCE)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
let left = Infinity;
|
|
46
|
+
let right = -Infinity;
|
|
47
|
+
let top = Infinity;
|
|
48
|
+
let bottom = -Infinity;
|
|
49
|
+
let previousHorizontal = null;
|
|
50
|
+
// Fill closes the last edge even without `h`. Alternating nonzero edges
|
|
51
|
+
// reject backtracking paths whose bounding box has area but fill does not.
|
|
52
|
+
for (let s = from; s < from + 4; s++) {
|
|
53
|
+
const x = points[2 * s] ?? 0;
|
|
54
|
+
const y = points[2 * s + 1] ?? 0;
|
|
55
|
+
const next = s === from + 3 ? from : s + 1;
|
|
56
|
+
const dx = Math.abs((points[2 * next] ?? 0) - x);
|
|
57
|
+
const dy = Math.abs((points[2 * next + 1] ?? 0) - y);
|
|
58
|
+
const horizontal = dy <= AXIS_TOLERANCE && dx > AXIS_TOLERANCE;
|
|
59
|
+
const vertical = dx <= AXIS_TOLERANCE && dy > AXIS_TOLERANCE;
|
|
60
|
+
if ((!horizontal && !vertical) || previousHorizontal === horizontal) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
left = Math.min(left, x);
|
|
64
|
+
right = Math.max(right, x);
|
|
65
|
+
top = Math.min(top, y);
|
|
66
|
+
bottom = Math.max(bottom, y);
|
|
67
|
+
previousHorizontal = horizontal;
|
|
68
|
+
}
|
|
69
|
+
return right - left > 0 && bottom - top > 0
|
|
70
|
+
? { left, top, right, bottom }
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Over a page's painted path objects: the line segments that move in both
|
|
75
|
+
* x and y by more than 0.01 plus curves (a cubic is three Bezier points,
|
|
76
|
+
* counted once), the union box of the paths that have any, and the
|
|
77
|
+
* rectangles of filled paths (four-sided axis-aligned subpaths, implicitly
|
|
78
|
+
* closed by fill; `re` is stored that way, so m/l/l/l rectangles count too).
|
|
79
|
+
*/
|
|
80
|
+
export function vectorMeasures(objects) {
|
|
81
|
+
const { paths, segments } = objects;
|
|
82
|
+
const { points, kinds } = segments;
|
|
83
|
+
let chartSegmentCount = 0;
|
|
84
|
+
let chartSegmentBounds = null;
|
|
85
|
+
const filledRectangles = [];
|
|
86
|
+
for (let p = 0; p < paths.count; p++) {
|
|
87
|
+
const from = paths.start[p] ?? 0;
|
|
88
|
+
const to = paths.start[p + 1] ?? from;
|
|
89
|
+
const filled = ((paths.flags[p] ?? 0) & PATH_FILL) !== 0;
|
|
90
|
+
let pathChartSegments = 0;
|
|
91
|
+
let beziers = 0;
|
|
92
|
+
let subpathStart = from;
|
|
93
|
+
for (let s = from; s <= to; s++) {
|
|
94
|
+
const kind = (kinds[s] ?? 0) & 3;
|
|
95
|
+
if (s === to || kind === SEGMENT_MOVE) {
|
|
96
|
+
// pdf.js counts only `re` rectangles, and so does this.
|
|
97
|
+
const drawnByRe = ((kinds[subpathStart] ?? 0) & SEGMENT_RECT) !== 0;
|
|
98
|
+
if (filled && s > subpathStart) {
|
|
99
|
+
const rect = drawnByRe
|
|
100
|
+
? rectangleOf(points, kinds, subpathStart, s)
|
|
101
|
+
: null;
|
|
102
|
+
if (rect) {
|
|
103
|
+
filledRectangles.push(rect);
|
|
104
|
+
}
|
|
105
|
+
const dx = (points[2 * (s - 1)] ?? 0) - (points[2 * subpathStart] ?? 0);
|
|
106
|
+
const dy = (points[2 * (s - 1) + 1] ?? 0) -
|
|
107
|
+
(points[2 * subpathStart + 1] ?? 0);
|
|
108
|
+
if (Math.abs(dx) > CHART_SEGMENT_MIN_DELTA &&
|
|
109
|
+
Math.abs(dy) > CHART_SEGMENT_MIN_DELTA) {
|
|
110
|
+
pathChartSegments += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
subpathStart = s;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (kind === SEGMENT_BEZIER) {
|
|
117
|
+
beziers += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (kind === SEGMENT_LINE && s > from) {
|
|
121
|
+
const dx = (points[2 * s] ?? 0) - (points[2 * s - 2] ?? 0);
|
|
122
|
+
const dy = (points[2 * s + 1] ?? 0) - (points[2 * s - 1] ?? 0);
|
|
123
|
+
if (Math.abs(dx) > CHART_SEGMENT_MIN_DELTA &&
|
|
124
|
+
Math.abs(dy) > CHART_SEGMENT_MIN_DELTA) {
|
|
125
|
+
pathChartSegments += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// A cubic is three Bezier points.
|
|
130
|
+
pathChartSegments += Math.ceil(beziers / 3);
|
|
131
|
+
if (pathChartSegments > 0) {
|
|
132
|
+
chartSegmentCount += pathChartSegments;
|
|
133
|
+
const x = paths.boxes[4 * p] ?? 0;
|
|
134
|
+
const y = paths.boxes[4 * p + 1] ?? 0;
|
|
135
|
+
const box = {
|
|
136
|
+
left: x,
|
|
137
|
+
top: y,
|
|
138
|
+
right: x + (paths.boxes[4 * p + 2] ?? 0),
|
|
139
|
+
bottom: y + (paths.boxes[4 * p + 3] ?? 0),
|
|
140
|
+
};
|
|
141
|
+
chartSegmentBounds = chartSegmentBounds
|
|
142
|
+
? {
|
|
143
|
+
left: Math.min(chartSegmentBounds.left, box.left),
|
|
144
|
+
top: Math.min(chartSegmentBounds.top, box.top),
|
|
145
|
+
right: Math.max(chartSegmentBounds.right, box.right),
|
|
146
|
+
bottom: Math.max(chartSegmentBounds.bottom, box.bottom),
|
|
147
|
+
}
|
|
148
|
+
: box;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { chartSegmentCount, chartSegmentBounds, filledRectangles };
|
|
152
|
+
}
|
|
153
|
+
/** Whether a page's text layer shows glyphs (non-space or unmapped chars). */
|
|
154
|
+
export function showsGlyphs(text) {
|
|
155
|
+
for (let i = 0; i < text.count; i++) {
|
|
156
|
+
const flags = text.flags[i] ?? 0;
|
|
157
|
+
if (flags & CHAR_GENERATED) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (flags & CHAR_UNMAPPED ||
|
|
161
|
+
String.fromCodePoint(text.codepoints[i] ?? 0x20).trim().length > 0) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|