@jasy/pdf 1.0.0-beta.3 → 1.0.0-beta.4

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 (210) hide show
  1. package/dist/api/content.d.ts +5 -5
  2. package/dist/api/content.js +12 -6
  3. package/dist/api/font-url.d.ts +1 -2
  4. package/dist/api/font-url.js +8 -74
  5. package/dist/api/forms.d.ts +3 -0
  6. package/dist/api/forms.js +3 -0
  7. package/dist/api/index.d.ts +2 -0
  8. package/dist/api/index.js +1 -0
  9. package/dist/api/layout.d.ts +6 -0
  10. package/dist/api/layout.js +7 -2
  11. package/dist/api/page-builder.d.ts +4 -0
  12. package/dist/api/page-builder.js +4 -0
  13. package/dist/api/structure.d.ts +10 -2
  14. package/dist/api/structure.js +9 -6
  15. package/dist/api/table.d.ts +5 -3
  16. package/dist/api/table.js +99 -22
  17. package/dist/common/diagnostics.d.ts +53 -0
  18. package/dist/common/diagnostics.js +77 -0
  19. package/dist/edit/acroform-reader.js +7 -7
  20. package/dist/edit/appearance.js +3 -3
  21. package/dist/edit/attachments.d.ts +52 -0
  22. package/dist/edit/attachments.js +178 -0
  23. package/dist/edit/document.d.ts +38 -8
  24. package/dist/edit/document.js +208 -51
  25. package/dist/edit/fill.js +8 -23
  26. package/dist/edit/filters.d.ts +15 -0
  27. package/dist/edit/filters.js +157 -0
  28. package/dist/edit/index.d.ts +4 -1
  29. package/dist/edit/index.js +3 -1
  30. package/dist/edit/objects.d.ts +3 -4
  31. package/dist/edit/objects.js +5 -18
  32. package/dist/elements/canvas-element.d.ts +3 -0
  33. package/dist/elements/canvas-element.js +14 -3
  34. package/dist/elements/container-element.d.ts +5 -1
  35. package/dist/elements/container-element.js +35 -15
  36. package/dist/elements/forms/checkbox-element.d.ts +2 -0
  37. package/dist/elements/forms/checkbox-element.js +12 -3
  38. package/dist/elements/forms/choice-element.d.ts +6 -1
  39. package/dist/elements/forms/choice-element.js +17 -6
  40. package/dist/elements/forms/field-font.d.ts +8 -0
  41. package/dist/elements/forms/field-font.js +5 -0
  42. package/dist/elements/forms/push-button-element.d.ts +6 -1
  43. package/dist/elements/forms/push-button-element.js +16 -4
  44. package/dist/elements/forms/radio-element.d.ts +2 -0
  45. package/dist/elements/forms/radio-element.js +12 -3
  46. package/dist/elements/forms/signature-element.d.ts +6 -1
  47. package/dist/elements/forms/signature-element.js +16 -4
  48. package/dist/elements/forms/text-field-element.d.ts +6 -1
  49. package/dist/elements/forms/text-field-element.js +16 -4
  50. package/dist/elements/image-element.d.ts +39 -1
  51. package/dist/elements/image-element.js +95 -12
  52. package/dist/elements/layout/anchor-element.d.ts +8 -10
  53. package/dist/elements/layout/anchor-element.js +10 -18
  54. package/dist/elements/layout/bookmark-element.d.ts +9 -11
  55. package/dist/elements/layout/bookmark-element.js +11 -20
  56. package/dist/elements/layout/default-text-style-element.d.ts +11 -13
  57. package/dist/elements/layout/default-text-style-element.js +14 -31
  58. package/dist/elements/layout/deferred-element.d.ts +12 -4
  59. package/dist/elements/layout/deferred-element.js +24 -7
  60. package/dist/elements/layout/expanded-element.d.ts +7 -0
  61. package/dist/elements/layout/expanded-element.js +40 -18
  62. package/dist/elements/layout/keep-together-element.d.ts +8 -18
  63. package/dist/elements/layout/keep-together-element.js +21 -45
  64. package/dist/elements/layout/link-element.d.ts +8 -13
  65. package/dist/elements/layout/link-element.js +9 -19
  66. package/dist/elements/layout/padding-element.d.ts +15 -1
  67. package/dist/elements/layout/padding-element.js +55 -27
  68. package/dist/elements/layout/page-break-element.d.ts +4 -1
  69. package/dist/elements/layout/page-break-element.js +6 -1
  70. package/dist/elements/layout/page-builder-element.d.ts +5 -0
  71. package/dist/elements/layout/page-builder-element.js +11 -0
  72. package/dist/elements/layout/positioned-element.d.ts +14 -2
  73. package/dist/elements/layout/positioned-element.js +36 -10
  74. package/dist/elements/layout/proxy-element.d.ts +43 -0
  75. package/dist/elements/layout/proxy-element.js +94 -0
  76. package/dist/elements/layout/repeating-header-element.d.ts +6 -0
  77. package/dist/elements/layout/repeating-header-element.js +40 -13
  78. package/dist/elements/layout/rotated-box-element.d.ts +6 -0
  79. package/dist/elements/layout/rotated-box-element.js +27 -14
  80. package/dist/elements/layout/rotated-element.d.ts +7 -0
  81. package/dist/elements/layout/rotated-element.js +25 -2
  82. package/dist/elements/layout/struct-group.d.ts +9 -17
  83. package/dist/elements/layout/struct-group.js +10 -35
  84. package/dist/elements/line-element.d.ts +1 -0
  85. package/dist/elements/line-element.js +6 -0
  86. package/dist/elements/page-element.d.ts +10 -3
  87. package/dist/elements/page-element.js +39 -31
  88. package/dist/elements/pdf-document-element.d.ts +3 -0
  89. package/dist/elements/pdf-document-element.js +7 -0
  90. package/dist/elements/pdf-element.d.ts +75 -15
  91. package/dist/elements/pdf-element.js +68 -18
  92. package/dist/elements/rectangle-element.d.ts +14 -0
  93. package/dist/elements/rectangle-element.js +97 -52
  94. package/dist/elements/row-element.d.ts +11 -0
  95. package/dist/elements/row-element.js +58 -8
  96. package/dist/elements/svg-element.d.ts +30 -6
  97. package/dist/elements/svg-element.js +88 -17
  98. package/dist/elements/text-element.d.ts +32 -13
  99. package/dist/elements/text-element.js +119 -97
  100. package/dist/forms/acroform.d.ts +9 -1
  101. package/dist/forms/acroform.js +83 -34
  102. package/dist/forms/appearance.d.ts +17 -6
  103. package/dist/forms/appearance.js +18 -16
  104. package/dist/ir/display-list.d.ts +12 -2
  105. package/dist/layout/box-constraints.d.ts +5 -0
  106. package/dist/layout/box-constraints.js +14 -0
  107. package/dist/layout/collect-images.d.ts +6 -4
  108. package/dist/layout/collect-images.js +6 -4
  109. package/dist/layout/flex-item.d.ts +9 -0
  110. package/dist/layout/flex-item.js +39 -0
  111. package/dist/layout/fragmentation.d.ts +12 -4
  112. package/dist/layout/fragmentation.js +37 -14
  113. package/dist/layout/layout-cache.d.ts +53 -0
  114. package/dist/layout/layout-cache.js +107 -0
  115. package/dist/renderer/anchor-renderer.d.ts +2 -1
  116. package/dist/renderer/anchor-renderer.js +5 -4
  117. package/dist/renderer/bookmark-renderer.d.ts +2 -1
  118. package/dist/renderer/bookmark-renderer.js +5 -4
  119. package/dist/renderer/canvas-renderer.d.ts +2 -1
  120. package/dist/renderer/canvas-renderer.js +5 -2
  121. package/dist/renderer/container-renderer.d.ts +2 -1
  122. package/dist/renderer/container-renderer.js +6 -5
  123. package/dist/renderer/default-text-style-renderer.d.ts +2 -1
  124. package/dist/renderer/default-text-style-renderer.js +2 -3
  125. package/dist/renderer/deferred-renderer.d.ts +2 -1
  126. package/dist/renderer/deferred-renderer.js +2 -3
  127. package/dist/renderer/expanded-renderer.d.ts +2 -1
  128. package/dist/renderer/expanded-renderer.js +2 -3
  129. package/dist/renderer/form-field-renderer.d.ts +2 -1
  130. package/dist/renderer/form-field-renderer.js +16 -3
  131. package/dist/renderer/image-renderer.d.ts +2 -1
  132. package/dist/renderer/image-renderer.js +5 -2
  133. package/dist/renderer/keep-together-renderer.d.ts +2 -1
  134. package/dist/renderer/keep-together-renderer.js +2 -3
  135. package/dist/renderer/line-renderer.d.ts +2 -1
  136. package/dist/renderer/line-renderer.js +7 -2
  137. package/dist/renderer/link-renderer.d.ts +2 -1
  138. package/dist/renderer/link-renderer.js +36 -5
  139. package/dist/renderer/padding-renderer.d.ts +2 -1
  140. package/dist/renderer/padding-renderer.js +5 -5
  141. package/dist/renderer/page-break-renderer.d.ts +6 -5
  142. package/dist/renderer/page-break-renderer.js +14 -7
  143. package/dist/renderer/page-builder-renderer.d.ts +2 -1
  144. package/dist/renderer/page-builder-renderer.js +2 -3
  145. package/dist/renderer/page-renderer.js +28 -7
  146. package/dist/renderer/pdf-backend.d.ts +4 -3
  147. package/dist/renderer/pdf-backend.js +5 -5
  148. package/dist/renderer/pdf-document-renderer.d.ts +1 -8
  149. package/dist/renderer/pdf-document-renderer.js +15 -16
  150. package/dist/renderer/pdf-renderer.js +11 -11
  151. package/dist/renderer/positioned-renderer.d.ts +6 -1
  152. package/dist/renderer/positioned-renderer.js +6 -5
  153. package/dist/renderer/rectangle-renderer.d.ts +2 -1
  154. package/dist/renderer/rectangle-renderer.js +9 -4
  155. package/dist/renderer/repeating-header-renderer.d.ts +2 -1
  156. package/dist/renderer/repeating-header-renderer.js +6 -5
  157. package/dist/renderer/rotated-renderer.d.ts +2 -1
  158. package/dist/renderer/rotated-renderer.js +8 -5
  159. package/dist/renderer/row-renderer.d.ts +2 -1
  160. package/dist/renderer/row-renderer.js +6 -5
  161. package/dist/renderer/struct-group-renderer.d.ts +2 -1
  162. package/dist/renderer/struct-group-renderer.js +2 -3
  163. package/dist/renderer/svg-renderer.d.ts +2 -1
  164. package/dist/renderer/svg-renderer.js +8 -2
  165. package/dist/renderer/text-renderer.d.ts +4 -6
  166. package/dist/renderer/text-renderer.js +101 -111
  167. package/dist/svg/gradients.js +5 -0
  168. package/dist/svg/index.js +10 -1
  169. package/dist/svg/style.d.ts +2 -0
  170. package/dist/svg/style.js +11 -0
  171. package/dist/text/advance.d.ts +4 -0
  172. package/dist/text/advance.js +17 -4
  173. package/dist/text/bidi.d.ts +0 -2
  174. package/dist/text/bidi.js +0 -10
  175. package/dist/text/glyph-coverage.d.ts +1 -1
  176. package/dist/text/glyph-coverage.js +7 -3
  177. package/dist/text/hyphen-breaks.d.ts +17 -0
  178. package/dist/text/hyphen-breaks.js +54 -0
  179. package/dist/text/line-breaker.d.ts +34 -31
  180. package/dist/text/line-breaker.js +364 -176
  181. package/dist/text/line-metrics.d.ts +6 -3
  182. package/dist/text/line-metrics.js +7 -2
  183. package/dist/text/pdf-text-string.d.ts +21 -0
  184. package/dist/text/pdf-text-string.js +114 -0
  185. package/dist/text/text-decoration.d.ts +5 -5
  186. package/dist/text/text-decoration.js +5 -5
  187. package/dist/text/word-splitting.d.ts +15 -2
  188. package/dist/text/word-splitting.js +34 -8
  189. package/dist/utils/afm-parser.d.ts +2 -0
  190. package/dist/utils/afm-parser.js +10 -1
  191. package/dist/utils/dest-registry.d.ts +1 -1
  192. package/dist/utils/dest-registry.js +3 -2
  193. package/dist/utils/fetch-bytes.d.ts +19 -0
  194. package/dist/utils/fetch-bytes.js +79 -0
  195. package/dist/utils/flex-layout.d.ts +13 -13
  196. package/dist/utils/flex-layout.js +318 -216
  197. package/dist/utils/font-metrics.d.ts +4 -3
  198. package/dist/utils/image-helper.js +2 -1
  199. package/dist/utils/pdf-object-manager.d.ts +11 -3
  200. package/dist/utils/pdf-object-manager.js +39 -13
  201. package/dist/utils/renderer-registry.d.ts +43 -2
  202. package/dist/utils/renderer-registry.js +40 -0
  203. package/dist/utils/struct-tree.d.ts +24 -0
  204. package/dist/utils/struct-tree.js +61 -9
  205. package/dist/utils/ttf-parser.d.ts +4 -0
  206. package/dist/utils/ttf-parser.js +27 -4
  207. package/dist/validators/element-validator.js +2 -1
  208. package/package.json +1 -1
  209. package/dist/elements/layout/sized-container-element.d.ts +0 -14
  210. package/dist/elements/layout/sized-container-element.js +0 -32
