@scanmate/merge 0.7.1 → 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
 
@@ -58,6 +59,24 @@ Measured on real scans: three 120-dpi page JPEGs plus a 7-page PDF merged in 20
58
59
 
59
60
  PDF writing uses `@cantoo/pdf-lib`, the maintained fork of pdf-lib, which is pure JavaScript with nothing to install on the host.
60
61
 
62
+ ## Drawing on a PDF, not just assembling one
63
+
64
+ The other thing this package writes is marks. `markPages` draws a set of regions on a PDF as vector rectangles, each with its bleed dashed around it, and hands the PDF back:
65
+
66
+ ```ts
67
+ import { markPages } from '@scanmate/merge'
68
+
69
+ const { pdf, drawn, warnings } = await markPages('issued.pdf', [
70
+ { page: 1, id: 'signature', x: 120, y: 577, width: 262, height: 22 },
71
+ ], { bleedTop: 2, bleedBottom: 12 })
72
+ ```
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.
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
+
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.
79
+
61
80
  ## How it decides
62
81
 
63
82
  [`documentation/algorithms.md`](./documentation/algorithms.md) has the algorithms in full: what each step measures, the decision flows, every constant with the measurement behind it, and what the package deliberately does not do.
package/dist/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
- import { PDFDocument, EncryptedPDFError } from '@cantoo/pdf-lib';
2
- import { isRaster, decodeImage, readImageMetadata, encodeImage } from '@scanmate/ink';
1
+ import { PDFDocument, rgb, StandardFonts, degrees } from '@cantoo/pdf-lib';
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';
5
5
 
@@ -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
  }
@@ -353,5 +414,204 @@ function embeddableFormat(bytes) {
353
414
  return null;
354
415
  }
355
416
 
