@scanmate/ocr 0.0.2 → 0.0.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.
package/dist/index.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { distance } from 'fastest-levenshtein';
2
- import { isRaster, encodeImage, resampleRaster, createRaster } from '@scanmate/ink';
2
+ import { resizeGray, isRaster, encodeImage, resampleRaster, createRaster, toGrayscale } from '@scanmate/ink';
3
3
  import { mkdir, copyFile, stat } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import { tmpdir } from 'node:os';
@@ -326,7 +326,12 @@ function judgeRun(expected, found, options) {
326
326
  agrees: true
327
327
  };
328
328
  }
329
- function judgeRuns(references, claims, options) {
329
+ /**
330
+ * @param verified - Runs the print check matched glyph by glyph and found
331
+ * changed. Their differences are evidence in themselves; every other one is
332
+ * a reading, which the pixels still have to agree with.
333
+ */
334
+ function judgeRuns(references, claims, options, verified = new Set()) {
330
335
  const differences = [];
331
336
  const expectedParts = [];
332
337
  const alignedParts = [];
@@ -343,6 +348,7 @@ function judgeRuns(references, claims, options) {
343
348
  found: verdict.kind === 'missing' ? null : found,
344
349
  similarity: verdict.similarity,
345
350
  reason: verdict.reason,
351
+ verified: verified.has(r),
346
352
  ...box(ref)
347
353
  });
348
354
  }
@@ -356,6 +362,7 @@ function judgeRuns(references, claims, options) {
356
362
  found,
357
363
  similarity: 0,
358
364
  reason: 'text',
365
+ verified: false,
359
366
  ...union(group)
360
367
  });
361
368
  }
@@ -433,6 +440,670 @@ function union(texts) {
433
440
  };
434
441
  }
435
442
 