@@ -3,12 +3,15 @@ import { DEFAULT_MAX_STREAM_SIZE, inflateBounded, PdfStreamTooLargeError, } from
3
3
  import { get, isDict, isRef, isStream, isString, nameOf, numberOf, stringsIn, } from "./objects.js";
4
4
  import { StandardAes256 } from "../crypto/security-handler.js";
5
5
  import { StandardLegacy } from "../crypto/legacy-handler.js";
6
+ import { ascii85Decode, asciiHexDecode, lzwDecode, PdfFilterError, runLengthDecode, } from "./filters.js";
6
7
  const EMPTY = new Uint8Array(0);
7
8
  /** Thrown when a file cannot be opened because of its encryption - always saying which of the reasons it
8
9
  * is: no password, wrong password, or a revision we do not implement. */
9
10
  export class PdfEncryptedError extends Error {
10
- constructor(message) {
11
+ /** `needsPassword`: the password is missing or wrong - as opposed to a file we cannot decrypt at all. */
12
+ constructor(message, needsPassword = false) {
11
13
  super(`@jasy/pdf: ${message}`);
14
+ this.needsPassword = needsPassword;
12
15
  }
13
16
  }
14
17
  /** The raw bytes of a `/Encrypt` entry that must be a string (`/U`, `/UE`). */
@@ -20,6 +23,8 @@ export class PdfDocument {
20
23
  this.cache = new Map();
21
24
  /** Object streams already unpacked, by their object number. */
22
25
  this.objStmCache = new Map();
26
+ /** Attached files encrypted on their own (`/EFF`) and opened without the password: their streams. */
27
+ this.lockedFiles = new Set();
23
28
  /** Decrypted stream payloads, keyed by the stream's data offset (unique per stream). */
24
29
  this.plainStreams = new Map();
25
30
  /** True when the cross-reference index was unusable and had to be rebuilt by scanning. Surfaced so
@@ -61,10 +66,37 @@ export class PdfDocument {
61
66
  const doc = PdfDocument.load(bytes, opts);
62
67
  if (!doc.isEncrypted)
63
68
  return doc;
64
- doc.security = await doc.securityHandlerFor(opts.password);
69
+ doc.enciphered = doc.encipheredParts();
70
+ try {
71
+ doc.security = await doc.securityHandlerFor(opts.password);
72
+ }
73
+ catch (e) {
74
+ // Only the attached files are encrypted (Acrobat's "encrypt only file attachments"): without a
75
+ // password the document opens as it is, and an attachment asks for it when it is read. A WRONG
76
+ // password is still reported as one.
77
+ const filesOnly = !doc.enciphered.streams && !doc.enciphered.strings;
78
+ const noPassword = opts.password === undefined;
79
+ if (!(filesOnly && noPassword && e instanceof PdfEncryptedError && e.needsPassword))
80
+ throw e;
81
+ }
65
82
  await doc.decryptEverything();
66
83
  return doc;
67
84
  }
85
+ /** Which parts the `/Encrypt` dictionary enciphers. Before V4 there are no crypt filters: everything. */
86
+ encipheredParts() {
87
+ const enc = this.resolve(this.trailer.map.get("Encrypt"));
88
+ if ((numberOf(this.lookup(enc, "V")) ?? 0) < 4)
89
+ return { streams: true, strings: true, files: true };
90
+ const real = (name) => name !== undefined &&
91
+ name !== "Identity" &&
92
+ nameOf(this.lookup(this.lookup(this.lookup(enc, "CF"), name), "CFM")) !== "None";
93
+ const stmf = nameOf(this.lookup(enc, "StmF")) ?? "Identity";
94
+ return {
95
+ streams: real(stmf),
96
+ strings: real(nameOf(this.lookup(enc, "StrF")) ?? "Identity"),
97
+ files: real(nameOf(this.lookup(enc, "EFF")) ?? stmf),
98
+ };
99
+ }
68
100
  /**
69
101
  * Build the handler for this file's `/Encrypt` dictionary, or say precisely why we cannot.
70
102
  *
@@ -85,8 +117,8 @@ export class PdfDocument {
85
117
  const pw = password ?? "";
86
118
  const rejected = () => {
87
119
  throw password === undefined
88
- ? new PdfEncryptedError("this PDF is encrypted; pass its password to open it")
89
- : new PdfEncryptedError("wrong password for this PDF");
120
+ ? new PdfEncryptedError("this PDF is encrypted; pass its password to open it", true)
121
+ : new PdfEncryptedError("wrong password for this PDF", true);
90
122
  };
91
123
  // `recoverFileKey` / `StandardLegacy.open` say "wrong password" by name; anything else that comes out
92
124
  // of them is structural (a malformed /U, /UE, /O) and must not be reported as a bad password.
@@ -118,10 +150,7 @@ export class PdfDocument {
118
150
  const u = stringBytes(this.lookup(enc, "U"));
119
151
  if (o === undefined || u === undefined)
120
152
  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
- }
153
+ const cipher = this.legacyCipher(enc, v);
125
154
  const ids = this.trailer.map.get("ID");
126
155
  const firstId = Array.isArray(ids) ? stringBytes(this.resolve(ids[0])) : undefined;
127
156
  try {
@@ -145,28 +174,42 @@ export class PdfDocument {
145
174
  throw new PdfEncryptedError(`this PDF uses standard security V${v}/R${r}, which jasy does not implement`);
146
175
  }
147
176
  /**
148
- * Which cipher and key length actually apply to the streams.
177
+ * Which cipher and key length a legacy (V2-V4) file uses.
149
178
  *
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.
179
+ * For V4 the answer is NOT the top-level `/Length`: the document names crypt filters - `/StmF` for
180
+ * streams, `/StrF` for strings, `/EFF` for attached files - and each carries its own `/CFM` and
181
+ * `/Length` (in BYTES, where the top-level one is in bits). `/Identity` means "not enciphered". One
182
+ * handler serves them all, so the filters that DO encipher must agree; two different ciphers in one
183
+ * file are refused by name.
153
184
  */
154
- streamCipher(enc, v) {
185
+ legacyCipher(enc, v) {
155
186
  const topBits = numberOf(this.lookup(enc, "Length")) ?? 40;
156
187
  if (v < 4)
157
188
  return { kind: "rc4", keyBits: topBits };
158
189
  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
- };
190
+ const used = [
191
+ stmf,
192
+ nameOf(this.lookup(enc, "StrF")) ?? "Identity",
193
+ nameOf(this.lookup(enc, "EFF")) ?? stmf,
194
+ ]
195
+ .filter((name) => name !== "Identity")
196
+ .map((name) => {
197
+ const cf = this.lookup(this.lookup(enc, "CF"), name);
198
+ const cfm = nameOf(this.lookup(cf, "CFM"));
199
+ const cfBytes = numberOf(this.lookup(cf, "Length"));
200
+ return cfm === undefined || cfm === "None"
201
+ ? undefined
202
+ : {
203
+ kind: cfm === "AESV2" ? "aes" : "rc4",
204
+ keyBits: cfBytes !== undefined ? cfBytes * 8 : topBits,
205
+ };
206
+ })
207
+ .filter((c) => c !== undefined);
208
+ const first = used[0] ?? { kind: "rc4", keyBits: topBits };
209
+ if (used.some((c) => c.kind !== first.kind || c.keyBits !== first.keyBits)) {
210
+ throw new PdfEncryptedError("this PDF enciphers its streams, strings and attached files with different crypt filters, which jasy does not model");
211
+ }
212
+ return first;
170
213
  }
171
214
  /**
172
215
  * Decrypt every string and stream, once, in place.
@@ -178,8 +221,7 @@ export class PdfDocument {
178
221
  */
179
222
  async decryptEverything() {
180
223
  const security = this.security;
181
- if (!security)
182
- return;
224
+ const enciphered = this.enciphered ?? { streams: true, strings: true, files: true };
183
225
  const encryptNum = isRef(this.trailer.map.get("Encrypt"))
184
226
  ? this.trailer.map.get("Encrypt").num
185
227
  : undefined;
@@ -199,6 +241,10 @@ export class PdfDocument {
199
241
  continue; // never encrypted - it holds the index itself
200
242
  if (type === "Metadata" && metadataInClear)
201
243
  continue;
244
+ if (type === "EmbeddedFile")
245
+ continue; // attached files follow /EFF, below
246
+ if (!security || !enciphered.streams || this.identityCrypt(obj))
247
+ continue;
202
248
  // Keyed by the stream's DATA OFFSET, not its object number: `streamData` receives the stream
203
249
  // object and has no number in hand.
204
250
  this.plainStreams.set(obj.start, await security.decrypt(this.rawStream(obj), { num, gen: entry.gen }));
@@ -209,7 +255,26 @@ export class PdfDocument {
209
255
  // itself stays - it was never encrypted.
210
256
  this.cache.clear();
211
257
  this.objStmCache.clear();
258
+ // Attached files follow their own crypt filter (/EFF). They are found by what points at them - an
259
+ // /EF entry - because a file stream need not say /Type /EmbeddedFile.
260
+ for (const num of this.embeddedFileStreams()) {
261
+ const entry = this.xref.get(num);
262
+ const obj = this.getObject(num);
263
+ if (entry?.kind !== "offset" || obj === undefined || !isStream(obj))
264
+ continue;
265
+ this.plainStreams.delete(obj.start); // a file stream without /Type was taken for a plain stream
266
+ if (!enciphered.files || this.identityCrypt(obj))
267
+ continue;
268
+ if (!security) {
269
+ this.lockedFiles.add(obj.start);
270
+ continue;
271
+ }
272
+ this.plainStreams.set(obj.start, await security.decrypt(this.rawStream(obj), { num, gen: entry.gen }));
273
+ }
274
+ this.cache.clear();
212
275
  // Then the strings, in objects that sit directly in the file.
276
+ if (!security || !enciphered.strings)
277
+ return;
213
278
  for (const [num, entry] of this.xref) {
214
279
  if (entry.kind !== "offset" || num === encryptNum)
215
280
  continue;
@@ -221,14 +286,40 @@ export class PdfDocument {
221
286
  s.bytes = await security.decrypt(s.bytes, { num, gen: entry.gen });
222
287
  }
223
288
  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");
289
+ // Only AES can fail here (RC4 has no padding). The password already validated against /U, so
290
+ // the key is right: a string that does not decipher was left in the clear by its producer -
291
+ // PDFKit does so with name-tree keys, of any length - and is read as it stands (and enciphered
292
+ // when written back).
228
293
  }
229
294
  }
230
295
  }
231
296
  }
297
+ /** A stream that names the `/Identity` crypt filter in its own `/Filter` is not enciphered. */
298
+ identityCrypt(stream) {
299
+ const filter = this.resolve(get(stream, "Filter"));
300
+ const filters = Array.isArray(filter) ? filter : [filter];
301
+ const i = filters.findIndex((f) => nameOf(this.resolve(f)) === "Crypt");
302
+ if (i < 0)
303
+ return false;
304
+ const parms = this.resolve(get(stream, "DecodeParms"));
305
+ const own = Array.isArray(parms) ? this.resolve(parms[i]) : parms;
306
+ return (nameOf(this.lookup(own, "Name")) ?? "Identity") === "Identity";
307
+ }
308
+ /** The object numbers of every attached file's stream: whatever an /EF dictionary points at. */
309
+ embeddedFileStreams() {
310
+ const out = new Set();
311
+ for (const num of this.xref.keys()) {
312
+ const obj = this.getObject(num);
313
+ if (obj !== undefined && isStream(obj) && nameOf(get(obj, "Type")) === "EmbeddedFile")
314
+ out.add(num);
315
+ const ef = isDict(obj) ? this.resolve(get(obj, "EF")) : undefined;
316
+ if (ef !== undefined && isDict(ef))
317
+ for (const ref of ef.map.values())
318
+ if (isRef(ref))
319
+ out.add(ref.num);
320
+ }
321
+ return out;
322
+ }
232
323
  // -------------------------------------------------------------------------------------------
233
324
  // Cross-reference index
234
325
  // -------------------------------------------------------------------------------------------
@@ -504,7 +595,11 @@ export class PdfDocument {
504
595
  /** True once the password has been accepted and the contents decrypted, so what is read is plaintext.
505
596
  * Always true for a document that was never encrypted. */
506
597
  get isReadable() {
507
- return !this.isEncrypted || this.security !== undefined;
598
+ return !this.isEncrypted || this.security !== undefined || !this.encryptsDocument;
599
+ }
600
+ /** Whether streams or strings are enciphered - not the case when only attached files are. */
601
+ get encryptsDocument() {
602
+ return this.enciphered === undefined || this.enciphered.streams || this.enciphered.strings;
508
603
  }
509
604
  /**
510
605
  * Encipher a value that is about to be written back INTO this document, or `undefined` when the
@@ -525,7 +620,13 @@ export class PdfDocument {
525
620
  * a string happening to be present in whatever we rewrite, which is a guarantee by accident.
526
621
  */
527
622
  get canReEncrypt() {
528
- return !this.isEncrypted || (this.security?.canEncrypt ?? false);
623
+ if (!this.isEncrypted)
624
+ return true;
625
+ // A part left as /Identity must stay in the clear when written back - one handler cannot do both.
626
+ const e = this.enciphered;
627
+ if (e && !(e.streams && e.strings && e.files))
628
+ return false;
629
+ return this.security?.canEncrypt ?? false;
529
630
  }
530
631
  /** The generation of an object as the file records it. Members of an object stream are always 0. */
531
632
  generationOf(num) {
@@ -550,8 +651,17 @@ export class PdfDocument {
550
651
  const end = text.indexOf("endstream", stream.start);
551
652
  return this.bytes.subarray(stream.start, end < 0 ? this.bytes.length : end);
552
653
  }
553
- /** A stream's DECODED bytes: deciphered if the document is encrypted, then run through its filters. */
554
- streamData(stream) {
654
+ /**
655
+ * A stream's DECODED bytes: deciphered if the document is encrypted, then run through its filters.
656
+ *
657
+ * `strict` is for data handed to a caller as it is (an attachment): every filter must decode, or this
658
+ * throws a `PdfFilterError` - never bytes still encoded or half decoded. The default suits the
659
+ * structural reads, where a filter that fails is skipped and the rest is read as it stands.
660
+ */
661
+ streamData(stream, strict = false) {
662
+ if (this.lockedFiles.has(stream.start)) {
663
+ throw new PdfEncryptedError("this attached file is encrypted on its own; open the PDF with its password to read it", true);
664
+ }
555
665
  // A decrypted payload replaces the bytes from the file; filters then run on the plaintext, which is
556
666
  // the order the format prescribes - encryption wraps the already-filtered data.
557
667
  const raw = this.plainStreams.get(stream.start) ?? this.rawStream(stream);
@@ -563,36 +673,68 @@ export class PdfDocument {
563
673
  const parms = Array.isArray(parmsRaw) ? parmsRaw : [parmsRaw];
564
674
  let data = raw;
565
675
  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
676
+ try {
677
+ data = this.decodeFilter(filter, data, this.resolve(parms[i]), strict);
678
+ }
679
+ catch (e) {
680
+ // A stream that blew the ceiling is an attack, not a stream we failed to decode - swallowing
681
+ // it here would hand back silent garbage.
682
+ if (e instanceof PdfStreamTooLargeError)
683
+ throw e;
684
+ if (strict) {
685
+ throw e instanceof PdfFilterError
686
+ ? e
687
+ : new PdfFilterError(`${filter}: ${String(e?.message ?? e)}`);
577
688
  }
578
- data = this.undoPredictor(data, this.resolve(parms[i]));
689
+ // not our data to repair; the rest is read as it stands
579
690
  }
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
691
  });
583
692
  return data;
584
693
  }
694
+ /**
695
+ * One filter. Each pass is bounded on what it produces, because /Filter is a list and
696
+ * [/FlateDecode /FlateDecode] is a legal nested bomb. The image codecs (DCT, JPX, CCITT, JBIG2) decode
697
+ * to pixels, not to a file: strict refuses them by name, the default leaves them encoded.
698
+ */
699
+ decodeFilter(filter, data, parms, strict) {
700
+ switch (filter) {
701
+ case "FlateDecode":
702
+ case "Fl":
703
+ return this.undoPredictor(inflateBounded(data, this.maxStreamSize), parms, strict);
704
+ case "LZWDecode":
705
+ case "LZW":
706
+ return this.undoPredictor(lzwDecode(data, this.maxStreamSize, numberOf(get(parms, "EarlyChange")) ?? 1), parms, strict);
707
+ case "ASCIIHexDecode":
708
+ case "AHx":
709
+ return asciiHexDecode(data);
710
+ case "ASCII85Decode":
711
+ case "A85":
712
+ return ascii85Decode(data, this.maxStreamSize);
713
+ case "RunLengthDecode":
714
+ case "RL":
715
+ return runLengthDecode(data, this.maxStreamSize);
716
+ case "Crypt":
717
+ return data; // deciphering happens once, on open
718
+ default:
719
+ if (strict) {
720
+ throw new PdfFilterError(`/${filter} decodes to image pixels, not to a file - jasy returns no data it cannot decode`);
721
+ }
722
+ return data;
723
+ }
724
+ }
585
725
  /**
586
726
  * Undo a PNG predictor (`/Predictor` 10-15), which xref streams almost always use - the IRS form in
587
727
  * our fixtures does. Each row is prefixed with its filter type and is decoded against the row above.
588
728
  */
589
- undoPredictor(data, parms) {
729
+ undoPredictor(data, parms, strict = false) {
590
730
  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
731
  const colors = numberOf(get(parms, "Colors")) ?? 1;
594
732
  const bpc = numberOf(get(parms, "BitsPerComponent")) ?? 8;
595
733
  const columns = numberOf(get(parms, "Columns")) ?? 1;
734
+ if (predictor === 2)
735
+ return this.undoTiffPredictor(data, colors, bpc, columns, strict);
736
+ if (predictor < 10)
737
+ return data; // 1 = none
596
738
  const bpp = Math.max(1, Math.ceil((colors * bpc) / 8));
597
739
  const rowLen = Math.ceil((colors * bpc * columns) / 8);
598
740
  const rows = Math.floor(data.length / (rowLen + 1));
@@ -634,6 +776,21 @@ export class PdfDocument {
634
776
  }
635
777
  return out;
636
778
  }
779
+ /** TIFF predictor 2: each component is the difference from the one to its left, per row. */
780
+ undoTiffPredictor(data, colors, bpc, columns, strict) {
781
+ if (bpc !== 8) {
782
+ if (strict)
783
+ throw new PdfFilterError(`TIFF predictor with ${bpc} bits per component`);
784
+ return data;
785
+ }
786
+ const out = Uint8Array.from(data);
787
+ const rowLen = colors * columns;
788
+ for (let row = 0; row + rowLen <= out.length; row += rowLen) {
789
+ for (let i = colors; i < rowLen; i++)
790
+ out[row + i] = (out[row + i] + out[row + i - colors]) & 0xff;
791
+ }
792
+ return out;
793
+ }
637
794
  /** Every object number the index knows, ascending - for walking a whole document. */
638
795
  objectNumbers() {
639
796
  return [...this.xref.keys()].sort((a, b) => a - b);
package/dist/edit/fill.js CHANGED
@@ -5,6 +5,7 @@ import { bakeAppearance, bakeButtonStates, fontMetrics, readLook } from "./appea
5
5
  import { flattenInto } from "./flatten.js";
6
6
  import { getArrayBuffer } from "../utils/utf8-to-windows1252-encoder.js";
7
7
  import { get, isDict, isRef } from "./objects.js";
8
+ import { encodePdfText } from "../text/pdf-text-string.js";
8
9
  /** Field flags we have to honour when filling (see `forms/acroform.ts` for the writer's side). */
9
10
  const FF_READ_ONLY = 1 << 0;
10
11
  const FF_MULTI_SELECT = 1 << 21;
@@ -12,23 +13,11 @@ const FF_EDIT = 1 << 18;
12
13
  const FF_PUSHBUTTON = 1 << 16;
13
14
  const FF_RADIO = 1 << 15;
14
15
  /**
15
- * A text value in PDF form. Plain ASCII becomes a `(literal)`; anything else becomes a UTF-16BE `<hex>`
16
- * string behind a byte-order mark - the encoding every producer uses for unicode, and the only one that
17
- * carries an umlaut or an emoji unambiguously. Writing UTF-8 into a literal (the obvious mistake) reads
18
- * back as mojibake, because a literal is PDFDocEncoded.
16
+ * A text value in PDF form. Plain ASCII becomes a `(literal)`; anything else becomes `<hex>` of the ONE
17
+ * text-string encoding the writer uses (`encodePdfText`: PDFDocEncoding, else UTF-16BE with its BOM).
18
+ * Writing UTF-8 into a literal (the obvious mistake) reads back as mojibake.
19
19
  */
20
- const plainBytes = (s) => {
21
- if (/^[\x20-\x7e]*$/.test(s))
22
- return Uint8Array.from(s, (c) => c.charCodeAt(0));
23
- const out = new Uint8Array(2 + s.length * 2);
24
- out[0] = 0xfe;
25
- out[1] = 0xff;
26
- for (let i = 0; i < s.length; i++) {
27
- out[2 + i * 2] = s.charCodeAt(i) >> 8;
28
- out[3 + i * 2] = s.charCodeAt(i) & 0xff;
29
- }
30
- return out;
31
- };
20
+ const isAscii = (s) => /^[\x20-\x7e]*$/.test(s);
32
21
  const hex = (b) => `<${Array.from(b, (x) => x.toString(16).padStart(2, "0").toUpperCase()).join("")}>`;
33
22
  /** A value in PDF form. `enciphered` holds ciphertext for the strings of an encrypted document; without
34
23
  * it the value is written in the clear, exactly as before. */
@@ -36,14 +25,10 @@ const pdfText = (s, enciphered) => {
36
25
  const ready = enciphered?.get(s);
37
26
  if (ready !== undefined)
38
27
  return ready;
39
- const ascii = /^[\x20-\x7e]*$/.test(s);
40
- if (ascii) {
28
+ if (isAscii(s)) {
41
29
  return `(${s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)")})`;
42
30
  }
43
- let out = "FEFF";
44
- for (let i = 0; i < s.length; i++)
45
- out += s.charCodeAt(i).toString(16).padStart(4, "0").toUpperCase();
46
- return `<${out}>`;
31
+ return hex(encodePdfText(s));
47
32
  };
48
33
  const escName = (s) => Array.from(new TextEncoder().encode(s))
49
34
  .map((b) => b >= 0x21 && b <= 0x7e && !"()<>[]{}/%#".includes(String.fromCharCode(b))
@@ -287,7 +272,7 @@ export async function fillForm(bytes, values, options = {}) {
287
272
  for (const text of typeof v === "string" ? [v] : Array.isArray(v) ? v : []) {
288
273
  if (enciphered.has(text))
289
274
  continue;
290
- const cipher = await doc.encryptForWrite(plainBytes(text));
275
+ const cipher = await doc.encryptForWrite(encodePdfText(text));
291
276
  if (cipher)
292
277
  enciphered.set(text, hex(cipher));
293
278
  }
@@ -0,0 +1,15 @@
1
+ /** Thrown when a stream's data is not valid for the filter it declares. */
2
+ export declare class PdfFilterError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ /** `/ASCIIHexDecode`: hex digits, whitespace ignored, `>` ends it; an odd last digit is followed by 0. */
6
+ export declare function asciiHexDecode(data: Uint8Array): Uint8Array;
7
+ /** `/ASCII85Decode`: groups of five in `!`..`u`, `z` for four zero bytes, `~>` ends it. */
8
+ export declare function ascii85Decode(data: Uint8Array, max: number): Uint8Array;
9
+ /** `/RunLengthDecode`: a length byte, then that many literals (0-127) or one byte repeated (129-255). */
10
+ export declare function runLengthDecode(data: Uint8Array, max: number): Uint8Array;
11
+ /**
12
+ * `/LZWDecode`: 9 to 12 bit codes, 256 clears the table, 257 ends. `earlyChange` 1 (the default) widens
13
+ * the code one entry early, as PDF's LZW does; 0 is the textbook variant.
14
+ */
15
+ export declare function lzwDecode(data: Uint8Array, max: number, earlyChange?: number): Uint8Array;
@@ -0,0 +1,157 @@
1
+ import { PdfStreamTooLargeError } from "../utils/inflate.js";
2
+ // The standard stream filters besides Flate (which `utils/inflate.ts` decodes). Each one is bounded on
3
+ // what it PRODUCES: LZW and RunLength expand, so a small stream can be a bomb just like a Flate one.
4
+ /** Thrown when a stream's data is not valid for the filter it declares. */
5
+ export class PdfFilterError extends Error {
6
+ constructor(message) {
7
+ super(`@jasy/pdf: ${message}`);
8
+ }
9
+ }
10
+ const tooLarge = (max) => new PdfStreamTooLargeError(max, `a stream in this PDF expands past the ${max} byte limit for a single stream; if the file is ` +
11
+ `genuinely that large, raise it with { maxStreamSize }`);
12
+ /** `/ASCIIHexDecode`: hex digits, whitespace ignored, `>` ends it; an odd last digit is followed by 0. */
13
+ export function asciiHexDecode(data) {
14
+ const out = [];
15
+ let high = -1;
16
+ for (const c of data) {
17
+ if (c === 0x3e)
18
+ break; // ">"
19
+ if (c === 0x20 || (c >= 0x09 && c <= 0x0d) || c === 0x00)
20
+ continue;
21
+ const v = c >= 0x30 && c <= 0x39
22
+ ? c - 0x30
23
+ : c >= 0x41 && c <= 0x46
24
+ ? c - 55
25
+ : c >= 0x61 && c <= 0x66
26
+ ? c - 87
27
+ : -1;
28
+ if (v < 0)
29
+ throw new PdfFilterError(`ASCIIHexDecode: "${String.fromCharCode(c)}" is not a hex digit`);
30
+ if (high < 0)
31
+ high = v;
32
+ else {
33
+ out.push((high << 4) | v);
34
+ high = -1;
35
+ }
36
+ }
37
+ if (high >= 0)
38
+ out.push(high << 4);
39
+ return Uint8Array.from(out);
40
+ }
41
+ /** `/ASCII85Decode`: groups of five in `!`..`u`, `z` for four zero bytes, `~>` ends it. */
42
+ export function ascii85Decode(data, max) {
43
+ const out = [];
44
+ const group = [];
45
+ const flush = (count) => {
46
+ let n = 0;
47
+ for (let i = 0; i < 5; i++)
48
+ n = n * 85 + (group[i] ?? 84);
49
+ if (n > 0xffffffff)
50
+ throw new PdfFilterError("ASCII85Decode: a group decodes past 32 bits");
51
+ for (let i = 0; i < count - 1; i++)
52
+ out.push((n >>> (24 - 8 * i)) & 0xff);
53
+ group.length = 0;
54
+ if (out.length > max)
55
+ throw tooLarge(max);
56
+ };
57
+ for (let i = 0; i < data.length; i++) {
58
+ const c = data[i];
59
+ if (c === 0x7e)
60
+ break; // "~", the start of "~>"
61
+ if (c === 0x20 || (c >= 0x09 && c <= 0x0d) || c === 0x00)
62
+ continue;
63
+ if (c === 0x7a && group.length === 0) {
64
+ out.push(0, 0, 0, 0); // "z"
65
+ if (out.length > max)
66
+ throw tooLarge(max);
67
+ continue;
68
+ }
69
+ if (c < 0x21 || c > 0x75) {
70
+ throw new PdfFilterError(`ASCII85Decode: "${String.fromCharCode(c)}" is outside the alphabet`);
71
+ }
72
+ group.push(c - 33);
73
+ if (group.length === 5)
74
+ flush(5);
75
+ }
76
+ if (group.length === 1)
77
+ throw new PdfFilterError("ASCII85Decode: a final group of one character");
78
+ if (group.length > 0)
79
+ flush(group.length);
80
+ return Uint8Array.from(out);
81
+ }
82
+ /** `/RunLengthDecode`: a length byte, then that many literals (0-127) or one byte repeated (129-255). */
83
+ export function runLengthDecode(data, max) {
84
+ const out = [];
85
+ let i = 0;
86
+ while (i < data.length) {
87
+ const len = data[i++];
88
+ if (len === 128)
89
+ break;
90
+ if (len < 128) {
91
+ if (i + len + 1 > data.length)
92
+ throw new PdfFilterError("RunLengthDecode: data ends inside a run");
93
+ for (let k = 0; k <= len; k++)
94
+ out.push(data[i + k]);
95
+ i += len + 1;
96
+ }
97
+ else {
98
+ if (i >= data.length)
99
+ throw new PdfFilterError("RunLengthDecode: data ends inside a run");
100
+ for (let k = 0; k < 257 - len; k++)
101
+ out.push(data[i]);
102
+ i += 1;
103
+ }
104
+ if (out.length > max)
105
+ throw tooLarge(max);
106
+ }
107
+ return Uint8Array.from(out);
108
+ }
109
+ /**
110
+ * `/LZWDecode`: 9 to 12 bit codes, 256 clears the table, 257 ends. `earlyChange` 1 (the default) widens
111
+ * the code one entry early, as PDF's LZW does; 0 is the textbook variant.
112
+ */
113
+ export function lzwDecode(data, max, earlyChange = 1) {
114
+ const out = [];
115
+ let table = [];
116
+ let width = 9;
117
+ let prev;
118
+ const reset = () => {
119
+ table = Array.from({ length: 258 }, (_, i) => Uint8Array.of(i));
120
+ width = 9;
121
+ prev = undefined;
122
+ };
123
+ reset();
124
+ let bitPos = 0;
125
+ const totalBits = data.length * 8;
126
+ while (bitPos + width <= totalBits) {
127
+ let code = 0;
128
+ for (let b = 0; b < width; b++) {
129
+ const bit = (data[(bitPos + b) >> 3] >> (7 - ((bitPos + b) & 7))) & 1;
130
+ code = (code << 1) | bit;
131
+ }
132
+ bitPos += width;
133
+ if (code === 256) {
134
+ reset();
135
+ continue;
136
+ }
137
+ if (code === 257)
138
+ break;
139
+ let entry;
140
+ if (code < table.length)
141
+ entry = table[code];
142
+ else if (code === table.length && prev)
143
+ entry = Uint8Array.from([...prev, prev[0]]);
144
+ else
145
+ throw new PdfFilterError(`LZWDecode: code ${code} is not in the table`);
146
+ for (const b of entry)
147
+ out.push(b);
148
+ if (out.length > max)
149
+ throw tooLarge(max);
150
+ if (prev)
151
+ table.push(Uint8Array.from([...prev, entry[0]]));
152
+ prev = entry;
153
+ if (table.length + earlyChange >= 1 << width && width < 12)
154
+ width++;
155
+ }
156
+ return Uint8Array.from(out);
157
+ }
@@ -5,11 +5,14 @@
5
5
  * that only generates should not carry it - the same reason jimp is kept out of the text path. Importing
6
6
  * `@jasy/pdf` alone pulls in none of this.
7
7
  *
8
- * The scope is deliberately narrow: AcroForm fields, and nothing else. This is not a PDF editor. What we
8
+ * The scope is deliberately narrow: AcroForm fields and attachments, nothing else. This is not a PDF editor. What we
9
9
  * do not understand, we leave untouched, and what we cannot do safely we refuse by name.
10
10
  */
11
11
  export { readAcroForm } from "./acroform-reader.js";
12
12
  export type { ReadField, ReadForm, ReadWidget } from "./acroform-reader.js";
13
+ export { readAttachments, AttachmentError } from "./attachments.js";
14
+ export type { Attachment, AttachmentLocation } from "./attachments.js";
15
+ export { PdfFilterError } from "./filters.js";
13
16
  export { fillForm, FillError } from "./fill.js";
14
17
  export { flattenForm, FlattenError } from "./flatten.js";
15
18
  export type { FlattenOptions, FlattenResult } from "./flatten.js";
@@ -5,10 +5,12 @@
5
5
  * that only generates should not carry it - the same reason jimp is kept out of the text path. Importing
6
6
  * `@jasy/pdf` alone pulls in none of this.
7
7
  *
8
- * The scope is deliberately narrow: AcroForm fields, and nothing else. This is not a PDF editor. What we
8
+ * The scope is deliberately narrow: AcroForm fields and attachments, nothing else. This is not a PDF editor. What we
9
9
  * do not understand, we leave untouched, and what we cannot do safely we refuse by name.
10
10
  */
11
11
  export { readAcroForm } from "./acroform-reader.js";
12
+ export { readAttachments, AttachmentError } from "./attachments.js";
13
+ export { PdfFilterError } from "./filters.js";
12
14
  export { fillForm, FillError } from "./fill.js";
13
15
  export { flattenForm, FlattenError } from "./flatten.js";
14
16
  // The document itself, for callers who want to look further than the form.