@2018kguo/recto 0.0.0-stage → 0.1.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 +14 -0
- package/dist/assets.js +169 -0
- package/dist/document.d.ts +261 -0
- package/dist/document.js +300 -0
- package/dist/engine.d.ts +54 -0
- package/dist/engine.js +205 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +10 -0
- package/dist/page-measures.d.ts +34 -0
- package/dist/page-measures.js +166 -0
- package/dist/ranged.d.ts +88 -0
- package/dist/ranged.js +470 -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 +47 -4
- package/wasm/THIRD_PARTY_NOTICES +2518 -0
- package/wasm/cjk-fallback.ttf +0 -0
- package/wasm/cmaps.brotli +0 -0
- package/wasm/fonts.bin +0 -0
- package/wasm/pins.d.mts +1 -0
- package/wasm/pins.mjs +7 -0
- package/wasm/recto.gen.d.mts +2 -0
- package/wasm/recto.gen.mjs +1097 -0
- package/wasm/recto.types.d.mts +250 -0
- package/wasm/recto.wasm +0 -0
package/dist/document.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// One open PDF. Every coordinate this returns is in the viewer's frame: PDF points, top-left
|
|
2
|
+
// origin, with /Rotate and the crop box applied.
|
|
3
|
+
import { PdfError } from "./errors.js";
|
|
4
|
+
/** `PdfPageText.flags` bit: a space or line break the engine inferred; not in the content stream. */
|
|
5
|
+
export const CHAR_GENERATED = 1;
|
|
6
|
+
/** `PdfPageText.flags` bit: the glyph has no Unicode mapping, so its text is unreadable. */
|
|
7
|
+
export const CHAR_UNMAPPED = 2;
|
|
8
|
+
/** Entry `i` of a `boxes` column. */
|
|
9
|
+
export function boxAt(boxes, i) {
|
|
10
|
+
return {
|
|
11
|
+
x: boxes[4 * i] ?? 0,
|
|
12
|
+
y: boxes[4 * i + 1] ?? 0,
|
|
13
|
+
width: boxes[4 * i + 2] ?? 0,
|
|
14
|
+
height: boxes[4 * i + 3] ?? 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** The text of chars `first..last` (inclusive). */
|
|
18
|
+
export function charsText(page, first, last) {
|
|
19
|
+
let out = "";
|
|
20
|
+
for (let i = first; i <= last; i++) {
|
|
21
|
+
out += String.fromCodePoint(page.codepoints[i] ?? 0xff_fd);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/** Segment kinds, in the low bits of `PdfPathSegments.kinds`. */
|
|
26
|
+
export const SEGMENT_LINE = 0;
|
|
27
|
+
export const SEGMENT_BEZIER = 1;
|
|
28
|
+
export const SEGMENT_MOVE = 2;
|
|
29
|
+
/** `PdfPathSegments.kinds` bit: the segment closes its subpath. */
|
|
30
|
+
export const SEGMENT_CLOSE = 4;
|
|
31
|
+
/**
|
|
32
|
+
* `PdfPathSegments.kinds` bit on a move: its subpath is a rectangle drawn by
|
|
33
|
+
* `re` (pdf.js counts only those as rectangles).
|
|
34
|
+
*/
|
|
35
|
+
export const SEGMENT_RECT = 8;
|
|
36
|
+
/** `paths.flags` bits. */
|
|
37
|
+
export const PATH_FILL = 1;
|
|
38
|
+
export const PATH_STROKE = 2;
|
|
39
|
+
export const ANNOT_SUBTYPE = {
|
|
40
|
+
text: 1,
|
|
41
|
+
link: 2,
|
|
42
|
+
freeText: 3,
|
|
43
|
+
stamp: 13,
|
|
44
|
+
widget: 20,
|
|
45
|
+
};
|
|
46
|
+
const DEFAULT_MAX_HEIGHT = 4096;
|
|
47
|
+
const MAX_DIMENSION = 16_384;
|
|
48
|
+
/** What a call into the instance threw, as what the caller should see. */
|
|
49
|
+
export function engineFailure(instance, error) {
|
|
50
|
+
if (error instanceof PdfError) {
|
|
51
|
+
return error;
|
|
52
|
+
}
|
|
53
|
+
// An exhausted call stack in WebAssembly is a RangeError, and leaves the instance in the
|
|
54
|
+
// same unknown state as a trap. The argument checks that throw RangeError run before any call.
|
|
55
|
+
if (error instanceof WebAssembly.RuntimeError || error instanceof RangeError) {
|
|
56
|
+
instance.dead = true;
|
|
57
|
+
return new PdfError("crash", `the engine crashed: ${error.message}`, error);
|
|
58
|
+
}
|
|
59
|
+
const code = error?.code;
|
|
60
|
+
if (typeof code === "string") {
|
|
61
|
+
return new PdfError(code, error.message);
|
|
62
|
+
}
|
|
63
|
+
return error;
|
|
64
|
+
}
|
|
65
|
+
/** Runs `read` over an object wasm-bindgen allocated, freeing it even if `read` throws. */
|
|
66
|
+
function consume(owned, read) {
|
|
67
|
+
try {
|
|
68
|
+
return read(owned);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
owned.free();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function toText(t) {
|
|
75
|
+
return consume(t, () => {
|
|
76
|
+
const fontFlags = t.fontFlags;
|
|
77
|
+
const fontWeights = t.fontWeights;
|
|
78
|
+
const fontEmbedded = t.fontEmbedded;
|
|
79
|
+
const fontAscents = t.fontAscents;
|
|
80
|
+
const fontDescents = t.fontDescents;
|
|
81
|
+
const fonts = t.fontNames.map((name, i) => ({
|
|
82
|
+
name,
|
|
83
|
+
flags: fontFlags[i] ?? 0,
|
|
84
|
+
weight: fontWeights[i] ?? 0,
|
|
85
|
+
embedded: fontEmbedded[i] === 1,
|
|
86
|
+
ascent: fontAscents[i] ?? 0,
|
|
87
|
+
descent: fontDescents[i] ?? 0,
|
|
88
|
+
}));
|
|
89
|
+
const wordFirstChar = t.wordFirstChar;
|
|
90
|
+
const runEms = t.runEms;
|
|
91
|
+
return {
|
|
92
|
+
text: t.text,
|
|
93
|
+
count: t.count,
|
|
94
|
+
codepoints: t.codepoints,
|
|
95
|
+
boxes: t.boxes,
|
|
96
|
+
fontSizes: t.fontSizes,
|
|
97
|
+
tfSizes: t.tfSizes,
|
|
98
|
+
truncated: t.truncated,
|
|
99
|
+
angles: t.angles,
|
|
100
|
+
fills: t.fills,
|
|
101
|
+
fontIds: t.fontIds,
|
|
102
|
+
flags: t.flags,
|
|
103
|
+
fonts,
|
|
104
|
+
words: {
|
|
105
|
+
count: wordFirstChar.length,
|
|
106
|
+
firstChar: wordFirstChar,
|
|
107
|
+
lastChar: t.wordLastChar,
|
|
108
|
+
boxes: t.wordBoxes,
|
|
109
|
+
},
|
|
110
|
+
runIds: t.runIds,
|
|
111
|
+
runs: { count: runEms.length, origins: t.runOrigins, ems: runEms },
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function toObjects(o) {
|
|
116
|
+
return consume(o, () => {
|
|
117
|
+
const imageBoxes = o.imageBoxes;
|
|
118
|
+
const pathFlags = o.pathFlags;
|
|
119
|
+
const segmentKinds = o.segmentKinds;
|
|
120
|
+
return {
|
|
121
|
+
textObjects: o.textObjects,
|
|
122
|
+
truncated: o.truncated,
|
|
123
|
+
images: {
|
|
124
|
+
count: imageBoxes.length / 4,
|
|
125
|
+
boxes: imageBoxes,
|
|
126
|
+
pixels: o.imagePixels,
|
|
127
|
+
},
|
|
128
|
+
paths: {
|
|
129
|
+
count: pathFlags.length,
|
|
130
|
+
boxes: o.pathBoxes,
|
|
131
|
+
flags: pathFlags,
|
|
132
|
+
start: o.pathStart,
|
|
133
|
+
},
|
|
134
|
+
segments: {
|
|
135
|
+
count: segmentKinds.length,
|
|
136
|
+
points: o.segmentPoints,
|
|
137
|
+
kinds: segmentKinds,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function toAnnotations(a) {
|
|
143
|
+
return consume(a, () => {
|
|
144
|
+
const subtypes = a.subtypes;
|
|
145
|
+
const boxes = a.boxes;
|
|
146
|
+
const contents = a.contents;
|
|
147
|
+
const hasField = a.hasField;
|
|
148
|
+
const fieldTypes = a.fieldTypes;
|
|
149
|
+
const fieldValues = a.fieldValues;
|
|
150
|
+
const objects = a.appearanceObjects;
|
|
151
|
+
const textObjects = a.appearanceTextObjects;
|
|
152
|
+
const text = a.appearanceText;
|
|
153
|
+
const out = [];
|
|
154
|
+
for (let i = 0; i < a.count; i++) {
|
|
155
|
+
out.push({
|
|
156
|
+
subtype: subtypes[i] ?? 0,
|
|
157
|
+
box: boxAt(boxes, i),
|
|
158
|
+
contents: contents[i] ?? "",
|
|
159
|
+
field: hasField[i] === 1
|
|
160
|
+
? { type: fieldTypes[i] ?? "", value: fieldValues[i] ?? "" }
|
|
161
|
+
: null,
|
|
162
|
+
appearanceObjects: objects[i] ?? 0,
|
|
163
|
+
appearanceTextObjects: textObjects[i] ?? 0,
|
|
164
|
+
appearanceText: text[i] ?? "",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export class RectoDocument {
|
|
171
|
+
pageCount;
|
|
172
|
+
#closed = false;
|
|
173
|
+
#doc;
|
|
174
|
+
#instance;
|
|
175
|
+
/** A ranged read's failure, thrown by the call whose fetch it failed. */
|
|
176
|
+
#fetchFailure;
|
|
177
|
+
constructor(instance, doc, fetchFailure = () => null) {
|
|
178
|
+
this.#instance = instance;
|
|
179
|
+
this.#doc = doc;
|
|
180
|
+
this.#fetchFailure = fetchFailure;
|
|
181
|
+
this.pageCount = doc.pageCount;
|
|
182
|
+
instance.openDocuments += 1;
|
|
183
|
+
}
|
|
184
|
+
#check(index) {
|
|
185
|
+
if (this.#closed) {
|
|
186
|
+
throw new PdfError("closed", "this document is closed");
|
|
187
|
+
}
|
|
188
|
+
if (!(Number.isInteger(index) && index >= 0 && index < this.pageCount)) {
|
|
189
|
+
throw new RangeError(`page ${index} is out of range (0..${this.pageCount - 1})`);
|
|
190
|
+
}
|
|
191
|
+
if (this.#instance.failure) {
|
|
192
|
+
throw this.#instance.failure;
|
|
193
|
+
}
|
|
194
|
+
if (this.#instance.dead) {
|
|
195
|
+
throw new PdfError("crash", "the engine crashed earlier on this thread; open the document again");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
#call(fn) {
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = fn();
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
// A failed fetch or asset surfaces inside the engine as some other error.
|
|
205
|
+
const failure = this.#instance.failure ?? this.#fetchFailure();
|
|
206
|
+
if (failure && !(error instanceof WebAssembly.RuntimeError)) {
|
|
207
|
+
throw failure;
|
|
208
|
+
}
|
|
209
|
+
throw engineFailure(this.#instance, error);
|
|
210
|
+
}
|
|
211
|
+
// Or as a page that reads short: blank text must not pass for the page's text.
|
|
212
|
+
const failure = this.#instance.failure ?? this.#fetchFailure();
|
|
213
|
+
if (failure) {
|
|
214
|
+
throw failure;
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
pageInfo(index) {
|
|
219
|
+
this.#check(index);
|
|
220
|
+
return this.#call(() => {
|
|
221
|
+
const [width = 0, height = 0, rotation = 0] = this.#doc.pageInfo(index);
|
|
222
|
+
return { width, height, rotation: rotation };
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
text(index) {
|
|
226
|
+
this.#check(index);
|
|
227
|
+
return this.#call(() => toText(this.#doc.pageText(index)));
|
|
228
|
+
}
|
|
229
|
+
objects(index) {
|
|
230
|
+
this.#check(index);
|
|
231
|
+
return this.#call(() => toObjects(this.#doc.objects(index)));
|
|
232
|
+
}
|
|
233
|
+
outline() {
|
|
234
|
+
if (this.#closed) {
|
|
235
|
+
throw new PdfError("closed", "this document is closed");
|
|
236
|
+
}
|
|
237
|
+
return this.#call(() => {
|
|
238
|
+
const top = [];
|
|
239
|
+
// The open item at each level; an item's parent is the last one a level up.
|
|
240
|
+
const open = [];
|
|
241
|
+
for (const [title, url, level, page] of this.#doc.outline()) {
|
|
242
|
+
const item = { title, page, url: url || null, children: [] };
|
|
243
|
+
(level === 0 ? top : (open[level - 1]?.children ?? top)).push(item);
|
|
244
|
+
open[level] = item;
|
|
245
|
+
}
|
|
246
|
+
return top;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
annotations(index) {
|
|
250
|
+
this.#check(index);
|
|
251
|
+
return this.#call(() => toAnnotations(this.#doc.annotations(index)));
|
|
252
|
+
}
|
|
253
|
+
scan(index) {
|
|
254
|
+
this.#check(index);
|
|
255
|
+
return this.#call(() => consume(this.#doc.scan(index), (scan) => ({
|
|
256
|
+
text: toText(scan.takeText()),
|
|
257
|
+
objects: toObjects(scan.takeObjects()),
|
|
258
|
+
annotations: toAnnotations(scan.takeAnnotations()),
|
|
259
|
+
})));
|
|
260
|
+
}
|
|
261
|
+
textAndAnnotations(index) {
|
|
262
|
+
this.#check(index);
|
|
263
|
+
return this.#call(() => consume(this.#doc.textAndAnnotations(index), (scan) => ({
|
|
264
|
+
text: toText(scan.takeText()),
|
|
265
|
+
annotations: toAnnotations(scan.takeAnnotations()),
|
|
266
|
+
})));
|
|
267
|
+
}
|
|
268
|
+
render(index, options) {
|
|
269
|
+
this.#check(index);
|
|
270
|
+
const maxHeight = options.maxHeight ?? DEFAULT_MAX_HEIGHT;
|
|
271
|
+
if (!(options.width > 0 && options.width <= MAX_DIMENSION) ||
|
|
272
|
+
!(maxHeight > 0)) {
|
|
273
|
+
throw new RangeError(`render width must be in 1..${MAX_DIMENSION} and maxHeight above 0`);
|
|
274
|
+
}
|
|
275
|
+
return this.#call(() => consume(this.#doc.render(index, options.width, maxHeight), (page) => ({
|
|
276
|
+
width: page.width,
|
|
277
|
+
height: page.height,
|
|
278
|
+
truncated: page.truncated,
|
|
279
|
+
rgba: page.takeRgba(),
|
|
280
|
+
})));
|
|
281
|
+
}
|
|
282
|
+
close() {
|
|
283
|
+
if (this.#closed) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
this.#closed = true;
|
|
287
|
+
this.#instance.openDocuments -= 1;
|
|
288
|
+
if (!this.#instance.dead) {
|
|
289
|
+
this.#doc.free();
|
|
290
|
+
}
|
|
291
|
+
this.#instance.onClose?.();
|
|
292
|
+
}
|
|
293
|
+
[Symbol.dispose]() {
|
|
294
|
+
this.close();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// A browser without Symbol.dispose yet: tools that lower `using` look for this one instead.
|
|
298
|
+
Object.defineProperty(RectoDocument.prototype, Symbol.for("Symbol.dispose"), {
|
|
299
|
+
value: RectoDocument.prototype.close,
|
|
300
|
+
});
|
package/dist/engine.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type AssetName } from "./assets.ts";
|
|
2
|
+
import { type PdfDocument } from "./document.ts";
|
|
3
|
+
import { type PdfRangedDocument, type PdfRangedOpenOptions, type PdfRangeSource } from "./ranged.ts";
|
|
4
|
+
export type PdfEngineOptions = {
|
|
5
|
+
/**
|
|
6
|
+
* The directory holding the package's wasm/ files, for setups that move the package
|
|
7
|
+
* without them (a bundler that does not rewrite `new URL`, a CDN). A URL in a browser; a
|
|
8
|
+
* path or file: URL in Node. Defaults to the package's own wasm/ directory.
|
|
9
|
+
*/
|
|
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. */
|
|
13
|
+
recycleAboveBytes?: number;
|
|
14
|
+
/**
|
|
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
|
+
*/
|
|
20
|
+
preloadAssets?: Exclude<AssetName, "recto.wasm">[];
|
|
21
|
+
};
|
|
22
|
+
export type PdfOpenOptions = {
|
|
23
|
+
password?: string;
|
|
24
|
+
/** Checked before the open. */
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
};
|
|
27
|
+
export type PdfEngineStats = {
|
|
28
|
+
heapBytes: number;
|
|
29
|
+
/** Increments every time the instance is replaced. heapBytes is 0 while there is none. */
|
|
30
|
+
generation: number;
|
|
31
|
+
openDocuments: number;
|
|
32
|
+
};
|
|
33
|
+
/** One instance per thread is enough; several documents can be open on it. */
|
|
34
|
+
export interface PdfEngine {
|
|
35
|
+
/**
|
|
36
|
+
* Opens a PDF held in memory. The bytes are copied before this returns its promise, so the
|
|
37
|
+
* caller may reuse, change or transfer its buffer straight away, without waiting for it.
|
|
38
|
+
*/
|
|
39
|
+
open(bytes: Uint8Array | ArrayBuffer, options?: PdfOpenOptions): Promise<PdfDocument>;
|
|
40
|
+
/**
|
|
41
|
+
* Opens a PDF read by byte range, fetching only what the engine parses. Every read of it
|
|
42
|
+
* can block this thread for a network round trip; see `timeoutMs`.
|
|
43
|
+
*/
|
|
44
|
+
openRanged(source: PdfRangeSource, options?: PdfRangedOpenOptions): Promise<PdfRangedDocument>;
|
|
45
|
+
stats(): PdfEngineStats;
|
|
46
|
+
}
|
|
47
|
+
export declare function createPdfEngine(options?: PdfEngineOptions): Promise<PdfEngine>;
|
|
48
|
+
/**
|
|
49
|
+
* Opens a PDF on this thread's shared engine:
|
|
50
|
+
*
|
|
51
|
+
* await using doc = await openPdf(bytes);
|
|
52
|
+
* doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
53
|
+
*/
|
|
54
|
+
export declare function openPdf(bytes: Uint8Array | ArrayBuffer, options?: PdfOpenOptions): Promise<PdfDocument>;
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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 as soon as its last document closes; documents still open on an instance keep it.
|
|
4
|
+
import { createGlue } from "../wasm/recto.gen.mjs";
|
|
5
|
+
import { assetBaseUrl, canReadSync, fontFromBundle, loadAssetSync, loadWasm, preloadAssets, } from "./assets.js";
|
|
6
|
+
import { engineFailure, RectoDocument, } from "./document.js";
|
|
7
|
+
import { PdfError } from "./errors.js";
|
|
8
|
+
import { ownedSource, startRangeLoader, } from "./ranged.js";
|
|
9
|
+
const CJK_FONT_ASSET = 14;
|
|
10
|
+
const CMAPS_ASSET = 15;
|
|
11
|
+
const DEFAULT_RECYCLE_ABOVE_BYTES = 512 * 1024 * 1024;
|
|
12
|
+
/** The wasm, verified and compiled once per thread, per location. */
|
|
13
|
+
const compiled = new Map();
|
|
14
|
+
function compile(base) {
|
|
15
|
+
const key = base?.href ?? "";
|
|
16
|
+
let module = compiled.get(key);
|
|
17
|
+
if (!module) {
|
|
18
|
+
module = loadWasm(base).then((bytes) => WebAssembly.compile(bytes).catch((error) => {
|
|
19
|
+
throw new PdfError("missing-asset", `recto could not compile recto.wasm: ${String(error)}. A Content-Security-Policy must allow 'wasm-unsafe-eval'.`, error);
|
|
20
|
+
}));
|
|
21
|
+
module.catch(() => compiled.delete(key));
|
|
22
|
+
compiled.set(key, module);
|
|
23
|
+
}
|
|
24
|
+
return module;
|
|
25
|
+
}
|
|
26
|
+
async function instantiate(generation, base) {
|
|
27
|
+
const module = await compile(base);
|
|
28
|
+
// Each instance gets its own glue closure (scripts/package-wasm.mjs), so a retired
|
|
29
|
+
// instance and its memory are collected with it.
|
|
30
|
+
const glue = createGlue();
|
|
31
|
+
const exports = glue.initSync({ module });
|
|
32
|
+
const instance = {
|
|
33
|
+
glue,
|
|
34
|
+
memory: exports.memory,
|
|
35
|
+
generation,
|
|
36
|
+
openDocuments: 0,
|
|
37
|
+
dead: false,
|
|
38
|
+
failure: undefined,
|
|
39
|
+
};
|
|
40
|
+
// The engine asks for a built-in font (0..13), the CJK fallback or its predefined CMaps
|
|
41
|
+
// the first time a document needs one. It reads a throw as a missing asset, which would
|
|
42
|
+
// be blank text, so keep the error for the call in progress to rethrow, and retire the
|
|
43
|
+
// instance so the next open tries again.
|
|
44
|
+
glue.setAssetLoader((index) => {
|
|
45
|
+
try {
|
|
46
|
+
if (index === CMAPS_ASSET) {
|
|
47
|
+
return loadAssetSync("cmaps.brotli", base);
|
|
48
|
+
}
|
|
49
|
+
if (index === CJK_FONT_ASSET) {
|
|
50
|
+
return loadAssetSync("cjk-fallback.ttf", base);
|
|
51
|
+
}
|
|
52
|
+
return fontFromBundle(loadAssetSync("fonts.bin", base), index);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
instance.dead = true;
|
|
56
|
+
instance.failure = error;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return instance;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A copy of the caller's bytes, taken before anything asynchronous, so the buffer is theirs
|
|
64
|
+
* again the moment the call returns its promise. (The engine then copies them into WebAssembly
|
|
65
|
+
* memory, so the file is held twice for the length of the open.)
|
|
66
|
+
*/
|
|
67
|
+
function ownedBytes(input) {
|
|
68
|
+
if (ArrayBuffer.isView(input)) {
|
|
69
|
+
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength).slice();
|
|
70
|
+
}
|
|
71
|
+
// Not instanceof: an ArrayBuffer from another realm (a test runner's sandbox) is just as good.
|
|
72
|
+
if (Object.prototype.toString.call(input) === "[object ArrayBuffer]") {
|
|
73
|
+
return new Uint8Array(input).slice();
|
|
74
|
+
}
|
|
75
|
+
throw new TypeError("expected the PDF's bytes as a Uint8Array, Buffer or ArrayBuffer");
|
|
76
|
+
}
|
|
77
|
+
function throwIfAborted(signal) {
|
|
78
|
+
if (signal?.aborted) {
|
|
79
|
+
throw new PdfError("aborted", "the signal was aborted", signal.reason);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
class RectoEngine {
|
|
83
|
+
#instance;
|
|
84
|
+
#generation;
|
|
85
|
+
#starting = null;
|
|
86
|
+
#base;
|
|
87
|
+
#recycleAboveBytes;
|
|
88
|
+
constructor(instance, base, recycleAboveBytes) {
|
|
89
|
+
this.#instance = instance;
|
|
90
|
+
this.#generation = instance.generation;
|
|
91
|
+
this.#base = base;
|
|
92
|
+
this.#recycleAboveBytes = recycleAboveBytes;
|
|
93
|
+
this.#watch(instance);
|
|
94
|
+
}
|
|
95
|
+
/** Memory only comes back with the instance, so don't keep one past the mark with nothing on it. */
|
|
96
|
+
#watch(instance) {
|
|
97
|
+
instance.onClose = () => {
|
|
98
|
+
if (this.#instance === instance &&
|
|
99
|
+
instance.openDocuments === 0 &&
|
|
100
|
+
instance.memory.buffer.byteLength > this.#recycleAboveBytes) {
|
|
101
|
+
this.#instance = null;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async #live() {
|
|
106
|
+
const current = this.#instance;
|
|
107
|
+
if (current && !current.dead && current.memory.buffer.byteLength <= this.#recycleAboveBytes) {
|
|
108
|
+
return current;
|
|
109
|
+
}
|
|
110
|
+
this.#starting ??= instantiate(this.#generation + 1, this.#base).finally(() => {
|
|
111
|
+
this.#starting = null;
|
|
112
|
+
});
|
|
113
|
+
const started = await this.#starting;
|
|
114
|
+
this.#generation = started.generation;
|
|
115
|
+
this.#instance = started;
|
|
116
|
+
this.#watch(started);
|
|
117
|
+
return started;
|
|
118
|
+
}
|
|
119
|
+
// Async only so a bad argument rejects; everything up to the copy runs synchronously.
|
|
120
|
+
async open(input, options = {}) {
|
|
121
|
+
return await this.openOwned(ownedBytes(input), options);
|
|
122
|
+
}
|
|
123
|
+
/** `bytes` is this call's own: getting an instance can take an await. */
|
|
124
|
+
async openOwned(bytes, options) {
|
|
125
|
+
throwIfAborted(options.signal);
|
|
126
|
+
const instance = await this.#live();
|
|
127
|
+
let doc;
|
|
128
|
+
try {
|
|
129
|
+
doc = new RectoDocument(instance, new instance.glue.Document(bytes, options.password ?? null));
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw engineFailure(instance, error);
|
|
133
|
+
}
|
|
134
|
+
if (instance.failure) {
|
|
135
|
+
throw instance.failure;
|
|
136
|
+
}
|
|
137
|
+
return doc;
|
|
138
|
+
}
|
|
139
|
+
async openRanged(source, options = {}) {
|
|
140
|
+
if (!canReadSync()) {
|
|
141
|
+
throw new PdfError("wrong-thread", "a ranged read blocks its thread on the network, which this thread can't do. Open it in a " +
|
|
142
|
+
'dedicated Web Worker: new Worker(new URL("./pdf-worker.js", import.meta.url), { type: "module" }).');
|
|
143
|
+
}
|
|
144
|
+
const owned = ownedSource(source);
|
|
145
|
+
const instance = await this.#live();
|
|
146
|
+
const loader = startRangeLoader(owned, options);
|
|
147
|
+
const read = (offset, length) => {
|
|
148
|
+
const out = new Uint8Array(length);
|
|
149
|
+
return loader.read(offset, out) ? out : undefined;
|
|
150
|
+
};
|
|
151
|
+
let doc;
|
|
152
|
+
try {
|
|
153
|
+
doc = new RectoDocument(instance, instance.glue.Document.openRanged(loader.length, loader.chunkBytes, read, options.password ?? null), () => loader.failure);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (loader.failure && !(error instanceof WebAssembly.RuntimeError)) {
|
|
157
|
+
throw loader.failure;
|
|
158
|
+
}
|
|
159
|
+
throw engineFailure(instance, error);
|
|
160
|
+
}
|
|
161
|
+
if (loader.failure) {
|
|
162
|
+
doc.close();
|
|
163
|
+
throw loader.failure;
|
|
164
|
+
}
|
|
165
|
+
return Object.assign(doc, { stats: () => loader.stats() });
|
|
166
|
+
}
|
|
167
|
+
stats() {
|
|
168
|
+
return {
|
|
169
|
+
heapBytes: this.#instance?.memory.buffer.byteLength ?? 0,
|
|
170
|
+
generation: this.#generation,
|
|
171
|
+
openDocuments: this.#instance?.openDocuments ?? 0,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
export async function createPdfEngine(options = {}) {
|
|
176
|
+
return await createEngine(options);
|
|
177
|
+
}
|
|
178
|
+
async function createEngine(options) {
|
|
179
|
+
const base = assetBaseUrl(options.assetBase);
|
|
180
|
+
if (!canReadSync()) {
|
|
181
|
+
await preloadAssets(base, options.preloadAssets ?? ["fonts.bin", "cmaps.brotli", "cjk-fallback.ttf"]);
|
|
182
|
+
}
|
|
183
|
+
return new RectoEngine(await instantiate(0, base), base, options.recycleAboveBytes ?? DEFAULT_RECYCLE_ABOVE_BYTES);
|
|
184
|
+
}
|
|
185
|
+
let shared = null;
|
|
186
|
+
/**
|
|
187
|
+
* Opens a PDF on this thread's shared engine:
|
|
188
|
+
*
|
|
189
|
+
* await using doc = await openPdf(bytes);
|
|
190
|
+
* doc.pageCount; doc.text(0); doc.render(0, { width: 1024 });
|
|
191
|
+
*/
|
|
192
|
+
export async function openPdf(bytes, options = {}) {
|
|
193
|
+
const owned = ownedBytes(bytes);
|
|
194
|
+
if (!shared) {
|
|
195
|
+
const starting = createEngine({});
|
|
196
|
+
shared = starting;
|
|
197
|
+
// A failure is not kept: the next call tries again.
|
|
198
|
+
starting.catch(() => {
|
|
199
|
+
if (shared === starting) {
|
|
200
|
+
shared = null;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return await (await shared).openOwned(owned, options);
|
|
205
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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`. */
|
|
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
|
+
constructor(code: PdfErrorCode, message: string, cause?: unknown);
|
|
34
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
constructor(code, message, cause) {
|
|
7
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
8
|
+
this.name = "PdfError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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 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 PageBox, rasterMeasures, showsGlyphs, vectorMeasures, } from "./page-measures.ts";
|
|
5
|
+
export type { PdfRangedDocument, PdfRangedOpenOptions, PdfRangedStats, PdfRangeSource, } from "./ranged.ts";
|
|
6
|
+
export { BREAK_LINE, BREAK_NONE, BREAK_SPACE, readingOrder, type ReadingOrder, } from "./reading-order.ts";
|
|
7
|
+
export { annotationPaintedText, annotationShownText, annotationTextItems, NOT_TEXT, type PdfTextItem, textItems, visibleCodepoint, } from "./text-items.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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";
|
|
@@ -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;
|