gestament 0.1.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 +122 -0
- package/dist/element.d.ts +15 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/generated/packageMetadata.d.ts +18 -0
- package/dist/generated/packageMetadata.d.ts.map +1 -0
- package/dist/gestament-config.cjs +87 -0
- package/dist/gestament-config.cjs.map +1 -0
- package/dist/gestament-config.d.ts +18 -0
- package/dist/gestament-config.d.ts.map +1 -0
- package/dist/gestament-config.mjs +86 -0
- package/dist/gestament-config.mjs.map +1 -0
- package/dist/gestament-tray-host.cjs +12 -0
- package/dist/gestament-tray-host.cjs.map +1 -0
- package/dist/gestament-tray-host.d.ts +13 -0
- package/dist/gestament-tray-host.d.ts.map +1 -0
- package/dist/gestament-tray-host.mjs +11 -0
- package/dist/gestament-tray-host.mjs.map +1 -0
- package/dist/gestament-xvfb-worker.cjs +138 -0
- package/dist/gestament-xvfb-worker.cjs.map +1 -0
- package/dist/gestament-xvfb-worker.d.ts +13 -0
- package/dist/gestament-xvfb-worker.d.ts.map +1 -0
- package/dist/gestament-xvfb-worker.mjs +137 -0
- package/dist/gestament-xvfb-worker.mjs.map +1 -0
- package/dist/gestament-xvfb.cjs +132 -0
- package/dist/gestament-xvfb.cjs.map +1 -0
- package/dist/gestament-xvfb.d.ts +13 -0
- package/dist/gestament-xvfb.d.ts.map +1 -0
- package/dist/gestament-xvfb.mjs +131 -0
- package/dist/gestament-xvfb.mjs.map +1 -0
- package/dist/index.cjs +1077 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1077 -0
- package/dist/index.mjs.map +1 -0
- package/dist/launchGtkApp.d.ts +37 -0
- package/dist/launchGtkApp.d.ts.map +1 -0
- package/dist/native-BRnrsqMn.cjs +249 -0
- package/dist/native-BRnrsqMn.cjs.map +1 -0
- package/dist/native-DAhTiLnf.js +249 -0
- package/dist/native-DAhTiLnf.js.map +1 -0
- package/dist/native.d.ts +170 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/testing.cjs +1180 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.ts +329 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.mjs +1158 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/tray.d.ts +17 -0
- package/dist/tray.d.ts.map +1 -0
- package/dist/types.d.ts +920 -0
- package/dist/types.d.ts.map +1 -0
- package/images/gestament-120.png +0 -0
- package/include/gestament/gtk.h +112 -0
- package/package.json +92 -0
- package/prebuilds/linux-arm/gtk3/node.napi.armv7.glibc.node +0 -0
- package/prebuilds/linux-arm/gtk4/node.napi.armv7.glibc.node +0 -0
- package/prebuilds/linux-arm64/gtk3/node.napi.glibc.node +0 -0
- package/prebuilds/linux-arm64/gtk4/node.napi.glibc.node +0 -0
- package/prebuilds/linux-ia32/gtk3/node.napi.glibc.node +0 -0
- package/prebuilds/linux-ia32/gtk4/node.napi.glibc.node +0 -0
- package/prebuilds/linux-riscv64/gtk3/node.napi.glibc.node +0 -0
- package/prebuilds/linux-riscv64/gtk4/node.napi.glibc.node +0 -0
- package/prebuilds/linux-x64/gtk3/node.napi.glibc.node +0 -0
- package/prebuilds/linux-x64/gtk4/node.napi.glibc.node +0 -0
package/dist/testing.mjs
ADDED
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { PNG } from "pngjs";
|
|
6
|
+
import { ssim } from "ssim.js";
|
|
7
|
+
import __screwUpDefaultImportModule1 from "@tesseract.js-data/eng";
|
|
8
|
+
function pixelmatch$1(img1, img2, output, width, height, options = {}) {
|
|
9
|
+
const {
|
|
10
|
+
threshold = 0.1,
|
|
11
|
+
alpha = 0.1,
|
|
12
|
+
aaColor = [255, 255, 0],
|
|
13
|
+
diffColor = [255, 0, 0],
|
|
14
|
+
checkerboard = true,
|
|
15
|
+
includeAA,
|
|
16
|
+
diffColorAlt,
|
|
17
|
+
diffMask
|
|
18
|
+
} = options;
|
|
19
|
+
if (!isPixelData(img1) || !isPixelData(img2) || output && !isPixelData(output))
|
|
20
|
+
throw new Error("Image data: Uint8Array, Uint8ClampedArray or Buffer expected.");
|
|
21
|
+
if (img1.length !== img2.length || output && output.length !== img1.length)
|
|
22
|
+
throw new Error(`Image sizes do not match. Image 1 size: ${img1.length}, image 2 size: ${img2.length}`);
|
|
23
|
+
if (img1.length !== width * height * 4) throw new Error(`Image data size does not match width/height. Expecting ${width * height * 4}. Got ${img1.length}`);
|
|
24
|
+
const len = width * height;
|
|
25
|
+
const a32 = new Uint32Array(img1.buffer, img1.byteOffset, len);
|
|
26
|
+
const b32 = new Uint32Array(img2.buffer, img2.byteOffset, len);
|
|
27
|
+
let identical = true;
|
|
28
|
+
for (let i = 0; i < len; i++) {
|
|
29
|
+
if (a32[i] !== b32[i]) {
|
|
30
|
+
identical = false;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (identical) {
|
|
35
|
+
if (output && !diffMask) {
|
|
36
|
+
for (let i = 0, pos = 0; i < len; i++, pos += 4) drawGrayPixel(img1, pos, alpha, output);
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
const maxDelta = 35215 * threshold * threshold;
|
|
41
|
+
const [aaR, aaG, aaB] = aaColor;
|
|
42
|
+
const [diffR, diffG, diffB] = diffColor;
|
|
43
|
+
const [altR, altG, altB] = diffColorAlt || diffColor;
|
|
44
|
+
let diff = 0;
|
|
45
|
+
for (let i = 0, pos = 0; i < len; i++, pos += 4) {
|
|
46
|
+
const delta = a32[i] === b32[i] ? 0 : colorDelta(img1, img2, pos, pos, checkerboard);
|
|
47
|
+
if (Math.abs(delta) > maxDelta) {
|
|
48
|
+
const x = i % width;
|
|
49
|
+
const y = i / width | 0;
|
|
50
|
+
const isExcludedAA = !includeAA && (antialiased(img1, x, y, width, height, a32, b32, checkerboard) || antialiased(img2, x, y, width, height, b32, a32, checkerboard));
|
|
51
|
+
if (isExcludedAA) {
|
|
52
|
+
if (output && !diffMask) drawPixel(output, pos, aaR, aaG, aaB);
|
|
53
|
+
} else {
|
|
54
|
+
if (output) {
|
|
55
|
+
if (delta < 0) {
|
|
56
|
+
drawPixel(output, pos, altR, altG, altB);
|
|
57
|
+
} else {
|
|
58
|
+
drawPixel(output, pos, diffR, diffG, diffB);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
diff++;
|
|
62
|
+
}
|
|
63
|
+
} else if (output && !diffMask) {
|
|
64
|
+
drawGrayPixel(img1, pos, alpha, output);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return diff;
|
|
68
|
+
}
|
|
69
|
+
function isPixelData(arr) {
|
|
70
|
+
return ArrayBuffer.isView(arr) && arr.BYTES_PER_ELEMENT === 1;
|
|
71
|
+
}
|
|
72
|
+
function antialiased(img, x1, y1, width, height, a32, b32, checkerboard) {
|
|
73
|
+
const x0 = Math.max(x1 - 1, 0);
|
|
74
|
+
const y0 = Math.max(y1 - 1, 0);
|
|
75
|
+
const x2 = Math.min(x1 + 1, width - 1);
|
|
76
|
+
const y2 = Math.min(y1 + 1, height - 1);
|
|
77
|
+
const pos4 = (y1 * width + x1) * 4;
|
|
78
|
+
const cr = img[pos4];
|
|
79
|
+
const cg = img[pos4 + 1];
|
|
80
|
+
const cb = img[pos4 + 2];
|
|
81
|
+
const ca = img[pos4 + 3];
|
|
82
|
+
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
|
83
|
+
let min = 0;
|
|
84
|
+
let max = 0;
|
|
85
|
+
let minX = 0;
|
|
86
|
+
let minY = 0;
|
|
87
|
+
let maxX = 0;
|
|
88
|
+
let maxY = 0;
|
|
89
|
+
for (let x = x0; x <= x2; x++) {
|
|
90
|
+
for (let y = y0; y <= y2; y++) {
|
|
91
|
+
if (x === x1 && y === y1) continue;
|
|
92
|
+
const delta = brightnessDelta(img, pos4, (y * width + x) * 4, cr, cg, cb, ca, checkerboard);
|
|
93
|
+
if (delta === 0) {
|
|
94
|
+
zeroes++;
|
|
95
|
+
if (zeroes > 2) return false;
|
|
96
|
+
} else if (delta < min) {
|
|
97
|
+
min = delta;
|
|
98
|
+
minX = x;
|
|
99
|
+
minY = y;
|
|
100
|
+
} else if (delta > max) {
|
|
101
|
+
max = delta;
|
|
102
|
+
maxX = x;
|
|
103
|
+
maxY = y;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (min === 0 || max === 0) return false;
|
|
108
|
+
return hasManySiblings(a32, minX, minY, width, height) && hasManySiblings(b32, minX, minY, width, height) || hasManySiblings(a32, maxX, maxY, width, height) && hasManySiblings(b32, maxX, maxY, width, height);
|
|
109
|
+
}
|
|
110
|
+
function hasManySiblings(img, x1, y1, width, height) {
|
|
111
|
+
const x0 = Math.max(x1 - 1, 0);
|
|
112
|
+
const y0 = Math.max(y1 - 1, 0);
|
|
113
|
+
const x2 = Math.min(x1 + 1, width - 1);
|
|
114
|
+
const y2 = Math.min(y1 + 1, height - 1);
|
|
115
|
+
const val = img[y1 * width + x1];
|
|
116
|
+
let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0;
|
|
117
|
+
for (let x = x0; x <= x2; x++) {
|
|
118
|
+
for (let y = y0; y <= y2; y++) {
|
|
119
|
+
if (x === x1 && y === y1) continue;
|
|
120
|
+
zeroes += +(val === img[y * width + x]);
|
|
121
|
+
if (zeroes > 2) return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
function colorDelta(img1, img2, k, m, checkerboard) {
|
|
127
|
+
const r1 = img1[k];
|
|
128
|
+
const g1 = img1[k + 1];
|
|
129
|
+
const b1 = img1[k + 2];
|
|
130
|
+
const a1 = img1[k + 3];
|
|
131
|
+
const r2 = img2[m];
|
|
132
|
+
const g2 = img2[m + 1];
|
|
133
|
+
const b2 = img2[m + 2];
|
|
134
|
+
const a2 = img2[m + 3];
|
|
135
|
+
let dr = r1 - r2;
|
|
136
|
+
let dg = g1 - g2;
|
|
137
|
+
let db = b1 - b2;
|
|
138
|
+
const da = a1 - a2;
|
|
139
|
+
if (a1 < 255 || a2 < 255) {
|
|
140
|
+
let rb = 255, gb = 255, bb = 255;
|
|
141
|
+
if (checkerboard) {
|
|
142
|
+
rb = 48 + 159 * (k % 2);
|
|
143
|
+
gb = 48 + 159 * ((k / 1.618033988749895 | 0) % 2);
|
|
144
|
+
bb = 48 + 159 * ((k / 2.618033988749895 | 0) % 2);
|
|
145
|
+
}
|
|
146
|
+
dr = (r1 * a1 - r2 * a2 - rb * da) / 255;
|
|
147
|
+
dg = (g1 * a1 - g2 * a2 - gb * da) / 255;
|
|
148
|
+
db = (b1 * a1 - b2 * a2 - bb * da) / 255;
|
|
149
|
+
}
|
|
150
|
+
const y = dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223;
|
|
151
|
+
const i = dr * 0.59597799 - dg * 0.2741761 - db * 0.32180189;
|
|
152
|
+
const q = dr * 0.21147017 - dg * 0.52261711 + db * 0.31114694;
|
|
153
|
+
const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
|
|
154
|
+
return y > 0 ? -delta : delta;
|
|
155
|
+
}
|
|
156
|
+
function brightnessDelta(img, k, m, r1, g1, b1, a1, checkerboard) {
|
|
157
|
+
const r2 = img[m];
|
|
158
|
+
const g2 = img[m + 1];
|
|
159
|
+
const b2 = img[m + 2];
|
|
160
|
+
const a2 = img[m + 3];
|
|
161
|
+
let dr = r1 - r2;
|
|
162
|
+
let dg = g1 - g2;
|
|
163
|
+
let db = b1 - b2;
|
|
164
|
+
const da = a1 - a2;
|
|
165
|
+
if (!dr && !dg && !db && !da) return 0;
|
|
166
|
+
if (a1 < 255 || a2 < 255) {
|
|
167
|
+
let rb = 255, gb = 255, bb = 255;
|
|
168
|
+
if (checkerboard) {
|
|
169
|
+
rb = 48 + 159 * (k % 2);
|
|
170
|
+
gb = 48 + 159 * ((k / 1.618033988749895 | 0) % 2);
|
|
171
|
+
bb = 48 + 159 * ((k / 2.618033988749895 | 0) % 2);
|
|
172
|
+
}
|
|
173
|
+
dr = (r1 * a1 - r2 * a2 - rb * da) / 255;
|
|
174
|
+
dg = (g1 * a1 - g2 * a2 - gb * da) / 255;
|
|
175
|
+
db = (b1 * a1 - b2 * a2 - bb * da) / 255;
|
|
176
|
+
}
|
|
177
|
+
return dr * 0.29889531 + dg * 0.58662247 + db * 0.11448223;
|
|
178
|
+
}
|
|
179
|
+
function drawPixel(output, pos, r, g, b) {
|
|
180
|
+
output[pos] = r;
|
|
181
|
+
output[pos + 1] = g;
|
|
182
|
+
output[pos + 2] = b;
|
|
183
|
+
output[pos + 3] = 255;
|
|
184
|
+
}
|
|
185
|
+
function drawGrayPixel(img, i, alpha, output) {
|
|
186
|
+
const val = 255 + (img[i] * 0.29889531 + img[i + 1] * 0.58662247 + img[i + 2] * 0.11448223 - 255) * alpha * img[i + 3] / 255;
|
|
187
|
+
drawPixel(output, i, val, val, val);
|
|
188
|
+
}
|
|
189
|
+
const __screwUpDefaultImportModule0 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
190
|
+
__proto__: null,
|
|
191
|
+
default: pixelmatch$1
|
|
192
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
193
|
+
const pixelmatch = __resolveDefaultExport(__screwUpDefaultImportModule0, true);
|
|
194
|
+
const englishLanguageData = __resolveDefaultExport(__screwUpDefaultImportModule1, false);
|
|
195
|
+
globalThis.__screwUpIsInCJS_249951a7145e = false;
|
|
196
|
+
function __resolveDefaultExport(module, isESM) {
|
|
197
|
+
const __isInCJS = typeof globalThis !== "undefined" && globalThis.__screwUpIsInCJS_249951a7145e === true;
|
|
198
|
+
const maybe = module;
|
|
199
|
+
const hasDefault = !!(maybe && typeof maybe === "object" && "default" in maybe);
|
|
200
|
+
const unwrapNamespaceDefault = (value) => {
|
|
201
|
+
if (!value || typeof value !== "object") {
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
const inner = value;
|
|
205
|
+
const isModule = inner.__esModule === true || typeof Symbol !== "undefined" && inner[Symbol.toStringTag] === "Module";
|
|
206
|
+
if (isModule && "default" in inner) {
|
|
207
|
+
return inner.default;
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
};
|
|
211
|
+
const resolvedDefault = hasDefault ? unwrapNamespaceDefault(maybe.default) : void 0;
|
|
212
|
+
if (__isInCJS) {
|
|
213
|
+
return hasDefault ? resolvedDefault ?? module : module;
|
|
214
|
+
}
|
|
215
|
+
if (isESM) {
|
|
216
|
+
if (hasDefault) {
|
|
217
|
+
return resolvedDefault;
|
|
218
|
+
}
|
|
219
|
+
throw new Error("Default export not found.");
|
|
220
|
+
}
|
|
221
|
+
return hasDefault ? resolvedDefault ?? module : module;
|
|
222
|
+
}
|
|
223
|
+
const padNumber = (value, width) => value.toString().padStart(width, "0");
|
|
224
|
+
const hashText = (value) => createHash("sha256").update(value).digest("hex").slice(0, 10);
|
|
225
|
+
const defaultOcrPageSegmentationModes = [
|
|
226
|
+
"singleBlock",
|
|
227
|
+
"sparseText",
|
|
228
|
+
"singleLine",
|
|
229
|
+
"singleWord"
|
|
230
|
+
];
|
|
231
|
+
const pageSegmentationModeValues = {
|
|
232
|
+
auto: "3",
|
|
233
|
+
autoOnly: "2",
|
|
234
|
+
autoOsd: "1",
|
|
235
|
+
circleWord: "9",
|
|
236
|
+
osdOnly: "0",
|
|
237
|
+
rawLine: "13",
|
|
238
|
+
singleBlock: "6",
|
|
239
|
+
singleBlockVerticalText: "5",
|
|
240
|
+
singleChar: "10",
|
|
241
|
+
singleColumn: "4",
|
|
242
|
+
singleLine: "7",
|
|
243
|
+
singleWord: "8",
|
|
244
|
+
sparseText: "11",
|
|
245
|
+
sparseTextOsd: "12"
|
|
246
|
+
};
|
|
247
|
+
let tesseractModulePromise;
|
|
248
|
+
const loadTesseractModule = async () => {
|
|
249
|
+
tesseractModulePromise ??= import("tesseract.js").then((loaded) => {
|
|
250
|
+
const module = loaded;
|
|
251
|
+
return "default" in module ? module.default : module;
|
|
252
|
+
});
|
|
253
|
+
return tesseractModulePromise;
|
|
254
|
+
};
|
|
255
|
+
const normalizePathSegment = (value, fallback) => {
|
|
256
|
+
const trimmed = value.trim();
|
|
257
|
+
const normalized = trimmed.replace(/[\x00-\x1f<>:"/\\|?*]+/g, "_").replace(/\s+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
|
|
258
|
+
if (normalized.length === 0) {
|
|
259
|
+
return fallback;
|
|
260
|
+
}
|
|
261
|
+
if (normalized.length <= 140) {
|
|
262
|
+
return normalized;
|
|
263
|
+
}
|
|
264
|
+
return `${normalized.slice(0, 129)}-${hashText(normalized)}`;
|
|
265
|
+
};
|
|
266
|
+
const serializeBounds = (bounds) => ({
|
|
267
|
+
height: bounds.height,
|
|
268
|
+
width: bounds.width,
|
|
269
|
+
x: bounds.x,
|
|
270
|
+
y: bounds.y
|
|
271
|
+
});
|
|
272
|
+
const resolveDefaults = (defaults, options) => {
|
|
273
|
+
const outputResultPath = options?.outputResultPath ?? defaults.outputResultPath ?? process.env.GESTAMENT_VISUAL_OUTPUT_RESULT_PATH;
|
|
274
|
+
if (outputResultPath === void 0) {
|
|
275
|
+
return void 0;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
outputResultPath: resolve(outputResultPath),
|
|
279
|
+
variant: options?.variant ?? defaults.variant ?? process.env.GESTAMENT_VISUAL_VARIANT ?? process.env.GESTAMENT_TEST_BACKEND ?? "default"
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const loadExpectedImage = async (expectedImage) => {
|
|
283
|
+
if (Buffer.isBuffer(expectedImage)) {
|
|
284
|
+
return {
|
|
285
|
+
data: expectedImage,
|
|
286
|
+
source: "buffer",
|
|
287
|
+
sourcePath: void 0
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (typeof expectedImage === "string") {
|
|
291
|
+
const sourcePath2 = resolve(expectedImage);
|
|
292
|
+
return {
|
|
293
|
+
data: await readFile(sourcePath2),
|
|
294
|
+
source: "path",
|
|
295
|
+
sourcePath: sourcePath2
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (expectedImage.protocol !== "file:") {
|
|
299
|
+
throw new TypeError("expectedImage URL must use the file: protocol.");
|
|
300
|
+
}
|
|
301
|
+
const sourcePath = fileURLToPath(expectedImage);
|
|
302
|
+
return {
|
|
303
|
+
data: await readFile(sourcePath),
|
|
304
|
+
source: "file-url",
|
|
305
|
+
sourcePath
|
|
306
|
+
};
|
|
307
|
+
};
|
|
308
|
+
const decodePng = (image) => {
|
|
309
|
+
const png = PNG.sync.read(image);
|
|
310
|
+
return {
|
|
311
|
+
data: png.data,
|
|
312
|
+
height: png.height,
|
|
313
|
+
width: png.width
|
|
314
|
+
};
|
|
315
|
+
};
|
|
316
|
+
const validateCaptureImage = (capture, png) => {
|
|
317
|
+
if (png.width !== capture.visibleBounds.width || png.height !== capture.visibleBounds.height) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Capture PNG size ${png.width}x${png.height} does not match visible bounds ${capture.visibleBounds.width}x${capture.visibleBounds.height}.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const validateFiniteInteger = (value, label) => {
|
|
324
|
+
if (!Number.isInteger(value)) {
|
|
325
|
+
throw new TypeError(`${label} must be an integer.`);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const validateRegion = (region, imageWidth, imageHeight, label) => {
|
|
329
|
+
validateFiniteInteger(region.x, `${label}.x`);
|
|
330
|
+
validateFiniteInteger(region.y, `${label}.y`);
|
|
331
|
+
validateFiniteInteger(region.width, `${label}.width`);
|
|
332
|
+
validateFiniteInteger(region.height, `${label}.height`);
|
|
333
|
+
if (region.x < 0 || region.y < 0) {
|
|
334
|
+
throw new TypeError(`${label} origin must be inside the capture image.`);
|
|
335
|
+
}
|
|
336
|
+
if (region.width <= 0 || region.height <= 0) {
|
|
337
|
+
throw new TypeError(`${label} size must be positive.`);
|
|
338
|
+
}
|
|
339
|
+
if (region.x + region.width > imageWidth || region.y + region.height > imageHeight) {
|
|
340
|
+
throw new TypeError(`${label} must be inside the capture image.`);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
const validateRatio = (value, label) => {
|
|
344
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
|
345
|
+
throw new TypeError(`${label} must be a number from 0 to 1.`);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const validateNonNegativeInteger = (value, label) => {
|
|
349
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
350
|
+
throw new TypeError(`${label} must be a non-negative integer.`);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
const getComparisonRegion = (options, imageWidth, imageHeight) => {
|
|
354
|
+
const region = options?.region ?? { height: imageHeight, width: imageWidth, x: 0, y: 0 };
|
|
355
|
+
validateRegion(region, imageWidth, imageHeight, "region");
|
|
356
|
+
return region;
|
|
357
|
+
};
|
|
358
|
+
const validateMasks = (masks, imageWidth, imageHeight) => {
|
|
359
|
+
if (masks === void 0) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
for (const [index, mask] of masks.entries()) {
|
|
363
|
+
validateRegion(mask, imageWidth, imageHeight, `masks[${index}]`);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const copyRegionData = (source, region) => {
|
|
367
|
+
const data = new Uint8Array(region.width * region.height * 4);
|
|
368
|
+
for (let y = 0; y < region.height; y += 1) {
|
|
369
|
+
const sourceStart = ((region.y + y) * source.width + region.x) * 4;
|
|
370
|
+
const sourceEnd = sourceStart + region.width * 4;
|
|
371
|
+
const targetStart = y * region.width * 4;
|
|
372
|
+
data.set(source.data.subarray(sourceStart, sourceEnd), targetStart);
|
|
373
|
+
}
|
|
374
|
+
return data;
|
|
375
|
+
};
|
|
376
|
+
const applyMasks = (actualData, expectedData, region, masks) => {
|
|
377
|
+
if (masks === void 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
for (const mask of masks) {
|
|
381
|
+
const left = Math.max(region.x, mask.x);
|
|
382
|
+
const top = Math.max(region.y, mask.y);
|
|
383
|
+
const right = Math.min(region.x + region.width, mask.x + mask.width);
|
|
384
|
+
const bottom = Math.min(region.y + region.height, mask.y + mask.height);
|
|
385
|
+
if (left >= right || top >= bottom) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
for (let y = top; y < bottom; y += 1) {
|
|
389
|
+
for (let x = left; x < right; x += 1) {
|
|
390
|
+
const index = ((y - region.y) * region.width + (x - region.x)) * 4;
|
|
391
|
+
actualData[index] = 0;
|
|
392
|
+
actualData[index + 1] = 0;
|
|
393
|
+
actualData[index + 2] = 0;
|
|
394
|
+
actualData[index + 3] = 0;
|
|
395
|
+
expectedData[index] = 0;
|
|
396
|
+
expectedData[index + 1] = 0;
|
|
397
|
+
expectedData[index + 2] = 0;
|
|
398
|
+
expectedData[index + 3] = 0;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
const prepareComparison = (actualPng, expectedPng, options) => {
|
|
404
|
+
if (actualPng.width !== expectedPng.width || actualPng.height !== expectedPng.height) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Expected image size ${expectedPng.width}x${expectedPng.height} does not match actual capture size ${actualPng.width}x${actualPng.height}.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const region = getComparisonRegion(
|
|
410
|
+
options,
|
|
411
|
+
actualPng.width,
|
|
412
|
+
actualPng.height
|
|
413
|
+
);
|
|
414
|
+
validateMasks(options?.masks, actualPng.width, actualPng.height);
|
|
415
|
+
const actualData = copyRegionData(actualPng, region);
|
|
416
|
+
const expectedData = copyRegionData(expectedPng, region);
|
|
417
|
+
applyMasks(actualData, expectedData, region, options?.masks);
|
|
418
|
+
return {
|
|
419
|
+
actualData,
|
|
420
|
+
expectedData,
|
|
421
|
+
height: region.height,
|
|
422
|
+
totalPixels: region.width * region.height,
|
|
423
|
+
width: region.width
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
const createDiffPng = (comparison, threshold) => {
|
|
427
|
+
const diffPng = new PNG({
|
|
428
|
+
height: comparison.height,
|
|
429
|
+
width: comparison.width
|
|
430
|
+
});
|
|
431
|
+
const diffPixels = pixelmatch(
|
|
432
|
+
comparison.expectedData,
|
|
433
|
+
comparison.actualData,
|
|
434
|
+
diffPng.data,
|
|
435
|
+
comparison.width,
|
|
436
|
+
comparison.height,
|
|
437
|
+
{ threshold }
|
|
438
|
+
);
|
|
439
|
+
return {
|
|
440
|
+
diffPixels,
|
|
441
|
+
diffPng
|
|
442
|
+
};
|
|
443
|
+
};
|
|
444
|
+
const createContext = async (defaults, options, name, counter) => {
|
|
445
|
+
const resolved = resolveDefaults(defaults, options);
|
|
446
|
+
if (resolved === void 0) {
|
|
447
|
+
return {
|
|
448
|
+
artifactsEnabled: false
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const safeVariant = normalizePathSegment(resolved.variant, "default");
|
|
452
|
+
const safeName = normalizePathSegment(name, "capture");
|
|
453
|
+
const outputResultPath = join(
|
|
454
|
+
resolved.outputResultPath,
|
|
455
|
+
safeVariant,
|
|
456
|
+
`${safeName}-${padNumber(counter, 6)}`
|
|
457
|
+
);
|
|
458
|
+
await mkdir(outputResultPath, { recursive: true });
|
|
459
|
+
return {
|
|
460
|
+
actualImagePath: join(outputResultPath, "actual.png"),
|
|
461
|
+
outputResultPath,
|
|
462
|
+
artifactsEnabled: true,
|
|
463
|
+
diffImagePath: join(outputResultPath, "diff.png"),
|
|
464
|
+
expectedImagePath: join(outputResultPath, "expected.png"),
|
|
465
|
+
metadataJsonPath: join(outputResultPath, "metadata.json"),
|
|
466
|
+
ocrInputImagePath: join(outputResultPath, "ocr-input.png"),
|
|
467
|
+
resolved
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
const writeMetadata = async (context, capture, name, matcher, options, expectedImage) => {
|
|
471
|
+
await writeFile(
|
|
472
|
+
context.metadataJsonPath,
|
|
473
|
+
`${JSON.stringify(
|
|
474
|
+
{
|
|
475
|
+
bounds: serializeBounds(capture.bounds),
|
|
476
|
+
clipped: capture.clipped,
|
|
477
|
+
expectedImageSource: expectedImage.source,
|
|
478
|
+
expectedImageSourcePath: expectedImage.sourcePath,
|
|
479
|
+
imageBytes: capture.image.length,
|
|
480
|
+
matcher,
|
|
481
|
+
masks: options?.masks,
|
|
482
|
+
name,
|
|
483
|
+
region: options?.region,
|
|
484
|
+
variant: context.resolved.variant,
|
|
485
|
+
visibleBounds: serializeBounds(capture.visibleBounds)
|
|
486
|
+
},
|
|
487
|
+
void 0,
|
|
488
|
+
2
|
|
489
|
+
)}
|
|
490
|
+
`
|
|
491
|
+
);
|
|
492
|
+
};
|
|
493
|
+
const createVisualError = (message, result) => Object.assign(new Error(message), {
|
|
494
|
+
result
|
|
495
|
+
});
|
|
496
|
+
const saveActualAndMetadata = async (context, capture, name, matcher, options, expectedImage) => {
|
|
497
|
+
if (!context.artifactsEnabled) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
await writeFile(context.actualImagePath, capture.image);
|
|
501
|
+
await writeMetadata(context, capture, name, matcher, options, expectedImage);
|
|
502
|
+
};
|
|
503
|
+
const buildBaseResult = (context) => {
|
|
504
|
+
if (!context.artifactsEnabled) {
|
|
505
|
+
return {
|
|
506
|
+
pass: true
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
actualImagePath: context.actualImagePath,
|
|
511
|
+
outputResultPath: context.outputResultPath,
|
|
512
|
+
metadataJsonPath: context.metadataJsonPath,
|
|
513
|
+
pass: true
|
|
514
|
+
};
|
|
515
|
+
};
|
|
516
|
+
const writeFailureArtifacts = async (context, expectedImage, diffPng) => {
|
|
517
|
+
if (!context.artifactsEnabled) {
|
|
518
|
+
return {};
|
|
519
|
+
}
|
|
520
|
+
await writeFile(context.expectedImagePath, expectedImage);
|
|
521
|
+
await writeFile(context.diffImagePath, PNG.sync.write(diffPng));
|
|
522
|
+
return {
|
|
523
|
+
diffImagePath: context.diffImagePath,
|
|
524
|
+
expectedImagePath: context.expectedImagePath
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
const isLookSimilarPass = (diffPixels, diffRatio, options) => {
|
|
528
|
+
const ratioPass = diffRatio <= options.maxDiffRatio;
|
|
529
|
+
const pixelsPass = options.maxDiffPixels === void 0 || diffPixels <= options.maxDiffPixels;
|
|
530
|
+
return ratioPass && pixelsPass;
|
|
531
|
+
};
|
|
532
|
+
const resolveLookSimilarOptions = (options) => {
|
|
533
|
+
const resolved = {
|
|
534
|
+
maxDiffPixels: options?.maxDiffPixels,
|
|
535
|
+
maxDiffRatio: options?.maxDiffRatio ?? 0.01,
|
|
536
|
+
threshold: options?.threshold ?? 0.1
|
|
537
|
+
};
|
|
538
|
+
validateRatio(resolved.threshold, "threshold");
|
|
539
|
+
validateRatio(resolved.maxDiffRatio, "maxDiffRatio");
|
|
540
|
+
if (resolved.maxDiffPixels !== void 0) {
|
|
541
|
+
validateNonNegativeInteger(resolved.maxDiffPixels, "maxDiffPixels");
|
|
542
|
+
}
|
|
543
|
+
return resolved;
|
|
544
|
+
};
|
|
545
|
+
const resolveSimilarityOptions = (options) => {
|
|
546
|
+
const resolved = {
|
|
547
|
+
minSimilarity: options?.minSimilarity ?? 0.985
|
|
548
|
+
};
|
|
549
|
+
validateRatio(resolved.minSimilarity, "minSimilarity");
|
|
550
|
+
return resolved;
|
|
551
|
+
};
|
|
552
|
+
const validateOcrPageSegmentationMode = (value, label) => {
|
|
553
|
+
if (!(value in pageSegmentationModeValues)) {
|
|
554
|
+
throw new TypeError(
|
|
555
|
+
`${label} must be a supported OCR page segmentation mode.`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const validateThreshold = (value, label) => {
|
|
560
|
+
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
|
561
|
+
throw new TypeError(`${label} must be an integer from 0 to 255.`);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const validatePositiveInteger = (value, label) => {
|
|
565
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
566
|
+
throw new TypeError(`${label} must be a positive integer.`);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const normalizeWhitespace = (value) => value.replace(/\s+/g, " ").trim();
|
|
570
|
+
const resolveOcrPreprocessOptions = (options) => {
|
|
571
|
+
const resolved = {
|
|
572
|
+
grayscale: options?.grayscale ?? false,
|
|
573
|
+
invert: options?.invert ?? false,
|
|
574
|
+
scale: options?.scale ?? 1,
|
|
575
|
+
threshold: options?.threshold ?? -1
|
|
576
|
+
};
|
|
577
|
+
validatePositiveInteger(resolved.scale, "preprocess.scale");
|
|
578
|
+
if (resolved.threshold !== -1) {
|
|
579
|
+
validateThreshold(resolved.threshold, "preprocess.threshold");
|
|
580
|
+
}
|
|
581
|
+
return resolved;
|
|
582
|
+
};
|
|
583
|
+
const resolveOcrOptions = (options, imageWidth, imageHeight) => {
|
|
584
|
+
const pageSegmentationModes = options?.pageSegmentationModes ?? defaultOcrPageSegmentationModes;
|
|
585
|
+
if (pageSegmentationModes.length === 0) {
|
|
586
|
+
throw new TypeError("pageSegmentationModes must not be empty.");
|
|
587
|
+
}
|
|
588
|
+
for (const [index, mode] of pageSegmentationModes.entries()) {
|
|
589
|
+
validateOcrPageSegmentationMode(mode, `pageSegmentationModes[${index}]`);
|
|
590
|
+
}
|
|
591
|
+
const region = options?.region ?? { height: imageHeight, width: imageWidth, x: 0, y: 0 };
|
|
592
|
+
validateRegion(region, imageWidth, imageHeight, "region");
|
|
593
|
+
return {
|
|
594
|
+
pageSegmentationModes,
|
|
595
|
+
parameters: options?.parameters ?? {},
|
|
596
|
+
preprocess: resolveOcrPreprocessOptions(options?.preprocess),
|
|
597
|
+
region
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
const hasOcrPreprocessing = (options, region, imageWidth, imageHeight) => options.grayscale || options.invert || options.scale !== 1 || options.threshold !== -1 || region.x !== 0 || region.y !== 0 || region.width !== imageWidth || region.height !== imageHeight;
|
|
601
|
+
const getLuminance = (red, green, blue) => Math.round(red * 0.2126 + green * 0.7152 + blue * 0.0722);
|
|
602
|
+
const prepareOcrImage = (originalImage, png, options) => {
|
|
603
|
+
if (!hasOcrPreprocessing(
|
|
604
|
+
options.preprocess,
|
|
605
|
+
options.region,
|
|
606
|
+
png.width,
|
|
607
|
+
png.height
|
|
608
|
+
)) {
|
|
609
|
+
return {
|
|
610
|
+
image: originalImage,
|
|
611
|
+
preprocess: options.preprocess,
|
|
612
|
+
region: options.region
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const output = new PNG({
|
|
616
|
+
height: options.region.height * options.preprocess.scale,
|
|
617
|
+
width: options.region.width * options.preprocess.scale
|
|
618
|
+
});
|
|
619
|
+
for (let y = 0; y < output.height; y += 1) {
|
|
620
|
+
for (let x = 0; x < output.width; x += 1) {
|
|
621
|
+
const sourceX = options.region.x + Math.floor(x / options.preprocess.scale);
|
|
622
|
+
const sourceY = options.region.y + Math.floor(y / options.preprocess.scale);
|
|
623
|
+
const sourceIndex = (sourceY * png.width + sourceX) * 4;
|
|
624
|
+
const targetIndex = (y * output.width + x) * 4;
|
|
625
|
+
const red = png.data[sourceIndex];
|
|
626
|
+
const green = png.data[sourceIndex + 1];
|
|
627
|
+
const blue = png.data[sourceIndex + 2];
|
|
628
|
+
const alpha = png.data[sourceIndex + 3];
|
|
629
|
+
const luminance = getLuminance(red, green, blue);
|
|
630
|
+
const thresholded = options.preprocess.threshold === -1 ? luminance : luminance >= options.preprocess.threshold ? 255 : 0;
|
|
631
|
+
const value = options.preprocess.grayscale || options.preprocess.threshold !== -1 ? thresholded : void 0;
|
|
632
|
+
output.data[targetIndex] = options.preprocess.invert ? 255 - (value ?? red) : value ?? red;
|
|
633
|
+
output.data[targetIndex + 1] = options.preprocess.invert ? 255 - (value ?? green) : value ?? green;
|
|
634
|
+
output.data[targetIndex + 2] = options.preprocess.invert ? 255 - (value ?? blue) : value ?? blue;
|
|
635
|
+
output.data[targetIndex + 3] = alpha;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
image: PNG.sync.write(output),
|
|
640
|
+
preprocess: options.preprocess,
|
|
641
|
+
region: options.region
|
|
642
|
+
};
|
|
643
|
+
};
|
|
644
|
+
const isEnglishOnlyLanguage = (languages) => {
|
|
645
|
+
if (languages === void 0) {
|
|
646
|
+
return true;
|
|
647
|
+
}
|
|
648
|
+
if (typeof languages === "string") {
|
|
649
|
+
return languages === "eng";
|
|
650
|
+
}
|
|
651
|
+
return languages.length === 1 && languages[0] === "eng";
|
|
652
|
+
};
|
|
653
|
+
const resolveOcrWorkerOptions = (defaults) => {
|
|
654
|
+
const languages = defaults?.languages === void 0 ? englishLanguageData.code : typeof defaults.languages === "string" ? defaults.languages : [...defaults.languages];
|
|
655
|
+
const useBundledEnglishData = defaults?.langPath === void 0 && isEnglishOnlyLanguage(languages);
|
|
656
|
+
const workerOptions = {
|
|
657
|
+
logger: () => void 0
|
|
658
|
+
};
|
|
659
|
+
if (defaults?.cachePath !== void 0) {
|
|
660
|
+
workerOptions.cachePath = defaults.cachePath;
|
|
661
|
+
}
|
|
662
|
+
if (defaults?.corePath !== void 0) {
|
|
663
|
+
workerOptions.corePath = defaults.corePath;
|
|
664
|
+
}
|
|
665
|
+
if (defaults?.workerPath !== void 0) {
|
|
666
|
+
workerOptions.workerPath = defaults.workerPath;
|
|
667
|
+
}
|
|
668
|
+
if (defaults?.langPath !== void 0) {
|
|
669
|
+
workerOptions.langPath = defaults.langPath;
|
|
670
|
+
} else if (useBundledEnglishData) {
|
|
671
|
+
workerOptions.langPath = englishLanguageData.langPath;
|
|
672
|
+
}
|
|
673
|
+
if (defaults?.gzip !== void 0) {
|
|
674
|
+
workerOptions.gzip = defaults.gzip;
|
|
675
|
+
} else if (useBundledEnglishData) {
|
|
676
|
+
workerOptions.gzip = englishLanguageData.gzip;
|
|
677
|
+
}
|
|
678
|
+
if (defaults?.cacheMethod !== void 0) {
|
|
679
|
+
workerOptions.cacheMethod = defaults.cacheMethod;
|
|
680
|
+
} else if (useBundledEnglishData) {
|
|
681
|
+
workerOptions.cacheMethod = "none";
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
languages,
|
|
685
|
+
options: workerOptions
|
|
686
|
+
};
|
|
687
|
+
};
|
|
688
|
+
const createOcrWorker = async (defaults) => {
|
|
689
|
+
const tesseract = await loadTesseractModule();
|
|
690
|
+
const resolved = resolveOcrWorkerOptions(defaults);
|
|
691
|
+
return await tesseract.createWorker(
|
|
692
|
+
resolved.languages,
|
|
693
|
+
void 0,
|
|
694
|
+
resolved.options
|
|
695
|
+
);
|
|
696
|
+
};
|
|
697
|
+
const recognizeWithWorker = async (worker, preparedImage, options) => {
|
|
698
|
+
const attempts = [];
|
|
699
|
+
for (const pageSegmentationMode of options.pageSegmentationModes) {
|
|
700
|
+
await worker.setParameters({
|
|
701
|
+
...options.parameters,
|
|
702
|
+
tessedit_pageseg_mode: pageSegmentationModeValues[pageSegmentationMode]
|
|
703
|
+
});
|
|
704
|
+
const recognized = await worker.recognize(preparedImage.image);
|
|
705
|
+
attempts.push({
|
|
706
|
+
confidence: Number.isFinite(recognized.data.confidence) ? recognized.data.confidence : 0,
|
|
707
|
+
normalizedText: normalizeWhitespace(recognized.data.text),
|
|
708
|
+
pageSegmentationMode,
|
|
709
|
+
text: recognized.data.text
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return attempts;
|
|
713
|
+
};
|
|
714
|
+
const createOcrWorkerController = (defaults) => {
|
|
715
|
+
if (defaults?.workerMode !== "shared") {
|
|
716
|
+
return {
|
|
717
|
+
recognize: async (preparedImage, options) => {
|
|
718
|
+
const worker = await createOcrWorker(defaults);
|
|
719
|
+
try {
|
|
720
|
+
return await recognizeWithWorker(worker, preparedImage, options);
|
|
721
|
+
} finally {
|
|
722
|
+
await worker.terminate();
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
release: async () => void 0
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
let workerPromise;
|
|
729
|
+
let queue = Promise.resolve();
|
|
730
|
+
const getWorker = () => {
|
|
731
|
+
workerPromise ??= createOcrWorker(defaults);
|
|
732
|
+
return workerPromise;
|
|
733
|
+
};
|
|
734
|
+
const enqueue = async (operation) => {
|
|
735
|
+
const previous = queue;
|
|
736
|
+
let resolveNext = () => void 0;
|
|
737
|
+
queue = new Promise((resolve2) => {
|
|
738
|
+
resolveNext = resolve2;
|
|
739
|
+
});
|
|
740
|
+
await previous;
|
|
741
|
+
try {
|
|
742
|
+
return await operation();
|
|
743
|
+
} finally {
|
|
744
|
+
resolveNext();
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
return {
|
|
748
|
+
recognize: async (preparedImage, options) => await enqueue(
|
|
749
|
+
async () => recognizeWithWorker(await getWorker(), preparedImage, options)
|
|
750
|
+
),
|
|
751
|
+
release: async () => {
|
|
752
|
+
await queue;
|
|
753
|
+
if (workerPromise === void 0) {
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const worker = await workerPromise;
|
|
757
|
+
workerPromise = void 0;
|
|
758
|
+
await worker.terminate();
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
const validateConfidence = (value, label) => {
|
|
763
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
|
764
|
+
throw new TypeError(`${label} must be a number from 0 to 100.`);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
const formatExpectedText = (expected) => typeof expected === "string" ? expected : expected.toString();
|
|
768
|
+
const serializeExpectedText = (expected) => {
|
|
769
|
+
if (expected === void 0) {
|
|
770
|
+
return void 0;
|
|
771
|
+
}
|
|
772
|
+
if (typeof expected === "string") {
|
|
773
|
+
return {
|
|
774
|
+
type: "string",
|
|
775
|
+
value: expected
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
flags: expected.flags,
|
|
780
|
+
source: expected.source,
|
|
781
|
+
type: "regexp"
|
|
782
|
+
};
|
|
783
|
+
};
|
|
784
|
+
const selectBestOcrAttempt = (attempts) => {
|
|
785
|
+
const [firstAttempt] = attempts;
|
|
786
|
+
if (firstAttempt === void 0) {
|
|
787
|
+
throw new Error("OCR did not return any recognition attempts.");
|
|
788
|
+
}
|
|
789
|
+
return attempts.reduce(
|
|
790
|
+
(best, current) => current.confidence > best.confidence ? current : best
|
|
791
|
+
);
|
|
792
|
+
};
|
|
793
|
+
const createOcrTextData = (context, attempts) => {
|
|
794
|
+
const bestAttempt = selectBestOcrAttempt(attempts);
|
|
795
|
+
if (!context.artifactsEnabled) {
|
|
796
|
+
return {
|
|
797
|
+
actualImagePath: void 0,
|
|
798
|
+
attempts,
|
|
799
|
+
confidence: bestAttempt.confidence,
|
|
800
|
+
metadataJsonPath: void 0,
|
|
801
|
+
normalizedText: bestAttempt.normalizedText,
|
|
802
|
+
ocrInputImagePath: void 0,
|
|
803
|
+
outputResultPath: void 0,
|
|
804
|
+
pageSegmentationMode: bestAttempt.pageSegmentationMode,
|
|
805
|
+
text: bestAttempt.text
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
actualImagePath: context.actualImagePath,
|
|
810
|
+
attempts,
|
|
811
|
+
confidence: bestAttempt.confidence,
|
|
812
|
+
metadataJsonPath: context.metadataJsonPath,
|
|
813
|
+
normalizedText: bestAttempt.normalizedText,
|
|
814
|
+
ocrInputImagePath: context.ocrInputImagePath,
|
|
815
|
+
outputResultPath: context.outputResultPath,
|
|
816
|
+
pageSegmentationMode: bestAttempt.pageSegmentationMode,
|
|
817
|
+
text: bestAttempt.text
|
|
818
|
+
};
|
|
819
|
+
};
|
|
820
|
+
const writeOcrMetadata = async (context, capture, name, matcher, options, preparedImage, attempts, expected) => {
|
|
821
|
+
await writeFile(
|
|
822
|
+
context.metadataJsonPath,
|
|
823
|
+
`${JSON.stringify(
|
|
824
|
+
{
|
|
825
|
+
attempts,
|
|
826
|
+
bounds: serializeBounds(capture.bounds),
|
|
827
|
+
clipped: capture.clipped,
|
|
828
|
+
expectedText: serializeExpectedText(expected),
|
|
829
|
+
imageBytes: capture.image.length,
|
|
830
|
+
matcher,
|
|
831
|
+
name,
|
|
832
|
+
ocrInputBytes: preparedImage.image.length,
|
|
833
|
+
pageSegmentationModes: options?.pageSegmentationModes,
|
|
834
|
+
parameters: options?.parameters,
|
|
835
|
+
preprocess: preparedImage.preprocess,
|
|
836
|
+
region: preparedImage.region,
|
|
837
|
+
variant: context.resolved.variant,
|
|
838
|
+
visibleBounds: serializeBounds(capture.visibleBounds)
|
|
839
|
+
},
|
|
840
|
+
void 0,
|
|
841
|
+
2
|
|
842
|
+
)}
|
|
843
|
+
`
|
|
844
|
+
);
|
|
845
|
+
};
|
|
846
|
+
const saveOcrArtifactsAndMetadata = async (context, capture, name, matcher, options, preparedImage, attempts, expected) => {
|
|
847
|
+
if (!context.artifactsEnabled) {
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
await writeFile(context.actualImagePath, capture.image);
|
|
851
|
+
await writeFile(context.ocrInputImagePath, preparedImage.image);
|
|
852
|
+
await writeOcrMetadata(
|
|
853
|
+
context,
|
|
854
|
+
capture,
|
|
855
|
+
name,
|
|
856
|
+
matcher,
|
|
857
|
+
options,
|
|
858
|
+
preparedImage,
|
|
859
|
+
attempts,
|
|
860
|
+
expected
|
|
861
|
+
);
|
|
862
|
+
};
|
|
863
|
+
const matchOcrAttempt = (attempt, expected, options) => {
|
|
864
|
+
const normalize = options?.normalizeWhitespace ?? true;
|
|
865
|
+
const candidate = normalize ? attempt.normalizedText : attempt.text;
|
|
866
|
+
const minConfidence = options?.minConfidence;
|
|
867
|
+
if (minConfidence !== void 0) {
|
|
868
|
+
validateConfidence(minConfidence, "minConfidence");
|
|
869
|
+
if (attempt.confidence < minConfidence) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (typeof expected === "string") {
|
|
874
|
+
if (expected.length === 0) {
|
|
875
|
+
throw new TypeError("expected text must not be empty.");
|
|
876
|
+
}
|
|
877
|
+
const expectedText = normalize ? normalizeWhitespace(expected) : expected;
|
|
878
|
+
if (expectedText.length === 0) {
|
|
879
|
+
throw new TypeError(
|
|
880
|
+
"expected text must not be empty after normalization."
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
if (options?.caseSensitive ?? false) {
|
|
884
|
+
return candidate.includes(expectedText);
|
|
885
|
+
}
|
|
886
|
+
return candidate.toLocaleLowerCase().includes(expectedText.toLocaleLowerCase());
|
|
887
|
+
}
|
|
888
|
+
expected.lastIndex = 0;
|
|
889
|
+
const matched = expected.test(candidate);
|
|
890
|
+
expected.lastIndex = 0;
|
|
891
|
+
return matched;
|
|
892
|
+
};
|
|
893
|
+
const createOcrResult = (data, expected, pass, selectedAttempt) => {
|
|
894
|
+
const artifactPaths = data.outputResultPath === void 0 ? {} : {
|
|
895
|
+
actualImagePath: data.actualImagePath,
|
|
896
|
+
metadataJsonPath: data.metadataJsonPath,
|
|
897
|
+
ocrInputImagePath: data.ocrInputImagePath,
|
|
898
|
+
outputResultPath: data.outputResultPath
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
...artifactPaths,
|
|
902
|
+
attempts: data.attempts,
|
|
903
|
+
confidence: selectedAttempt.confidence,
|
|
904
|
+
expectedText: formatExpectedText(expected),
|
|
905
|
+
normalizedText: selectedAttempt.normalizedText,
|
|
906
|
+
pageSegmentationMode: selectedAttempt.pageSegmentationMode,
|
|
907
|
+
pass,
|
|
908
|
+
text: selectedAttempt.text
|
|
909
|
+
};
|
|
910
|
+
};
|
|
911
|
+
const assertOcrText = async (data, expected, options) => {
|
|
912
|
+
const matchedAttempt = data.attempts.find(
|
|
913
|
+
(attempt) => matchOcrAttempt(attempt, expected, options)
|
|
914
|
+
);
|
|
915
|
+
if (matchedAttempt !== void 0) {
|
|
916
|
+
return createOcrResult(data, expected, true, matchedAttempt);
|
|
917
|
+
}
|
|
918
|
+
const bestAttempt = selectBestOcrAttempt(data.attempts);
|
|
919
|
+
const failedResult = createOcrResult(data, expected, false, bestAttempt);
|
|
920
|
+
throw createVisualError(
|
|
921
|
+
`Capture OCR text did not contain ${formatExpectedText(
|
|
922
|
+
expected
|
|
923
|
+
)}. Recognized text: ${JSON.stringify(bestAttempt.normalizedText)}.`,
|
|
924
|
+
failedResult
|
|
925
|
+
);
|
|
926
|
+
};
|
|
927
|
+
const createOcrText = (data) => {
|
|
928
|
+
const artifactPaths = data.outputResultPath === void 0 ? {} : {
|
|
929
|
+
actualImagePath: data.actualImagePath,
|
|
930
|
+
metadataJsonPath: data.metadataJsonPath,
|
|
931
|
+
ocrInputImagePath: data.ocrInputImagePath,
|
|
932
|
+
outputResultPath: data.outputResultPath
|
|
933
|
+
};
|
|
934
|
+
return {
|
|
935
|
+
...artifactPaths,
|
|
936
|
+
attempts: data.attempts,
|
|
937
|
+
confidence: data.confidence,
|
|
938
|
+
normalizedText: data.normalizedText,
|
|
939
|
+
pageSegmentationMode: data.pageSegmentationMode,
|
|
940
|
+
text: data.text,
|
|
941
|
+
toContainText: async (expected, options) => await assertOcrText(data, expected, options)
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
const readCaptureText = async (defaults, nextCounter, workerController, capture, name, matcher, options, expected) => {
|
|
945
|
+
const context = await createContext(defaults, options, name, nextCounter());
|
|
946
|
+
const actualPng = decodePng(capture.image);
|
|
947
|
+
validateCaptureImage(capture, actualPng);
|
|
948
|
+
const ocrOptions = resolveOcrOptions(
|
|
949
|
+
options,
|
|
950
|
+
actualPng.width,
|
|
951
|
+
actualPng.height
|
|
952
|
+
);
|
|
953
|
+
const preparedImage = prepareOcrImage(capture.image, actualPng, ocrOptions);
|
|
954
|
+
const attempts = await workerController.recognize(preparedImage, ocrOptions);
|
|
955
|
+
await saveOcrArtifactsAndMetadata(
|
|
956
|
+
context,
|
|
957
|
+
capture,
|
|
958
|
+
name,
|
|
959
|
+
matcher,
|
|
960
|
+
options,
|
|
961
|
+
preparedImage,
|
|
962
|
+
attempts,
|
|
963
|
+
expected
|
|
964
|
+
);
|
|
965
|
+
return createOcrText(createOcrTextData(context, attempts));
|
|
966
|
+
};
|
|
967
|
+
const createLookSimilarAssertion = (defaults, nextCounter, capture, name) => {
|
|
968
|
+
const toLookSimilar = async (expectedImage, options) => {
|
|
969
|
+
const context = await createContext(defaults, options, name, nextCounter());
|
|
970
|
+
const actualPng = decodePng(capture.image);
|
|
971
|
+
validateCaptureImage(capture, actualPng);
|
|
972
|
+
const loadedExpectedImage = await loadExpectedImage(expectedImage);
|
|
973
|
+
const expectedPng = decodePng(loadedExpectedImage.data);
|
|
974
|
+
await saveActualAndMetadata(
|
|
975
|
+
context,
|
|
976
|
+
capture,
|
|
977
|
+
name,
|
|
978
|
+
"toLookSimilar",
|
|
979
|
+
options,
|
|
980
|
+
loadedExpectedImage
|
|
981
|
+
);
|
|
982
|
+
const comparisonOptions = resolveLookSimilarOptions(options);
|
|
983
|
+
const comparison = prepareComparison(actualPng, expectedPng, options);
|
|
984
|
+
const { diffPixels, diffPng } = createDiffPng(
|
|
985
|
+
comparison,
|
|
986
|
+
comparisonOptions.threshold
|
|
987
|
+
);
|
|
988
|
+
const diffRatio = diffPixels / comparison.totalPixels;
|
|
989
|
+
const result = {
|
|
990
|
+
...buildBaseResult(context),
|
|
991
|
+
diffPixels,
|
|
992
|
+
diffRatio,
|
|
993
|
+
totalPixels: comparison.totalPixels
|
|
994
|
+
};
|
|
995
|
+
if (isLookSimilarPass(diffPixels, diffRatio, comparisonOptions)) {
|
|
996
|
+
return result;
|
|
997
|
+
}
|
|
998
|
+
const failureArtifacts = await writeFailureArtifacts(
|
|
999
|
+
context,
|
|
1000
|
+
loadedExpectedImage.data,
|
|
1001
|
+
diffPng
|
|
1002
|
+
);
|
|
1003
|
+
const failedResult = {
|
|
1004
|
+
...result,
|
|
1005
|
+
...failureArtifacts,
|
|
1006
|
+
pass: false
|
|
1007
|
+
};
|
|
1008
|
+
throw createVisualError(
|
|
1009
|
+
`Capture image differs: ${diffPixels} pixels (${diffRatio.toFixed(
|
|
1010
|
+
6
|
|
1011
|
+
)}) exceeded maxDiffRatio ${comparisonOptions.maxDiffRatio}${comparisonOptions.maxDiffPixels === void 0 ? "" : ` and maxDiffPixels ${comparisonOptions.maxDiffPixels}`}.`,
|
|
1012
|
+
failedResult
|
|
1013
|
+
);
|
|
1014
|
+
};
|
|
1015
|
+
return toLookSimilar;
|
|
1016
|
+
};
|
|
1017
|
+
const createSimilarityAssertion = (defaults, nextCounter, capture, name) => {
|
|
1018
|
+
const toHaveSimilarity = async (expectedImage, options) => {
|
|
1019
|
+
const context = await createContext(defaults, options, name, nextCounter());
|
|
1020
|
+
const actualPng = decodePng(capture.image);
|
|
1021
|
+
validateCaptureImage(capture, actualPng);
|
|
1022
|
+
const loadedExpectedImage = await loadExpectedImage(expectedImage);
|
|
1023
|
+
const expectedPng = decodePng(loadedExpectedImage.data);
|
|
1024
|
+
await saveActualAndMetadata(
|
|
1025
|
+
context,
|
|
1026
|
+
capture,
|
|
1027
|
+
name,
|
|
1028
|
+
"toHaveSimilarity",
|
|
1029
|
+
options,
|
|
1030
|
+
loadedExpectedImage
|
|
1031
|
+
);
|
|
1032
|
+
const comparisonOptions = resolveSimilarityOptions(options);
|
|
1033
|
+
const comparison = prepareComparison(actualPng, expectedPng, options);
|
|
1034
|
+
const similarity = ssim(
|
|
1035
|
+
{
|
|
1036
|
+
data: new Uint8ClampedArray(comparison.expectedData),
|
|
1037
|
+
height: comparison.height,
|
|
1038
|
+
width: comparison.width
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
data: new Uint8ClampedArray(comparison.actualData),
|
|
1042
|
+
height: comparison.height,
|
|
1043
|
+
width: comparison.width
|
|
1044
|
+
}
|
|
1045
|
+
).mssim;
|
|
1046
|
+
const { diffPixels, diffPng } = createDiffPng(comparison, 0.1);
|
|
1047
|
+
const diffRatio = diffPixels / comparison.totalPixels;
|
|
1048
|
+
const result = {
|
|
1049
|
+
...buildBaseResult(context),
|
|
1050
|
+
diffPixels,
|
|
1051
|
+
diffRatio,
|
|
1052
|
+
similarity,
|
|
1053
|
+
totalPixels: comparison.totalPixels
|
|
1054
|
+
};
|
|
1055
|
+
if (similarity >= comparisonOptions.minSimilarity) {
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
1058
|
+
const failureArtifacts = await writeFailureArtifacts(
|
|
1059
|
+
context,
|
|
1060
|
+
loadedExpectedImage.data,
|
|
1061
|
+
diffPng
|
|
1062
|
+
);
|
|
1063
|
+
const failedResult = {
|
|
1064
|
+
...result,
|
|
1065
|
+
...failureArtifacts,
|
|
1066
|
+
pass: false
|
|
1067
|
+
};
|
|
1068
|
+
throw createVisualError(
|
|
1069
|
+
`Capture image similarity ${similarity.toFixed(
|
|
1070
|
+
6
|
|
1071
|
+
)} is below minSimilarity ${comparisonOptions.minSimilarity}.`,
|
|
1072
|
+
failedResult
|
|
1073
|
+
);
|
|
1074
|
+
};
|
|
1075
|
+
return toHaveSimilarity;
|
|
1076
|
+
};
|
|
1077
|
+
const createReadTextAssertion = (defaults, nextCounter, workerController, capture, name) => {
|
|
1078
|
+
const readText = async (options) => await readCaptureText(
|
|
1079
|
+
defaults,
|
|
1080
|
+
nextCounter,
|
|
1081
|
+
workerController,
|
|
1082
|
+
capture,
|
|
1083
|
+
name,
|
|
1084
|
+
"readText",
|
|
1085
|
+
options,
|
|
1086
|
+
void 0
|
|
1087
|
+
);
|
|
1088
|
+
return readText;
|
|
1089
|
+
};
|
|
1090
|
+
const createContainTextAssertion = (defaults, nextCounter, workerController, capture, name) => {
|
|
1091
|
+
const toContainText = async (expected, options) => {
|
|
1092
|
+
const ocrText = await readCaptureText(
|
|
1093
|
+
defaults,
|
|
1094
|
+
nextCounter,
|
|
1095
|
+
workerController,
|
|
1096
|
+
capture,
|
|
1097
|
+
name,
|
|
1098
|
+
"toContainText",
|
|
1099
|
+
options,
|
|
1100
|
+
expected
|
|
1101
|
+
);
|
|
1102
|
+
return await ocrText.toContainText(expected, options);
|
|
1103
|
+
};
|
|
1104
|
+
return toContainText;
|
|
1105
|
+
};
|
|
1106
|
+
const createGtkCaptureExpect = (defaults) => {
|
|
1107
|
+
const resolvedDefaults = defaults ?? {};
|
|
1108
|
+
const workerController = createOcrWorkerController(resolvedDefaults.ocr);
|
|
1109
|
+
let counter = 0;
|
|
1110
|
+
const nextCounter = () => {
|
|
1111
|
+
const value = counter;
|
|
1112
|
+
counter += 1;
|
|
1113
|
+
return value;
|
|
1114
|
+
};
|
|
1115
|
+
const release = async () => {
|
|
1116
|
+
await workerController.release();
|
|
1117
|
+
};
|
|
1118
|
+
const expectCapture2 = (capture, name) => ({
|
|
1119
|
+
readText: createReadTextAssertion(
|
|
1120
|
+
resolvedDefaults,
|
|
1121
|
+
nextCounter,
|
|
1122
|
+
workerController,
|
|
1123
|
+
capture,
|
|
1124
|
+
name
|
|
1125
|
+
),
|
|
1126
|
+
toContainText: createContainTextAssertion(
|
|
1127
|
+
resolvedDefaults,
|
|
1128
|
+
nextCounter,
|
|
1129
|
+
workerController,
|
|
1130
|
+
capture,
|
|
1131
|
+
name
|
|
1132
|
+
),
|
|
1133
|
+
toHaveSimilarity: createSimilarityAssertion(
|
|
1134
|
+
resolvedDefaults,
|
|
1135
|
+
nextCounter,
|
|
1136
|
+
capture,
|
|
1137
|
+
name
|
|
1138
|
+
),
|
|
1139
|
+
toLookSimilar: createLookSimilarAssertion(
|
|
1140
|
+
resolvedDefaults,
|
|
1141
|
+
nextCounter,
|
|
1142
|
+
capture,
|
|
1143
|
+
name
|
|
1144
|
+
)
|
|
1145
|
+
});
|
|
1146
|
+
return {
|
|
1147
|
+
expectCapture: expectCapture2,
|
|
1148
|
+
release,
|
|
1149
|
+
[Symbol.asyncDispose]: release
|
|
1150
|
+
};
|
|
1151
|
+
};
|
|
1152
|
+
const defaultGtkCaptureExpect = createGtkCaptureExpect();
|
|
1153
|
+
const expectCapture = (capture, name) => defaultGtkCaptureExpect.expectCapture(capture, name);
|
|
1154
|
+
export {
|
|
1155
|
+
createGtkCaptureExpect,
|
|
1156
|
+
expectCapture
|
|
1157
|
+
};
|
|
1158
|
+
//# sourceMappingURL=testing.mjs.map
|