@scanmate/merge 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,6 +51,7 @@ By default each image page is the image at its resolution. A 2480 × 3508 scan a
51
51
  | `quality` | `92` | JPEG quality. |
52
52
  | `passThrough` | `true` | Return a lone PDF unchanged. |
53
53
  | `metadata` | none | Title, author, subject, keywords, creator. The producer is always `@scanmate/merge`. |
54
+ | `password` | none | Opens an encrypted PDF source that needs a password to be read. A signed or permission-restricted PDF - locked with an owner password alone - needs none. Tried on every encrypted source; one it does not open is tried with no password instead, so it cannot lock out a source that needed nothing. |
54
55
 
55
56
  Each entry in `pages` says which source and source page it came from, how it was embedded, and its size. A source that cannot be used raises a `MergeSourceError` carrying its `index`.
56
57
 
@@ -72,6 +73,8 @@ const { pdf, drawn, warnings } = await markPages('issued.pdf', [
72
73
 
73
74
  It exists to check that the regions a validation will measure are where the document's fields actually are - most people reach it as `Scanmate.mark` in `@scanmate/scan`, whose README has a worked example on the W-9.
74
75
 
76
+ **Signed documents open as they are.** They usually arrive encrypted - an owner password restricting editing, none needed to read - which pdf-lib refuses by default. They are decrypted as they are read, with nothing to pass; `password` is only for a PDF that needs one to be read at all.
77
+
75
78
  Two things it gets right that a naive version would not. **Rotation and crop box:** a region is in points from the top-left of the page *as displayed*, which is how `@scanmate/extract` reports text, while pdf-lib draws from the bottom-left of the unrotated media box. The conversion between the two is pdf.js's own page transform, ported line for line and inverted, and its spec pins it to values read off real pdf.js for every quarter turn, with and without an offset crop box. **Bleed:** it is resolved by `resolveBleed` from `@scanmate/ink`, the same function the pixel comparison uses, so the band drawn is the band measured.
76
79
 
77
80
  ## How it decides
package/dist/index.esm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PDFDocument, EncryptedPDFError, rgb, StandardFonts, degrees } from '@cantoo/pdf-lib';
1
+ import { PDFDocument, rgb, StandardFonts, degrees } from '@cantoo/pdf-lib';
2
2
  import { isRaster, decodeImage, readImageMetadata, encodeImage, resolveBleed, hasBleed, growBy } from '@scanmate/ink';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { fileURLToPath } from 'node:url';
@@ -125,6 +125,60 @@ async function readBytes(source, index) {
125
125
  throw new MergeSourceError(index, 'expected a path, a file URL, bytes, a raster or { raster, dpi }');
126
126
  }
127
127
 
128
+ /** A PDF that needs a password to open, which was not given or did not work. */
129
+ class PdfPasswordError extends Error {
130
+ /** Whether a password was given at all - wrong, or missing. */
131
+ given;
132
+ constructor(given) {
133
+ super(given ? 'this PDF is encrypted, and the password given does not open it' : 'this PDF is encrypted with a password it needs to be opened - pass `password`');
134
+ this.name = 'PdfPasswordError';
135
+ this.given = given;
136
+ }
137
+ }
138
+ /**
139
+ * Opens a PDF for writing, decrypting it if it is encrypted.
140
+ *
141
+ * Signed documents usually arrive encrypted: an owner password restricting
142
+ * what may be done with them, and no password needed to read them. pdf-lib
143
+ * refuses those outright, and its own error suggests `ignoreEncryption: true`.
144
+ * That is not taken here, because it is measured to be wrong for anything that
145
+ * writes: the document loads and saves and still opens, with its text intact,
146
+ * but whatever was drawn on it is silently missing - written unencrypted into a
147
+ * file that still declares itself encrypted, so a viewer "decrypts" it into
148
+ * nothing. A helper that draws marks would hand back a clean PDF with no marks
149
+ * and no error.
150
+ *
151
+ * Decrypting instead is correct, and costs a caller nothing in the common case:
152
+ * the empty password opens an owner-password-only document. Only a PDF that
153
+ * needs a password to be read at all needs one given.
154
+ *
155
+ * Nor is the document loaded for incremental update, which would keep the
156
+ * original bytes intact and with them an existing digital signature. That is
157
+ * measured too: on an encrypted document the original page content does not
158
+ * survive it. A marked copy is for looking at; it is not the signed document,
159
+ * and does not pretend to be.
160
+ */
161
+ async function openPdf(bytes, {
162
+ password
163
+ } = {}) {
164
+ // The password given first, then none. A caller's password is for the document
165
+ // that needs one; tried alone it would lock out the owner-password-only PDF that
166
+ // needed nothing - which is what happened when one password was applied to every
167
+ // source of a merge. The empty password opens only what anyone may read anyway.
168
+ const attempts = password === undefined || password === '' ? [''] : [password, ''];
169
+ for (const attempt of attempts) try {
170
+ // An empty password changes nothing for a document that is not encrypted.
171
+ return await PDFDocument.load(bytes, {
172
+ password: attempt,
173
+ updateMetadata: false
174
+ });
175
+ } catch (error) {
176
+ // pdf-lib reports these as plain errors: "NEEDS PASSWORD", "Password incorrect".
177
+ if (!(error instanceof Error) || !/password/i.test(error.message)) throw error;
178
+ }
179
+ throw new PdfPasswordError(password !== undefined);
180
+ }
181
+
128
182
  /**
129
183
  * Many files in, one PDF out: PDFs, images and rasters, in the order given,
130
184
  * mixed freely.
@@ -148,6 +202,7 @@ async function mergeDocuments(sources, options = {}) {
148
202
  quality = 92,
149
203
  passThrough = true,
150
204
  metadata,
205
+ password,
151
206
  onProgress
152
207
  } = options;
153
208
  if (sources.length === 0) throw new RangeError('nothing to merge');
@@ -160,7 +215,8 @@ async function mergeDocuments(sources, options = {}) {
160
215
  margin,
161
216
  imageDpi,
162
217
  encoding,
163
- quality
218
+ quality,
219
+ password
164
220
  };
165
221
  for (const [index, source] of resolved.entries()) {
166
222
  const started = Date.now();
@@ -210,7 +266,7 @@ async function mergeDocuments(sources, options = {}) {
210
266
  };
211
267
  }
212
268
  async function appendPdf(context, bytes, index) {
213
- const source = await loadPdf(bytes, index);
269
+ const source = await loadPdf(bytes, index, context.password);
214
270
  const copied = await context.merged.copyPages(source, source.getPageIndices());
215
271
  for (const [i, page] of copied.entries()) {
216
272
  context.merged.addPage(page);
@@ -230,13 +286,18 @@ async function appendPdf(context, bytes, index) {
230
286
  });
231
287
  }
232
288
  }
233
- async function loadPdf(bytes, index) {
289
+ /**
290
+ * A source PDF, decrypted if it is encrypted - an owner-password-only document,
291
+ * the usual signed or restricted PDF, needs nothing given. The merged document
292
+ * is a new one, so it comes out unencrypted whatever went in.
293
+ */
294
+ async function loadPdf(bytes, index, password) {
234
295
  try {
235
- return await PDFDocument.load(bytes, {
236
- updateMetadata: false
296
+ return await openPdf(bytes, {
297
+ password
237
298
  });
238
299
  } catch (error) {
239
- if (error instanceof EncryptedPDFError) throw new MergeSourceError(index, 'it is an encrypted PDF, which cannot be copied without its password');
300
+ if (error instanceof PdfPasswordError) throw new MergeSourceError(index, error.given ? 'it is an encrypted PDF, and `password` does not open it' : 'it is an encrypted PDF that needs a password to open - pass `password`');
240
301
  throw new MergeSourceError(index, `it is not a readable PDF (${error instanceof Error ? error.message : String(error)})`);
241
302
  }
242
303
  }
@@ -445,7 +506,11 @@ const LABEL_SIZE = 7;
445
506
  async function markPages(pdf, marks, options = {}) {
446
507
  const source = await readSource(pdf, 0);
447
508
  if (source.kind !== 'pdf') throw new TypeError('marks are drawn on a PDF, and this is not one');
448
- const document = await PDFDocument.load(source.bytes);
509
+ // Decrypted if encrypted, as a signed document usually is - see `openPdf` for
510
+ // why that and not `ignoreEncryption`, which would silently drop every mark.
511
+ const document = await openPdf(source.bytes, {
512
+ password: options.password
513
+ });
449
514
  const font = options.labels === false ? null : await document.embedFont(StandardFonts.Helvetica);
450
515
  const bleed = resolveBleed(options);
451
516
  const pages = document.getPages();
@@ -548,5 +613,5 @@ function round(value) {
548
613
  return Math.round(value * 10) / 10;
549
614
  }
550
615
 
551
- export { MIN_RECORDED_DPI, MergeSourceError, PAPER, isPdf, markPages, mergeDocuments, placeImage, readSource, resolveDpi, toUserSpace, viewportSize, viewportTransform };
616
+ export { MIN_RECORDED_DPI, MergeSourceError, PAPER, PdfPasswordError, isPdf, markPages, mergeDocuments, openPdf, placeImage, readSource, resolveDpi, toUserSpace, viewportSize, viewportTransform };
552
617
  //# sourceMappingURL=index.esm.js.map
@@ -2,6 +2,13 @@ import type { ProgressCallback } from '@scanmate/ink';
2
2
  import type { PageSize } from '../page-placement/index.js';
3
3
  import type { SourceKind } from '../source-reading/index.js';
4
4
  export interface MergeOptions {
5
+ /**
6
+ * Opens an encrypted PDF source that needs a password to be read. The usual
7
+ * encrypted PDF - a signed or permission-restricted document, locked with an
8
+ * owner password alone - needs none and is decrypted as it is copied. The
9
+ * same password is tried on every encrypted source.
10
+ */
11
+ password?: string;
5
12
  /** Size of each image page: the image at its resolution (`'image'`, the default), or a paper size to fit it in. */
6
13
  pageSize?: PageSize;
7
14
  /** Points of white kept around an image on a paper-size page. Default `0`. */
@@ -21,7 +21,8 @@
21
21
  */
22
22
  export { mergeDocuments } from './document-merge/index.js';
23
23
  export type { Embedding, MergedPage, MergeOptions, MergeResult } from './document-merge/index.js';
24
- export { MergeSourceError } from './source-reading/index.js';
24
+ export { MergeSourceError, openPdf, PdfPasswordError } from './source-reading/index.js';
25
+ export type { OpenPdfOptions } from './source-reading/index.js';
25
26
  export type { SourceKind } from './source-reading/index.js';
26
27
  export { markPages } from './page-marking/index.js';
27
28
  export type { MarkOptions, MarkResult, PageMark } from './page-marking/index.js';
@@ -23,6 +23,12 @@ export interface PageMark extends ScanmateRect {
23
23
  export interface MarkOptions extends Bleed {
24
24
  /** Write each mark's `id` beside it. Default `true`. */
25
25
  labels?: boolean;
26
+ /**
27
+ * Opens a PDF encrypted with a password it needs to be read. A signed or
28
+ * permission-restricted document, locked with an owner password alone, needs
29
+ * none: it is decrypted as it is read, and the marks are drawn on it.
30
+ */
31
+ password?: string;
26
32
  }
27
33
  export interface MarkResult {
28
34
  /** The original, with the marks drawn on it. Nothing else about it changes. */
@@ -2,5 +2,7 @@
2
2
  export { MergeSourceError } from './merge-source.contract.js';
3
3
  export type { SourceKind } from './merge-source.contract.js';
4
4
  export { isPdf, readSource } from './read-source.use-case.js';
5
+ export { openPdf, PdfPasswordError } from './open-pdf.use-case.js';
6
+ export type { OpenPdfOptions } from './open-pdf.use-case.js';
5
7
  export type { ResolvedSource } from './read-source.use-case.js';
6
8
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,42 @@
1
+ import { PDFDocument } from '@cantoo/pdf-lib';
2
+ /** A PDF that needs a password to open, which was not given or did not work. */
3
+ export declare class PdfPasswordError extends Error {
4
+ /** Whether a password was given at all - wrong, or missing. */
5
+ readonly given: boolean;
6
+ constructor(given: boolean);
7
+ }
8
+ export interface OpenPdfOptions {
9
+ /**
10
+ * The password that opens the document, for a PDF encrypted with one.
11
+ *
12
+ * Not needed for the usual encrypted PDF - a signed or permission-restricted
13
+ * document, locked with an owner password and nothing else - which opens with
14
+ * no password at all and is decrypted as it is read.
15
+ */
16
+ password?: string;
17
+ }
18
+ /**
19
+ * Opens a PDF for writing, decrypting it if it is encrypted.
20
+ *
21
+ * Signed documents usually arrive encrypted: an owner password restricting
22
+ * what may be done with them, and no password needed to read them. pdf-lib
23
+ * refuses those outright, and its own error suggests `ignoreEncryption: true`.
24
+ * That is not taken here, because it is measured to be wrong for anything that
25
+ * writes: the document loads and saves and still opens, with its text intact,
26
+ * but whatever was drawn on it is silently missing - written unencrypted into a
27
+ * file that still declares itself encrypted, so a viewer "decrypts" it into
28
+ * nothing. A helper that draws marks would hand back a clean PDF with no marks
29
+ * and no error.
30
+ *
31
+ * Decrypting instead is correct, and costs a caller nothing in the common case:
32
+ * the empty password opens an owner-password-only document. Only a PDF that
33
+ * needs a password to be read at all needs one given.
34
+ *
35
+ * Nor is the document loaded for incremental update, which would keep the
36
+ * original bytes intact and with them an existing digital signature. That is
37
+ * measured too: on an encrypted document the original page content does not
38
+ * survive it. A marked copy is for looking at; it is not the signed document,
39
+ * and does not pretend to be.
40
+ */
41
+ export declare function openPdf(bytes: Uint8Array, { password }?: OpenPdfOptions): Promise<PDFDocument>;
42
+ //# sourceMappingURL=open-pdf.use-case.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scanmate/merge",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Merge PDFs, images and rasters into one PDF: PDF pages copied as they are, JPEGs embedded without recompression, every page of a TIFF.",
5
5
  "license": "MIT",
6
6
  "author": "Eduardo Russo",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@cantoo/pdf-lib": "^2.11.1",
44
- "@scanmate/ink": "^0.8.0"
44
+ "@scanmate/ink": "^0.9.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"