@scanmate/ocr 0.4.0 → 0.6.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
@@ -26,7 +26,7 @@ const report = await ocrPages(await alignPages(pages))
26
26
  report.pages[0].text.differences // [{ kind: 'changed', expected: 'Account 4412-9087-3355',
27
27
  // found: 'Account 4412-9987-3355', reason: 'numbers',
28
28
  // verified: true, x, y, width, height }]
29
- report.pages[0].text.printChecks // { checked, different }: figures matched against the original's glyphs
29
+ report.pages[0].text.printChecks // { checked, different, skipped }: figures matched against the original's glyphs
30
30
  report.score // document score, weighted by characters
31
31
  report.pages[0].text.metrics // levenshtein, jaccard, dice, cosine, CER, WER, word recall, ...
32
32
  report.pages[0].text.original.text // the original's text
@@ -41,6 +41,7 @@ report.pages[0].aligned.raster // the page itself comes back too
41
41
  - **Matching by place, not order.** Alignment puts the scan on the original's canvas, so each word read is claimed by the run of the original printed where it was read. A two-column page read column by column is therefore not a page of errors, and every difference has a position.
42
42
  - **Figures must keep their digits.** A run whose digits read back differently has changed, however similar the rest is: "Total 1,250.00" read as "Total 7,250.00" is 93% similar. A figure whose separators alone differ ("5.768.700 00") is the same figure. Words keep OCR's tolerance (`matchThreshold`, 0.8).
43
43
  - **Figures are matched, not only read.** Every printed figure is checked glyph by glyph against the original's own ink and against the other digits the page prints, at the scan's own sharpness. That settles what no reading of a coarse scan can: whether this is still the digit that was printed. On real returned scans it verified 35 of 40 printed figures at 125 dpi with no false calls, and read a digit replaced by another of the same run for what it is.
44
+ - **It says why it abstained.** `printChecks.skipped` counts each reason - `no-figure`, `unplaceable`, `few-rivals`, `too-coarse`, `undecided` - because "checked and agreed" and "never looked at" are otherwise the same silence, and telling them apart by inference is slow and can come out wrong.
44
45
  - **It abstains rather than confirm.** A page that prints too few digits to offer a full set of rivals, a run too small to segment, a cell too soft to call: each is left to the reading, and the answer is applied only to the cells it actually decided. Confirming a digit that had in fact been altered would be worse than saying nothing, so every threshold is set to fail that way.
45
46
  - **What is not an addition.** Words over something the original prints without text, such as a logo, are not additions. Nor are specks under 4 pt tall.
46
47
 
package/dist/index.esm.js CHANGED
@@ -923,8 +923,38 @@ const MAX_PRINTED_SCORE = 0.85;
923
923
  const MATCH_HEIGHT = 16;
924
924
  /** The template is slid this far, in match pixels, to absorb a cell landing half a pixel out. */
925
925
  const SHIFT = 1;
926
- /** How much the print may be softened by to meet the scan, in match pixels. */
927
- const SOFTENING = [0, 0.5, 1, 1.5, 2];
926
+ /**
927
+ * The scan is sharpened to meet the print, rather than the print softened to
928
+ * meet the scan, and that direction is the whole of it.
929
+ *
930
+ * Softening the template was the obvious way round and it is wrong: the blur
931
+ * lands on the printed glyph and on all nine rivals alike, so above about one
932
+ * match pixel a `3` and an `8` become the same blob and every rival ties the
933
+ * truth. Measured on a returned order confirmation, the totals on its shaded
934
+ * bar fitted a softening of 1.5 and scored 0.937 against the digit actually
935
+ * printed and 0.928 against the best rival - a margin of 0.006 where 0.12 is
936
+ * needed, so not one of 109 cells could be decided. The same collapse hits
937
+ * ordinary black-on-white text on a 93 dpi scan, which is how we know it is
938
+ * the softening and not the shaded bar.
939
+ *
940
+ * Sharpening the scan puts back what the scanner took out and leaves the
941
+ * template's own detail intact, so what survives is what distinguishes one
942
+ * digit from another. On the same document that took light-on-dark cells from
943
+ * 0 decided to 12, and dark-on-light from 316 to 343, with no false call.
944
+ *
945
+ * `RECOVERY` is how far, in match pixels; `SHARPENING` is how hard.
946
+ */
947
+ const RECOVERY = [0, 0.5, 1, 1.5, 2];
948
+ /**
949
+ * A verdict has to hold at every one of these, or the cell is left undecided.
950
+ *
951
+ * Not a search for the amount that gives an answer - that is how a sweep turns
952
+ * into a way of manufacturing one. Sharpening can create a stroke that was
953
+ * never scanned, and a fabricated stroke favours whichever rival it happens to
954
+ * resemble, so a reading that moves as the amount moves is an artefact of the
955
+ * sharpening and is discarded.
956
+ */
957
+ const SHARPENING = [1, 1.5, 2];
928
958
  /**
929
959
  * Checks the figures of one run against the original's print.
930
960
  *
@@ -934,7 +964,7 @@ const SOFTENING = [0, 0.5, 1, 1.5, 2];
934
964
  * @param run - The run to check, from the original's text layer.
935
965
  * @param templates - Glyphs collected from the original by `collectTemplates`.
936
966
  * @param options - Margin, figure length and the characters figures use.
937
- * @returns What the ink says, or `null` when the run could not be checked.
967
+ * @returns What the ink says, or why it would not say.
938
968
  */
