@jasy/pdf 1.0.0-alpha.6 → 1.0.0-alpha.8
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/README.md +7 -7
- package/dist/api/descriptor.js +26 -1
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/layout.d.ts +47 -14
- package/dist/api/layout.js +47 -11
- package/dist/api/structure.d.ts +11 -0
- package/dist/api/structure.js +2 -0
- package/dist/api/text.d.ts +18 -2
- package/dist/api/text.js +15 -0
- package/dist/crypto/legacy-handler.d.ts +43 -0
- package/dist/crypto/legacy-handler.js +145 -0
- package/dist/crypto/security-handler.d.ts +16 -0
- package/dist/crypto/security-handler.js +26 -0
- package/dist/edit/acroform-reader.d.ts +70 -0
- package/dist/edit/acroform-reader.js +126 -0
- package/dist/edit/appearance.d.ts +47 -0
- package/dist/edit/appearance.js +171 -0
- package/dist/edit/document.d.ts +135 -0
- package/dist/edit/document.js +649 -0
- package/dist/edit/fill.d.ts +48 -0
- package/dist/edit/fill.js +395 -0
- package/dist/edit/flatten.d.ts +42 -0
- package/dist/edit/flatten.js +437 -0
- package/dist/edit/index.d.ts +18 -0
- package/dist/edit/index.js +16 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +63 -0
- package/dist/edit/objects.js +56 -0
- package/dist/edit/writer.d.ts +53 -0
- package/dist/edit/writer.js +261 -0
- package/dist/elements/container-element.d.ts +10 -1
- package/dist/elements/container-element.js +26 -8
- package/dist/elements/forms/checkbox-element.d.ts +51 -0
- package/dist/elements/forms/checkbox-element.js +48 -0
- package/dist/elements/forms/choice-element.d.ts +53 -0
- package/dist/elements/forms/choice-element.js +59 -0
- package/dist/elements/forms/push-button-element.d.ts +56 -0
- package/dist/elements/forms/push-button-element.js +49 -0
- package/dist/elements/forms/radio-element.d.ts +52 -0
- package/dist/elements/forms/radio-element.js +49 -0
- package/dist/elements/forms/signature-element.d.ts +54 -0
- package/dist/elements/forms/signature-element.js +47 -0
- package/dist/elements/forms/text-field-element.d.ts +61 -0
- package/dist/elements/forms/text-field-element.js +54 -0
- package/dist/elements/index.d.ts +8 -0
- package/dist/elements/index.js +8 -0
- package/dist/elements/layout/keep-together-element.d.ts +42 -0
- package/dist/elements/layout/keep-together-element.js +85 -0
- package/dist/elements/layout/padding-element.d.ts +3 -0
- package/dist/elements/layout/padding-element.js +12 -0
- package/dist/elements/layout/page-break-element.d.ts +17 -0
- package/dist/elements/layout/page-break-element.js +24 -0
- package/dist/elements/layout/positioned-element.d.ts +10 -7
- package/dist/elements/layout/positioned-element.js +21 -9
- package/dist/elements/page-element.d.ts +11 -1
- package/dist/elements/page-element.js +41 -16
- package/dist/elements/pdf-element.d.ts +28 -0
- package/dist/elements/pdf-element.js +32 -0
- package/dist/elements/rectangle-element.d.ts +10 -1
- package/dist/elements/rectangle-element.js +27 -6
- package/dist/elements/row-element.d.ts +9 -1
- package/dist/elements/row-element.js +9 -1
- package/dist/elements/text-element.d.ts +30 -4
- package/dist/elements/text-element.js +40 -17
- package/dist/forms/acroform.d.ts +63 -0
- package/dist/forms/acroform.js +427 -0
- package/dist/forms/appearance.d.ts +58 -0
- package/dist/forms/appearance.js +204 -0
- package/dist/forms/field.d.ts +164 -0
- package/dist/forms/field.js +1 -0
- package/dist/forms/pdf.d.ts +19 -0
- package/dist/forms/pdf.js +28 -0
- package/dist/ir/display-list.d.ts +18 -1
- package/dist/layout/fragmentation.d.ts +12 -0
- package/dist/layout/fragmentation.js +45 -8
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/keep-together-renderer.d.ts +10 -0
- package/dist/renderer/keep-together-renderer.js +12 -0
- package/dist/renderer/page-break-renderer.d.ts +14 -0
- package/dist/renderer/page-break-renderer.js +15 -0
- package/dist/renderer/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.d.ts +7 -0
- package/dist/renderer/pdf-backend.js +48 -6
- package/dist/renderer/pdf-document-class.js +3 -0
- package/dist/renderer/pdf-document-renderer.js +26 -7
- package/dist/renderer/pdf-renderer.js +25 -1
- package/dist/renderer/text-renderer.d.ts +1 -1
- package/dist/renderer/text-renderer.js +107 -59
- package/dist/text/advance.d.ts +34 -0
- package/dist/text/advance.js +42 -0
- package/dist/text/line-breaker.d.ts +9 -8
- package/dist/text/line-breaker.js +28 -21
- package/dist/text/line-metrics.d.ts +49 -0
- package/dist/text/line-metrics.js +57 -0
- package/dist/text/text-decoration.d.ts +50 -0
- package/dist/text/text-decoration.js +62 -0
- package/dist/text/text-style.d.ts +13 -3
- package/dist/text/text-style.js +10 -3
- package/dist/utils/afm-parser.d.ts +34 -0
- package/dist/utils/afm-parser.js +67 -0
- package/dist/utils/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/font-metrics.d.ts +14 -0
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +45 -1
- package/dist/utils/pdf-object-manager.js +193 -26
- package/dist/utils/struct-tree.js +2 -3
- package/dist/utils/ttf-parser.d.ts +53 -0
- package/dist/utils/ttf-parser.js +368 -0
- package/dist/validators/element-validator.js +7 -3
- package/package.json +9 -2
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
import { unzlibSync } from "fflate";
|
|
2
|
+
import { Lexer } from "./lexer.js";
|
|
3
|
+
import { get, isDict, isRef, isStream, isString, nameOf, numberOf, } from "./objects.js";
|
|
4
|
+
import { StandardAes256 } from "../crypto/security-handler.js";
|
|
5
|
+
import { StandardLegacy } from "../crypto/legacy-handler.js";
|
|
6
|
+
const EMPTY = new Uint8Array(0);
|
|
7
|
+
/** Thrown when a file cannot be opened because of its encryption - always saying which of the reasons it
|
|
8
|
+
* is: no password, wrong password, or a revision we do not implement. */
|
|
9
|
+
export class PdfEncryptedError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(`@jasy/pdf: ${message}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** The raw bytes of a `/Encrypt` entry that must be a string (`/U`, `/UE`). */
|
|
15
|
+
const stringBytes = (o) => isString(o) ? o.bytes : undefined;
|
|
16
|
+
/** Every string reachable inside an object, so the whole tree can be decrypted in one walk. */
|
|
17
|
+
function stringsIn(o, out = [], depth = 0) {
|
|
18
|
+
if (o === undefined || o === null || depth > 64)
|
|
19
|
+
return out;
|
|
20
|
+
if (isString(o))
|
|
21
|
+
out.push(o);
|
|
22
|
+
else if (Array.isArray(o))
|
|
23
|
+
for (const e of o)
|
|
24
|
+
stringsIn(e, out, depth + 1);
|
|
25
|
+
else if (isStream(o))
|
|
26
|
+
stringsIn(o.dict, out, depth + 1);
|
|
27
|
+
else if (isDict(o))
|
|
28
|
+
for (const v of o.map.values())
|
|
29
|
+
stringsIn(v, out, depth + 1);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export class PdfDocument {
|
|
33
|
+
constructor(bytes) {
|
|
34
|
+
this.bytes = bytes;
|
|
35
|
+
this.xref = new Map();
|
|
36
|
+
this.cache = new Map();
|
|
37
|
+
/** Object streams already unpacked, by their object number. */
|
|
38
|
+
this.objStmCache = new Map();
|
|
39
|
+
/** Decrypted stream payloads, keyed by the stream's data offset (unique per stream). */
|
|
40
|
+
this.plainStreams = new Map();
|
|
41
|
+
/** True when the cross-reference index was unusable and had to be rebuilt by scanning. Surfaced so
|
|
42
|
+
* a caller can report it - a silently repaired file is exactly the kind of guess we avoid. */
|
|
43
|
+
this.recovered = false;
|
|
44
|
+
this.trailer = { kind: "dict", map: new Map() };
|
|
45
|
+
/** Whether this file indexes itself with an xref STREAM rather than a classic table. An incremental
|
|
46
|
+
* update has to follow suit: mixing the two leaves readers that understand only one kind unable to
|
|
47
|
+
* walk the chain back. */
|
|
48
|
+
this.usesXrefStream = false;
|
|
49
|
+
}
|
|
50
|
+
static load(bytes) {
|
|
51
|
+
const doc = new PdfDocument(bytes);
|
|
52
|
+
try {
|
|
53
|
+
doc.readXrefChain();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
doc.xref.clear();
|
|
57
|
+
}
|
|
58
|
+
// No index, or one that does not lead to a catalog: rebuild it by scanning for object headers.
|
|
59
|
+
// Damaged tables are common enough in the wild that refusing here would reject usable files.
|
|
60
|
+
if (doc.xref.size === 0 || doc.catalog === undefined)
|
|
61
|
+
doc.rebuildByScanning();
|
|
62
|
+
return doc;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Open a document, decrypting it when it is password-protected.
|
|
66
|
+
*
|
|
67
|
+
* Async because key derivation and AES are (WebCrypto), and because decryption happens ONCE here,
|
|
68
|
+
* eagerly, rather than at every read: that keeps `getObject`, `streamData` and the whole form layer
|
|
69
|
+
* above them synchronous, which is the same trade the writer makes (`structure.ts` builds the handler
|
|
70
|
+
* before the synchronous constructor).
|
|
71
|
+
*/
|
|
72
|
+
static async open(bytes, opts = {}) {
|
|
73
|
+
const doc = PdfDocument.load(bytes);
|
|
74
|
+
if (!doc.isEncrypted)
|
|
75
|
+
return doc;
|
|
76
|
+
doc.security = await doc.securityHandlerFor(opts.password);
|
|
77
|
+
await doc.decryptEverything();
|
|
78
|
+
return doc;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Build the handler for this file's `/Encrypt` dictionary, or say precisely why we cannot.
|
|
82
|
+
*
|
|
83
|
+
* Every refusal names its reason: no password given, wrong password, or an encryption revision we do
|
|
84
|
+
* not implement. "Cannot open this file" would be the unhelpful answer, and guessing would be worse.
|
|
85
|
+
*/
|
|
86
|
+
async securityHandlerFor(password) {
|
|
87
|
+
const enc = this.resolve(this.trailer.map.get("Encrypt"));
|
|
88
|
+
const filter = nameOf(this.lookup(enc, "Filter"));
|
|
89
|
+
const v = numberOf(this.lookup(enc, "V")) ?? 0;
|
|
90
|
+
const r = numberOf(this.lookup(enc, "R")) ?? 0;
|
|
91
|
+
if (filter !== "Standard") {
|
|
92
|
+
throw new PdfEncryptedError(`this PDF uses the ${filter ?? "unknown"} security handler; jasy implements only the standard one`);
|
|
93
|
+
}
|
|
94
|
+
// An EMPTY user password is legal and common: a document restricted only by an owner password opens
|
|
95
|
+
// everywhere without prompting. So always try "" first and only complain about a MISSING password
|
|
96
|
+
// once that has failed - refusing up front would reject files nothing else refuses.
|
|
97
|
+
const pw = password ?? "";
|
|
98
|
+
const rejected = () => {
|
|
99
|
+
throw password === undefined
|
|
100
|
+
? new PdfEncryptedError("this PDF is encrypted; pass its password to open it")
|
|
101
|
+
: new PdfEncryptedError("wrong password for this PDF");
|
|
102
|
+
};
|
|
103
|
+
// `recoverFileKey` / `StandardLegacy.open` say "wrong password" by name; anything else that comes out
|
|
104
|
+
// of them is structural (a malformed /U, /UE, /O) and must not be reported as a bad password.
|
|
105
|
+
const isWrongPassword = (e) => String(e?.message ?? e).includes("wrong password");
|
|
106
|
+
const incomplete = (what) => {
|
|
107
|
+
throw new PdfEncryptedError(`this PDF's /Encrypt dictionary is ${what}; it cannot be opened`);
|
|
108
|
+
};
|
|
109
|
+
// R5 and R6 are AES-256 with one file key; R2 to R4 derive a key per object and may use RC4.
|
|
110
|
+
if (v === 5 && (r === 5 || r === 6)) {
|
|
111
|
+
const u = stringBytes(this.lookup(enc, "U"));
|
|
112
|
+
const ue = stringBytes(this.lookup(enc, "UE"));
|
|
113
|
+
if (u === undefined || ue === undefined || u.length < 48)
|
|
114
|
+
incomplete("incomplete");
|
|
115
|
+
// /UE is unwrapped with AES-CBC and no padding, so a length that is not a whole number of blocks
|
|
116
|
+
// is a broken file, not a wrong password.
|
|
117
|
+
if (ue.length === 0 || ue.length % 16 !== 0)
|
|
118
|
+
incomplete("/UE is not a whole number of AES blocks");
|
|
119
|
+
try {
|
|
120
|
+
return await StandardAes256.forReading(pw, u, ue, r);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (isWrongPassword(e))
|
|
124
|
+
rejected();
|
|
125
|
+
throw new PdfEncryptedError(`this PDF's encryption could not be unwrapped: ${String(e?.message ?? e)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (r >= 2 && r <= 4) {
|
|
129
|
+
const o = stringBytes(this.lookup(enc, "O"));
|
|
130
|
+
const u = stringBytes(this.lookup(enc, "U"));
|
|
131
|
+
if (o === undefined || u === undefined)
|
|
132
|
+
incomplete("incomplete");
|
|
133
|
+
const cipher = this.streamCipher(enc, v);
|
|
134
|
+
if (cipher.kind === "identity") {
|
|
135
|
+
throw new PdfEncryptedError("this PDF declares encryption but selects the Identity crypt filter, which jasy does not model");
|
|
136
|
+
}
|
|
137
|
+
const ids = this.trailer.map.get("ID");
|
|
138
|
+
const firstId = Array.isArray(ids) ? stringBytes(this.resolve(ids[0])) : undefined;
|
|
139
|
+
try {
|
|
140
|
+
return StandardLegacy.open(pw, {
|
|
141
|
+
revision: r,
|
|
142
|
+
keyBits: cipher.keyBits,
|
|
143
|
+
o: o,
|
|
144
|
+
u: u,
|
|
145
|
+
permissions: numberOf(this.lookup(enc, "P")) ?? 0,
|
|
146
|
+
id: firstId ?? EMPTY,
|
|
147
|
+
encryptMetadata: this.lookup(enc, "EncryptMetadata") !== false,
|
|
148
|
+
aes: cipher.kind === "aes",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
if (isWrongPassword(e))
|
|
153
|
+
rejected();
|
|
154
|
+
throw new PdfEncryptedError(`this PDF's encryption could not be set up: ${String(e?.message ?? e)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw new PdfEncryptedError(`this PDF uses standard security V${v}/R${r}, which jasy does not implement`);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Which cipher and key length actually apply to the streams.
|
|
161
|
+
*
|
|
162
|
+
* For V4 the answer is NOT the top-level `/Length`: the document names a crypt filter in `/StmF` and
|
|
163
|
+
* that filter carries its own `/CFM` and `/Length` (in BYTES, where the top-level one is in bits).
|
|
164
|
+
* `/Identity` means the streams are not enciphered at all - decrypting them anyway would destroy them.
|
|
165
|
+
*/
|
|
166
|
+
streamCipher(enc, v) {
|
|
167
|
+
const topBits = numberOf(this.lookup(enc, "Length")) ?? 40;
|
|
168
|
+
if (v < 4)
|
|
169
|
+
return { kind: "rc4", keyBits: topBits };
|
|
170
|
+
const stmf = nameOf(this.lookup(enc, "StmF")) ?? "Identity";
|
|
171
|
+
if (stmf === "Identity")
|
|
172
|
+
return { kind: "identity", keyBits: topBits };
|
|
173
|
+
const cf = this.lookup(this.lookup(enc, "CF"), stmf);
|
|
174
|
+
const cfm = nameOf(this.lookup(cf, "CFM"));
|
|
175
|
+
if (cfm === "None" || cfm === undefined)
|
|
176
|
+
return { kind: "identity", keyBits: topBits };
|
|
177
|
+
const cfBytes = numberOf(this.lookup(cf, "Length"));
|
|
178
|
+
return {
|
|
179
|
+
kind: cfm === "AESV2" ? "aes" : "rc4",
|
|
180
|
+
keyBits: cfBytes !== undefined ? cfBytes * 8 : topBits,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Decrypt every string and stream, once, in place.
|
|
185
|
+
*
|
|
186
|
+
* Three things are deliberately NOT decrypted, because they were never encrypted (ISO 32000-1 7.6.2):
|
|
187
|
+
* the `/Encrypt` dictionary's own strings, a cross-reference stream, and strings of objects that live
|
|
188
|
+
* inside an object stream - the object stream was enciphered as a whole, so its members come out in
|
|
189
|
+
* the clear already.
|
|
190
|
+
*/
|
|
191
|
+
async decryptEverything() {
|
|
192
|
+
const security = this.security;
|
|
193
|
+
if (!security)
|
|
194
|
+
return;
|
|
195
|
+
const encryptNum = isRef(this.trailer.map.get("Encrypt"))
|
|
196
|
+
? this.trailer.map.get("Encrypt").num
|
|
197
|
+
: undefined;
|
|
198
|
+
// `/EncryptMetadata false` means the XMP metadata stream was left in the CLEAR on purpose - the
|
|
199
|
+
// industry norm, so indexers can read a file they cannot open. Deciphering it anyway destroys it,
|
|
200
|
+
// and it is what made an accessible+encrypted document unopenable.
|
|
201
|
+
const metadataInClear = this.lookup(this.resolve(this.trailer.map.get("Encrypt")), "EncryptMetadata") === false;
|
|
202
|
+
// Streams first: an object stream has to be readable before its members can be served at all.
|
|
203
|
+
for (const [num, entry] of this.xref) {
|
|
204
|
+
if (entry.kind !== "offset" || num === encryptNum)
|
|
205
|
+
continue;
|
|
206
|
+
const obj = this.getObject(num);
|
|
207
|
+
if (obj === undefined || !isStream(obj))
|
|
208
|
+
continue;
|
|
209
|
+
const type = nameOf(get(obj, "Type"));
|
|
210
|
+
if (type === "XRef")
|
|
211
|
+
continue; // never encrypted - it holds the index itself
|
|
212
|
+
if (type === "Metadata" && metadataInClear)
|
|
213
|
+
continue;
|
|
214
|
+
// Keyed by the stream's DATA OFFSET, not its object number: `streamData` receives the stream
|
|
215
|
+
// object and has no number in hand.
|
|
216
|
+
this.plainStreams.set(obj.start, await security.decrypt(this.rawStream(obj), { num, gen: entry.gen }));
|
|
217
|
+
}
|
|
218
|
+
// Everything parsed so far came out of ENCIPHERED bytes: `load` already resolved the catalog to check
|
|
219
|
+
// it, and if that catalog lives in an object stream its members were inflated from ciphertext and
|
|
220
|
+
// cached as garbage. Drop both caches now that `plainStreams` can serve the real data. The xref index
|
|
221
|
+
// itself stays - it was never encrypted.
|
|
222
|
+
this.cache.clear();
|
|
223
|
+
this.objStmCache.clear();
|
|
224
|
+
// Then the strings, in objects that sit directly in the file.
|
|
225
|
+
for (const [num, entry] of this.xref) {
|
|
226
|
+
if (entry.kind !== "offset" || num === encryptNum)
|
|
227
|
+
continue;
|
|
228
|
+
const obj = this.getObject(num);
|
|
229
|
+
if (obj === undefined)
|
|
230
|
+
continue;
|
|
231
|
+
for (const s of stringsIn(obj)) {
|
|
232
|
+
try {
|
|
233
|
+
s.bytes = await security.decrypt(s.bytes, { num, gen: entry.gen });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// The password already validated against /U, so the key is right and this string simply is
|
|
237
|
+
// not ciphertext - a producer left it in the clear. Say so instead of surfacing an AES error.
|
|
238
|
+
throw new PdfEncryptedError(`object ${num} contains a string that is not encrypted, so this file does not follow its own ` +
|
|
239
|
+
"/Encrypt declaration and cannot be read safely");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// -------------------------------------------------------------------------------------------
|
|
245
|
+
// Cross-reference index
|
|
246
|
+
// -------------------------------------------------------------------------------------------
|
|
247
|
+
/** The byte offset in the `startxref` trailer at the very end of the file. */
|
|
248
|
+
findStartXref() {
|
|
249
|
+
const tail = new TextDecoder("latin1").decode(this.bytes.subarray(Math.max(0, this.bytes.length - 2048)));
|
|
250
|
+
const at = tail.lastIndexOf("startxref");
|
|
251
|
+
if (at < 0)
|
|
252
|
+
return undefined;
|
|
253
|
+
const n = /startxref\s+(\d+)/.exec(tail.slice(at));
|
|
254
|
+
return n ? Number(n[1]) : undefined;
|
|
255
|
+
}
|
|
256
|
+
/** Read the index at `startxref`, then walk `/Prev` back through earlier revisions. Entries already
|
|
257
|
+
* known win: the NEWEST revision is read first, and an older one must not overwrite it. */
|
|
258
|
+
readXrefChain() {
|
|
259
|
+
let offset = this.findStartXref();
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
while (offset !== undefined && offset >= 0 && offset < this.bytes.length && !seen.has(offset)) {
|
|
262
|
+
seen.add(offset);
|
|
263
|
+
const dict = this.readXrefSection(offset);
|
|
264
|
+
if (!dict)
|
|
265
|
+
break;
|
|
266
|
+
if (this.trailer.map.size === 0)
|
|
267
|
+
this.trailer = dict;
|
|
268
|
+
// A hybrid-reference file keeps a second, stream-based index for the same revision.
|
|
269
|
+
const hybrid = numberOf(dict.map.get("XRefStm"));
|
|
270
|
+
if (hybrid !== undefined && !seen.has(hybrid)) {
|
|
271
|
+
seen.add(hybrid);
|
|
272
|
+
this.readXrefSection(hybrid);
|
|
273
|
+
}
|
|
274
|
+
offset = numberOf(dict.map.get("Prev"));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** One index section, either format. Returns its trailer dictionary. */
|
|
278
|
+
readXrefSection(offset) {
|
|
279
|
+
const lx = new Lexer(this.bytes, offset);
|
|
280
|
+
if (lx.eatWord("xref"))
|
|
281
|
+
return this.readXrefTable(lx);
|
|
282
|
+
// Otherwise it must be an `N G obj` header introducing an xref STREAM.
|
|
283
|
+
lx.pos = offset;
|
|
284
|
+
lx.parse(); // object number
|
|
285
|
+
lx.parse(); // generation
|
|
286
|
+
if (!lx.eatWord("obj"))
|
|
287
|
+
return undefined;
|
|
288
|
+
const obj = lx.parse();
|
|
289
|
+
if (obj === undefined || !isStream(obj))
|
|
290
|
+
return undefined;
|
|
291
|
+
if (nameOf(get(obj, "Type")) !== "XRef")
|
|
292
|
+
return undefined;
|
|
293
|
+
this.usesXrefStream = true;
|
|
294
|
+
this.readXrefStream(obj);
|
|
295
|
+
return obj.dict;
|
|
296
|
+
}
|
|
297
|
+
/** The classic table: `xref`, then `start count` subsections of 20-byte entries, then `trailer`. */
|
|
298
|
+
readXrefTable(lx) {
|
|
299
|
+
for (;;) {
|
|
300
|
+
if (lx.eatWord("trailer")) {
|
|
301
|
+
const t = lx.parse();
|
|
302
|
+
return t !== undefined && isDict(t) ? t : undefined;
|
|
303
|
+
}
|
|
304
|
+
const start = lx.parse();
|
|
305
|
+
const count = lx.parse();
|
|
306
|
+
if (typeof start !== "number" || typeof count !== "number")
|
|
307
|
+
return undefined;
|
|
308
|
+
for (let i = 0; i < count; i++) {
|
|
309
|
+
const offset = lx.parse();
|
|
310
|
+
const gen = lx.parse();
|
|
311
|
+
lx.skip();
|
|
312
|
+
const type = String.fromCharCode(this.bytes[lx.pos]);
|
|
313
|
+
lx.pos++;
|
|
314
|
+
if (typeof offset !== "number" || typeof gen !== "number")
|
|
315
|
+
return undefined;
|
|
316
|
+
// 'n' = in use, 'f' = free. A free slot is not an object.
|
|
317
|
+
if (type === "n")
|
|
318
|
+
this.note(start + i, { kind: "offset", offset, gen });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* An xref STREAM: fixed-width binary rows, widths in `/W`, object numbers from `/Index`. Row type 1
|
|
324
|
+
* is a plain byte offset, type 2 points into an object stream, type 0 is a free slot.
|
|
325
|
+
*/
|
|
326
|
+
readXrefStream(stream) {
|
|
327
|
+
const data = this.streamData(stream);
|
|
328
|
+
const w = get(stream, "W")?.map((x) => numberOf(x) ?? 0) ?? [];
|
|
329
|
+
if (w.length < 3)
|
|
330
|
+
return;
|
|
331
|
+
const rowLen = w[0] + w[1] + w[2];
|
|
332
|
+
if (rowLen === 0)
|
|
333
|
+
return;
|
|
334
|
+
const size = numberOf(get(stream, "Size")) ?? 0;
|
|
335
|
+
const indexRaw = get(stream, "Index");
|
|
336
|
+
const index = Array.isArray(indexRaw) ? indexRaw.map((x) => numberOf(x) ?? 0) : [0, size]; // the default covers every object
|
|
337
|
+
// Read a big-endian field of `n` bytes; a width of 0 means "use the default" (type defaults to 1).
|
|
338
|
+
const field = (at, n, dflt) => {
|
|
339
|
+
if (n === 0)
|
|
340
|
+
return dflt;
|
|
341
|
+
let v = 0;
|
|
342
|
+
for (let i = 0; i < n; i++)
|
|
343
|
+
v = v * 256 + data[at + i];
|
|
344
|
+
return v;
|
|
345
|
+
};
|
|
346
|
+
let row = 0;
|
|
347
|
+
for (let s = 0; s + 1 < index.length; s += 2) {
|
|
348
|
+
const first = index[s];
|
|
349
|
+
const count = index[s + 1];
|
|
350
|
+
for (let i = 0; i < count; i++, row++) {
|
|
351
|
+
const at = row * rowLen;
|
|
352
|
+
if (at + rowLen > data.length)
|
|
353
|
+
return;
|
|
354
|
+
const type = field(at, w[0], 1);
|
|
355
|
+
const f2 = field(at + w[0], w[1], 0);
|
|
356
|
+
const f3 = field(at + w[0] + w[1], w[2], 0);
|
|
357
|
+
if (type === 1)
|
|
358
|
+
this.note(first + i, { kind: "offset", offset: f2, gen: f3 });
|
|
359
|
+
else if (type === 2)
|
|
360
|
+
this.note(first + i, { kind: "inStream", stream: f2, index: f3 });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** Record an entry unless a NEWER revision already claimed that object number. */
|
|
365
|
+
note(num, entry) {
|
|
366
|
+
if (!this.xref.has(num))
|
|
367
|
+
this.xref.set(num, entry);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Last resort: scan the whole file for `N G obj` headers and believe those. Used when the index is
|
|
371
|
+
* missing or does not lead anywhere - and flagged via `recovered`, never silently.
|
|
372
|
+
*/
|
|
373
|
+
rebuildByScanning() {
|
|
374
|
+
this.recovered = true;
|
|
375
|
+
this.xref.clear();
|
|
376
|
+
this.cache.clear();
|
|
377
|
+
// Unpacked object streams belong to the index we just threw away; keeping them would serve members
|
|
378
|
+
// resolved against offsets that no longer apply.
|
|
379
|
+
this.objStmCache.clear();
|
|
380
|
+
const text = new TextDecoder("latin1").decode(this.bytes);
|
|
381
|
+
for (const m of text.matchAll(/(?:^|[\s>])(\d+)\s+(\d+)\s+obj\b/g)) {
|
|
382
|
+
const num = Number(m[1]);
|
|
383
|
+
// A later definition wins here: scanning walks the file forwards, and an incremental update
|
|
384
|
+
// appends its newer version further down.
|
|
385
|
+
this.xref.set(num, {
|
|
386
|
+
kind: "offset",
|
|
387
|
+
offset: (m.index ?? 0) + m[0].indexOf(m[1]),
|
|
388
|
+
gen: Number(m[2]),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
// A scan only finds top-level `N G obj` headers, but in a modern file most objects - the catalog
|
|
392
|
+
// included - live INSIDE an object stream. Unpack every /ObjStm we found so they can be reached at
|
|
393
|
+
// all; without this, recovering a compressed file finds a handful of streams and no catalog.
|
|
394
|
+
// A snapshot, not the live keys: the loop ADDS entries to the same map.
|
|
395
|
+
const scanned = Array.from(this.xref.keys());
|
|
396
|
+
for (const num of scanned) {
|
|
397
|
+
const stm = this.getObject(num);
|
|
398
|
+
if (stm === undefined || !isStream(stm) || nameOf(get(stm, "Type")) !== "ObjStm")
|
|
399
|
+
continue;
|
|
400
|
+
const data = this.streamData(stm);
|
|
401
|
+
const head = new Lexer(data, 0);
|
|
402
|
+
const count = numberOf(get(stm, "N")) ?? 0;
|
|
403
|
+
for (let i = 0; i < count; i++) {
|
|
404
|
+
const on = head.parse();
|
|
405
|
+
const off = head.parse();
|
|
406
|
+
if (typeof on !== "number" || typeof off !== "number")
|
|
407
|
+
break;
|
|
408
|
+
// A top-level definition found by the scan is the newer one and keeps precedence.
|
|
409
|
+
this.note(on, { kind: "inStream", stream: num, index: i });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (this.trailer.map.size === 0 || this.trailer.map.get("Root") === undefined) {
|
|
413
|
+
const t = /trailer\s*<</g;
|
|
414
|
+
let last, found;
|
|
415
|
+
while ((last = t.exec(text)) !== null)
|
|
416
|
+
found = last.index;
|
|
417
|
+
if (found !== undefined) {
|
|
418
|
+
const lx = new Lexer(this.bytes, found);
|
|
419
|
+
lx.eatWord("trailer");
|
|
420
|
+
const d = lx.parse();
|
|
421
|
+
if (d !== undefined && isDict(d))
|
|
422
|
+
this.trailer = d;
|
|
423
|
+
}
|
|
424
|
+
// A file with only xref streams has no `trailer` keyword; its catalog is found by scanning.
|
|
425
|
+
if (this.trailer.map.get("Root") === undefined) {
|
|
426
|
+
for (const [num] of this.xref) {
|
|
427
|
+
const o = this.getObject(num);
|
|
428
|
+
if (o !== undefined && nameOf(get(o, "Type")) === "Catalog") {
|
|
429
|
+
this.trailer.map.set("Root", { kind: "ref", num, gen: 0 });
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// -------------------------------------------------------------------------------------------
|
|
437
|
+
// Objects
|
|
438
|
+
// -------------------------------------------------------------------------------------------
|
|
439
|
+
/** The object with that number, or `undefined` when the file does not have it. */
|
|
440
|
+
getObject(num) {
|
|
441
|
+
if (this.cache.has(num))
|
|
442
|
+
return this.cache.get(num);
|
|
443
|
+
this.cache.set(num, undefined); // guard against a reference cycle while we resolve
|
|
444
|
+
const entry = this.xref.get(num);
|
|
445
|
+
let value;
|
|
446
|
+
if (entry?.kind === "offset")
|
|
447
|
+
value = this.parseObjectAt(entry.offset, num);
|
|
448
|
+
else if (entry?.kind === "inStream")
|
|
449
|
+
value = this.objectFromStream(entry.stream, num);
|
|
450
|
+
this.cache.set(num, value);
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
/** Parse `N G obj … endobj` at a byte offset, checking that it really is object `num`. */
|
|
454
|
+
parseObjectAt(offset, num) {
|
|
455
|
+
if (offset <= 0 || offset >= this.bytes.length)
|
|
456
|
+
return undefined;
|
|
457
|
+
const lx = new Lexer(this.bytes, offset);
|
|
458
|
+
const n = lx.parse();
|
|
459
|
+
lx.parse(); // generation
|
|
460
|
+
if (n !== num || !lx.eatWord("obj"))
|
|
461
|
+
return undefined;
|
|
462
|
+
return lx.parse();
|
|
463
|
+
}
|
|
464
|
+
/** Unpack an object stream once, then serve its members from the cache. */
|
|
465
|
+
objectFromStream(streamNum, num) {
|
|
466
|
+
let members = this.objStmCache.get(streamNum);
|
|
467
|
+
if (!members) {
|
|
468
|
+
members = new Map();
|
|
469
|
+
this.objStmCache.set(streamNum, members);
|
|
470
|
+
const stm = this.getObject(streamNum);
|
|
471
|
+
if (stm !== undefined && isStream(stm) && nameOf(get(stm, "Type")) === "ObjStm") {
|
|
472
|
+
const data = this.streamData(stm);
|
|
473
|
+
const count = numberOf(get(stm, "N")) ?? 0;
|
|
474
|
+
const first = numberOf(get(stm, "First")) ?? 0;
|
|
475
|
+
// The stream opens with `count` pairs of "object number, offset relative to /First".
|
|
476
|
+
const head = new Lexer(data, 0);
|
|
477
|
+
const pairs = [];
|
|
478
|
+
for (let i = 0; i < count; i++) {
|
|
479
|
+
const on = head.parse();
|
|
480
|
+
const off = head.parse();
|
|
481
|
+
if (typeof on !== "number" || typeof off !== "number")
|
|
482
|
+
break;
|
|
483
|
+
pairs.push([on, off]);
|
|
484
|
+
}
|
|
485
|
+
for (const [on, off] of pairs) {
|
|
486
|
+
const o = new Lexer(data, first + off).parse();
|
|
487
|
+
if (o !== undefined)
|
|
488
|
+
members.set(on, o);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return members.get(num);
|
|
493
|
+
}
|
|
494
|
+
/** Follow indirect references until a direct object is reached. */
|
|
495
|
+
resolve(o) {
|
|
496
|
+
let seen = 0;
|
|
497
|
+
while (o !== undefined && isRef(o)) {
|
|
498
|
+
if (++seen > 64)
|
|
499
|
+
return undefined; // a cyclic file must not hang us
|
|
500
|
+
o = this.getObject(o.num);
|
|
501
|
+
}
|
|
502
|
+
return o;
|
|
503
|
+
}
|
|
504
|
+
/** A dictionary entry, with the reference already followed. */
|
|
505
|
+
lookup(o, key) {
|
|
506
|
+
return this.resolve(get(this.resolve(o), key));
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The file is password-protected. Every string and stream in it is enciphered, so anything read out
|
|
510
|
+
* of it is ciphertext until a decryption path exists - which is why the form layer refuses rather
|
|
511
|
+
* than handing back nonsense field names.
|
|
512
|
+
*/
|
|
513
|
+
get isEncrypted() {
|
|
514
|
+
return this.trailer.map.get("Encrypt") !== undefined;
|
|
515
|
+
}
|
|
516
|
+
/** True once the password has been accepted and the contents decrypted, so what is read is plaintext.
|
|
517
|
+
* Always true for a document that was never encrypted. */
|
|
518
|
+
get isReadable() {
|
|
519
|
+
return !this.isEncrypted || this.security !== undefined;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Encipher a value that is about to be written back INTO this document, or `undefined` when the
|
|
523
|
+
* document is not encrypted and the value goes in as it is.
|
|
524
|
+
*
|
|
525
|
+
* Needed because we decrypt everything on open: a string we then hand back to the writer is plaintext
|
|
526
|
+
* in memory, and writing it into an encrypted file unchanged would both leak it and produce a document
|
|
527
|
+
* that contradicts its own /Encrypt declaration.
|
|
528
|
+
*/
|
|
529
|
+
async encryptForWrite(data) {
|
|
530
|
+
return this.security ? this.security.encrypt(data) : undefined;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Whether values written back into this document can be enciphered again.
|
|
534
|
+
*
|
|
535
|
+
* False for a legacy-encrypted file: we can READ RC4 and AES-128, but writing one would downgrade the
|
|
536
|
+
* user's document to a broken cipher. Asked BEFORE any work is done - otherwise the refusal depends on
|
|
537
|
+
* a string happening to be present in whatever we rewrite, which is a guarantee by accident.
|
|
538
|
+
*/
|
|
539
|
+
get canReEncrypt() {
|
|
540
|
+
return !this.isEncrypted || (this.security?.canEncrypt ?? false);
|
|
541
|
+
}
|
|
542
|
+
/** The generation of an object as the file records it. Members of an object stream are always 0. */
|
|
543
|
+
generationOf(num) {
|
|
544
|
+
const entry = this.xref.get(num);
|
|
545
|
+
return entry?.kind === "offset" ? entry.gen : 0;
|
|
546
|
+
}
|
|
547
|
+
get catalog() {
|
|
548
|
+
const root = this.resolve(this.trailer.map.get("Root"));
|
|
549
|
+
return root !== undefined && isDict(root) ? root : undefined;
|
|
550
|
+
}
|
|
551
|
+
// -------------------------------------------------------------------------------------------
|
|
552
|
+
// Stream data
|
|
553
|
+
// -------------------------------------------------------------------------------------------
|
|
554
|
+
/** A stream's bytes as they sit in the file: sliced by `/Length`, still filtered and still encrypted. */
|
|
555
|
+
rawStream(stream) {
|
|
556
|
+
const length = numberOf(this.resolve(get(stream, "Length")));
|
|
557
|
+
if (length !== undefined && stream.start + length <= this.bytes.length) {
|
|
558
|
+
return this.bytes.subarray(stream.start, stream.start + length);
|
|
559
|
+
}
|
|
560
|
+
// A missing or wrong /Length happens; fall back to the next `endstream`.
|
|
561
|
+
const text = new TextDecoder("latin1").decode(this.bytes);
|
|
562
|
+
const end = text.indexOf("endstream", stream.start);
|
|
563
|
+
return this.bytes.subarray(stream.start, end < 0 ? this.bytes.length : end);
|
|
564
|
+
}
|
|
565
|
+
/** A stream's DECODED bytes: deciphered if the document is encrypted, then run through its filters. */
|
|
566
|
+
streamData(stream) {
|
|
567
|
+
// A decrypted payload replaces the bytes from the file; filters then run on the plaintext, which is
|
|
568
|
+
// the order the format prescribes - encryption wraps the already-filtered data.
|
|
569
|
+
const raw = this.plainStreams.get(stream.start) ?? this.rawStream(stream);
|
|
570
|
+
const filterRaw = this.resolve(get(stream, "Filter"));
|
|
571
|
+
const filters = (Array.isArray(filterRaw) ? filterRaw : [filterRaw])
|
|
572
|
+
.map((f) => nameOf(this.resolve(f)))
|
|
573
|
+
.filter((f) => f !== undefined);
|
|
574
|
+
const parmsRaw = this.resolve(get(stream, "DecodeParms"));
|
|
575
|
+
const parms = Array.isArray(parmsRaw) ? parmsRaw : [parmsRaw];
|
|
576
|
+
let data = raw;
|
|
577
|
+
filters.forEach((filter, i) => {
|
|
578
|
+
if (filter === "FlateDecode") {
|
|
579
|
+
try {
|
|
580
|
+
data = unzlibSync(data);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return; // not our data to repair; hand back what we have
|
|
584
|
+
}
|
|
585
|
+
data = this.undoPredictor(data, this.resolve(parms[i]));
|
|
586
|
+
}
|
|
587
|
+
// Other filters (LZW, DCT, …) are left encoded on purpose: this reader only needs the structural
|
|
588
|
+
// streams - the index, object streams and appearances - and every one of those is Flate.
|
|
589
|
+
});
|
|
590
|
+
return data;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Undo a PNG predictor (`/Predictor` 10-15), which xref streams almost always use - the IRS form in
|
|
594
|
+
* our fixtures does. Each row is prefixed with its filter type and is decoded against the row above.
|
|
595
|
+
*/
|
|
596
|
+
undoPredictor(data, parms) {
|
|
597
|
+
const predictor = numberOf(get(parms, "Predictor")) ?? 1;
|
|
598
|
+
if (predictor < 10)
|
|
599
|
+
return data; // 1 = none; 2 (TIFF) does not occur in the streams we read
|
|
600
|
+
const colors = numberOf(get(parms, "Colors")) ?? 1;
|
|
601
|
+
const bpc = numberOf(get(parms, "BitsPerComponent")) ?? 8;
|
|
602
|
+
const columns = numberOf(get(parms, "Columns")) ?? 1;
|
|
603
|
+
const bpp = Math.max(1, Math.ceil((colors * bpc) / 8));
|
|
604
|
+
const rowLen = Math.ceil((colors * bpc * columns) / 8);
|
|
605
|
+
const rows = Math.floor(data.length / (rowLen + 1));
|
|
606
|
+
const out = new Uint8Array(rows * rowLen);
|
|
607
|
+
let prev = new Uint8Array(rowLen);
|
|
608
|
+
for (let r = 0; r < rows; r++) {
|
|
609
|
+
const type = data[r * (rowLen + 1)];
|
|
610
|
+
const src = data.subarray(r * (rowLen + 1) + 1, (r + 1) * (rowLen + 1));
|
|
611
|
+
const row = out.subarray(r * rowLen, (r + 1) * rowLen);
|
|
612
|
+
for (let i = 0; i < rowLen; i++) {
|
|
613
|
+
const a = i >= bpp ? row[i - bpp] : 0; // left
|
|
614
|
+
const b = prev[i]; // up
|
|
615
|
+
const c = i >= bpp ? prev[i - bpp] : 0; // upper left
|
|
616
|
+
const x = src[i];
|
|
617
|
+
switch (type) {
|
|
618
|
+
case 0:
|
|
619
|
+
row[i] = x;
|
|
620
|
+
break;
|
|
621
|
+
case 1:
|
|
622
|
+
row[i] = (x + a) & 0xff;
|
|
623
|
+
break;
|
|
624
|
+
case 2:
|
|
625
|
+
row[i] = (x + b) & 0xff;
|
|
626
|
+
break;
|
|
627
|
+
case 3:
|
|
628
|
+
row[i] = (x + ((a + b) >> 1)) & 0xff;
|
|
629
|
+
break;
|
|
630
|
+
case 4: {
|
|
631
|
+
const p = a + b - c;
|
|
632
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
633
|
+
row[i] = (x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
default:
|
|
637
|
+
row[i] = x;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
prev = row;
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
/** Every object number the index knows, ascending - for walking a whole document. */
|
|
645
|
+
objectNumbers() {
|
|
646
|
+
return [...this.xref.keys()].sort((a, b) => a - b);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
export { EMPTY };
|