356
- export { MIN_RECORDED_DPI, MergeSourceError, PAPER, isPdf, mergeDocuments, placeImage, readSource, resolveDpi };
417
+ /**
418
+ * The page as a reader sees it, and the way back to the PDF's own coordinates.
419
+ *
420
+ * A mark is measured the way `@scanmate/extract` reports text: from the top-left
421
+ * of the page as displayed, in points, with the page's `/Rotate` and crop box
422
+ * already applied. pdf-lib draws in the PDF's own user space instead: from the
423
+ * bottom-left of the media box, before any rotation.
424
+ *
425
+ * Getting from one to the other is the whole difficulty of drawing a mark in
426
+ * the right place, and doing it approximately would defeat the purpose - a
427
+ * helper for checking positions that is wrong on rotated or cropped pages gives
428
+ * false confidence on exactly the pages that most need checking.
429
+ *
430
+ * So this is pdf.js's own `PageViewport` transform at scale 1, ported line for
431
+ * line rather than re-derived, and inverted. The text a mark was measured from
432
+ * and the box drawn for it then go through the same arithmetic in opposite
433
+ * directions.
434
+ */
435
+ /** User space to what a reader sees - pdf.js's `PageViewport` at scale 1. */
436
+ function viewportTransform({
437
+ view,
438
+ rotation
439
+ }) {
440
+ const centerX = (view[2] + view[0]) / 2;
441
+ const centerY = (view[3] + view[1]) / 2;
442
+ const [a, b, c, d] = rotationOf(rotation);
443
+ const quarter = a === 0;
444
+ const offsetX = quarter ? Math.abs(centerY - view[1]) : Math.abs(centerX - view[0]);
445
+ const offsetY = quarter ? Math.abs(centerX - view[0]) : Math.abs(centerY - view[1]);
446
+ return [a, b, c, d, offsetX - a * centerX - c * centerY, offsetY - b * centerX - d * centerY];
447
+ }
448
+ /** How large the page is as a reader sees it: a quarter turn swaps width and height. */
449
+ function viewportSize({
450
+ view,
451
+ rotation
452
+ }) {
453
+ const [a] = rotationOf(rotation);
454
+ const across = view[2] - view[0];
455
+ const down = view[3] - view[1];
456
+ return a === 0 ? {
457
+ width: down,
458
+ height: across
459
+ } : {
460
+ width: across,
461
+ height: down
462
+ };
463
+ }
464
+ /** A point a reader sees, back in the PDF's own coordinates. */
465
+ function toUserSpace([a, b, c, d, e, f], x, y) {
466
+ const det = a * d - b * c;
467
+ const dx = x - e;
468
+ const dy = y - f;
469
+ // `+ 0` turns a negative zero positive: harmless to draw at, noisy to read back.
470
+ return {
471
+ x: (d * dx - c * dy) / det + 0,
472
+ y: (-b * dx + a * dy) / det + 0
473
+ };
474
+ }
475
+ /**
476
+ * pdf.js's rotation matrix for a page turned by `rotation` degrees.
477
+ *
478
+ * Only quarter turns are legal in a PDF; anything else is refused here exactly
479
+ * as pdf.js refuses it, rather than drawn somewhere plausible.
480
+ */
481
+ function rotationOf(rotation) {
482
+ const matrix = ROTATIONS.get((rotation % 360 + 360) % 360);
483
+ if (matrix === undefined) throw new RangeError(`a page may only be rotated by a multiple of 90 degrees, and is rotated by ${rotation}`);
484
+ return matrix;
485
+ }
486
+ /** pdf.js's `rotateA` to `rotateD`, by quarter turn. */
487
+ const ROTATIONS = new Map([[0, [1, 0, 0, -1]], [90, [0, 1, 1, 0]], [180, [-1, 0, 0, 1]], [270, [0, -1, -1, 0]]]);
488
+
489
+ /**
490
+ * The colours of the audit's evidence page, so the two read the same way: the
491
+ * region in blue, its bleed in magenta.
492
+ */
493
+ const REGION = rgb(0, 23 / 255, 252 / 255);
494
+ const BLEED = rgb(245 / 255, 0, 252 / 255);
495
+ const LABEL_SIZE = 7;
496
+ /**
497
+ * Draws each mark on the original, with its bleed around it, and hands the PDF
498
+ * back - a way to see whether the positions a validation will use are where
499
+ * the fields actually are, before anything is measured with them.
500
+ *
501
+ * Drawn on the document itself, as vectors: the page is not re-rendered, so it
502
+ * stays sharp at any zoom and the boxes sit exactly where their coordinates
503
+ * say. Nothing is aligned or adjusted, because there is nothing to align - this
504
+ * is the original, and the marks were measured against it.
505
+ */
506
+ async function markPages(pdf, marks, options = {}) {
507
+ const source = await readSource(pdf, 0);
508
+ if (source.kind !== 'pdf') throw new TypeError('marks are drawn on a PDF, and this is not one');
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
+ });
514
+ const font = options.labels === false ? null : await document.embedFont(StandardFonts.Helvetica);
515
+ const bleed = resolveBleed(options);
516
+ const pages = document.getPages();
517
+ const warnings = [];
518
+ let drawn = 0;
519
+ for (const mark of marks) {
520
+ const name = mark.id ?? `mark ${drawn + warnings.length + 1}`;
521
+ const page = pages[mark.page - 1];
522
+ if (page === undefined) {
523
+ warnings.push(`${name} is on page ${mark.page}, and the document has ${pages.length}`);
524
+ continue;
525
+ }
526
+ const geometry = geometryOf(page);
527
+ const size = viewportSize(geometry);
528
+ if (mark.x < 0 || mark.y < 0 || mark.x + mark.width > size.width || mark.y + mark.height > size.height) warnings.push(`${name} reaches past the edge of page ${mark.page}, which is ${round(size.width)} x ${round(size.height)} pt`);
529
+ // The band first, so the region's own outline draws over it where they meet.
530
+ if (hasBleed(bleed)) draw(page, geometry, growBy(mark, bleed), {
531
+ colour: BLEED,
532
+ dashed: true
533
+ });
534
+ draw(page, geometry, mark, {
535
+ colour: REGION,
536
+ dashed: false
537
+ });
538
+ if (font !== null && mark.id !== undefined) label(page, geometry, font, mark);
539
+ drawn++;
540
+ }
541
+ return {
542
+ pdf: await document.save(),
543
+ drawn,
544
+ warnings
545
+ };
546
+ }
547
+ /** The page's visible box and rotation, as pdf.js would read them. */
548
+ function geometryOf(page) {
549
+ const crop = page.getCropBox();
550
+ return {
551
+ view: [crop.x, crop.y, crop.x + crop.width, crop.y + crop.height],
552
+ rotation: page.getRotation().angle
553
+ };
554
+ }
555
+ /**
556
+ * A rectangle measured as displayed, drawn in the PDF's own coordinates.
557
+ *
558
+ * A quarter turn keeps a rectangle a rectangle, so its corners are taken back
559
+ * to user space and their extent is the box to draw.
560
+ */
561
+ function draw(page, geometry, rect, style) {
562
+ const {
563
+ x,
564
+ y,
565
+ width,
566
+ height
567
+ } = userBox(geometry, rect);
568
+ page.drawRectangle({
569
+ x,
570
+ y,
571
+ width,
572
+ height,
573
+ borderColor: style.colour,
574
+ borderWidth: style.dashed ? 0.6 : 0.9,
575
+ borderDashArray: style.dashed ? [2.5, 1.5] : undefined,
576
+ opacity: 0,
577
+ borderOpacity: 1
578
+ });
579
+ }
580
+ /**
581
+ * The id inside the box's top-left corner, upright to the reader however the
582
+ * page is turned.
583
+ *
584
+ * Inside rather than above: on the original a field is empty, so the corner is
585
+ * free, while the space above it is usually the printed line before - and a
586
+ * label written over that would obscure the very edge being checked.
587
+ */
588
+ function label(page, geometry, font, mark) {
589
+ const at = toUserSpace(viewportTransform(geometry), mark.x + 2, mark.y + LABEL_SIZE);
590
+ page.drawText(mark.id ?? '', {
591
+ x: at.x,
592
+ y: at.y,
593
+ size: LABEL_SIZE,
594
+ font,
595
+ color: REGION,
596
+ // Text is laid in user space; turning it with the page keeps it readable.
597
+ rotate: degrees(geometry.rotation)
598
+ });
599
+ }
600
+ function userBox(geometry, rect) {
601
+ const transform = viewportTransform(geometry);
602
+ const corners = [toUserSpace(transform, rect.x, rect.y), toUserSpace(transform, rect.x + rect.width, rect.y + rect.height)];
603
+ const xs = corners.map(corner => corner.x);
604
+ const ys = corners.map(corner => corner.y);
605
+ return {
606
+ x: Math.min(...xs),
607
+ y: Math.min(...ys),
608
+ width: Math.abs(xs[1] - xs[0]),
609
+ height: Math.abs(ys[1] - ys[0])
610
+ };
611
+ }
612
+ function round(value) {
613
+ return Math.round(value * 10) / 10;
614
+ }
615
+
616
+ export { MIN_RECORDED_DPI, MergeSourceError, PAPER, PdfPasswordError, isPdf, markPages, mergeDocuments, openPdf, placeImage, readSource, resolveDpi, toUserSpace, viewportSize, viewportTransform };
357
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,11 +21,16 @@
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';
27
+ export { markPages } from './page-marking/index.js';
28
+ export type { MarkOptions, MarkResult, PageMark } from './page-marking/index.js';
26
29
  export type { PageSize } from './page-placement/index.js';
