@scanmate/ocr 0.0.2
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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +947 -0
- package/dist/src/index.d.ts +37 -0
- package/dist/src/ocr-engine/index.d.ts +5 -0
- package/dist/src/ocr-engine/ocr-engine.contract.d.ts +54 -0
- package/dist/src/ocr-engine/tesseract-engine.client.d.ts +71 -0
- package/dist/src/page-reading/index.d.ts +8 -0
- package/dist/src/page-reading/match-words.use-case.d.ts +65 -0
- package/dist/src/page-reading/ocr-pages.use-case.d.ts +17 -0
- package/dist/src/page-reading/ocr-report.contract.d.ts +154 -0
- package/dist/src/page-reading/recheck-run.use-case.d.ts +49 -0
- package/dist/src/text-normalisation/confusables.mapper.d.ts +12 -0
- package/dist/src/text-normalisation/diacritics.mapper.d.ts +15 -0
- package/dist/src/text-normalisation/index.d.ts +6 -0
- package/dist/src/text-normalisation/normalise-text.use-case.d.ts +36 -0
- package/dist/src/text-similarity/compare-texts.use-case.d.ts +30 -0
- package/dist/src/text-similarity/index.d.ts +5 -0
- package/dist/src/text-similarity/similarity-metrics.use-case.d.ts +35 -0
- package/package.json +56 -0
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { distance } from 'fastest-levenshtein';
|
|
2
|
+
import { isRaster, encodeImage, resampleRaster, createRaster } from '@scanmate/ink';
|
|
3
|
+
import { mkdir, copyFile, stat } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { createWorker, OEM } from 'tesseract.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Characters and pairs OCR mistakes for one another, folded to one form each.
|
|
11
|
+
*
|
|
12
|
+
* Off by default, and deliberately so: `0` for `o` and `5` for `s` are OCR's
|
|
13
|
+
* commonest errors, and the very substitutions someone altering an amount or a
|
|
14
|
+
* reference would make. Folding them raises a score by hiding exactly what a
|
|
15
|
+
* check is for. Use it to compare prose, never figures.
|
|
16
|
+
*
|
|
17
|
+
* Applied after case folding, to lower-case text.
|
|
18
|
+
*/
|
|
19
|
+
const PAIRS = [[/rn/gu, 'm'], [/cl/gu, 'd'], [/vv/gu, 'w']];
|
|
20
|
+
const SINGLES = new Map([['0', 'o'], ['1', 'l'], ['i', 'l'], ['|', 'l'], ['!', 'l'], ['5', 's'], ['8', 'b'], ['6', 'b'], ['2', 'z']]);
|
|
21
|
+
function foldConfusables(text) {
|
|
22
|
+
let out = text;
|
|
23
|
+
for (const [pattern, replacement] of PAIRS) out = out.replaceAll(pattern, () => replacement);
|
|
24
|
+
let folded = '';
|
|
25
|
+
for (const character of out) folded += SINGLES.get(character) ?? character;
|
|
26
|
+
return folded;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Letters with diacritics, ligatures and look-alike forms, folded to plain
|
|
31
|
+
* Latin: `é` to `e`, `Æ` to `AE`, a circled or full-width `A` to `A`.
|
|
32
|
+
*
|
|
33
|
+
* OCR drops and invents accents - a scanned `é` reads as `e` or `è` as the
|
|
34
|
+
* print allows - so text is compared with them folded away. Some bases are two
|
|
35
|
+
* letters, so folding can lengthen a string. The table is the project's own
|
|
36
|
+
* diacritics map (86 bases, 874 letters), kept as escapes so every code point
|
|
37
|
+
* is visible in review; it is expanded into a lookup once, on first use.
|
|
38
|
+
*/
|
|
39
|
+
/** [base, letters that fold to it] */
|
|
40
|
+
const TABLE = [['A', '\u{41}\u{24B6}\u{FF21}\u{C0}\u{C1}\u{C2}\u{1EA6}\u{1EA4}\u{1EAA}\u{1EA8}\u{C3}\u{100}\u{102}\u{1EB0}\u{1EAE}\u{1EB4}\u{1EB2}\u{226}\u{1E0}\u{C4}\u{1DE}\u{1EA2}\u{C5}\u{1FA}\u{1CD}\u{200}\u{202}\u{1EA0}\u{1EAC}\u{1EB6}\u{1E00}\u{104}\u{23A}\u{2C6F}'], ['AA', '\u{A732}'], ['AE', '\u{C6}\u{1FC}\u{1E2}'], ['AO', '\u{A734}'], ['AU', '\u{A736}'], ['AV', '\u{A738}\u{A73A}'], ['AY', '\u{A73C}'], ['B', '\u{42}\u{24B7}\u{FF22}\u{1E02}\u{1E04}\u{1E06}\u{243}\u{182}\u{181}'], ['C', '\u{43}\u{24B8}\u{FF23}\u{106}\u{108}\u{10A}\u{10C}\u{C7}\u{1E08}\u{187}\u{23B}\u{A73E}'], ['D', '\u{44}\u{24B9}\u{FF24}\u{1E0A}\u{10E}\u{1E0C}\u{1E10}\u{1E12}\u{1E0E}\u{110}\u{18B}\u{18A}\u{189}\u{A779}'], ['DZ', '\u{1F1}\u{1C4}'], ['Dz', '\u{1F2}\u{1C5}'], ['E', '\u{45}\u{24BA}\u{FF25}\u{C8}\u{C9}\u{CA}\u{1EC0}\u{1EBE}\u{1EC4}\u{1EC2}\u{1EBC}\u{112}\u{1E14}\u{1E16}\u{114}\u{116}\u{CB}\u{1EBA}\u{11A}\u{204}\u{206}\u{1EB8}\u{1EC6}\u{228}\u{1E1C}\u{118}\u{1E18}\u{1E1A}\u{190}\u{18E}'], ['F', '\u{46}\u{24BB}\u{FF26}\u{1E1E}\u{191}\u{A77B}'], ['G', '\u{47}\u{24BC}\u{FF27}\u{1F4}\u{11C}\u{1E20}\u{11E}\u{120}\u{1E6}\u{122}\u{1E4}\u{193}\u{A7A0}\u{A77D}\u{A77E}'], ['H', '\u{48}\u{24BD}\u{FF28}\u{124}\u{1E22}\u{1E26}\u{21E}\u{1E24}\u{1E28}\u{1E2A}\u{126}\u{2C67}\u{2C75}\u{A78D}'], ['I', '\u{49}\u{24BE}\u{FF29}\u{CC}\u{CD}\u{CE}\u{128}\u{12A}\u{12C}\u{130}\u{CF}\u{1E2E}\u{1EC8}\u{1CF}\u{208}\u{20A}\u{1ECA}\u{12E}\u{1E2C}\u{197}'], ['J', '\u{4A}\u{24BF}\u{FF2A}\u{134}\u{248}'], ['K', '\u{4B}\u{24C0}\u{FF2B}\u{1E30}\u{1E8}\u{1E32}\u{136}\u{1E34}\u{198}\u{2C69}\u{A740}\u{A742}\u{A744}\u{A7A2}'], ['L', '\u{4C}\u{24C1}\u{FF2C}\u{13F}\u{139}\u{13D}\u{1E36}\u{1E38}\u{13B}\u{1E3C}\u{1E3A}\u{141}\u{23D}\u{2C62}\u{2C60}\u{A748}\u{A746}\u{A780}'], ['LJ', '\u{1C7}'], ['Lj', '\u{1C8}'], ['M', '\u{4D}\u{24C2}\u{FF2D}\u{1E3E}\u{1E40}\u{1E42}\u{2C6E}\u{19C}'], ['N', '\u{4E}\u{24C3}\u{FF2E}\u{1F8}\u{143}\u{D1}\u{1E44}\u{147}\u{1E46}\u{145}\u{1E4A}\u{1E48}\u{220}\u{19D}\u{A790}\u{A7A4}'], ['NJ', '\u{1CA}'], ['Nj', '\u{1CB}'], ['O', '\u{4F}\u{24C4}\u{FF2F}\u{D2}\u{D3}\u{D4}\u{1ED2}\u{1ED0}\u{1ED6}\u{1ED4}\u{D5}\u{1E4C}\u{22C}\u{1E4E}\u{14C}\u{1E50}\u{1E52}\u{14E}\u{22E}\u{230}\u{D6}\u{22A}\u{1ECE}\u{150}\u{1D1}\u{20C}\u{20E}\u{1A0}\u{1EDC}\u{1EDA}\u{1EE0}\u{1EDE}\u{1EE2}\u{1ECC}\u{1ED8}\u{1EA}\u{1EC}\u{D8}\u{1FE}\u{186}\u{19F}\u{A74A}\u{A74C}'], ['OI', '\u{1A2}'], ['OO', '\u{A74E}'], ['OU', '\u{222}'], ['OE', '\u{8C}\u{152}'], ['oe', '\u{9C}\u{153}'], ['P', '\u{50}\u{24C5}\u{FF30}\u{1E54}\u{1E56}\u{1A4}\u{2C63}\u{A750}\u{A752}\u{A754}'], ['Q', '\u{51}\u{24C6}\u{FF31}\u{A756}\u{A758}\u{24A}'], ['R', '\u{52}\u{24C7}\u{FF32}\u{154}\u{1E58}\u{158}\u{210}\u{212}\u{1E5A}\u{1E5C}\u{156}\u{1E5E}\u{24C}\u{2C64}\u{A75A}\u{A7A6}\u{A782}'], ['S', '\u{53}\u{24C8}\u{FF33}\u{1E9E}\u{15A}\u{1E64}\u{15C}\u{1E60}\u{160}\u{1E66}\u{1E62}\u{1E68}\u{218}\u{15E}\u{2C7E}\u{A7A8}\u{A784}'], ['T', '\u{54}\u{24C9}\u{FF34}\u{1E6A}\u{164}\u{1E6C}\u{21A}\u{162}\u{1E70}\u{1E6E}\u{166}\u{1AC}\u{1AE}\u{23E}\u{A786}'], ['TZ', '\u{A728}'], ['U', '\u{55}\u{24CA}\u{FF35}\u{D9}\u{DA}\u{DB}\u{168}\u{1E78}\u{16A}\u{1E7A}\u{16C}\u{DC}\u{1DB}\u{1D7}\u{1D5}\u{1D9}\u{1EE6}\u{16E}\u{170}\u{1D3}\u{214}\u{216}\u{1AF}\u{1EEA}\u{1EE8}\u{1EEE}\u{1EEC}\u{1EF0}\u{1EE4}\u{1E72}\u{172}\u{1E76}\u{1E74}\u{244}'], ['V', '\u{56}\u{24CB}\u{FF36}\u{1E7C}\u{1E7E}\u{1B2}\u{A75E}\u{245}'], ['VY', '\u{A760}'], ['W', '\u{57}\u{24CC}\u{FF37}\u{1E80}\u{1E82}\u{174}\u{1E86}\u{1E84}\u{1E88}\u{2C72}'], ['X', '\u{58}\u{24CD}\u{FF38}\u{1E8A}\u{1E8C}'], ['Y', '\u{59}\u{24CE}\u{FF39}\u{1EF2}\u{DD}\u{176}\u{1EF8}\u{232}\u{1E8E}\u{178}\u{1EF6}\u{1EF4}\u{1B3}\u{24E}\u{1EFE}'], ['Z', '\u{5A}\u{24CF}\u{FF3A}\u{179}\u{1E90}\u{17B}\u{17D}\u{1E92}\u{1E94}\u{1B5}\u{224}\u{2C7F}\u{2C6B}\u{A762}'], ['a', '\u{61}\u{24D0}\u{FF41}\u{1E9A}\u{E0}\u{E1}\u{E2}\u{1EA7}\u{1EA5}\u{1EAB}\u{1EA9}\u{E3}\u{101}\u{103}\u{1EB1}\u{1EAF}\u{1EB5}\u{1EB3}\u{227}\u{1E1}\u{E4}\u{1DF}\u{1EA3}\u{E5}\u{1FB}\u{1CE}\u{201}\u{203}\u{1EA1}\u{1EAD}\u{1EB7}\u{1E01}\u{105}\u{2C65}\u{250}'], ['aa', '\u{A733}'], ['ae', '\u{E6}\u{1FD}\u{1E3}'], ['ao', '\u{A735}'], ['au', '\u{A737}'], ['av', '\u{A739}\u{A73B}'], ['ay', '\u{A73D}'], ['b', '\u{62}\u{24D1}\u{FF42}\u{1E03}\u{1E05}\u{1E07}\u{180}\u{183}\u{253}'], ['c', '\u{63}\u{24D2}\u{FF43}\u{107}\u{109}\u{10B}\u{10D}\u{E7}\u{1E09}\u{188}\u{23C}\u{A73F}\u{2184}'], ['d', '\u{64}\u{24D3}\u{FF44}\u{1E0B}\u{10F}\u{1E0D}\u{1E11}\u{1E13}\u{1E0F}\u{111}\u{18C}\u{256}\u{257}\u{A77A}'], ['dz', '\u{1F3}\u{1C6}'], ['e', '\u{65}\u{24D4}\u{FF45}\u{E8}\u{E9}\u{EA}\u{1EC1}\u{1EBF}\u{1EC5}\u{1EC3}\u{1EBD}\u{113}\u{1E15}\u{1E17}\u{115}\u{117}\u{EB}\u{1EBB}\u{11B}\u{205}\u{207}\u{1EB9}\u{1EC7}\u{229}\u{1E1D}\u{119}\u{1E19}\u{1E1B}\u{247}\u{25B}\u{1DD}'], ['f', '\u{66}\u{24D5}\u{FF46}\u{1E1F}\u{192}\u{A77C}'], ['g', '\u{67}\u{24D6}\u{FF47}\u{1F5}\u{11D}\u{1E21}\u{11F}\u{121}\u{1E7}\u{123}\u{1E5}\u{260}\u{A7A1}\u{1D79}\u{A77F}'], ['h', '\u{68}\u{24D7}\u{FF48}\u{125}\u{1E23}\u{1E27}\u{21F}\u{1E25}\u{1E29}\u{1E2B}\u{1E96}\u{127}\u{2C68}\u{2C76}\u{265}'], ['hv', '\u{195}'], ['i', '\u{69}\u{24D8}\u{FF49}\u{EC}\u{ED}\u{EE}\u{129}\u{12B}\u{12D}\u{EF}\u{1E2F}\u{1EC9}\u{1D0}\u{209}\u{20B}\u{1ECB}\u{12F}\u{1E2D}\u{268}\u{131}'], ['j', '\u{6A}\u{24D9}\u{FF4A}\u{135}\u{1F0}\u{249}'], ['k', '\u{6B}\u{24DA}\u{FF4B}\u{1E31}\u{1E9}\u{1E33}\u{137}\u{1E35}\u{199}\u{2C6A}\u{A741}\u{A743}\u{A745}\u{A7A3}'], ['l', '\u{6C}\u{24DB}\u{FF4C}\u{140}\u{13A}\u{13E}\u{1E37}\u{1E39}\u{13C}\u{1E3D}\u{1E3B}\u{17F}\u{142}\u{19A}\u{26B}\u{2C61}\u{A749}\u{A781}\u{A747}'], ['lj', '\u{1C9}'], ['m', '\u{6D}\u{24DC}\u{FF4D}\u{1E3F}\u{1E41}\u{1E43}\u{271}\u{26F}'], ['n', '\u{6E}\u{24DD}\u{FF4E}\u{1F9}\u{144}\u{F1}\u{1E45}\u{148}\u{1E47}\u{146}\u{1E4B}\u{1E49}\u{19E}\u{272}\u{149}\u{A791}\u{A7A5}'], ['nj', '\u{1CC}'], ['o', '\u{6F}\u{24DE}\u{FF4F}\u{F2}\u{F3}\u{F4}\u{1ED3}\u{1ED1}\u{1ED7}\u{1ED5}\u{F5}\u{1E4D}\u{22D}\u{1E4F}\u{14D}\u{1E51}\u{1E53}\u{14F}\u{22F}\u{231}\u{F6}\u{22B}\u{1ECF}\u{151}\u{1D2}\u{20D}\u{20F}\u{1A1}\u{1EDD}\u{1EDB}\u{1EE1}\u{1EDF}\u{1EE3}\u{1ECD}\u{1ED9}\u{1EB}\u{1ED}\u{F8}\u{1FF}\u{254}\u{A74B}\u{A74D}\u{275}'], ['oi', '\u{1A3}'], ['ou', '\u{223}'], ['oo', '\u{A74F}'], ['p', '\u{70}\u{24DF}\u{FF50}\u{1E55}\u{1E57}\u{1A5}\u{1D7D}\u{A751}\u{A753}\u{A755}'], ['q', '\u{71}\u{24E0}\u{FF51}\u{24B}\u{A757}\u{A759}'], ['r', '\u{72}\u{24E1}\u{FF52}\u{155}\u{1E59}\u{159}\u{211}\u{213}\u{1E5B}\u{1E5D}\u{157}\u{1E5F}\u{24D}\u{27D}\u{A75B}\u{A7A7}\u{A783}'], ['s', '\u{73}\u{24E2}\u{FF53}\u{DF}\u{15B}\u{1E65}\u{15D}\u{1E61}\u{161}\u{1E67}\u{1E63}\u{1E69}\u{219}\u{15F}\u{23F}\u{A7A9}\u{A785}\u{1E9B}'], ['t', '\u{74}\u{24E3}\u{FF54}\u{1E6B}\u{1E97}\u{165}\u{1E6D}\u{21B}\u{163}\u{1E71}\u{1E6F}\u{167}\u{1AD}\u{288}\u{2C66}\u{A787}'], ['tz', '\u{A729}'], ['u', '\u{75}\u{24E4}\u{FF55}\u{F9}\u{FA}\u{FB}\u{169}\u{1E79}\u{16B}\u{1E7B}\u{16D}\u{FC}\u{1DC}\u{1D8}\u{1D6}\u{1DA}\u{1EE7}\u{16F}\u{171}\u{1D4}\u{215}\u{217}\u{1B0}\u{1EEB}\u{1EE9}\u{1EEF}\u{1EED}\u{1EF1}\u{1EE5}\u{1E73}\u{173}\u{1E77}\u{1E75}\u{289}'], ['v', '\u{76}\u{24E5}\u{FF56}\u{1E7D}\u{1E7F}\u{28B}\u{A75F}\u{28C}'], ['vy', '\u{A761}'], ['w', '\u{77}\u{24E6}\u{FF57}\u{1E81}\u{1E83}\u{175}\u{1E87}\u{1E85}\u{1E98}\u{1E89}\u{2C73}'], ['x', '\u{78}\u{24E7}\u{FF58}\u{1E8B}\u{1E8D}'], ['y', '\u{79}\u{24E8}\u{FF59}\u{1EF3}\u{FD}\u{177}\u{1EF9}\u{233}\u{1E8F}\u{FF}\u{1EF7}\u{1E99}\u{1EF5}\u{1B4}\u{24F}\u{1EFF}'], ['z', '\u{7A}\u{24E9}\u{FF5A}\u{17A}\u{1E91}\u{17C}\u{17E}\u{1E93}\u{1E95}\u{1B6}\u{225}\u{240}\u{2C6C}\u{A763}']];
|
|
41
|
+
let lookup;
|
|
42
|
+
/** Every folded letter and what it folds to. */
|
|
43
|
+
function diacriticsMap() {
|
|
44
|
+
if (lookup !== undefined) return lookup;
|
|
45
|
+
lookup = new Map();
|
|
46
|
+
for (const [base, letters] of TABLE) for (const letter of letters) if (letter !== base) lookup.set(letter, base);
|
|
47
|
+
return lookup;
|
|
48
|
+
}
|
|
49
|
+
/** Replace every letter in the table by its plain base; everything else is kept as it is. */
|
|
50
|
+
function foldDiacritics(text) {
|
|
51
|
+
const map = diacriticsMap();
|
|
52
|
+
let out = '';
|
|
53
|
+
for (const character of text) out += map.get(character) ?? character;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const DEFAULT_NORMALISE = {
|
|
58
|
+
nfkc: true,
|
|
59
|
+
typography: true,
|
|
60
|
+
dehyphenate: true,
|
|
61
|
+
diacritics: true,
|
|
62
|
+
caseFold: true,
|
|
63
|
+
dropNoise: true,
|
|
64
|
+
stripPunctuation: false,
|
|
65
|
+
confusables: false
|
|
66
|
+
};
|
|
67
|
+
const LINE_END_HYPHEN = /(\p{L})[-\u{AD}\u{2010}\u{2011}][\t ]*\r?\n[\t ]*(?=\p{Ll})/gu;
|
|
68
|
+
const TYPOGRAPHY = [
|
|
69
|
+
// single quotes and prime, double quotes and double prime
|
|
70
|
+
[/[\u{2018}\u{2019}\u{201A}\u{201B}\u{2032}]/gu, '\''], [/[\u{201C}\u{201D}\u{201E}\u{201F}\u{2033}]/gu, '"'],
|
|
71
|
+
// hyphens, figure dash, en and em dashes, horizontal bar, minus
|
|
72
|
+
[/[\u{2010}\u{2011}\u{2012}\u{2013}\u{2014}\u{2015}\u{2212}]/gu, '-'], [/\u{2026}/gu, '...'],
|
|
73
|
+
// no-break and typographic spaces
|
|
74
|
+
[/[\u{A0}\u{2000}-\u{200A}\u{202F}\u{205F}\u{3000}]/gu, ' '],
|
|
75
|
+
// zero-width space and non-joiner, word joiner, byte-order mark
|
|
76
|
+
[/[\u{200B}\u{200C}\u{2060}\u{FEFF}]/gu, ''],
|
|
77
|
+
// zero-width joiner, on its own: in a character class it reads as joining its neighbours
|
|
78
|
+
[/\u{200D}/gu, '']];
|
|
79
|
+
const MEANINGFUL = /[\p{L}\p{N}\p{Sc}]/u;
|
|
80
|
+
const PUNCTUATION = /\p{P}+/gu;
|
|
81
|
+
const WHITESPACE = /\s+/gu;
|
|
82
|
+
function normaliseText(text, options = {}) {
|
|
83
|
+
const o = {
|
|
84
|
+
...DEFAULT_NORMALISE,
|
|
85
|
+
...options
|
|
86
|
+
};
|
|
87
|
+
let out = text;
|
|
88
|
+
if (o.nfkc) out = out.normalize('NFKC');
|
|
89
|
+
if (o.typography) for (const [pattern, replacement] of TYPOGRAPHY) out = out.replaceAll(pattern, () => replacement);
|
|
90
|
+
if (o.dehyphenate) out = out.replaceAll(LINE_END_HYPHEN, '$1');
|
|
91
|
+
if (o.diacritics) out = foldDiacritics(out);
|
|
92
|
+
if (o.caseFold) out = out.toLowerCase();
|
|
93
|
+
if (o.dropNoise) out = out.split(WHITESPACE).filter(token => MEANINGFUL.test(token)).join(' ');
|
|
94
|
+
if (o.stripPunctuation) out = out.replaceAll(PUNCTUATION, ' ');
|
|
95
|
+
if (o.confusables) out = foldConfusables(out);
|
|
96
|
+
return out.replaceAll(WHITESPACE, ' ').trim();
|
|
97
|
+
}
|
|
98
|
+
/** Normalised words, in order. */
|
|
99
|
+
function tokenise(text, options = {}) {
|
|
100
|
+
const normalised = normaliseText(text, options);
|
|
101
|
+
return normalised === '' ? [] : normalised.split(' ');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Ways to say how alike two texts are. They fail differently, which is why
|
|
106
|
+
* several are reported rather than one:
|
|
107
|
+
*
|
|
108
|
+
* - edit distance counts every insertion, deletion and substitution, so it
|
|
109
|
+
* notices a changed digit - and punishes text that reflowed;
|
|
110
|
+
* - set and bag measures (Jaccard, Dice, cosine, recall) ignore order, so they
|
|
111
|
+
* survive reflow and a reading order OCR got wrong - and miss a swapped clause;
|
|
112
|
+
* - character and word error rates are what anyone who works with OCR expects
|
|
113
|
+
* to see;
|
|
114
|
+
* - Jaro-Winkler is for short strings - names, references - and meaningless on
|
|
115
|
+
* a page.
|
|
116
|
+
*
|
|
117
|
+
* All take text already normalised; similarities run from 0 to 1.
|
|
118
|
+
*/
|
|
119
|
+
/** Characters inserted, deleted or substituted to turn one text into the other (Myers' bit-parallel algorithm). */
|
|
120
|
+
function levenshtein(a, b) {
|
|
121
|
+
return distance(a, b);
|
|
122
|
+
}
|
|
123
|
+
/** `1 - distance / longer length`: 1 for identical, 0 for nothing in common. */
|
|
124
|
+
function levenshteinSimilarity(a, b) {
|
|
125
|
+
const longest = Math.max(a.length, b.length);
|
|
126
|
+
return longest === 0 ? 1 : 1 - distance(a, b) / longest;
|
|
127
|
+
}
|
|
128
|
+
/** Words inserted, deleted or substituted, as edit distance over words rather than characters. */
|
|
129
|
+
function wordDistance(a, b) {
|
|
130
|
+
// Each distinct word becomes one private-use character, so the fast
|
|
131
|
+
// character routine does the work - while they last.
|
|
132
|
+
const codes = new Map();
|
|
133
|
+
const PRIVATE_USE = 0xE000;
|
|
134
|
+
const AVAILABLE = 0xF8FF - PRIVATE_USE + 1;
|
|
135
|
+
for (const word of [...a, ...b]) if (!codes.has(word)) codes.set(word, String.fromCodePoint(PRIVATE_USE + codes.size));
|
|
136
|
+
if (codes.size <= AVAILABLE) return distance(a.map(w => codes.get(w)).join(''), b.map(w => codes.get(w)).join(''));
|
|
137
|
+
let previous = Int32Array.from({
|
|
138
|
+
length: b.length + 1
|
|
139
|
+
}, (_, j) => j);
|
|
140
|
+
for (let i = 1; i <= a.length; i++) {
|
|
141
|
+
const current = new Int32Array(b.length + 1);
|
|
142
|
+
current[0] = i;
|
|
143
|
+
for (let j = 1; j <= b.length; j++) current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
144
|
+
previous = current;
|
|
145
|
+
}
|
|
146
|
+
return previous[b.length];
|
|
147
|
+
}
|
|
148
|
+
/** Shared distinct words over all distinct words. */
|
|
149
|
+
function jaccard(a, b) {
|
|
150
|
+
const left = new Set(a);
|
|
151
|
+
const right = new Set(b);
|
|
152
|
+
if (left.size === 0 && right.size === 0) return 1;
|
|
153
|
+
let shared = 0;
|
|
154
|
+
for (const word of left) if (right.has(word)) shared++;
|
|
155
|
+
return shared / (left.size + right.size - shared);
|
|
156
|
+
}
|
|
157
|
+
/** Sørensen-Dice over character bigrams, counted with multiplicity. */
|
|
158
|
+
function dice(a, b) {
|
|
159
|
+
if (a === b) return 1;
|
|
160
|
+
if (a.length < 2 || b.length < 2) return 0;
|
|
161
|
+
const bigrams = new Map();
|
|
162
|
+
for (let i = 0; i < a.length - 1; i++) {
|
|
163
|
+
const pair = a.slice(i, i + 2);
|
|
164
|
+
bigrams.set(pair, (bigrams.get(pair) ?? 0) + 1);
|
|
165
|
+
}
|
|
166
|
+
let shared = 0;
|
|
167
|
+
for (let i = 0; i < b.length - 1; i++) {
|
|
168
|
+
const pair = b.slice(i, i + 2);
|
|
169
|
+
const count = bigrams.get(pair) ?? 0;
|
|
170
|
+
if (count > 0) {
|
|
171
|
+
shared++;
|
|
172
|
+
bigrams.set(pair, count - 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return 2 * shared / (a.length - 1 + b.length - 1);
|
|
176
|
+
}
|
|
177
|
+
/** Cosine of the two word-frequency vectors. */
|
|
178
|
+
function cosine(a, b) {
|
|
179
|
+
const left = frequencies(a);
|
|
180
|
+
const right = frequencies(b);
|
|
181
|
+
if (left.size === 0 && right.size === 0) return 1;
|
|
182
|
+
let dot = 0;
|
|
183
|
+
for (const [word, count] of left) dot += count * (right.get(word) ?? 0);
|
|
184
|
+
const denominator = magnitude(left) * magnitude(right);
|
|
185
|
+
return denominator === 0 ? 0 : dot / denominator;
|
|
186
|
+
}
|
|
187
|
+
/** Share of the expected words found, each found word used once - the question "is what should be there, there?". */
|
|
188
|
+
function wordRecall(expected, actual) {
|
|
189
|
+
if (expected.length === 0) return 1;
|
|
190
|
+
const available = frequencies(actual);
|
|
191
|
+
let found = 0;
|
|
192
|
+
for (const word of expected) {
|
|
193
|
+
const count = available.get(word) ?? 0;
|
|
194
|
+
if (count > 0) {
|
|
195
|
+
found++;
|
|
196
|
+
available.set(word, count - 1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return found / expected.length;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Jaro-Winkler: characters matched within a window, transpositions counted, a
|
|
203
|
+
* bonus for a shared prefix of up to four. Tuned for short strings.
|
|
204
|
+
*/
|
|
205
|
+
function jaroWinkler(a, b, prefixScale = 0.1) {
|
|
206
|
+
if (a === b) return 1;
|
|
207
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
208
|
+
const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
|
|
209
|
+
const left = a.split('');
|
|
210
|
+
const right = b.split('');
|
|
211
|
+
const aMatched = new Uint8Array(left.length);
|
|
212
|
+
const bMatched = new Uint8Array(right.length);
|
|
213
|
+
let matches = 0;
|
|
214
|
+
for (const [i, character] of left.entries()) {
|
|
215
|
+
const j = partner(right, bMatched, character, Math.max(0, i - window), Math.min(right.length - 1, i + window));
|
|
216
|
+
if (j === -1) continue;
|
|
217
|
+
aMatched[i] = 1;
|
|
218
|
+
bMatched[j] = 1;
|
|
219
|
+
matches++;
|
|
220
|
+
}
|
|
221
|
+
if (matches === 0) return 0;
|
|
222
|
+
// Matched characters of each, in order; every position where they disagree is half a transposition.
|
|
223
|
+
const leftMatched = left.filter((_, i) => aMatched[i] === 1);
|
|
224
|
+
const rightMatched = right.filter((_, j) => bMatched[j] === 1);
|
|
225
|
+
const transpositions = leftMatched.filter((character, k) => character !== rightMatched[k]).length;
|
|
226
|
+
const jaro = (matches / a.length + matches / b.length + (matches - transpositions / 2) / matches) / 3;
|
|
227
|
+
let prefix = 0;
|
|
228
|
+
while (prefix < Math.min(4, a.length, b.length) && a[prefix] === b[prefix]) prefix++;
|
|
229
|
+
return jaro + prefix * prefixScale * (1 - jaro);
|
|
230
|
+
}
|
|
231
|
+
/** The first unmatched `character` in `text[from..to]`, or `-1`. */
|
|
232
|
+
function partner(text, matched, character, from, to) {
|
|
233
|
+
for (let j = from; j <= to; j++) if (matched[j] === 0 && text[j] === character) return j;
|
|
234
|
+
return -1;
|
|
235
|
+
}
|
|
236
|
+
function magnitude(counts) {
|
|
237
|
+
let sum = 0;
|
|
238
|
+
for (const count of counts.values()) sum += count * count;
|
|
239
|
+
return Math.sqrt(sum);
|
|
240
|
+
}
|
|
241
|
+
function frequencies(words) {
|
|
242
|
+
const counts = new Map();
|
|
243
|
+
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
244
|
+
return counts;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Normalise both texts the same way, then measure them every way. */
|
|
248
|
+
function compareTexts(expected, actual, options = {}) {
|
|
249
|
+
const a = normaliseText(expected, options);
|
|
250
|
+
const b = normaliseText(actual, options);
|
|
251
|
+
const aWords = a === '' ? [] : a.split(' ');
|
|
252
|
+
const bWords = b === '' ? [] : b.split(' ');
|
|
253
|
+
const edits = levenshtein(a, b);
|
|
254
|
+
const longest = Math.max(a.length, b.length);
|
|
255
|
+
return {
|
|
256
|
+
levenshtein: edits,
|
|
257
|
+
levenshteinSimilarity: longest === 0 ? 1 : 1 - edits / longest,
|
|
258
|
+
jaccard: jaccard(aWords, bWords),
|
|
259
|
+
dice: dice(a, b),
|
|
260
|
+
cosine: cosine(aWords, bWords),
|
|
261
|
+
jaroWinkler: jaroWinkler(a, b),
|
|
262
|
+
characterErrorRate: a.length === 0 ? b.length === 0 ? 0 : 1 : edits / a.length,
|
|
263
|
+
wordErrorRate: aWords.length === 0 ? bWords.length === 0 ? 0 : 1 : wordDistance(aWords, bWords) / aWords.length,
|
|
264
|
+
wordRecall: wordRecall(aWords, bWords),
|
|
265
|
+
lengthRatio: a.length === 0 ? b.length === 0 ? 1 : Infinity : b.length / a.length,
|
|
266
|
+
characters: a.length
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A run that is only a figure: digits and the marks figures are written with. */
|
|
271
|
+
const FIGURE = /^[\d\s.,:/%()+-]+$/u;
|
|
272
|
+
/**
|
|
273
|
+
* Words shorter than this, in points, are specks, not writing - OCR reads a
|
|
274
|
+
* dot of toner as a comma or a `4`. Body text is 7 pt and up; its capitals
|
|
275
|
+
* stand about 5 pt tall.
|
|
276
|
+
*/
|
|
277
|
+
const MIN_WORD_HEIGHT = 4;
|
|
278
|
+
function claimWords(references, words, lineOf, options) {
|
|
279
|
+
const claimed = references.map(() => []);
|
|
280
|
+
const groups = new Map();
|
|
281
|
+
for (const [i, word] of words.entries()) {
|
|
282
|
+
const owner = claimant(word, references);
|
|
283
|
+
if (owner !== -1) claimed[owner].push(word);else if (isWriting(word, options)) groups.set(lineOf[i], [...(groups.get(lineOf[i]) ?? []), word]);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
found: claimed.map(run => inReadingOrder(run).map(w => w.text).join(' ')),
|
|
287
|
+
added: [...groups.values()]
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The rule every run is held to - on the page, and again when it is re-read on
|
|
292
|
+
* its own. A run that is only a figure agrees when its digits do, in order:
|
|
293
|
+
* OCR trades commas for full stops constantly, and `5,768,700.00` read as
|
|
294
|
+
* `5.768.700 00` is the same amount. Any other run must also keep its digits,
|
|
295
|
+
* and read within the similarity threshold.
|
|
296
|
+
*/
|
|
297
|
+
function judgeRun(expected, found, options) {
|
|
298
|
+
const want = normaliseText(expected, options.normalise);
|
|
299
|
+
if (want === '') return {
|
|
300
|
+
agrees: true
|
|
301
|
+
};
|
|
302
|
+
const read = normaliseText(found, options.normalise);
|
|
303
|
+
if (read === '') return {
|
|
304
|
+
agrees: false,
|
|
305
|
+
kind: 'missing',
|
|
306
|
+
reason: 'text',
|
|
307
|
+
similarity: 0
|
|
308
|
+
};
|
|
309
|
+
const similarity = levenshteinSimilarity(want, read);
|
|
310
|
+
if (digits(want) !== digits(read)) return {
|
|
311
|
+
agrees: false,
|
|
312
|
+
kind: 'changed',
|
|
313
|
+
reason: 'numbers',
|
|
314
|
+
similarity
|
|
315
|
+
};
|
|
316
|
+
if (FIGURE.test(want)) return {
|
|
317
|
+
agrees: true
|
|
318
|
+
};
|
|
319
|
+
if (similarity < options.matchThreshold) return {
|
|
320
|
+
agrees: false,
|
|
321
|
+
kind: 'changed',
|
|
322
|
+
reason: 'text',
|
|
323
|
+
similarity
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
agrees: true
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function judgeRuns(references, claims, options) {
|
|
330
|
+
const differences = [];
|
|
331
|
+
const expectedParts = [];
|
|
332
|
+
const alignedParts = [];
|
|
333
|
+
for (const [r, ref] of references.entries()) {
|
|
334
|
+
const found = claims.found[r];
|
|
335
|
+
const separator = ref.endsLine === true ? '\n' : ' ';
|
|
336
|
+
expectedParts.push(ref.text + separator);
|
|
337
|
+
alignedParts.push(found + separator);
|
|
338
|
+
const verdict = judgeRun(ref.text, found, options);
|
|
339
|
+
if (verdict.agrees) continue;
|
|
340
|
+
differences.push({
|
|
341
|
+
kind: verdict.kind,
|
|
342
|
+
expected: ref.text,
|
|
343
|
+
found: verdict.kind === 'missing' ? null : found,
|
|
344
|
+
similarity: verdict.similarity,
|
|
345
|
+
reason: verdict.reason,
|
|
346
|
+
...box(ref)
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// Added words, grouped along the engine's lines so a stray phrase is one difference.
|
|
350
|
+
for (const group of claims.added) {
|
|
351
|
+
const found = group.map(w => w.text).join(' ');
|
|
352
|
+
alignedParts.push(found + '\n');
|
|
353
|
+
differences.push({
|
|
354
|
+
kind: 'added',
|
|
355
|
+
expected: null,
|
|
356
|
+
found,
|
|
357
|
+
similarity: 0,
|
|
358
|
+
reason: 'text',
|
|
359
|
+
...union(group)
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
alignedText: alignedParts.join('').trim(),
|
|
364
|
+
expectedText: expectedParts.join('').trim(),
|
|
365
|
+
differences
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/** Claim and judge in one go, for a page read once. */
|
|
369
|
+
function matchWords(references, words, lineOf, options) {
|
|
370
|
+
return judgeRuns(references, claimWords(references, words, lineOf, options), options);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* The run whose box - grown by a third of its height, at least 1.5 pt - holds
|
|
374
|
+
* the word's centre, the smallest where several do; `-1` when none does.
|
|
375
|
+
*/
|
|
376
|
+
function claimant(word, references) {
|
|
377
|
+
const cx = word.x + word.width / 2;
|
|
378
|
+
const cy = word.y + word.height / 2;
|
|
379
|
+
let best = -1;
|
|
380
|
+
let bestArea = Infinity;
|
|
381
|
+
for (const [r, ref] of references.entries()) {
|
|
382
|
+
const pad = Math.max(1.5, ref.height * 0.3);
|
|
383
|
+
const inside = cx >= ref.x - pad && cx <= ref.x + ref.width + pad && cy >= ref.y - pad && cy <= ref.y + ref.height + pad;
|
|
384
|
+
const area = ref.width * ref.height;
|
|
385
|
+
if (inside && area < bestArea) {
|
|
386
|
+
best = r;
|
|
387
|
+
bestArea = area;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return best;
|
|
391
|
+
}
|
|
392
|
+
/** A word no run claimed that counts as added: confidently read, big enough to be writing, not noise. */
|
|
393
|
+
function isWriting(word, options) {
|
|
394
|
+
return (word.confidence ?? 0) >= options.minWordConfidence && word.height >= MIN_WORD_HEIGHT && normaliseText(word.text, options.normalise) !== '';
|
|
395
|
+
}
|
|
396
|
+
/** Every digit, in order - what a figure must reproduce exactly. */
|
|
397
|
+
function digits(text) {
|
|
398
|
+
return text.replaceAll(/\D/gu, '');
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Words as a reader meets them: rows top to bottom - a word starts a new row
|
|
402
|
+
* once its centre is more than half a word's height below the row's first -
|
|
403
|
+
* and each row left to right.
|
|
404
|
+
*/
|
|
405
|
+
function inReadingOrder(words) {
|
|
406
|
+
const byHeight = words.toSorted((a, b) => a.y + a.height / 2 - (b.y + b.height / 2));
|
|
407
|
+
const rows = [];
|
|
408
|
+
for (const word of byHeight) {
|
|
409
|
+
const row = rows.at(-1);
|
|
410
|
+
const centre = word.y + word.height / 2;
|
|
411
|
+
if (row !== undefined && centre - (row[0].y + row[0].height / 2) <= Math.min(row[0].height, word.height) / 2) row.push(word);else rows.push([word]);
|
|
412
|
+
}
|
|
413
|
+
return rows.flatMap(row => row.toSorted((a, b) => a.x - b.x));
|
|
414
|
+
}
|
|
415
|
+
function box(text) {
|
|
416
|
+
return {
|
|
417
|
+
x: text.x,
|
|
418
|
+
y: text.y,
|
|
419
|
+
width: text.width,
|
|
420
|
+
height: text.height
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function union(texts) {
|
|
424
|
+
const left = Math.min(...texts.map(t => t.x));
|
|
425
|
+
const top = Math.min(...texts.map(t => t.y));
|
|
426
|
+
const right = Math.max(...texts.map(t => t.x + t.width));
|
|
427
|
+
const bottom = Math.max(...texts.map(t => t.y + t.height));
|
|
428
|
+
return {
|
|
429
|
+
x: left,
|
|
430
|
+
y: top,
|
|
431
|
+
width: right - left,
|
|
432
|
+
height: bottom - top
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const DEFAULT_TESSERACT_OPTIONS = {
|
|
437
|
+
languages: ['eng'],
|
|
438
|
+
model: 'best',
|
|
439
|
+
cache: {
|
|
440
|
+
method: 'none',
|
|
441
|
+
path: join(tmpdir(), 'scanmate-ocr')
|
|
442
|
+
},
|
|
443
|
+
pageSegmentation: 3
|
|
444
|
+
};
|
|
445
|
+
const MODEL_FOLDER = {
|
|
446
|
+
best: '4.0.0_best_int',
|
|
447
|
+
standard: '4.0.0'
|
|
448
|
+
};
|
|
449
|
+
async function createTesseractEngine(options = {}) {
|
|
450
|
+
const languages = [...(options.languages ?? DEFAULT_TESSERACT_OPTIONS.languages)];
|
|
451
|
+
if (languages.length === 0) throw new RangeError('at least one language is needed');
|
|
452
|
+
const model = options.model ?? DEFAULT_TESSERACT_OPTIONS.model;
|
|
453
|
+
const cacheMethod = options.cache?.method ?? DEFAULT_TESSERACT_OPTIONS.cache.method;
|
|
454
|
+
const cachePath = options.cache?.path ?? DEFAULT_TESSERACT_OPTIONS.cache.path;
|
|
455
|
+
const pageSegmentation = options.pageSegmentation ?? DEFAULT_TESSERACT_OPTIONS.pageSegmentation;
|
|
456
|
+
const languageData = options.languageData ?? (await languageFolder(languages, model, cachePath));
|
|
457
|
+
if (cacheMethod !== 'none') await mkdir(cachePath, {
|
|
458
|
+
recursive: true
|
|
459
|
+
});
|
|
460
|
+
const worker = await createWorker(languages, OEM.LSTM_ONLY, {
|
|
461
|
+
langPath: languageData,
|
|
462
|
+
gzip: true,
|
|
463
|
+
cacheMethod,
|
|
464
|
+
cachePath
|
|
465
|
+
});
|
|
466
|
+
// Tesseract reports what it estimates ("Estimating resolution as 362") to
|
|
467
|
+
// stderr; a library has no business writing to its host's console.
|
|
468
|
+
await worker.setParameters({
|
|
469
|
+
debug_file: '/dev/null'
|
|
470
|
+
});
|
|
471
|
+
let terminated = false;
|
|
472
|
+
let applied = '';
|
|
473
|
+
const settings = {
|
|
474
|
+
languages,
|
|
475
|
+
model,
|
|
476
|
+
languageData,
|
|
477
|
+
cacheMethod,
|
|
478
|
+
cachePath,
|
|
479
|
+
pageSegmentation
|
|
480
|
+
};
|
|
481
|
+
const LAYOUT = {
|
|
482
|
+
page: pageSegmentation,
|
|
483
|
+
line: 7,
|
|
484
|
+
word: 8
|
|
485
|
+
};
|
|
486
|
+
return {
|
|
487
|
+
name: 'tesseract.js',
|
|
488
|
+
version: tesseractVersion(),
|
|
489
|
+
languages,
|
|
490
|
+
settings,
|
|
491
|
+
async recognise(image, hints = {}) {
|
|
492
|
+
const mode = LAYOUT[hints.layout ?? 'page'];
|
|
493
|
+
const characters = hints.characters ?? '';
|
|
494
|
+
// Parameters persist on the worker, so set them only when they change.
|
|
495
|
+
if (applied !== `${mode}|${characters}`) {
|
|
496
|
+
await worker.setParameters({
|
|
497
|
+
tessedit_pageseg_mode: String(mode),
|
|
498
|
+
tessedit_char_whitelist: characters
|
|
499
|
+
});
|
|
500
|
+
applied = `${mode}|${characters}`;
|
|
501
|
+
}
|
|
502
|
+
const bytes = isRaster(image) ? await encodeImage(image, {
|
|
503
|
+
format: 'png'
|
|
504
|
+
}) : image;
|
|
505
|
+
const {
|
|
506
|
+
data
|
|
507
|
+
} = await worker.recognize(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), {}, {
|
|
508
|
+
text: true,
|
|
509
|
+
blocks: true
|
|
510
|
+
});
|
|
511
|
+
const lines = [];
|
|
512
|
+
const blocks = data.blocks ?? [];
|
|
513
|
+
for (const block of blocks) for (const paragraph of block.paragraphs) for (const line of paragraph.lines) lines.push({
|
|
514
|
+
text: line.text.trim(),
|
|
515
|
+
words: line.words.map(w => ({
|
|
516
|
+
text: w.text,
|
|
517
|
+
confidence: w.confidence,
|
|
518
|
+
x: w.bbox.x0,
|
|
519
|
+
y: w.bbox.y0,
|
|
520
|
+
width: w.bbox.x1 - w.bbox.x0,
|
|
521
|
+
height: w.bbox.y1 - w.bbox.y0
|
|
522
|
+
}))
|
|
523
|
+
});
|
|
524
|
+
return {
|
|
525
|
+
text: data.text ?? '',
|
|
526
|
+
confidence: data.confidence ?? 0,
|
|
527
|
+
lines
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
async terminate() {
|
|
531
|
+
if (terminated) return;
|
|
532
|
+
terminated = true;
|
|
533
|
+
await worker.terminate();
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const require$1 = createRequire(import.meta.url);
|
|
538
|
+
/**
|
|
539
|
+
* The folder tesseract.js will read every language from. One language: its
|
|
540
|
+
* package's own folder, read in place. Several: their files copied - once, and
|
|
541
|
+
* again only if a size differs - into one folder under `cachePath`.
|
|
542
|
+
*/
|
|
543
|
+
async function languageFolder(languages, model, cachePath) {
|
|
544
|
+
const sources = languages.map(code => ({
|
|
545
|
+
code,
|
|
546
|
+
folder: join(packageFolder(code), MODEL_FOLDER[model])
|
|
547
|
+
}));
|
|
548
|
+
if (sources.length === 1) return sources[0].folder;
|
|
549
|
+
const staged = join(cachePath, `tessdata-${MODEL_FOLDER[model]}`);
|
|
550
|
+
await mkdir(staged, {
|
|
551
|
+
recursive: true
|
|
552
|
+
});
|
|
553
|
+
for (const {
|
|
554
|
+
code,
|
|
555
|
+
folder
|
|
556
|
+
} of sources) {
|
|
557
|
+
const file = `${code}.traineddata.gz`;
|
|
558
|
+
const [from, to] = await Promise.all([sizeOf(join(folder, file)), sizeOf(join(staged, file))]);
|
|
559
|
+
if (to !== from) await copyFile(join(folder, file), join(staged, file));
|
|
560
|
+
}
|
|
561
|
+
return staged;
|
|
562
|
+
}
|
|
563
|
+
/** Bytes in a file, or `null` when there is no file. */
|
|
564
|
+
async function sizeOf(path) {
|
|
565
|
+
try {
|
|
566
|
+
const {
|
|
567
|
+
size
|
|
568
|
+
} = await stat(path);
|
|
569
|
+
return size;
|
|
570
|
+
} catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function packageFolder(code) {
|
|
575
|
+
try {
|
|
576
|
+
return dirname(require$1.resolve(`@tesseract.js-data/${code}/package.json`));
|
|
577
|
+
} catch {
|
|
578
|
+
throw new Error(`no data for OCR language '${code}': install it with npm install @tesseract.js-data/${code}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function tesseractVersion() {
|
|
582
|
+
try {
|
|
583
|
+
return require$1('tesseract.js/package.json').version;
|
|
584
|
+
} catch {
|
|
585
|
+
return 'unknown';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const DEFAULT_RECHECK_PASSES = [{
|
|
590
|
+
dpi: 300,
|
|
591
|
+
layout: 'line'
|
|
592
|
+
}, {
|
|
593
|
+
dpi: 400,
|
|
594
|
+
layout: 'line'
|
|
595
|
+
}, {
|
|
596
|
+
dpi: 400,
|
|
597
|
+
layout: 'line',
|
|
598
|
+
stretch: true
|
|
599
|
+
}, {
|
|
600
|
+
dpi: 500,
|
|
601
|
+
layout: 'line'
|
|
602
|
+
}, {
|
|
603
|
+
dpi: 400,
|
|
604
|
+
layout: 'word'
|
|
605
|
+
}, {
|
|
606
|
+
dpi: 600,
|
|
607
|
+
layout: 'line',
|
|
608
|
+
stretch: true
|
|
609
|
+
}];
|
|
610
|
+
/** What a figure may be read as, when a run is only a figure. */
|
|
611
|
+
const FIGURE_CHARACTERS = '0123456789.,:/%()+-';
|
|
612
|
+
/** White kept around a crop, in pixels: tesseract reads poorly off the edge of an image. */
|
|
613
|
+
const MARGIN = 12;
|
|
614
|
+
async function recheckRun(engine, image, run, options) {
|
|
615
|
+
const {
|
|
616
|
+
passes = DEFAULT_RECHECK_PASSES,
|
|
617
|
+
agree = 2
|
|
618
|
+
} = options;
|
|
619
|
+
const crop = cropRun(image.raster, image.dpi, run);
|
|
620
|
+
if (crop === null) return {
|
|
621
|
+
cleared: false,
|
|
622
|
+
reading: null,
|
|
623
|
+
passes: 0,
|
|
624
|
+
agreed: 0
|
|
625
|
+
};
|
|
626
|
+
const characters = FIGURE.test(run.text.trim()) ? FIGURE_CHARACTERS : undefined;
|
|
627
|
+
let agreed = 0;
|
|
628
|
+
let reading = null;
|
|
629
|
+
let tried = 0;
|
|
630
|
+
for (const pass of passes) {
|
|
631
|
+
if (agreed + (passes.length - tried) < agree) break;
|
|
632
|
+
tried++;
|
|
633
|
+
const scale = Math.max(1, pass.dpi / image.dpi);
|
|
634
|
+
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);
|
|
636
|
+
const read = await engine.recognise(prepared, {
|
|
637
|
+
layout: pass.layout,
|
|
638
|
+
characters
|
|
639
|
+
});
|
|
640
|
+
const text = read.lines.map(line => line.text).join(' ').trim();
|
|
641
|
+
if (judgeRun(run.text, text, options).agrees) {
|
|
642
|
+
agreed++;
|
|
643
|
+
reading ??= text;
|
|
644
|
+
if (agreed >= agree) return {
|
|
645
|
+
cleared: true,
|
|
646
|
+
reading,
|
|
647
|
+
passes: tried,
|
|
648
|
+
agreed
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
cleared: false,
|
|
654
|
+
reading: null,
|
|
655
|
+
passes: tried,
|
|
656
|
+
agreed
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* 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.
|
|
662
|
+
*/
|
|
663
|
+
function cropRun(raster, dpi, run) {
|
|
664
|
+
const s = dpi / 72;
|
|
665
|
+
const pad = Math.max(2, run.height * 0.25);
|
|
666
|
+
const left = Math.max(0, Math.floor((run.x - pad) * s));
|
|
667
|
+
const top = Math.max(0, Math.floor((run.y - pad) * s));
|
|
668
|
+
const right = Math.min(raster.width, Math.ceil((run.x + run.width + pad) * s));
|
|
669
|
+
const bottom = Math.min(raster.height, Math.ceil((run.y + run.height + pad) * s));
|
|
670
|
+
if (right - left < 2 || bottom - top < 2) return null;
|
|
671
|
+
const out = createRaster(right - left, bottom - top);
|
|
672
|
+
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
|
+
let sum = 0;
|
|
674
|
+
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];
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
/** Linear stretch so the darkest 2% become black and the lightest 2% white. */
|
|
679
|
+
function stretch(raster) {
|
|
680
|
+
const values = new Uint8Array(raster.width * raster.height);
|
|
681
|
+
for (let p = 0; p < values.length; p++) values[p] = luminance(raster.data, p * 4);
|
|
682
|
+
const sorted = values.toSorted();
|
|
683
|
+
const low = sorted[Math.floor(sorted.length * 0.02)];
|
|
684
|
+
const high = sorted[Math.floor(sorted.length * 0.98)];
|
|
685
|
+
if (high <= low) return raster;
|
|
686
|
+
const out = createRaster(raster.width, raster.height);
|
|
687
|
+
for (const [p, value] of values.entries()) {
|
|
688
|
+
const v = Math.round((value - low) / (high - low) * 255);
|
|
689
|
+
out.data.fill(Math.min(255, Math.max(0, v)), p * 4, p * 4 + 3);
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
/** The crop on a white border. */
|
|
694
|
+
function frame(raster) {
|
|
695
|
+
const out = createRaster(raster.width + 2 * MARGIN, raster.height + 2 * MARGIN);
|
|
696
|
+
for (let y = 0; y < raster.height; y++) out.data.set(raster.data.subarray(y * raster.width * 4, (y + 1) * raster.width * 4), ((y + MARGIN) * out.width + MARGIN) * 4);
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
function luminance(data, i) {
|
|
700
|
+
return 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Read every aligned page and say how closely the scan's text matches the
|
|
705
|
+
* original's - per page, and for the document.
|
|
706
|
+
*
|
|
707
|
+
* The scan is read from its enhanced image when there is one, otherwise from
|
|
708
|
+
* the aligned scan enlarged to `targetDpi`. The original comes from its text
|
|
709
|
+
* layer when it has one, which is exact, so every error counted is the scan's;
|
|
710
|
+
* otherwise it is read the same way. Both are then compared run by run of the
|
|
711
|
+
* original, on the shared canvas, rather than in whatever order the engine
|
|
712
|
+
* read them.
|
|
713
|
+
*
|
|
714
|
+
* One engine serves the whole call. Pass `engine` to share one across calls;
|
|
715
|
+
* it is then left running.
|
|
716
|
+
*/
|
|
717
|
+
async function ocrPages(pages, options = {}) {
|
|
718
|
+
const engine = options.engine ?? (await createTesseractEngine(options.tesseract));
|
|
719
|
+
try {
|
|
720
|
+
const results = [];
|
|
721
|
+
for (const [position, page] of pages.entries()) {
|
|
722
|
+
const index = position + 1;
|
|
723
|
+
const started = Date.now();
|
|
724
|
+
options.onProgress?.({
|
|
725
|
+
stage: 'ocr',
|
|
726
|
+
phase: 'start',
|
|
727
|
+
page: page.page,
|
|
728
|
+
index,
|
|
729
|
+
total: pages.length
|
|
730
|
+
});
|
|
731
|
+
const result = await readPage(page, engine, options);
|
|
732
|
+
results.push(result);
|
|
733
|
+
options.onProgress?.({
|
|
734
|
+
stage: 'ocr',
|
|
735
|
+
phase: 'done',
|
|
736
|
+
page: page.page,
|
|
737
|
+
index,
|
|
738
|
+
total: pages.length,
|
|
739
|
+
durationMs: Date.now() - started,
|
|
740
|
+
detail: {
|
|
741
|
+
score: result.score,
|
|
742
|
+
original: result.original.source,
|
|
743
|
+
confidence: result.scanned.confidence,
|
|
744
|
+
differences: result.differences.length,
|
|
745
|
+
rechecked: result.rechecks.attempted,
|
|
746
|
+
cleared: result.rechecks.cleared
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
const characters = results.reduce((sum, r) => sum + r.metrics.characters, 0);
|
|
751
|
+
return {
|
|
752
|
+
score: characters === 0 ? mean(results.map(r => r.score)) : results.reduce((sum, r) => sum + r.score * r.metrics.characters, 0) / characters,
|
|
753
|
+
pageMean: mean(results.map(r => r.score)),
|
|
754
|
+
pages: results,
|
|
755
|
+
engine: {
|
|
756
|
+
name: engine.name,
|
|
757
|
+
version: engine.version,
|
|
758
|
+
languages: engine.languages
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
} finally {
|
|
762
|
+
if (options.engine === undefined) await engine.terminate();
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async function readPage(page, engine, options) {
|
|
766
|
+
const {
|
|
767
|
+
original: originalSource = 'auto',
|
|
768
|
+
targetDpi = 300,
|
|
769
|
+
assumeDpi = 150,
|
|
770
|
+
normalise = DEFAULT_NORMALISE,
|
|
771
|
+
scoreMetric = 'levenshteinSimilarity',
|
|
772
|
+
matchThreshold = 0.8,
|
|
773
|
+
minWordConfidence = 60,
|
|
774
|
+
recheck = {}
|
|
775
|
+
} = options;
|
|
776
|
+
const warnings = [];
|
|
777
|
+
// The scan: its enhanced image as it is, or the aligned scan made fine enough to read.
|
|
778
|
+
const scanImage = page.enhanced ? {
|
|
779
|
+
raster: page.enhanced.raster,
|
|
780
|
+
dpi: page.enhanced.dpi ?? assumeDpi
|
|
781
|
+
} : await readable(page.aligned.raster, page.original.dpi ?? assumeDpi, targetDpi);
|
|
782
|
+
const scanned = placed(await engine.recognise(scanImage.raster), scanImage.dpi);
|
|
783
|
+
if (scanned.side.text.trim() === '') warnings.push('nothing could be read on the scanned page');
|
|
784
|
+
// The original: its own text where it has some, else read like the scan.
|
|
785
|
+
const items = page.metadata?.original?.textItems ?? [];
|
|
786
|
+
const useLayer = originalSource !== 'ocr' && items.length > 0;
|
|
787
|
+
if (originalSource === 'text-layer' && items.length === 0) warnings.push('the original has no text layer, so it was read by OCR');
|
|
788
|
+
let original;
|
|
789
|
+
let references;
|
|
790
|
+
if (useLayer) {
|
|
791
|
+
references = items.map(item => ({
|
|
792
|
+
text: item.text,
|
|
793
|
+
x: item.x,
|
|
794
|
+
y: item.y,
|
|
795
|
+
width: item.width,
|
|
796
|
+
height: item.height,
|
|
797
|
+
endsLine: item.endsLine,
|
|
798
|
+
confidence: null
|
|
799
|
+
}));
|
|
800
|
+
original = {
|
|
801
|
+
source: 'text-layer',
|
|
802
|
+
text: items.map(i => i.text + (i.endsLine === true ? '\n' : ' ')).join('').trim(),
|
|
803
|
+
confidence: null,
|
|
804
|
+
lines: references,
|
|
805
|
+
words: references
|
|
806
|
+
};
|
|
807
|
+
} else {
|
|
808
|
+
const image = await readable(page.original.raster, page.original.dpi ?? assumeDpi, targetDpi);
|
|
809
|
+
original = placed(await engine.recognise(image.raster), image.dpi).side;
|
|
810
|
+
references = original.lines.map(line => ({
|
|
811
|
+
...line,
|
|
812
|
+
endsLine: true
|
|
813
|
+
}));
|
|
814
|
+
}
|
|
815
|
+
// Place the scan's words on the original's runs; give every doubted run a
|
|
816
|
+
// second look of its own before it counts as a difference.
|
|
817
|
+
const rules = {
|
|
818
|
+
normalise,
|
|
819
|
+
matchThreshold,
|
|
820
|
+
minWordConfidence
|
|
821
|
+
};
|
|
822
|
+
const claims = claimWords(references, scanned.side.words, scanned.lineOf, rules);
|
|
823
|
+
// Words over ink the original itself printed - a logo, a heading set as an
|
|
824
|
+
// image - belong to the original even though its text layer lacks them.
|
|
825
|
+
// Writing over printed matter is for @scanmate/diff to see, not this.
|
|
826
|
+
const originalDpi = page.original.dpi ?? assumeDpi;
|
|
827
|
+
claims.added = claims.added.map(group => group.filter(word => !printedUnder(page.original.raster, originalDpi, word))).filter(group => group.length > 0);
|
|
828
|
+
const rechecks = {
|
|
829
|
+
attempted: 0,
|
|
830
|
+
cleared: 0
|
|
831
|
+
};
|
|
832
|
+
const rechecked = new Set();
|
|
833
|
+
if (recheck !== false) for (const [r, run] of references.entries()) {
|
|
834
|
+
if (judgeRun(run.text, claims.found[r], rules).agrees) continue;
|
|
835
|
+
rechecks.attempted++;
|
|
836
|
+
rechecked.add(r);
|
|
837
|
+
const second = await recheckRun(engine, scanImage, run, {
|
|
838
|
+
...rules,
|
|
839
|
+
...recheck
|
|
840
|
+
});
|
|
841
|
+
if (second.reading === null) continue;
|
|
842
|
+
claims.found[r] = second.reading;
|
|
843
|
+
rechecks.cleared++;
|
|
844
|
+
}
|
|
845
|
+
const match = judgeRuns(references, claims, rules);
|
|
846
|
+
const runs = references.map((run, r) => ({
|
|
847
|
+
text: run.text,
|
|
848
|
+
found: claims.found[r],
|
|
849
|
+
agrees: judgeRun(run.text, claims.found[r], rules).agrees,
|
|
850
|
+
rechecked: rechecked.has(r),
|
|
851
|
+
x: run.x,
|
|
852
|
+
y: run.y,
|
|
853
|
+
width: run.width,
|
|
854
|
+
height: run.height
|
|
855
|
+
}));
|
|
856
|
+
const metrics = compareTexts(match.expectedText, match.alignedText, normalise);
|
|
857
|
+
if (scanned.side.confidence !== null && scanned.side.confidence < 60) warnings.push(`low OCR confidence on the scanned page (${Math.round(scanned.side.confidence)})`);
|
|
858
|
+
return {
|
|
859
|
+
page: page.page,
|
|
860
|
+
original,
|
|
861
|
+
scanned: scanned.side,
|
|
862
|
+
runs,
|
|
863
|
+
alignedText: match.alignedText,
|
|
864
|
+
score: metrics[scoreMetric],
|
|
865
|
+
metrics,
|
|
866
|
+
differences: match.differences,
|
|
867
|
+
rechecks,
|
|
868
|
+
warnings
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* At least this share of a word's box dark in the original means the original
|
|
873
|
+
* printed something there. Printed words - a logo's letters - cover a third of
|
|
874
|
+
* their box or more; a form's rule or a checkbox edge crossing a handwritten
|
|
875
|
+
* word covers a tenth. Only the first explains the word away.
|
|
876
|
+
*/
|
|
877
|
+
const PRINTED_SHARE = 0.2;
|
|
878
|
+
/** The original has ink under the word: something is printed there, text layer or not. */
|
|
879
|
+
function printedUnder(raster, dpi, word) {
|
|
880
|
+
const s = dpi / 72;
|
|
881
|
+
const left = Math.max(0, Math.floor(word.x * s));
|
|
882
|
+
const top = Math.max(0, Math.floor(word.y * s));
|
|
883
|
+
const right = Math.min(raster.width, Math.ceil((word.x + word.width) * s));
|
|
884
|
+
const bottom = Math.min(raster.height, Math.ceil((word.y + word.height) * s));
|
|
885
|
+
if (right <= left || bottom <= top) return false;
|
|
886
|
+
let dark = 0;
|
|
887
|
+
for (let y = top; y < bottom; y++) for (let x = left, i = (y * raster.width + left) * 4; x < right; x++, i += 4) if (0.299 * raster.data[i] + 0.587 * raster.data[i + 1] + 0.114 * raster.data[i + 2] < 128) dark++;
|
|
888
|
+
return dark / ((right - left) * (bottom - top)) >= PRINTED_SHARE;
|
|
889
|
+
}
|
|
890
|
+
/** An image at `targetDpi` or finer: enlarged when below it, kept as it is otherwise. */
|
|
891
|
+
async function readable(raster, dpi, targetDpi) {
|
|
892
|
+
if (targetDpi === null || dpi >= targetDpi) return {
|
|
893
|
+
raster,
|
|
894
|
+
dpi
|
|
895
|
+
};
|
|
896
|
+
const scale = targetDpi / dpi;
|
|
897
|
+
return {
|
|
898
|
+
raster: await resampleRaster(raster, Math.round(raster.width * scale), Math.round(raster.height * scale)),
|
|
899
|
+
dpi: targetDpi
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
/** An engine's reading, moved from the image's pixels to points on the page. */
|
|
903
|
+
function placed(read, dpi) {
|
|
904
|
+
const toPoints = 72 / dpi;
|
|
905
|
+
const words = [];
|
|
906
|
+
const lineOf = [];
|
|
907
|
+
const lines = [];
|
|
908
|
+
for (const [l, line] of read.lines.entries()) {
|
|
909
|
+
if (line.words.length === 0) continue;
|
|
910
|
+
const inPoints = line.words.map(w => ({
|
|
911
|
+
text: w.text,
|
|
912
|
+
confidence: w.confidence,
|
|
913
|
+
x: w.x * toPoints,
|
|
914
|
+
y: w.y * toPoints,
|
|
915
|
+
width: w.width * toPoints,
|
|
916
|
+
height: w.height * toPoints
|
|
917
|
+
}));
|
|
918
|
+
words.push(...inPoints);
|
|
919
|
+
lineOf.push(...inPoints.map(() => l));
|
|
920
|
+
const left = Math.min(...inPoints.map(w => w.x));
|
|
921
|
+
const top = Math.min(...inPoints.map(w => w.y));
|
|
922
|
+
lines.push({
|
|
923
|
+
text: line.text,
|
|
924
|
+
confidence: mean(inPoints.map(w => w.confidence ?? 0)),
|
|
925
|
+
x: left,
|
|
926
|
+
y: top,
|
|
927
|
+
width: Math.max(...inPoints.map(w => w.x + w.width)) - left,
|
|
928
|
+
height: Math.max(...inPoints.map(w => w.y + w.height)) - top
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
side: {
|
|
933
|
+
source: 'ocr',
|
|
934
|
+
text: read.text.trim(),
|
|
935
|
+
confidence: read.confidence,
|
|
936
|
+
lines,
|
|
937
|
+
words
|
|
938
|
+
},
|
|
939
|
+
lineOf
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
function mean(values) {
|
|
943
|
+
return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length;
|
|
944
|
+
}
|
|
945
|
+
|
|
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 };
|
|
947
|
+
//# sourceMappingURL=index.esm.js.map
|