@scanmate/merge 0.7.1 → 0.8.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
@@ -58,6 +58,22 @@ Measured on real scans: three 120-dpi page JPEGs plus a 7-page PDF merged in 20
58
58
 
59
59
  PDF writing uses `@cantoo/pdf-lib`, the maintained fork of pdf-lib, which is pure JavaScript with nothing to install on the host.
60
60
 
61
+ ## Drawing on a PDF, not just assembling one
62
+
63
+ 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:
64
+
65
+ ```ts
66
+ import { markPages } from '@scanmate/merge'
67
+
68
+ const { pdf, drawn, warnings } = await markPages('issued.pdf', [
69
+ { page: 1, id: 'signature', x: 120, y: 577, width: 262, height: 22 },
70
+ ], { bleedTop: 2, bleedBottom: 12 })
71
+ ```
72
+
73
+ 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
+ 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
+
61
77
  ## How it decides
62
78
 
63
79
  [`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, EncryptedPDFError, 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
 
@@ -353,5 +353,200 @@ function embeddableFormat(bytes) {
353
353
  return null;
354
354
  }
355
355
 
356
- export { MIN_RECORDED_DPI, MergeSourceError, PAPER, isPdf, mergeDocuments, placeImage, readSource, resolveDpi };
356
+ /**
357
+ * The page as a reader sees it, and the way back to the PDF's own coordinates.
358
+ *
359
+ * A mark is measured the way `@scanmate/extract` reports text: from the top-left
360
+ * of the page as displayed, in points, with the page's `/Rotate` and crop box
361
+ * already applied. pdf-lib draws in the PDF's own user space instead: from the
362
+ * bottom-left of the media box, before any rotation.
363
+ *
364
+ * Getting from one to the other is the whole difficulty of drawing a mark in
365
+ * the right place, and doing it approximately would defeat the purpose - a
366
+ * helper for checking positions that is wrong on rotated or cropped pages gives
367
+ * false confidence on exactly the pages that most need checking.
368
+ *
369
+ * So this is pdf.js's own `PageViewport` transform at scale 1, ported line for
370
+ * line rather than re-derived, and inverted. The text a mark was measured from
371
+ * and the box drawn for it then go through the same arithmetic in opposite
372
+ * directions.
373
+ */
374
+ /** User space to what a reader sees - pdf.js's `PageViewport` at scale 1. */
375
+ function viewportTransform({
376
+ view,
377
+ rotation
378
+ }) {
379
+ const centerX = (view[2] + view[0]) / 2;
380
+ const centerY = (view[3] + view[1]) / 2;
381
+ const [a, b, c, d] = rotationOf(rotation);
382
+ const quarter = a === 0;
383
+ const offsetX = quarter ? Math.abs(centerY - view[1]) : Math.abs(centerX - view[0]);
384
+ const offsetY = quarter ? Math.abs(centerX - view[0]) : Math.abs(centerY - view[1]);
385
+ return [a, b, c, d, offsetX - a * centerX - c * centerY, offsetY - b * centerX - d * centerY];
386
+ }
387
+ /** How large the page is as a reader sees it: a quarter turn swaps width and height. */
388
+ function viewportSize({
389
+ view,
390
+ rotation
391
+ }) {
392
+ const [a] = rotationOf(rotation);
393
+ const across = view[2] - view[0];
394
+ const down = view[3] - view[1];
395
+ return a === 0 ? {
396
+ width: down,
397
+ height: across
398
+ } : {
399
+ width: across,
400
+ height: down
401
+ };
402
+ }
403
+ /** A point a reader sees, back in the PDF's own coordinates. */
404
+ function toUserSpace([a, b, c, d, e, f], x, y) {
405
+ const det = a * d - b * c;
406
+ const dx = x - e;
407
+ const dy = y - f;
408
+ // `+ 0` turns a negative zero positive: harmless to draw at, noisy to read back.
409
+ return {
410
+ x: (d * dx - c * dy) / det + 0,
411
+ y: (-b * dx + a * dy) / det + 0
412
+ };
413
+ }
414
+ /**
415
+ * pdf.js's rotation matrix for a page turned by `rotation` degrees.
416
+ *
417
+ * Only quarter turns are legal in a PDF; anything else is refused here exactly
418
+ * as pdf.js refuses it, rather than drawn somewhere plausible.
419
+ */
420
+ function rotationOf(rotation) {
421
+ const matrix = ROTATIONS.get((rotation % 360 + 360) % 360);
422
+ if (matrix === undefined) throw new RangeError(`a page may only be rotated by a multiple of 90 degrees, and is rotated by ${rotation}`);
423
+ return matrix;
424
+ }
425
+ /** pdf.js's `rotateA` to `rotateD`, by quarter turn. */
426
+ const ROTATIONS = new Map([[0, [1, 0, 0, -1]], [90, [0, 1, 1, 0]], [180, [-1, 0, 0, 1]], [270, [0, -1, -1, 0]]]);
427
+
428
+ /**
429
+ * The colours of the audit's evidence page, so the two read the same way: the
430
+ * region in blue, its bleed in magenta.
431
+ */
432
+ const REGION = rgb(0, 23 / 255, 252 / 255);
433
+ const BLEED = rgb(245 / 255, 0, 252 / 255);
434
+ const LABEL_SIZE = 7;
435
+ /**
436
+ * Draws each mark on the original, with its bleed around it, and hands the PDF
437
+ * back - a way to see whether the positions a validation will use are where
438
+ * the fields actually are, before anything is measured with them.
439
+ *
440
+ * Drawn on the document itself, as vectors: the page is not re-rendered, so it
441
+ * stays sharp at any zoom and the boxes sit exactly where their coordinates
442
+ * say. Nothing is aligned or adjusted, because there is nothing to align - this
443
+ * is the original, and the marks were measured against it.
444
+ */
445
+ async function markPages(pdf, marks, options = {}) {
446
+ const source = await readSource(pdf, 0);
447
+ 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);
449
+ const font = options.labels === false ? null : await document.embedFont(StandardFonts.Helvetica);
450
+ const bleed = resolveBleed(options);
451
+ const pages = document.getPages();
452
+ const warnings = [];
453
+ let drawn = 0;
454
+ for (const mark of marks) {
455
+ const name = mark.id ?? `mark ${drawn + warnings.length + 1}`;
456
+ const page = pages[mark.page - 1];
457
+ if (page === undefined) {
458
+ warnings.push(`${name} is on page ${mark.page}, and the document has ${pages.length}`);
459
+ continue;
460
+ }
461
+ const geometry = geometryOf(page);
462
+ const size = viewportSize(geometry);
463
+ 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`);
464
+ // The band first, so the region's own outline draws over it where they meet.
465
+ if (hasBleed(bleed)) draw(page, geometry, growBy(mark, bleed), {
466
+ colour: BLEED,
467
+ dashed: true
468
+ });
469
+ draw(page, geometry, mark, {
470
+ colour: REGION,
471
+ dashed: false
472
+ });
473
+ if (font !== null && mark.id !== undefined) label(page, geometry, font, mark);
474
+ drawn++;
475
+ }
476
+ return {
477
+ pdf: await document.save(),
478
+ drawn,
479
+ warnings
480
+ };
481
+ }
482
+ /** The page's visible box and rotation, as pdf.js would read them. */
483
+ function geometryOf(page) {
484
+ const crop = page.getCropBox();
485
+ return {
486
+ view: [crop.x, crop.y, crop.x + crop.width, crop.y + crop.height],
487
+ rotation: page.getRotation().angle
488
+ };
489
+ }
490
+ /**
491
+ * A rectangle measured as displayed, drawn in the PDF's own coordinates.
492
+ *
493
+ * A quarter turn keeps a rectangle a rectangle, so its corners are taken back
494
+ * to user space and their extent is the box to draw.
495
+ */
496
+ function draw(page, geometry, rect, style) {
497
+ const {
498
+ x,
499
+ y,
500
+ width,
501
+ height
502
+ } = userBox(geometry, rect);
503
+ page.drawRectangle({
504
+ x,
505
+ y,
506
+ width,
507
+ height,
508
+ borderColor: style.colour,
509
+ borderWidth: style.dashed ? 0.6 : 0.9,
510
+ borderDashArray: style.dashed ? [2.5, 1.5] : undefined,
511
+ opacity: 0,
512
+ borderOpacity: 1
513
+ });
514
+ }
515
+ /**
516
+ * The id inside the box's top-left corner, upright to the reader however the
517
+ * page is turned.
518
+ *
519
+ * Inside rather than above: on the original a field is empty, so the corner is
520
+ * free, while the space above it is usually the printed line before - and a
521
+ * label written over that would obscure the very edge being checked.
522
+ */
523
+ function label(page, geometry, font, mark) {
524
+ const at = toUserSpace(viewportTransform(geometry), mark.x + 2, mark.y + LABEL_SIZE);
525
+ page.drawText(mark.id ?? '', {
526
+ x: at.x,
527
+ y: at.y,
528
+ size: LABEL_SIZE,
529
+ font,
530
+ color: REGION,
531
+ // Text is laid in user space; turning it with the page keeps it readable.
532
+ rotate: degrees(geometry.rotation)
533
+ });
534
+ }
535
+ function userBox(geometry, rect) {
536
+ const transform = viewportTransform(geometry);
537
+ const corners = [toUserSpace(transform, rect.x, rect.y), toUserSpace(transform, rect.x + rect.width, rect.y + rect.height)];
538
+ const xs = corners.map(corner => corner.x);
539
+ const ys = corners.map(corner => corner.y);
540
+ return {
541
+ x: Math.min(...xs),
542
+ y: Math.min(...ys),
543
+ width: Math.abs(xs[1] - xs[0]),
544
+ height: Math.abs(ys[1] - ys[0])
545
+ };
546
+ }
547
+ function round(value) {
548
+ return Math.round(value * 10) / 10;
549
+ }
550
+
551
+ export { MIN_RECORDED_DPI, MergeSourceError, PAPER, isPdf, markPages, mergeDocuments, placeImage, readSource, resolveDpi, toUserSpace, viewportSize, viewportTransform };
357
552
  //# sourceMappingURL=index.esm.js.map