27
30
  export { MIN_RECORDED_DPI, PAPER, placeImage, resolveDpi } from './page-placement/index.js';
28
31
  export type { Placement } from './page-placement/index.js';
29
32
  export { isPdf, readSource } from './source-reading/index.js';
30
33
  export type { ResolvedSource } from './source-reading/index.js';
34
+ export { toUserSpace, viewportSize, viewportTransform } from './page-marking/index.js';
35
+ export type { Affine, PageGeometry } from './page-marking/index.js';
31
36
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** Drawing expected regions on the original, to see whether they are where the fields are. */
2
+ export { markPages } from './mark-pages.use-case.js';
3
+ export type { MarkOptions, MarkResult, PageMark } from './page-mark.contract.js';
4
+ export { toUserSpace, viewportSize, viewportTransform } from './page-viewport.policy.js';
5
+ export type { Affine, PageGeometry } from './page-viewport.policy.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,14 @@
1
+ import type { ScanmateBinarySource } from '@scanmate/ink';
2
+ import type { MarkOptions, MarkResult, PageMark } from './page-mark.contract.js';
3
+ /**
4
+ * Draws each mark on the original, with its bleed around it, and hands the PDF
5
+ * back - a way to see whether the positions a validation will use are where
6
+ * the fields actually are, before anything is measured with them.
7
+ *
8
+ * Drawn on the document itself, as vectors: the page is not re-rendered, so it
9
+ * stays sharp at any zoom and the boxes sit exactly where their coordinates
10
+ * say. Nothing is aligned or adjusted, because there is nothing to align - this
11
+ * is the original, and the marks were measured against it.
12
+ */
13
+ export declare function markPages(pdf: ScanmateBinarySource, marks: readonly PageMark[], options?: MarkOptions): Promise<MarkResult>;
14
+ //# sourceMappingURL=mark-pages.use-case.d.ts.map
@@ -0,0 +1,46 @@
1
+ import type { Bleed, ScanmateRect } from '@scanmate/ink';
2
+ /**
3
+ * One region to draw, where the document's own fields are expected to be.
4
+ *
5
+ * In points, from the top-left of the page as displayed - the same coordinates
6
+ * `@scanmate/extract` reports text in, and the same shape as `@scanmate/diff`'s
7
+ * `ExpectedChange`. So the array about to be handed to an audit can be drawn
8
+ * as it is, and what is checked visually is exactly what will be measured.
9
+ */
10
+ export interface PageMark extends ScanmateRect {
11
+ /** One-based page number. */
12
+ page: number;
13
+ /** Written beside the box, so a reviewer can tell which field is which. */
14
+ id?: string;
15
+ }
16
+ /**
17
+ * How the marks are drawn. The bleed - `bleed`, and `bleedTop`, `bleedRight`,
18
+ * `bleedBottom`, `bleedLeft` to override a side - is drawn as a dashed band
19
+ * around each mark, and is the same rule the pixel comparison measures with. A
20
+ * mark checked here with a given bleed is checked against the region an audit
21
+ * with that bleed will actually claim. Default 6 points on every side.
22
+ */
23
+ export interface MarkOptions extends Bleed {
24
+ /** Write each mark's `id` beside it. Default `true`. */
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;
32
+ }
33
+ export interface MarkResult {
34
+ /** The original, with the marks drawn on it. Nothing else about it changes. */
35
+ pdf: Uint8Array;
36
+ /** How many marks were drawn. */
37
+ drawn: number;
38
+ /**
39
+ * What could not be drawn, or was drawn somewhere it cannot be right: a mark
40
+ * on a page the document does not have, or one that reaches past the edge of
41
+ * its page. The second is itself a positioning error, and exactly the kind
42
+ * this exists to catch.
43
+ */
44
+ warnings: string[];
45
+ }
46
+ //# sourceMappingURL=page-mark.contract.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The page as a reader sees it, and the way back to the PDF's own coordinates.
3
+ *
4
+ * A mark is measured the way `@scanmate/extract` reports text: from the top-left
5
+ * of the page as displayed, in points, with the page's `/Rotate` and crop box
6
+ * already applied. pdf-lib draws in the PDF's own user space instead: from the
7
+ * bottom-left of the media box, before any rotation.
8
+ *
9
+ * Getting from one to the other is the whole difficulty of drawing a mark in
10
+ * the right place, and doing it approximately would defeat the purpose - a
11
+ * helper for checking positions that is wrong on rotated or cropped pages gives
12
+ * false confidence on exactly the pages that most need checking.
13
+ *
14
+ * So this is pdf.js's own `PageViewport` transform at scale 1, ported line for
15
+ * line rather than re-derived, and inverted. The text a mark was measured from
16
+ * and the box drawn for it then go through the same arithmetic in opposite
17
+ * directions.
18
+ */
19
+ /** A two-dimensional affine transform, `[a, b, c, d, e, f]` as PDF writes them. */
20
+ export type Affine = readonly [number, number, number, number, number, number];
21
+ /** The page's visible box, `[x0, y0, x1, y1]` in user space, and its rotation. */
22
+ export interface PageGeometry {
23
+ view: readonly [number, number, number, number];
24
+ rotation: number;
25
+ }
26
+ /** User space to what a reader sees - pdf.js's `PageViewport` at scale 1. */
27
+ export declare function viewportTransform({ view, rotation }: PageGeometry): Affine;
28
+ /** How large the page is as a reader sees it: a quarter turn swaps width and height. */
29
+ export declare function viewportSize({ view, rotation }: PageGeometry): {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ /** A point a reader sees, back in the PDF's own coordinates. */
34
+ export declare function toUserSpace([a, b, c, d, e, f]: Affine, x: number, y: number): {
35
+ x: number;
36
+ y: number;
37
+ };
38
+ //# sourceMappingURL=page-viewport.policy.d.ts.map
@@ -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.7.1",
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.7.1"
44
+ "@scanmate/ink": "^0.9.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"