443
+ /**
444
+ * The least a pixel may stand out from the paper around it and still count as
445
+ * print, `0` to `1`. The run's own contrast decides above this.
446
+ *
447
+ * Measured against the run's own background rather than against white, because
448
+ * the paper under a run is whatever the page puts there: white, the grey of a
449
+ * shaded row, or the colour of a total bar.
450
+ */
451
+ const CONTRAST = 0.19;
452
+ /** Columns are grown by this, in pixels, before grouping, so a dotted stem stays one glyph. */
453
+ const JOIN = 0;
454
+ /**
455
+ * The box of each printed character of a run, left to right.
456
+ *
457
+ * @param page - The original page, greyscale, `0` black to `1` white.
458
+ * @param dpi - What that page was rendered at.
459
+ * @param run - The run's box, in points.
460
+ * @param count - How many characters the run prints, spaces excluded.
461
+ * @param options - Contrast against the run's paper, column joining, and polarity.
462
+ * @returns One box per character, or `null` when they cannot be told apart.
463
+ */
464
+ function glyphCells(page, dpi, run, count, options = {}) {
465
+ const profile = profileOf(page, dpi, run, count, options);
466
+ if (profile === null) return null;
467
+ const groups = fitGroups(profile.inked, options.join ?? JOIN, count);
468
+ return groups === null ? null : groups.map(group => boxOf(run, profile, group));
469
+ }
470
+ /**
471
+ * The box of each word of a run, in order, split at the run's own spaces.
472
+ *
473
+ * @param page - The original page, greyscale.
474
+ * @param dpi - What that page was rendered at.
475
+ * @param run - The run's box, in points, with its angle.
476
+ * @param counts - Characters in each word, in order, spaces excluded.
477
+ * @param options - Contrast against the run's paper, column joining, and polarity.
478
+ * @returns One box per word, or `null` when the words cannot be told apart.
479
+ */
480
+ function glyphWords(page, dpi, run, counts, options = {}) {
481
+ const total = counts.reduce((sum, count) => sum + count, 0);
482
+ const profile = profileOf(page, dpi, run, total, options);
483
+ if (profile === null) return null;
484
+ if (counts.length === 1) return [{
485
+ ...run
486
+ }];
487
+ // The widest gaps are the spaces: as many cuts as the run has spaces.
488
+ const groups = groupsOf(profile.inked, options.join ?? JOIN);
489
+ if (groups.length < counts.length) return null;
490
+ const gaps = groups.slice(1).map((group, index) => ({
491
+ at: index + 1,
492
+ gap: group.start - groups[index].end
493
+ })).toSorted((a, b) => b.gap - a.gap).slice(0, counts.length - 1).map(gap => gap.at).toSorted((a, b) => a - b);
494
+ const words = [];
495
+ let from = 0;
496
+ for (const cut of [...gaps, groups.length]) {
497
+ const first = groups[from];
498
+ const last = groups[cut - 1];
499
+ if (first === undefined || last === undefined) return null;
500
+ // A word holding fewer groups than it has characters has glyphs that run
501
+ // together, and nothing inside it can be placed - but that is that word's
502
+ // problem. Its box is still returned, and it is the caller who finds the
503
+ // letters unplaceable, one word at a time.
504
+ words.push(boxOf(run, profile, {
505
+ start: first.start,
506
+ end: last.end
507
+ }));
508
+ from = cut;
509
+ }
510
+ return words;
511
+ }
512
+ /**
513
+ * How much ink stands in each step along the run, in the run's own direction.
514
+ *
515
+ * @returns The profile and the frame to read boxes back out of, or `null` when
516
+ * the run is turned by something other than a quarter turn, or is too small.
517
+ */
518
+ function profileOf(page, dpi, run, count, options) {
519
+ const {
520
+ contrast = CONTRAST,
521
+ lightOnDark = false
522
+ } = options;
523
+ if (count <= 0) return null;
524
+ const turn = Math.round(((run.angle ?? 0) % 360 + 360) % 360 / 90) % 4;
525
+ if (Math.abs(((run.angle ?? 0) % 360 + 360) % 360 - turn * 90) > 1) return null;
526
+ const along = turn % 2 === 0 ? 'x' : 'y';
527
+ // A quarter turn one way advances up the page, the other way down.
528
+ const reverse = turn === 2 || turn === 3;
529
+ const s = dpi / 72;
530
+ const left = Math.max(0, Math.floor(run.x * s));
531
+ const right = Math.min(page.width, Math.ceil((run.x + run.width) * s));
532
+ const top = Math.max(0, Math.floor(run.y * s));
533
+ const bottom = Math.min(page.height, Math.ceil((run.y + run.height) * s));
534
+ const steps = along === 'x' ? right - left : bottom - top;
535
+ const across = along === 'x' ? bottom - top : right - left;
536
+ if (steps < count || across < 2) return null;
537
+ // The paper this run is printed on: most of its box is background. How far the
538
+ // glyphs stand from it is the run's own contrast, and half of that separates
539
+ // ink from paper - on white or on the colour of a total bar alike.
540
+ const values = [];
541
+ for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) values.push(page.data[y * page.width + x]);
542
+ const sorted = values.toSorted((a, b) => a - b);
543
+ const paper = sorted[Math.floor(sorted.length / 2)];
544
+ const darkest = sorted[Math.floor(sorted.length * (lightOnDark ? 0.98 : 0.02))];
545
+ const threshold = Math.max(contrast, Math.abs(darkest - paper) / 2);
546
+ const inked = [];
547
+ for (let step = 0; step < steps; step++) {
548
+ let dark = 0;
549
+ for (let other = 0; other < across; other++) {
550
+ const x = left + (along === 'x' ? step : other);
551
+ const y = top + (along === 'x' ? other : step);
552
+ const difference = lightOnDark ? page.data[y * page.width + x] - paper : paper - page.data[y * page.width + x];
553
+ if (difference > threshold) dark++;
554
+ }
555
+ inked.push(dark > 0);
556
+ }
557
+ return {
558
+ inked: reverse ? inked.toReversed() : inked,
559
+ along,
560
+ reverse,
561
+ left,
562
+ top,
563
+ right,
564
+ bottom,
565
+ s
566
+ };
567
+ }
568
+ /** A span of the profile, back in page points. */
569
+ function boxOf(run, profile, group) {
570
+ const steps = profile.along === 'x' ? profile.right - profile.left : profile.bottom - profile.top;
571
+ const start = profile.reverse ? steps - 1 - group.end : group.start;
572
+ const end = profile.reverse ? steps - 1 - group.start : group.end;
573
+ return profile.along === 'x' ? {
574
+ x: (profile.left + start) / profile.s,
575
+ y: run.y,
576
+ width: (end - start + 1) / profile.s,
577
+ height: run.height,
578
+ angle: run.angle
579
+ } : {
580
+ x: run.x,
581
+ y: (profile.top + start) / profile.s,
582
+ width: run.width,
583
+ height: (end - start + 1) / profile.s,
584
+ angle: run.angle
585
+ };
586
+ }
587
+ /** The groups of inked steps, closed up until there are exactly `count` of them. */
588
+ function fitGroups(inked, join, count) {
589
+ let groups = groupsOf(inked, join);
590
+ // Characters printed in two parts - an i, a colon, a percent sign - read as
591
+ // more groups than there are characters; the narrowest gaps close first.
592
+ while (groups.length > count) {
593
+ const gaps = groups.slice(1).map((group, i) => ({
594
+ at: i + 1,
595
+ gap: group.start - groups[i].end
596
+ }));
597
+ const narrowest = gaps.toSorted((a, b) => a.gap - b.gap)[0];
598
+ if (narrowest === undefined) break;
599
+ groups = [...groups.slice(0, narrowest.at - 1), {
600
+ start: groups[narrowest.at - 1].start,
601
+ end: groups[narrowest.at].end
602
+ }, ...groups.slice(narrowest.at + 1)];
603
+ }
604
+ return groups.length === count ? groups : null;
605
+ }
606
+ /** Runs of inked columns, separated by more than `join` empty ones. */
607
+ function groupsOf(inked, join) {
608
+ const groups = [];
609
+ let start = -1;
610
+ let last = -1;
611
+ for (const [x, dark] of inked.entries()) {
612
+ if (!dark) continue;
613
+ if (start === -1) start = x;else if (x - last - 1 > join) {
614
+ groups.push({
615
+ start,
616
+ end: last
617
+ });
618
+ start = x;
619
+ }
620
+ last = x;
621
+ }
622
+ if (start !== -1) groups.push({
623
+ start,
624
+ end: last
625
+ });
626
+ return groups;
627
+ }
628
+ /**
629
+ * Where every character of a run sits, or `null` where it could not be placed.
630
+ *
631
+ * The whole run is tried first, which is what a figure wants: short, its glyphs
632
+ * separate, and nothing gained by taking it apart. When that fails the run is
633
+ * split at its spaces and each word placed on its own, so one pair of touching
634
+ * letters costs its own word rather than the sentence around it - on the W-9's
635
+ * certification line, two characters rather than fifty-two.
636
+ *
637
+ * @param page - The original page, greyscale.
638
+ * @param dpi - What that page was rendered at.
639
+ * @param run - The run's box, in points, with its angle.
640
+ * @param text - What the run prints; spaces divide the words.
641
+ * @param options - Contrast against the run's paper, column joining, and polarity.
642
+ * @returns One entry per printed character, spaces excluded, or `null` when not
643
+ * even the words could be told apart.
644
+ */
645
+ function placeGlyphs(page, dpi, run, text, options = {}) {
646
+ const characters = [...text].filter(character => character.trim() !== '');
647
+ const whole = glyphCells(page, dpi, run, characters.length, options);
648
+ if (whole !== null) return whole;
649
+ const words = text.split(/\s+/).filter(word => word !== '');
650
+ if (words.length < 2) return null;
651
+ const boxes = glyphWords(page, dpi, run, words.map(word => [...word].length), options);
652
+ if (boxes === null) return null;
653
+ const cells = [];
654
+ for (const [index, word] of words.entries()) {
655
+ const letters = [...word].length;
656
+ const placed = glyphCells(page, dpi, {
657
+ ...boxes[index],
658
+ angle: run.angle
659
+ }, letters, options);
660
+ for (let letter = 0; letter < letters; letter++) cells.push(placed === null ? null : placed[letter]);
661
+ }
662
+ return cells.length === characters.length ? cells : null;
663
+ }
664
+
665
+ /**
666
+ * Which way the original prints a run, read off its own crisp rendering: the
667
+ * glyphs are the pixels far from the background, and the background is most of
668
+ * the box.
669
+ */
670
+ function printPolarity(page, dpi, run) {
671
+ const s = dpi / 72;
672
+ const left = Math.max(0, Math.floor(run.x * s));
673
+ const top = Math.max(0, Math.floor(run.y * s));
674
+ const right = Math.min(page.width, Math.ceil((run.x + run.width) * s));
675
+ const bottom = Math.min(page.height, Math.ceil((run.y + run.height) * s));
676
+ const values = [];
677
+ for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) values.push(page.data[y * page.width + x]);
678
+ if (values.length === 0) return 'dark-on-light';
679
+ const background = values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)];
680
+ const glyphs = values.filter(v => Math.abs(v - background) > 0.19);
681
+ if (glyphs.length === 0) return 'dark-on-light';
682
+ return glyphs.reduce((a, b) => a + b, 0) / glyphs.length > background ? 'light-on-dark' : 'dark-on-light';
683
+ }
684
+
685
+ /** Most glyphs kept per character - more is slower and adds nothing. */
686
+ const PER_CHARACTER = 4;
687
+ /** The key a run's glyphs are filed under. */
688
+ function templateKey(run, character) {
689
+ // The turn belongs in the key: a glyph printed up the margin and the same
690
+ // glyph printed across the page are different pictures, and matching one
691
+ // against the other would compare a letter with its own rotation.
692
+ const turn = (Math.round((run.angle ?? 0) / 90) % 4 + 4) % 4;
693
+ return `${run.fontName ?? ''}|${Math.round((run.fontSize ?? run.height) * 2) / 2}|${turn}|${character}`;
694
+ }
695
+ /**
696
+ * Collects a glyph image for every character the page prints and can place.
697
+ *
698
+ * @param page - The original page, greyscale.
699
+ * @param dpi - What it was rendered at.
700
+ * @param runs - Its text layer.
701
+ * @returns Glyph images, by {@link templateKey}.
702
+ */
703
+ function collectTemplates(page, dpi, runs) {
704
+ const templates = new Map();
705
+ for (const run of runs) {
706
+ const characters = [...run.text].filter(character => character.trim() !== '');
707
+ const lightOnDark = printPolarity(page, dpi, run) === 'light-on-dark';
708
+ const cells = placeGlyphs(page, dpi, run, run.text, {
709
+ lightOnDark
710
+ });
711
+ if (cells === null) continue;
712
+ for (const [index, character] of characters.entries()) {
713
+ const cell = cells[index];
714
+ if (cell !== null) keep(templates, templateKey(run, character), cut(page, dpi, cell, lightOnDark));
715
+ }
716
+ }
717
+ return templates;
718
+ }
719
+ /** Files one glyph under its key, up to the few that are worth keeping. */
720
+ function keep(templates, key, glyph) {
721
+ if (glyph === null) return;
722
+ const kept = templates.get(key) ?? [];
723
+ if (kept.length >= PER_CHARACTER) return;
724
+ kept.push(glyph);
725
+ templates.set(key, kept);
726
+ }
727
+ /**
728
+ * The greyscale of one box of the page, or `null` when it lies outside it;
729
+ * inverted when the run it belongs to is printed light on dark.
730
+ */
731
+ function cut(page, dpi, box, invert = false) {
732
+ const s = dpi / 72;
733
+ const left = Math.max(0, Math.floor(box.x * s));
734
+ const top = Math.max(0, Math.floor(box.y * s));
735
+ const right = Math.min(page.width, Math.ceil((box.x + box.width) * s));
736
+ const bottom = Math.min(page.height, Math.ceil((box.y + box.height) * s));
737
+ const width = right - left;
738
+ const height = bottom - top;
739
+ if (width < 1 || height < 1) return null;
740
+ const data = new Float32Array(width * height);
741
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
742
+ const value = page.data[(top + y) * page.width + left + x];
743
+ data[y * width + x] = invert ? 1 - value : value;
744
+ }
745
+ return {
746
+ width,
747
+ height,
748
+ data
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Puts what the ink settled into what the scan was read as.
754
+ *
755
+ * The check speaks for the figures it decided, and for nothing else. It looks at
756
+ * digits, cell by cell, and leaves a cell it cannot call. So its answer is merged
757
+ * into the reading one character at a time rather than replacing it: replacing it
758
+ * would erase a changed letter, or a digit too soft to judge, along with the
759
+ * misreadings this is here to clear - and erasing a real change is the one thing
760
+ * the check must never do.
761
+ *
762
+ * @param read - What the scan was read as; empty when nothing was read there.
763
+ * @param run - The run as the original prints it.
764
+ * @param verification - What `verifyPrintedRun` decided about it.
765
+ * @returns The reading with the decided figures settled.
766
+ */
767
+ function mergeVerifiedFigures(read, run, verification) {
768
+ const decided = verification.cells.filter(cell => cell.read !== null);
769
+ if (decided.length === 0) return read;
770
+ // Nothing was read here at all, yet the ink shows the glyphs: the ink is the better witness.
771
+ if (read.trim() === '') return verification.reading;
772
+ // `at` counts the run's printed characters, spaces aside, so the reading has to
773
+ // line up the same way before a position in one means anything in the other.
774
+ const places = [...read].map((character, index) => ({
775
+ character,
776
+ index
777
+ })).filter(entry => entry.character.trim() !== '');
778
+ const changed = decided.some(cell => cell.read !== cell.printed);
779
+ if (places.length !== [...run.text].filter(character => character.trim() !== '').length) return changed ? verification.reading : read;
780
+ const merged = [...read];
781
+ for (const cell of decided) if (cell.read !== null) merged[places[cell.at].index] = cell.read;
782
+ return merged.join('');
783
+ }
784
+
785
+ /**
786
+ * Whether a printed figure is still the figure that was printed, decided by
787
+ * matching its ink rather than by reading it.
788
+ *
789
+ * OCR asks an open question - what does this say? - and at the resolution of a
790
+ * returned scan it answers badly: a 0 on a shaded bar at 120 dpi comes back as
791
+ * a 9, and a digit written over another comes back as whatever the language
792
+ * model prefers. The closed question is far easier: is this still the glyph the
793
+ * original printed here, or does it look more like a different one?
794
+ *
795
+ * The print is crisp and the scan is not, so the print is first softened to the
796
+ * scan's own sharpness - the amount that makes the scan's glyphs sit best on
797
+ * the original's, measured on the run itself - and every glyph is compared at
798
+ * that sharpness. Without it a blurred 0 matches a crisp 8 as well as it
799
+ * matches a crisp 0.
800
+ *
801
+ * Each character of a figure is matched twice over. Once against the original's
802
+ * own ink at that very place - same face, same size, same position, so a scan
803
+ * of it correlates highly however grey or grainy it is - and once against every
804
+ * other character the page prints in that face and size. The printed glyph has
805
+ * to win by a margin to pass, and a rival has to win by a margin to count as a
806
+ * change; anything in between is left undecided rather than guessed at.
807
+ *
808
+ * A run whose glyphs cannot be told apart is left to the reading, and so is a
809
+ * run on a page that prints too few characters in that face for the rivals to
810
+ * be represented - a glyph whose own template is missing could otherwise be
811
+ * confirmed as the one it replaced, simply for lacking anything better to
812
+ * match.
813
+ *
814
+ * **Two scopes.** `'figures'` checks the digits of a figure against the ten
815
+ * digits, and nothing else: a full stop read as a comma is how a figure is
816
+ * written, not what it says. It is cheap enough to run over every printed run
817
+ * of a page, which is how a changed amount is caught even when the reading
818
+ * never noticed.
819
+ *
820
+ * `'text'` checks every character against letters and digits alike. The rival
821
+ * set is six times larger and the work grows with it, so this is not for a
822
+ * whole page - it is for one run somebody is already arguing about, where the
823
+ * reading disagrees and the ink at that run says nothing moved. There it
824
+ * answers the question the reading could not: are these the same glyphs?
825
+ *
826
+ * Its two answers are not worth the same. Swept over 327 runs of four real
827
+ * documents, `'text'` called four unchanged runs changed - a `t` read as a `k`,
828
+ * a `g` as a `t` - where `'figures'` has never made a false call on any scan
829
+ * measured here. Letters at 8 pt through a scanner are simply more confusable
830
+ * than digits, and the margin that separates the ten does not separate the
831
+ * sixty-two. So `agrees === true` is good evidence that a run is untouched, and
832
+ * `agrees === false` is a reason to look closer rather than a verdict; callers
833
+ * are expected to use it to clear a dispute, not to open one.
834
+ */
835
+ /** What a figure's characters are checked against: the ten digits, and only those. */
836
+ const FIGURE_CHARACTERS$1 = '0123456789';
837
+ /**
838
+ * What a run of text is checked against, under `scope: 'text'`: letters and
839
+ * digits. Punctuation is left out deliberately - a comma and a full stop differ
840
+ * by a few pixels at these sizes, and telling them apart is neither reliable
841
+ * nor worth reporting.
842
+ */
843
+ const TEXT_CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
844
+ /**
845
+ * How much better the winner must correlate than the runner-up for the cell to
846
+ * be decided either way.
847
+ *
848
+ * Measured on real returned scans, matching every printed digit of pages 1 and
849
+ * 2 against the ten the page prints: on a genuine scan the printed glyph wins
850
+ * by 0.13 or more at 125 dpi, and where a badly scanned digit loses at 90 dpi
851
+ * it loses by 0.03 or less. A digit replaced by another - the same ink, a few
852
+ * points along - was won by its rival by 0.17.
853
+ */
854
+ const MIN_MARGIN = 0.12;
855
+ /** A rival has to match this well before it can overturn what the original prints. */
856
+ const MIN_SCORE = 0.5;
857
+ /**
858
+ * ...and the printed glyph has to match at least this well before the ink can be
859
+ * called unchanged. A digit that genuinely changed may still beat the rivals the
860
+ * page happens to print, and confirming it on that basis would be the one error
861
+ * this check must never make.
862
+ */
863
+ const MIN_PRINTED_SCORE = 0.7;
864
+ /**
865
+ * Distinct characters the page must print in this face and size before a run is
866
+ * checked at all. Fewer than this and the character actually on the scan may
867
+ * have no template to win with, which turns a change into a confirmation.
868
+ *
869
+ * Eight of the ten digits for a figure. For text the alphabet is far larger and
870
+ * no page prints all of it, so the bar is what a page of ordinary prose prints
871
+ * comfortably and a lone heading in a display face does not.
872
+ */
873
+ const MIN_RIVALS = 8;
874
+ const MIN_TEXT_RIVALS = 24;
875
+ /** ...and the printed glyph has to match no better than this: a near-perfect match is not a forgery. */
876
+ const MAX_PRINTED_SCORE = 0.85;
877
+ /**
878
+ * Each cell is matched at this height, in pixels: the shape of a digit survives,
879
+ * the scanner's grain does not.
880
+ */
881
+ const MATCH_HEIGHT = 16;
882
+ /** The template is slid this far, in match pixels, to absorb a cell landing half a pixel out. */
883
+ const SHIFT = 1;
884
+ /** How much the print may be softened by to meet the scan, in match pixels. */
885
+ const SOFTENING = [0, 0.5, 1, 1.5, 2];
886
+ /**
887
+ * Checks the figures of one run against the original's print.
888
+ *
889
+ * @param original - The original page, greyscale.
890
+ * @param scan - The scan, aligned onto the original's canvas, greyscale.
891
+ * @param dpi - What the original was rendered at.
892
+ * @param run - The run to check, from the original's text layer.
893
+ * @param templates - Glyphs collected from the original by `collectTemplates`.
894
+ * @param options - Margin, figure length and the characters figures use.
895
+ * @returns What the ink says, or `null` when the run could not be checked.
896
+ */
897
+ function verifyPrintedRun(original, scan, dpi, run, templates, options = {}) {
898
+ const {
899
+ scope = 'figures',
900
+ minMargin = MIN_MARGIN,
901
+ minScore = MIN_SCORE,
902
+ maxPrinted = MAX_PRINTED_SCORE,
903
+ minPrinted = MIN_PRINTED_SCORE,
904
+ minRivals = scope === 'text' ? MIN_TEXT_RIVALS : MIN_RIVALS,
905
+ minDigits = 2,
906
+ characters = scope === 'text' ? TEXT_CHARACTERS : FIGURE_CHARACTERS$1
907
+ } = options;
908
+ const printed = [...run.text].filter(character => character.trim() !== '');
909
+ const wanted = scope === 'text' ? textCells(printed, characters) : figureCells(printed, minDigits);
910
+ if (wanted.size === 0) return null;
911
+ // A total on a shaded bar is printed light on dark; turned round, it is a figure like any other.
912
+ const lightOnDark = printPolarity(original, dpi, run) === 'light-on-dark';
913
+ const cells = placeGlyphs(original, dpi, run, run.text, {
914
+ lightOnDark
915
+ });
916
+ if (cells === null) return null;
917
+ // Rivals: every character the page prints in this face and size that a figure could use.
918
+ const rivals = [...characters].filter(character => templates.has(templateKey(run, character)));
919
+ if (rivals.length < minRivals) return null;
920
+ // How soft this scan's print is, measured where the answer is known: each cell
921
+ // against the very glyph the original prints there.
922
+ const pairs = [...wanted].flatMap(index => {
923
+ const cell = cells[index];
924
+ return cell === null ? [] : [{
925
+ glyph: cut(scan, dpi, cell, lightOnDark),
926
+ printed: cut(original, dpi, cell, lightOnDark)
927
+ }];
928
+ }).filter(pair => pair.glyph !== null && pair.printed !== null);
929
+ const softening = softeningFor(pairs);
930
+ const reading = [...printed];
931
+ const scored = [];
932
+ let confidence = 1;
933
+ let checked = 0;
934
+ for (const index of wanted) {
935
+ // The cell exactly: a margin would bring in the neighbouring glyphs, which
936
+ // both crops share, and shared ink correlates whatever the character is.
937
+ const cell = cells[index];
938
+ if (cell === null) continue;
939
+ const glyph = cut(scan, dpi, cell, lightOnDark);
940
+ // The original's own ink here: the same glyph, at the same size, in the same place.
941
+ const asPrinted = cut(original, dpi, cell, lightOnDark);
942
+ if (glyph === null || asPrinted === null) continue;
943
+ const printedScore = correlate(glyph, asPrinted, softening);
944
+ const others = rivals.filter(character => character !== printed[index]).map(character => ({
945
+ character,
946
+ score: bestMatch(glyph, templates.get(templateKey(run, character)) ?? [], softening)
947
+ })).toSorted((a, b) => b.score - a.score);
948
+ const rival = others[0];
949
+ if (rival === undefined) continue;
950
+ // Undecided unless one of the two wins clearly, and a change has to look like
951
+ // the character it is being read as, not merely less like the printed one.
952
+ const changed = rival.score >= printedScore + minMargin && rival.score >= minScore && printedScore <= maxPrinted;
953
+ const unchanged = printedScore >= rival.score + minMargin && printedScore >= minPrinted;
954
+ scored.push({
955
+ at: index,
956
+ printed: printed[index],
957
+ printedScore,
958
+ rival: rival.character,
959
+ rivalScore: rival.score,
960
+ read: changed ? rival.character : unchanged ? printed[index] : null
961
+ });
962
+ if (!changed && !unchanged) continue;
963
+ checked++;
964
+ if (changed) reading[index] = rival.character;
965
+ confidence = Math.min(confidence, Math.abs(printedScore - rival.score));
966
+ }
967
+ // Nothing decided is not an answer: say nothing rather than guess a digit.
968
+ if (checked === 0) return null;
969
+ // Put the spaces back, so the reading reads like the run it is about.
970
+ let cell = 0;
971
+ const text = [...run.text].map(character => character.trim() === '' ? character : reading[cell++]).join('');
972
+ return {
973
+ reading: text,
974
+ agrees: text === run.text,
975
+ checked,
976
+ confidence,
977
+ cells: scored
978
+ };
979
+ }
980
+ /**
981
+ * The digits of every figure of at least `minDigits` digits.
982
+ *
983
+ * Only digits: a full stop read as a comma is how a figure is written, not what
984
+ * it says, and at the size these are printed the two are a pixel apart.
985
+ */
986
+ /** Every cell the character set covers: under `'text'`, the letters and digits of the run. */
987
+ function textCells(printed, characters) {
988
+ const wanted = new Set();
989
+ for (const [index, character] of printed.entries()) if (characters.includes(character)) wanted.add(index);
990
+ return wanted;
991
+ }
992
+ function figureCells(printed, minDigits) {
993
+ const wanted = new Set();
994
+ let group = [];
995
+ const close = () => {
996
+ if (group.length >= minDigits) for (const index of group) wanted.add(index);
997
+ group = [];
998
+ };
999
+ for (const [index, character] of printed.entries()) {
1000
+ if (/\d/.test(character)) group.push(index);
1001
+ // A separator inside a figure carries on the group; anything else ends it.
1002
+ else if (!/[.,\-/\s]/.test(character) || group.length === 0) close();
1003
+ }
1004
+ close();
1005
+ return wanted;
1006
+ }
1007
+ /** How soft the print has to be drawn to sit best on the scan's own glyphs. */
1008
+ function softeningFor(pairs) {
1009
+ let best = {
1010
+ softening: 0,
1011
+ score: -Infinity
1012
+ };
1013
+ for (const softening of SOFTENING) {
1014
+ const score = pairs.reduce((sum, pair) => sum + correlate(pair.glyph, pair.printed, softening), 0);
1015
+ if (score > best.score) best = {
1016
+ softening,
1017
+ score
1018
+ };
1019
+ }
1020
+ return best.softening;
1021
+ }
1022
+ /** The best correlation between one glyph and a character's templates. */
1023
+ function bestMatch(glyph, templates, softening) {
1024
+ let best = -1;
1025
+ for (const template of templates) best = Math.max(best, correlate(glyph, template, softening));
1026
+ return best;
1027
+ }
1028
+ /**
1029
+ * How alike two glyphs are, each scaled to the same small box, and the template
1030
+ * slid a pixel each way to allow for a cell that landed a fraction out.
1031
+ *
1032
+ * Correlation, rather than a difference of pixels, because a scan is darker or
1033
+ * lighter than the print it came from and that must not decide anything.
1034
+ */
1035
+ function correlate(glyph, template, softening = 0) {
1036
+ const height = MATCH_HEIGHT;
1037
+ const width = Math.max(2, Math.round(height * ((glyph.width / glyph.height + template.width / template.height) / 2)));
1038
+ const a = resizeGray(glyph, width, height);
1039
+ const b = soften(resizeGray(template, width, height), softening);
1040
+ let best = -1;
1041
+ for (let dy = -SHIFT; dy <= SHIFT; dy++) for (let dx = -SHIFT; dx <= SHIFT; dx++) best = Math.max(best, pearson(a, b, dx, dy));
1042
+ return best;
1043
+ }
1044
+ /** A Gaussian blur of `sigma` match pixels, which is how print looks once it has been scanned. */
1045
+ function soften(image, sigma) {
1046
+ if (sigma <= 0) return image;
1047
+ const radius = Math.max(1, Math.ceil(sigma * 2));
1048
+ const kernel = [];
1049
+ for (let i = -radius; i <= radius; i++) kernel.push(Math.exp(-(i * i) / (2 * sigma * sigma)));
1050
+ const total = kernel.reduce((a, b) => a + b, 0);
1051
+ const weights = kernel.map(value => value / total);
1052
+ const {
1053
+ width,
1054
+ height
1055
+ } = image;
1056
+ const horizontal = new Float32Array(width * height);
1057
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
1058
+ let sum = 0;
1059
+ for (const [k, weight] of weights.entries()) {
1060
+ const sx = Math.min(width - 1, Math.max(0, x + k - radius));
1061
+ sum += image.data[y * width + sx] * weight;
1062
+ }
1063
+ horizontal[y * width + x] = sum;
1064
+ }
1065
+ const data = new Float32Array(width * height);
1066
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
1067
+ let sum = 0;
1068
+ for (const [k, weight] of weights.entries()) {
1069
+ const sy = Math.min(height - 1, Math.max(0, y + k - radius));
1070
+ sum += horizontal[sy * width + x] * weight;
1071
+ }
1072
+ data[y * width + x] = sum;
1073
+ }
1074
+ return {
1075
+ width,
1076
+ height,
1077
+ data
1078
+ };
1079
+ }
1080
+ /** Pearson correlation of two images of one size, the second shifted by `dx`, `dy`. */
1081
+ function pearson(a, b, dx, dy) {
1082
+ let count = 0;
1083
+ let sumA = 0;
1084
+ let sumB = 0;
1085
+ for (let y = Math.max(0, -dy); y < Math.min(a.height, a.height - dy); y++) for (let x = Math.max(0, -dx); x < Math.min(a.width, a.width - dx); x++) {
1086
+ sumA += a.data[y * a.width + x];
1087
+ sumB += b.data[(y + dy) * b.width + x + dx];
1088
+ count++;
1089
+ }
1090
+ if (count === 0) return 0;
1091
+ const meanA = sumA / count;
1092
+ const meanB = sumB / count;
1093
+ let covariance = 0;
1094
+ let varianceA = 0;
1095
+ let varianceB = 0;
1096
+ for (let y = Math.max(0, -dy); y < Math.min(a.height, a.height - dy); y++) for (let x = Math.max(0, -dx); x < Math.min(a.width, a.width - dx); x++) {
1097
+ const da = a.data[y * a.width + x] - meanA;
1098
+ const db = b.data[(y + dy) * b.width + x + dx] - meanB;
1099
+ covariance += da * db;
1100
+ varianceA += da * da;
1101
+ varianceB += db * db;
1102
+ }
1103
+ const spread = Math.sqrt(varianceA * varianceB);
1104
+ return spread === 0 ? 0 : covariance / spread;
1105
+ }
1106
+
436
1107
  const DEFAULT_TESSERACT_OPTIONS = {
437
1108
  languages: ['eng'],
438
1109
  model: 'best',
@@ -611,12 +1282,51 @@ const DEFAULT_RECHECK_PASSES = [{
611
1282
  const FIGURE_CHARACTERS = '0123456789.,:/%()+-';
612
1283
  /** White kept around a crop, in pixels: tesseract reads poorly off the edge of an image. */
613
1284
  const MARGIN = 12;
614
- async function recheckRun(engine, image, run, options) {
1285
+ /**
1286
+ * Reads one run's crop once per pass, and says what each pass read - without
1287
+ * judging it against anything.
1288
+ *
1289
+ * `recheckRun` asks "does this still say what the original prints?", which is
1290
+ * the right question while reading a page. It is the wrong question once the
1291
+ * pixels have disagreed with the reading, because a systematic misreading -
1292
+ * a face, a size, a resolution the engine handles badly - misreads the
1293
+ * *original* just as surely as the scan. Reading both sides the same way and
1294
+ * comparing the two readings to each other cancels exactly that error, and
1295
+ * needs the readings themselves rather than a verdict.
1296
+ *
1297
+ * @param engine - The engine to read with.
1298
+ * @param image - The page to crop from, and its resolution.
1299
+ * @param run - The run to read, placed on that page.
1300
+ * @param options - Which passes to try; the matching rules pick the charset.
1301
+ * @param polarity - Whether the original prints this run light on dark.
1302
+ * @returns What each pass read, in order; empty when the run cannot be cropped.
1303
+ */
1304
+ async function readRun(engine, image, run, options, polarity) {
1305
+ const {
1306
+ passes = DEFAULT_RECHECK_PASSES
1307
+ } = options;
1308
+ const crop = cropRun(image.raster, image.dpi, run, polarity);
1309
+ if (crop === null) return [];
1310
+ const characters = FIGURE.test(run.text.trim()) ? FIGURE_CHARACTERS : undefined;
1311
+ const readings = [];
1312
+ for (const pass of passes) {
1313
+ const scale = Math.max(1, pass.dpi / image.dpi);
1314
+ const enlarged = scale === 1 ? crop : await resampleRaster(crop, Math.round(crop.width * scale), Math.round(crop.height * scale));
1315
+ const prepared = frame(polarity === 'light-on-dark' || pass.stretch === true ? stretch(enlarged) : enlarged);
1316
+ const read = await engine.recognise(prepared, {
1317
+ layout: pass.layout,
1318
+ characters
1319
+ });
1320
+ readings.push(read.lines.map(line => line.text).join(' ').trim());
1321
+ }
1322
+ return readings;
1323
+ }
1324
+ async function recheckRun(engine, image, run, options, polarity) {
615
1325
  const {
616
1326
  passes = DEFAULT_RECHECK_PASSES,
617
1327
  agree = 2
618
1328
  } = options;
619
- const crop = cropRun(image.raster, image.dpi, run);
1329
+ const crop = cropRun(image.raster, image.dpi, run, polarity);
620
1330
  if (crop === null) return {
621
1331
  cleared: false,
622
1332
  reading: null,
@@ -632,7 +1342,8 @@ async function recheckRun(engine, image, run, options) {
632
1342
  tried++;
633
1343
  const scale = Math.max(1, pass.dpi / image.dpi);
634
1344
  const enlarged = scale === 1 ? crop : await resampleRaster(crop, Math.round(crop.width * scale), Math.round(crop.height * scale));
635
- const prepared = frame(pass.stretch === true ? stretch(enlarged) : enlarged);
1345
+ // Inverted text keeps the bar's grey behind it; only a stretch makes that paper white.
1346
+ const prepared = frame(polarity === 'light-on-dark' || pass.stretch === true ? stretch(enlarged) : enlarged);
636
1347
  const read = await engine.recognise(prepared, {
637
1348
  layout: pass.layout,
638
1349
  characters
@@ -658,9 +1369,10 @@ async function recheckRun(engine, image, run, options) {
658
1369
  }
659
1370
  /**
660
1371
  * The run's box on the image, grown by a quarter of its height (at least 2 pt)
661
- * so the glyphs are whole; light text on a dark bar is turned dark on light.
1372
+ * so the glyphs are whole; light text on a dark bar is turned dark on light -
1373
+ * as the original prints it when that is known, else when the crop is dark.
662
1374
  */
663
- function cropRun(raster, dpi, run) {
1375
+ function cropRun(raster, dpi, run, polarity) {
664
1376
  const s = dpi / 72;
665
1377
  const pad = Math.max(2, run.height * 0.25);
666
1378
  const left = Math.max(0, Math.floor((run.x - pad) * s));
@@ -672,7 +1384,8 @@ function cropRun(raster, dpi, run) {
672
1384
  for (let y = top; y < bottom; y++) out.data.set(raster.data.subarray((y * raster.width + left) * 4, (y * raster.width + right) * 4), (y - top) * out.width * 4);
673
1385
  let sum = 0;
674
1386
  for (let i = 0; i < out.data.length; i += 4) sum += luminance(out.data, i);
675
- if (sum / (out.width * out.height) < 110) for (let i = 0; i < out.data.length; i += 4) for (let c = 0; c < 3; c++) out.data[i + c] = 255 - out.data[i + c];
1387
+ const invert = polarity === undefined ? sum / (out.width * out.height) < 110 : polarity === 'light-on-dark';
1388
+ if (invert) for (let i = 0; i < out.data.length; i += 4) for (let c = 0; c < 3; c++) out.data[i + c] = 255 - out.data[i + c];
676
1389
  return out;
677
1390
  }
678
1391
  /** Linear stretch so the darkest 2% become black and the lightest 2% white. */
@@ -771,7 +1484,8 @@ async function readPage(page, engine, options) {
771
1484
  scoreMetric = 'levenshteinSimilarity',
772
1485
  matchThreshold = 0.8,
773
1486
  minWordConfidence = 60,
774
- recheck = {}
1487
+ recheck = {},
1488
+ printCheck = {}
775
1489
  } = options;
776
1490
  const warnings = [];
777
1491
  // The scan: its enhanced image as it is, or the aligned scan made fine enough to read.
@@ -824,6 +1538,7 @@ async function readPage(page, engine, options) {
824
1538
  // image - belong to the original even though its text layer lacks them.
825
1539
  // Writing over printed matter is for @scanmate/diff to see, not this.
826
1540
  const originalDpi = page.original.dpi ?? assumeDpi;
1541
+ const originalGray = toGrayscale(page.original.raster);
827
1542
  claims.added = claims.added.map(group => group.filter(word => !printedUnder(page.original.raster, originalDpi, word))).filter(group => group.length > 0);
828
1543
  const rechecks = {
829
1544
  attempted: 0,
@@ -837,12 +1552,36 @@ async function readPage(page, engine, options) {
837
1552
  const second = await recheckRun(engine, scanImage, run, {
838
1553
  ...rules,
839
1554
  ...recheck
840
- });
1555
+ }, printPolarity(originalGray, originalDpi, run));
841
1556
  if (second.reading === null) continue;
842
1557
  claims.found[r] = second.reading;
843
1558
  rechecks.cleared++;
844
1559
  }
845
- const match = judgeRuns(references, claims, rules);
1560
+ // Figures are matched against the original's own glyphs, which settles what no
1561
+ // reading of a returned scan can: whether this is still the digit that was printed.
1562
+ const printChecks = {
1563
+ checked: 0,
1564
+ different: 0
1565
+ };
1566
+ const seenChanged = new Set();
1567
+ if (printCheck !== false && useLayer) {
1568
+ const scanGray = toGrayscale(page.aligned.raster);
1569
+ const printed = items.map(item => ({
1570
+ ...item
1571
+ }));
1572
+ const templates = collectTemplates(originalGray, originalDpi, printed);
1573
+ for (const [r, run] of printed.entries()) {
1574
+ const verified = verifyPrintedRun(originalGray, scanGray, originalDpi, run, templates, printCheck);
1575
+ if (verified === null) continue;
1576
+ printChecks.checked++;
1577
+ if (!verified.agrees) {
1578
+ printChecks.different++;
1579
+ seenChanged.add(r);
1580
+ }
1581
+ claims.found[r] = mergeVerifiedFigures(claims.found[r], run, verified);
1582
+ }
1583
+ }
1584
+ const match = judgeRuns(references, claims, rules, seenChanged);
846
1585
  const runs = references.map((run, r) => ({
847
1586
  text: run.text,
848
1587
  found: claims.found[r],
@@ -865,6 +1604,7 @@ async function readPage(page, engine, options) {
865
1604
  metrics,
866
1605
  differences: match.differences,
867
1606
  rechecks,
1607
+ printChecks,
868
1608
  warnings
869
1609
  };
870
1610
  }
@@ -943,5 +1683,5 @@ function mean(values) {
943
1683
  return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length;
944
1684
  }
945
1685
 
946
- export { DEFAULT_NORMALISE, DEFAULT_RECHECK_PASSES, DEFAULT_TESSERACT_OPTIONS, claimWords, compareTexts, cosine, createTesseractEngine, diacriticsMap, dice, foldConfusables, foldDiacritics, jaccard, jaroWinkler, judgeRun, judgeRuns, levenshtein, levenshteinSimilarity, matchWords, normaliseText, ocrPages, recheckRun, tokenise, wordDistance, wordRecall };
1686
+ export { DEFAULT_NORMALISE, DEFAULT_RECHECK_PASSES, DEFAULT_TESSERACT_OPTIONS, FIGURE_CHARACTERS$1 as FIGURE_CHARACTERS, TEXT_CHARACTERS, claimWords, collectTemplates, compareTexts, cosine, createTesseractEngine, diacriticsMap, dice, foldConfusables, foldDiacritics, glyphCells, glyphWords, jaccard, jaroWinkler, judgeRun, judgeRuns, levenshtein, levenshteinSimilarity, matchWords, normaliseText, ocrPages, placeGlyphs, printPolarity, readRun, recheckRun, templateKey, tokenise, verifyPrintedRun, wordDistance, wordRecall };
947
1687
  //# sourceMappingURL=index.esm.js.map