@jasy/pdf 1.0.0-alpha.7 → 1.0.0-alpha.9

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