@@ -23,9 +23,13 @@ export { mergeDocuments } from './document-merge/index.js';
23
23
  export type { Embedding, MergedPage, MergeOptions, MergeResult } from './document-merge/index.js';
24
24
  export { MergeSourceError } from './source-reading/index.js';
25
25
  export type { SourceKind } from './source-reading/index.js';
26
+ export { markPages } from './page-marking/index.js';
27
+ export type { MarkOptions, MarkResult, PageMark } from './page-marking/index.js';
26
28
  export type { PageSize } from './page-placement/index.js';
27
29
  export { MIN_RECORDED_DPI, PAPER, placeImage, resolveDpi } from './page-placement/index.js';
28
30
  export type { Placement } from './page-placement/index.js';
29
31
  export { isPdf, readSource } from './source-reading/index.js';
30
32
  export type { ResolvedSource } from './source-reading/index.js';
33
+ export { toUserSpace, viewportSize, viewportTransform } from './page-marking/index.js';
34
+ export type { Affine, PageGeometry } from './page-marking/index.js';
31
35
  //# 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,40 @@
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
+ export interface MarkResult {
28
+ /** The original, with the marks drawn on it. Nothing else about it changes. */
29
+ pdf: Uint8Array;
30
+ /** How many marks were drawn. */
31
+ drawn: number;
32
+ /**
33
+ * What could not be drawn, or was drawn somewhere it cannot be right: a mark
34
+ * on a page the document does not have, or one that reaches past the edge of
35
+ * its page. The second is itself a positioning error, and exactly the kind
36
+ * this exists to catch.
37
+ */
38
+ warnings: string[];
39
+ }
40
+ //# 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scanmate/merge",
3
- "version": "0.7.1",
3
+ "version": "0.8.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.8.0"
45
45
  },
46
46
  "publishConfig": {
47
47
  "access": "public"