939
969
  function verifyPrintedRun(original, scan, dpi, run, templates, options = {}) {
940
970
  const {
@@ -945,73 +975,127 @@ function verifyPrintedRun(original, scan, dpi, run, templates, options = {}) {
945
975
  minPrinted = MIN_PRINTED_SCORE,
946
976
  minRivals = scope === 'text' ? MIN_TEXT_RIVALS : MIN_RIVALS,
947
977
  minDigits = 2,
948
- characters = scope === 'text' ? TEXT_CHARACTERS : FIGURE_CHARACTERS$1
978
+ characters = scope === 'figures' ? FIGURE_CHARACTERS$1 : TEXT_CHARACTERS,
979
+ claimed
949
980
  } = options;
950
981
  const printed = [...run.text].filter(character => character.trim() !== '');
951
- const wanted = scope === 'text' ? textCells(printed, characters) : figureCells(printed, minDigits);
952
- if (wanted.size === 0) return null;
982
+ // What the reading says it is, lined up with what the original printed. The
983
+ // two must correspond character for character or the comparison is meaningless.
984
+ const proposed = scope === 'confirm' ? [...(claimed ?? '')].filter(character => character.trim() !== '') : null;
985
+ if (proposed !== null && (proposed.length === 0 || proposed.length !== printed.length)) return {
986
+ verified: false,
987
+ because: 'no-claim'
988
+ };
989
+ const wanted = proposed === null ? scope === 'text' ? textCells(printed, characters) : figureCells(printed, minDigits) : disputedCells(printed, proposed, characters);
990
+ if (wanted.size === 0) return {
991
+ verified: false,
992
+ because: proposed === null ? 'no-figure' : 'no-claim'
993
+ };
953
994
  // A total on a shaded bar is printed light on dark; turned round, it is a figure like any other.
954
995
  const lightOnDark = printPolarity(original, dpi, run) === 'light-on-dark';
955
996
  const cells = placeGlyphs(original, dpi, run, run.text, {
956
997
  lightOnDark
957
998
  });
958
- if (cells === null) return null;
959
- // Rivals: every character the page prints in this face and size that a figure could use.
999
+ if (cells === null) return {
1000
+ verified: false,
1001
+ because: 'unplaceable'
1002
+ };
1003
+ /**
1004
+ * Who each cell is up against.
1005
+ *
1006
+ * Identifying an unknown glyph needs most of the alphabet: to assert "this is
1007
+ * a 7" you must be able to rule the others out, and the bar of eight digits
1008
+ * is what the false-call rate justified. A settlement is not asking that. The
1009
+ * reading has already named its answer, so the question is which of two named
1010
+ * characters this ink is - and two templates answer it. A wrong verdict then
1011
+ * requires the ink to match the *other specific character* better, not merely
1012
+ * to be hard to read.
1013
+ */
960
1014
  const rivals = [...characters].filter(character => templates.has(templateKey(run, character)));
961
- if (rivals.length < minRivals) return null;
962
- // How soft this scan's print is, measured where the answer is known: each cell
963
- // against the very glyph the original prints there.
964
- const pairs = [...wanted].flatMap(index => {
965
- const cell = cells[index];
966
- return cell === null ? [] : [{
967
- glyph: cut(scan, dpi, cell, lightOnDark),
968
- printed: cut(original, dpi, cell, lightOnDark)
969
- }];
970
- }).filter(pair => pair.glyph !== null && pair.printed !== null);
971
- const softening = softeningFor(pairs);
972
- const reading = [...printed];
973
- const scored = [];
974
- let confidence = 1;
975
- let checked = 0;
1015
+ const rivalsAt = index => {
1016
+ if (proposed === null) return rivals;
1017
+ const against = proposed[index];
1018
+ return against !== undefined && templates.has(templateKey(run, against)) ? [against] : [];
1019
+ };
1020
+ const enough = proposed === null ? rivals.length >= minRivals : [...wanted].some(index => rivalsAt(index).length > 0);
1021
+ if (!enough) return {
1022
+ verified: false,
1023
+ because: 'few-rivals'
1024
+ };
1025
+ // The cell exactly: a margin would bring in the neighbouring glyphs, which
1026
+ // both crops share, and shared ink correlates whatever the character is.
1027
+ const crops = new Map();
1028
+ let coarse = false;
976
1029
  for (const index of wanted) {
977
- // The cell exactly: a margin would bring in the neighbouring glyphs, which
978
- // both crops share, and shared ink correlates whatever the character is.
979
1030
  const cell = cells[index];
980
1031
  if (cell === null) continue;
981
1032
  const glyph = cut(scan, dpi, cell, lightOnDark);
982
1033
  // The original's own ink here: the same glyph, at the same size, in the same place.
983
1034
  const asPrinted = cut(original, dpi, cell, lightOnDark);
984
1035
  if (glyph === null || asPrinted === null) continue;
985
- const printedScore = correlate(glyph, asPrinted, softening);
986
- const others = rivals.filter(character => character !== printed[index]).map(character => ({
987
- character,
988
- score: bestMatch(glyph, templates.get(templateKey(run, character)) ?? [], softening)
989
- })).toSorted((a, b) => b.score - a.score);
990
- const rival = others[0];
991
- if (rival === undefined) continue;
992
- // Undecided unless one of the two wins clearly, and a change has to look like
993
- // the character it is being read as, not merely less like the printed one.
994
- const changed = rival.score >= printedScore + minMargin && rival.score >= minScore && printedScore <= maxPrinted;
995
- const unchanged = printedScore >= rival.score + minMargin && printedScore >= minPrinted;
1036
+ // Never invent resolution. Matching happens at MATCH_HEIGHT, so a shorter
1037
+ // cell is scaled up and the detail that decides between two digits is
1038
+ // interpolated rather than scanned - and an invented stroke favours
1039
+ // whichever rival it resembles. The one false call this check produced on
1040
+ // a 93 dpi scan came from cells of fourteen pixels.
1041
+ if (glyph.height < MATCH_HEIGHT) {
1042
+ coarse = true;
1043
+ continue;
1044
+ }
1045
+ crops.set(index, {
1046
+ glyph,
1047
+ printed: asPrinted
1048
+ });
1049
+ }
1050
+ if (crops.size === 0) return {
1051
+ verified: false,
1052
+ because: coarse ? 'too-coarse' : 'unplaceable'
1053
+ };
1054
+ // How far this scan's print has to be sharpened, measured where the answer is
1055
+ // known: each cell against the very glyph the original prints there.
1056
+ const pairs = [...crops.values()];
1057
+ const recoveries = SHARPENING.map(amount => recoveryFor(pairs, amount));
1058
+ const limits = {
1059
+ minMargin,
1060
+ minScore,
1061
+ maxPrinted,
1062
+ minPrinted
1063
+ };
1064
+ const reading = [...printed];
1065
+ const scored = [];
1066
+ let confidence = 1;
1067
+ let checked = 0;
1068
+ for (const [index, crop] of crops) {
1069
+ const passes = recoveries.map(recovery => judgeCell(crop, printed[index], rivalsAt(index), run, templates, recovery, limits));
1070
+ const first = passes[0];
1071
+ if (first === undefined) continue;
1072
+ // Undecided unless every pass reads it the same way: a verdict that changes
1073
+ // as the sharpening changes is the sharpening talking, not the ink.
1074
+ const settled = first.read !== null && passes.every(pass => pass.read === first.read);
996
1075
  scored.push({
997
1076
  at: index,
998
1077
  printed: printed[index],
999
- printedScore,
1000
- rival: rival.character,
1001
- rivalScore: rival.score,
1002
- read: changed ? rival.character : unchanged ? printed[index] : null
1078
+ printedScore: first.printedScore,
1079
+ rival: first.rival,
1080
+ rivalScore: first.rivalScore,
1081
+ read: settled ? first.read : null
1003
1082
  });
1004
- if (!changed && !unchanged) continue;
1083
+ if (!settled || first.read === null) continue;
1005
1084
  checked++;
1006
- if (changed) reading[index] = rival.character;
1007
- confidence = Math.min(confidence, Math.abs(printedScore - rival.score));
1085
+ reading[index] = first.read;
1086
+ // The narrowest margin any pass decided by, so confidence is the weakest link.
1087
+ confidence = Math.min(confidence, ...passes.map(pass => Math.abs(pass.printedScore - pass.rivalScore)));
1008
1088
  }
1009
1089
  // Nothing decided is not an answer: say nothing rather than guess a digit.
1010
- if (checked === 0) return null;
1090
+ if (checked === 0) return {
1091
+ verified: false,
1092
+ because: 'undecided'
1093
+ };
1011
1094
  // Put the spaces back, so the reading reads like the run it is about.
1012
1095
  let cell = 0;
1013
1096
  const text = [...run.text].map(character => character.trim() === '' ? character : reading[cell++]).join('');
1014
1097
  return {
1098
+ verified: true,
1015
1099
  reading: text,
1016
1100
  agrees: text === run.text,
1017
1101
  checked,
@@ -1019,6 +1103,31 @@ function verifyPrintedRun(original, scan, dpi, run, templates, options = {}) {
1019
1103
  cells: scored
1020
1104
  };
1021
1105
  }
1106
+ /** What one pass of the sharpening sweep makes of a single cell. */
1107
+ function judgeCell(crop, printed, rivals, run, templates, recovery, limits) {
1108
+ const printedScore = correlate(crop.glyph, crop.printed, recovery);
1109
+ const others = rivals.filter(character => character !== printed).map(character => ({
1110
+ character,
1111
+ score: bestMatch(crop.glyph, templates.get(templateKey(run, character)) ?? [], recovery)
1112
+ })).toSorted((a, b) => b.score - a.score);
1113
+ const rival = others[0];
1114
+ if (rival === undefined) return {
1115
+ read: null,
1116
+ printedScore,
1117
+ rival: '',
1118
+ rivalScore: -1
1119
+ };
1120
+ // A change has to look like the character it is being read as, not merely
1121
+ // less like the printed one.
1122
+ const changed = rival.score >= printedScore + limits.minMargin && rival.score >= limits.minScore && printedScore <= limits.maxPrinted;
1123
+ const unchanged = printedScore >= rival.score + limits.minMargin && printedScore >= limits.minPrinted;
1124
+ return {
1125
+ read: changed ? rival.character : unchanged ? printed : null,
1126
+ printedScore,
1127
+ rival: rival.character,
1128
+ rivalScore: rival.score
1129
+ };
1130
+ }
1022
1131
  /**
1023
1132
  * The digits of every figure of at least `minDigits` digits.
1024
1133
  *
@@ -1031,6 +1140,15 @@ function textCells(printed, characters) {
1031
1140
  for (const [index, character] of printed.entries()) if (characters.includes(character)) wanted.add(index);
1032
1141
  return wanted;
1033
1142
  }
1143
+ /** Where the print and the reading disagree, which is the whole of what a settlement asks about. */
1144
+ function disputedCells(printed, proposed, characters) {
1145
+ const wanted = new Set();
1146
+ for (const [index, character] of printed.entries()) {
1147
+ const against = proposed[index];
1148
+ if (against !== undefined && against !== character && characters.includes(character) && characters.includes(against)) wanted.add(index);
1149
+ }
1150
+ return wanted;
1151
+ }
1034
1152
  function figureCells(printed, minDigits) {
1035
1153
  const wanted = new Set();
1036
1154
  let group = [];
@@ -1047,38 +1165,62 @@ function figureCells(printed, minDigits) {
1047
1165
  return wanted;
1048
1166
  }
1049
1167
  /** How soft the print has to be drawn to sit best on the scan's own glyphs. */
1050
- function softeningFor(pairs) {
1168
+ function recoveryFor(pairs, amount) {
1051
1169
  let best = {
1052
- softening: 0,
1170
+ recovery: {
1171
+ sigma: 0,
1172
+ amount
1173
+ },
1053
1174
  score: -Infinity
1054
1175
  };
1055
- for (const softening of SOFTENING) {
1056
- const score = pairs.reduce((sum, pair) => sum + correlate(pair.glyph, pair.printed, softening), 0);
1176
+ for (const sigma of RECOVERY) {
1177
+ const recovery = {
1178
+ sigma,
1179
+ amount
1180
+ };
1181
+ const score = pairs.reduce((sum, pair) => sum + correlate(pair.glyph, pair.printed, recovery), 0);
1057
1182
  if (score > best.score) best = {
1058
- softening,
1183
+ recovery,
1059
1184
  score
1060
1185
  };
1061
1186
  }
1062
- return best.softening;
1187
+ return best.recovery;
1063
1188
  }
1064
1189
  /** The best correlation between one glyph and a character's templates. */
1065
- function bestMatch(glyph, templates, softening) {
1190
+ function bestMatch(glyph, templates, recovery) {
1066
1191
  let best = -1;
1067
- for (const template of templates) best = Math.max(best, correlate(glyph, template, softening));
1192
+ for (const template of templates) best = Math.max(best, correlate(glyph, template, recovery));
1068
1193
  return best;
1069
1194
  }
1195
+ /** An unsharp mask: what the scanner blurred away, added back. */
1196
+ function sharpen(image, {
1197
+ sigma,
1198
+ amount
1199
+ }) {
1200
+ if (sigma <= 0 || amount <= 0) return image;
1201
+ const blurred = soften(image, sigma);
1202
+ const data = new Float32Array(image.data.length);
1203
+ for (let i = 0; i < data.length; i++) data[i] = image.data[i] + amount * (image.data[i] - blurred.data[i]);
1204
+ return {
1205
+ width: image.width,
1206
+ height: image.height,
1207
+ data
1208
+ };
1209
+ }
1070
1210
  /**
1071
1211
  * How alike two glyphs are, each scaled to the same small box, and the template
1072
1212
  * slid a pixel each way to allow for a cell that landed a fraction out.
1073
1213
  *
1074
1214
  * Correlation, rather than a difference of pixels, because a scan is darker or
1075
- * lighter than the print it came from and that must not decide anything.
1215
+ * lighter than the print it came from and that must not decide anything. The
1216
+ * scan's glyph is sharpened; the template is left as it was printed. See
1217
+ * {@link RECOVERY} for why that direction and not the other.
1076
1218
  */
1077
- function correlate(glyph, template, softening = 0) {
1219
+ function correlate(glyph, template, recovery) {
1078
1220
  const height = MATCH_HEIGHT;
1079
1221
  const width = Math.max(2, Math.round(height * ((glyph.width / glyph.height + template.width / template.height) / 2)));
1080
- const a = resizeGray(glyph, width, height);
1081
- const b = soften(resizeGray(template, width, height), softening);
1222
+ const a = sharpen(resizeGray(glyph, width, height), recovery);
1223
+ const b = resizeGray(template, width, height);
1082
1224
  let best = -1;
1083
1225
  for (let dy = -SHIFT; dy <= SHIFT; dy++) for (let dx = -SHIFT; dx <= SHIFT; dx++) best = Math.max(best, pearson(a, b, dx, dy));
1084
1226
  return best;
@@ -1625,7 +1767,15 @@ async function readPage(page, engine, options, templates) {
1625
1767
  // reading of a returned scan can: whether this is still the digit that was printed.
1626
1768
  const printChecks = {
1627
1769
  checked: 0,
1628
- different: 0
1770
+ different: 0,
1771
+ skipped: {
1772
+ 'no-figure': 0,
1773
+ 'unplaceable': 0,
1774
+ 'few-rivals': 0,
1775
+ 'too-coarse': 0,
1776
+ 'undecided': 0,
1777
+ 'no-claim': 0
1778
+ }
1629
1779
  };
1630
1780
  const seenChanged = new Set();
1631
1781
  if (printCheck !== false && useLayer) {
@@ -1635,7 +1785,10 @@ async function readPage(page, engine, options, templates) {
1635
1785
  }));
1636
1786
  for (const [r, run] of printed.entries()) {
1637
1787
  const verified = verifyPrintedRun(originalGray, scanGray, originalDpi, run, templates, printCheck);
1638
- if (verified === null) continue;
1788
+ if (!verified.verified) {
1789
+ printChecks.skipped[verified.because]++;
1790
+ continue;
1791
+ }
1639
1792
  printChecks.checked++;
1640
1793
  if (!verified.agrees) {
1641
1794
  printChecks.different++;
@@ -31,7 +31,7 @@ export type { ScoreMetric, TextMetrics } from './text-similarity/index.js';
31
31
  export { DEFAULT_NORMALISE, normaliseText, tokenise } from './text-normalisation/index.js';
32
32
  export type { NormaliseOptions } from './text-normalisation/index.js';
33
33
  export { collectInto, collectTemplates, FIGURE_CHARACTERS, glyphCells, glyphWords, placeGlyphs, printPolarity, templateKey, TEXT_CHARACTERS, verifyPrintedRun } from './print-verification/index.js';
34
- export type { CellOptions, PrintPolarity, PrintVerification, TemplateStore, Templates, VerifyOptions } from './print-verification/index.js';
34
+ export type { CellOptions, PrintAbstention, PrintCheck, PrintPolarity, PrintVerification, TemplateStore, Templates, VerifyOptions } from './print-verification/index.js';
35
35
  export { claimWords, DEFAULT_RECHECK_PASSES, judgeRun, judgeRuns, matchWords, readRun, recheckRun } from './page-reading/index.js';
36
36
  export type { Claims, MatchOptions, Recheck, RecheckOptions, RecheckPass, Reference, Verdict, WordMatch } from './page-reading/index.js';
37
37
  export { cosine, dice, jaccard, jaroWinkler, levenshtein, levenshteinSimilarity, wordDistance, wordRecall } from './text-similarity/index.js';
@@ -1,6 +1,6 @@
1
1
  import type { ProgressCallback, ReadablePage } from '@scanmate/ink';
2
2
  import type { OcrEngine, TesseractEngineOptions } from '../ocr-engine/index.js';
3
- import type { VerifyOptions } from '../print-verification/index.js';
3
+ import type { PrintAbstention, VerifyOptions } from '../print-verification/index.js';
4
4
  import type { RecheckOptions } from './recheck-run.use-case.js';
5
5
  import type { NormaliseOptions } from '../text-normalisation/index.js';
6
6
  import type { ScoreMetric, TextMetrics } from '../text-similarity/index.js';
@@ -136,10 +136,16 @@ export interface PageOcr {
136
136
  attempted: number;
137
137
  cleared: number;
138
138
  };
139
- /** Printed figures matched against the original's own glyphs, and how many read as something else. */
139
+ /**
140
+ * Printed figures matched against the original's own glyphs: how many were
141
+ * decided, how many read as something else, and why the rest were not looked
142
+ * at. Without `skipped`, "checked and agreed" and "never checked" are the
143
+ * same silence from outside.
144
+ */
140
145
  printChecks: {
141
146
  checked: number;
142
147
  different: number;
148
+ skipped: Record<PrintAbstention, number>;
143
149
  };
144
150
  warnings: string[];
145
151
  }
@@ -6,5 +6,6 @@ export { mergeVerifiedFigures } from './merge-reading.mapper.js';
6
6
  export { printPolarity } from './print-polarity.policy.js';
7
7
  export type { PrintPolarity } from './print-polarity.policy.js';
8
8
  export { FIGURE_CHARACTERS, TEXT_CHARACTERS, verifyPrintedRun } from './verify-print.use-case.js';
9
+ export type { PrintAbstention, PrintCheck } from './verify-print.use-case.js';
9
10
  export type { PrintVerification, VerifyOptions } from './verify-print.use-case.js';
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -70,10 +70,22 @@ export interface VerifyOptions {
70
70
  minRivals?: number;
71
71
  /**
72
72
  * What to check: the digits of a figure (`'figures'`, the default, cheap
73
- * enough for a whole page), or every letter and digit of the run (`'text'`,
74
- * for a single run under dispute).
73
+ * enough for a whole page), every letter and digit of the run (`'text'`, for
74
+ * a single run under dispute), or the one question a settlement actually
75
+ * asks (`'confirm'`).
76
+ *
77
+ * `'confirm'` needs {@link claimed} and answers "is this ink the character
78
+ * the original printed, or the one the reading says it is?" - which takes
79
+ * two templates rather than a near-complete alphabet, because the reading
80
+ * has already named the alternative. See {@link VerifyOptions.claimed}.
75
81
  */
76
- scope?: 'figures' | 'text';
82
+ scope?: 'figures' | 'text' | 'confirm';
83
+ /**
84
+ * What the reading claims the run says, for `scope: 'confirm'`. Compared
85
+ * character by character against the original's own text, so it has to hold
86
+ * the same number of printed characters; anything else abstains.
87
+ */
88
+ claimed?: string;
77
89
  /** How badly the printed glyph must match for a character to count as changed. Default `0.85`. */
78
90
  maxPrinted?: number;
79
91
  /** Digits in a row before a group is treated as a figure. Default `2`. */
@@ -95,6 +107,31 @@ export interface CellVerification {
95
107
  /** What this cell was taken to be, or `null` when it was too close to call. */
96
108
  read: string | null;
97
109
  }
110
+ /**
111
+ * Why a run was not checked.
112
+ *
113
+ * Reported rather than folded into a bare `null`, because "checked and agreed"
114
+ * and "never looked at" are the same silence from outside, and telling them
115
+ * apart by inference costs a consumer days and can still come out wrong.
116
+ *
117
+ * - `no-figure` - the run holds no group of digits long enough to be a figure.
118
+ * - `unplaceable` - the glyphs could not be cut apart.
119
+ * - `few-rivals` - the document prints too little of this face to rule other
120
+ * characters out.
121
+ * - `too-coarse` - the scan resolves the cell below the height it is matched
122
+ * at, so a verdict would rest on detail it never captured.
123
+ * - `undecided` - measured, and nothing won by enough to say.
124
+ * - `no-claim` - `scope: 'confirm'` without a reading to test, or one that
125
+ * does not line up with the print character for character.
126
+ */
127
+ export type PrintAbstention = 'no-figure' | 'unplaceable' | 'few-rivals' | 'too-coarse' | 'undecided' | 'no-claim';
128
+ /** What the ink said, or why it would not say. */
129
+ export type PrintCheck = ({
130
+ verified: true;
131
+ } & PrintVerification) | {
132
+ verified: false;
133
+ because: PrintAbstention;
134
+ };
98
135
  export interface PrintVerification {
99
136
  /** The run as its ink reads, printed characters kept where nothing was checked. */
100
137
  reading: string;
@@ -116,7 +153,7 @@ export interface PrintVerification {
116
153
  * @param run - The run to check, from the original's text layer.
117
154
  * @param templates - Glyphs collected from the original by `collectTemplates`.
118
155
  * @param options - Margin, figure length and the characters figures use.
119
- * @returns What the ink says, or `null` when the run could not be checked.
156
+ * @returns What the ink says, or why it would not say.
120
157
  */
121
- export declare function verifyPrintedRun(original: GrayImage, scan: GrayImage, dpi: number, run: TextRun, templates: Templates, options?: VerifyOptions): PrintVerification | null;
158
+ export declare function verifyPrintedRun(original: GrayImage, scan: GrayImage, dpi: number, run: TextRun, templates: Templates, options?: VerifyOptions): PrintCheck;
122
159
  //# sourceMappingURL=verify-print.use-case.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scanmate/ocr",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "How closely a scan's text matches the original's: OCR matched run by run on the page, figures held exact, every difference located.",
5
5
  "license": "MIT",
6
6
  "author": "Eduardo Russo",
@@ -41,7 +41,7 @@
41
41
  "!**/*.js.map"
42
42
  ],
43
43
  "dependencies": {
44
- "@scanmate/ink": "^0.4.0",
44
+ "@scanmate/ink": "^0.6.0",
45
45
  "@tesseract.js-data/eng": "^1.0.0",
46
46
  "fastest-levenshtein": "^1.0.16",
47
47
  "tesseract.js": "^7.0.0"
@@ -50,7 +50,7 @@
50
50
  "access": "public"
51
51
  },
52
52
  "devDependencies": {
53
- "@scanmate/align": "^0.4.0",
54
- "@scanmate/extract": "^0.4.0"
53
+ "@scanmate/align": "^0.6.0",
54
+ "@scanmate/extract": "^0.6.0"
55
55
  }
56
56
  }