document-ocr 0.0.0-stage → 3.1.3
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 +260 -2
- package/dist/index.cjs +659 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +293 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +616 -0
- package/dist/index.js.map +1 -0
- package/hosting/.dockerignore +17 -0
- package/hosting/.gcloudignore +14 -0
- package/hosting/FEATURES.md +172 -0
- package/hosting/HOSTING.md +268 -0
- package/hosting/LICENSE +21 -0
- package/hosting/README.md +369 -0
- package/hosting/core/__init__.py +0 -0
- package/hosting/core/aadhaar_extractor.py +298 -0
- package/hosting/core/back_page_extractor.py +492 -0
- package/hosting/core/barcodes.py +125 -0
- package/hosting/core/card_recovery.py +244 -0
- package/hosting/core/document_bundle.py +82 -0
- package/hosting/core/document_classifier.py +233 -0
- package/hosting/core/document_input.py +135 -0
- package/hosting/core/document_registry.py +143 -0
- package/hosting/core/driving_licence_extractor.py +510 -0
- package/hosting/core/ead_recovery.py +54 -0
- package/hosting/core/evidence.py +54 -0
- package/hosting/core/form_recovery.py +61 -0
- package/hosting/core/jobs.py +232 -0
- package/hosting/core/kyc_ocr.py +132 -0
- package/hosting/core/kyc_validation.py +411 -0
- package/hosting/core/model_setup.py +13 -0
- package/hosting/core/mrz_parser.py +336 -0
- package/hosting/core/mrz_recovery.py +193 -0
- package/hosting/core/npr_extractor.py +410 -0
- package/hosting/core/nrega_extractor.py +827 -0
- package/hosting/core/ocr_engine.py +207 -0
- package/hosting/core/page_classifier.py +137 -0
- package/hosting/core/pan_extractor.py +183 -0
- package/hosting/core/pipeline.py +712 -0
- package/hosting/core/preprocessor.py +297 -0
- package/hosting/core/recovery_budget.py +67 -0
- package/hosting/core/redaction.py +48 -0
- package/hosting/core/review.html +244 -0
- package/hosting/core/structured_documents.py +422 -0
- package/hosting/core/travel_mrz.py +164 -0
- package/hosting/core/travel_recovery.py +187 -0
- package/hosting/core/validator.py +396 -0
- package/hosting/core/validators.py +179 -0
- package/hosting/core/voter_id_extractor.py +363 -0
- package/hosting/deploy/cloudflare/deploy.sh +44 -0
- package/hosting/deploy/cloudflare/package-lock.json +3088 -0
- package/hosting/deploy/cloudflare/package.json +29 -0
- package/hosting/deploy/cloudflare/src/gateway.ts +42 -0
- package/hosting/deploy/cloudflare/src/index.ts +21 -0
- package/hosting/deploy/cloudflare/test/gateway.test.ts +59 -0
- package/hosting/deploy/cloudflare/tsconfig.json +12 -0
- package/hosting/deploy/cloudflare/worker-configuration.d.ts +16253 -0
- package/hosting/deploy/cloudflare/wrangler.jsonc +26 -0
- package/hosting/deploy/cloudrun/cloudbuild.yaml +18 -0
- package/hosting/deploy/cloudrun/deploy.sh +71 -0
- package/hosting/deploy/cloudrun/render_service.py +47 -0
- package/hosting/deploy/common.sh +14 -0
- package/hosting/deploy/docker/Dockerfile +16 -0
- package/hosting/deploy/docker/server.py +313 -0
- package/hosting/deploy/lambda/Dockerfile.lambda +11 -0
- package/hosting/deploy/lambda/deploy.sh +22 -0
- package/hosting/deploy/lambda/handler.py +132 -0
- package/hosting/deploy/lambda/template.yaml +59 -0
- package/hosting/deploy/local/dev.sh +18 -0
- package/hosting/deploy/server/Caddyfile +5 -0
- package/hosting/deploy/server/compose.jobs.yaml +36 -0
- package/hosting/deploy/server/compose.yaml +38 -0
- package/hosting/deploy/server/deploy.sh +23 -0
- package/hosting/infra/cloudrun/main.tf +104 -0
- package/hosting/infra/cloudrun/terraform.tfvars.example +7 -0
- package/hosting/infra/cloudrun/variables.tf +55 -0
- package/hosting/infra/lambda/main.tf +63 -0
- package/hosting/infra/lambda/terraform.tfvars.example +6 -0
- package/hosting/infra/lambda/variables.tf +37 -0
- package/hosting/pyproject.toml +65 -0
- package/hosting/requirements.lock +134 -0
- package/package.json +76 -4
- package/python/core/__init__.py +0 -0
- package/python/core/aadhaar_extractor.py +298 -0
- package/python/core/back_page_extractor.py +492 -0
- package/python/core/barcodes.py +125 -0
- package/python/core/card_recovery.py +244 -0
- package/python/core/document_bundle.py +82 -0
- package/python/core/document_classifier.py +233 -0
- package/python/core/document_input.py +135 -0
- package/python/core/document_registry.py +143 -0
- package/python/core/driving_licence_extractor.py +510 -0
- package/python/core/ead_recovery.py +54 -0
- package/python/core/evidence.py +54 -0
- package/python/core/form_recovery.py +61 -0
- package/python/core/jobs.py +232 -0
- package/python/core/kyc_ocr.py +132 -0
- package/python/core/kyc_validation.py +411 -0
- package/python/core/model_setup.py +13 -0
- package/python/core/mrz_parser.py +336 -0
- package/python/core/mrz_recovery.py +193 -0
- package/python/core/npr_extractor.py +410 -0
- package/python/core/nrega_extractor.py +827 -0
- package/python/core/ocr_engine.py +207 -0
- package/python/core/page_classifier.py +137 -0
- package/python/core/pan_extractor.py +183 -0
- package/python/core/pipeline.py +712 -0
- package/python/core/preprocessor.py +297 -0
- package/python/core/recovery_budget.py +67 -0
- package/python/core/redaction.py +48 -0
- package/python/core/review.html +244 -0
- package/python/core/structured_documents.py +422 -0
- package/python/core/travel_mrz.py +164 -0
- package/python/core/travel_recovery.py +187 -0
- package/python/core/validator.py +396 -0
- package/python/core/validators.py +179 -0
- package/python/core/voter_id_extractor.py +363 -0
- package/python/pyproject.toml +34 -0
- package/python/requirements.lock +134 -0
- package/python/server.py +313 -0
- package/scripts/dococr.mjs +163 -0
- package/scripts/postinstall.mjs +173 -0
- package/scripts/sync-hosting.mjs +35 -0
- package/scripts/sync-python.sh +20 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DocumentOCR: () => DocumentOCR,
|
|
34
|
+
HttpError: () => HttpError,
|
|
35
|
+
InputValidationError: () => InputValidationError,
|
|
36
|
+
LocalServer: () => LocalServer,
|
|
37
|
+
PassportOCR: () => PassportOCR,
|
|
38
|
+
getLocalServer: () => getLocalServer
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(index_exports);
|
|
41
|
+
|
|
42
|
+
// src/errors.ts
|
|
43
|
+
var HttpError = class extends Error {
|
|
44
|
+
constructor(status, message) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.status = status;
|
|
47
|
+
this.name = "HttpError";
|
|
48
|
+
}
|
|
49
|
+
status;
|
|
50
|
+
get retryable() {
|
|
51
|
+
return this.status === 408 || this.status === 429 || this.status >= 500;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var InputValidationError = class extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "InputValidationError";
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/image.ts
|
|
62
|
+
function isHttpUrl(input) {
|
|
63
|
+
return /^https?:\/\//i.test(input);
|
|
64
|
+
}
|
|
65
|
+
async function fetchImage(url, signal) {
|
|
66
|
+
const res = await fetch(url, { signal });
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
await res.body?.cancel();
|
|
69
|
+
throw new HttpError(res.status, `Failed to fetch image from URL: ${res.status}`);
|
|
70
|
+
}
|
|
71
|
+
return res;
|
|
72
|
+
}
|
|
73
|
+
async function normalizeToBase64(input, signal) {
|
|
74
|
+
if (typeof input === "string" && !isHttpUrl(input)) {
|
|
75
|
+
validateBase64(input);
|
|
76
|
+
return input;
|
|
77
|
+
}
|
|
78
|
+
if (typeof input === "string" && isHttpUrl(input)) {
|
|
79
|
+
const res = await fetchImage(input, signal);
|
|
80
|
+
const buf = await res.arrayBuffer();
|
|
81
|
+
return arrayBufferToBase64(buf);
|
|
82
|
+
}
|
|
83
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) {
|
|
84
|
+
return input.toString("base64");
|
|
85
|
+
}
|
|
86
|
+
if (input instanceof ArrayBuffer) {
|
|
87
|
+
return arrayBufferToBase64(input);
|
|
88
|
+
}
|
|
89
|
+
if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
90
|
+
const buf = await input.arrayBuffer();
|
|
91
|
+
return arrayBufferToBase64(buf);
|
|
92
|
+
}
|
|
93
|
+
throw new InputValidationError("Unsupported image input type");
|
|
94
|
+
}
|
|
95
|
+
async function normalizeToBlob(input, signal) {
|
|
96
|
+
if (typeof Blob !== "undefined" && input instanceof Blob) {
|
|
97
|
+
if (input.type.startsWith("image/") || input.type === "application/pdf") return input;
|
|
98
|
+
const header = new Uint8Array(await input.slice(0, 16).arrayBuffer());
|
|
99
|
+
return new Blob([input], { type: mediaType(header) });
|
|
100
|
+
}
|
|
101
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(input)) {
|
|
102
|
+
const copy = new Uint8Array(input);
|
|
103
|
+
return new Blob([copy], { type: mediaType(copy) });
|
|
104
|
+
}
|
|
105
|
+
if (input instanceof ArrayBuffer) {
|
|
106
|
+
return new Blob([input], { type: mediaType(new Uint8Array(input)) });
|
|
107
|
+
}
|
|
108
|
+
if (typeof input === "string" && isHttpUrl(input)) {
|
|
109
|
+
const res = await fetchImage(input, signal);
|
|
110
|
+
return normalizeToBlob(await res.blob(), signal);
|
|
111
|
+
}
|
|
112
|
+
if (typeof input === "string") {
|
|
113
|
+
validateBase64(input);
|
|
114
|
+
const binary = atob(input);
|
|
115
|
+
const bytes = new Uint8Array(binary.length);
|
|
116
|
+
for (let i = 0; i < binary.length; i++) {
|
|
117
|
+
bytes[i] = binary.charCodeAt(i);
|
|
118
|
+
}
|
|
119
|
+
return new Blob([bytes], { type: mediaType(bytes) });
|
|
120
|
+
}
|
|
121
|
+
throw new InputValidationError("Unsupported image input type");
|
|
122
|
+
}
|
|
123
|
+
function validateBase64(value) {
|
|
124
|
+
if (!value || !/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1 || value.includes("=") && value.length % 4 !== 0) {
|
|
125
|
+
throw new InputValidationError("Image string must be base64 or an HTTP(S) URL");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function mediaType(bytes) {
|
|
129
|
+
const header = String.fromCharCode(...bytes.subarray(0, 16));
|
|
130
|
+
if (header.startsWith("%PDF-")) return "application/pdf";
|
|
131
|
+
if (header.startsWith("\x89PNG\r\n\n")) return "image/png";
|
|
132
|
+
if (header.startsWith("GIF87a") || header.startsWith("GIF89a")) return "image/gif";
|
|
133
|
+
if (header.startsWith("II*\0") || header.startsWith("MM\0*")) return "image/tiff";
|
|
134
|
+
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP") return "image/webp";
|
|
135
|
+
if (header.startsWith("BM")) return "image/bmp";
|
|
136
|
+
if (header.slice(4, 8) === "ftyp") return header.slice(8, 12) === "avif" ? "image/avif" : "image/heif";
|
|
137
|
+
return "image/jpeg";
|
|
138
|
+
}
|
|
139
|
+
function arrayBufferToBase64(buffer) {
|
|
140
|
+
if (typeof Buffer !== "undefined") {
|
|
141
|
+
return Buffer.from(buffer).toString("base64");
|
|
142
|
+
}
|
|
143
|
+
const bytes = new Uint8Array(buffer);
|
|
144
|
+
let binary = "";
|
|
145
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
146
|
+
binary += String.fromCharCode(bytes[i]);
|
|
147
|
+
}
|
|
148
|
+
return btoa(binary);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/retry.ts
|
|
152
|
+
function validateRetryOptions({ retries, timeoutMs }) {
|
|
153
|
+
if (!Number.isInteger(retries) || retries < 0) {
|
|
154
|
+
throw new RangeError("retries must be a non-negative integer");
|
|
155
|
+
}
|
|
156
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2147483647) {
|
|
157
|
+
throw new RangeError("timeoutMs must be between 0 and 2147483647 milliseconds (exclusive of 0)");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function withRetry(fn, options) {
|
|
161
|
+
validateRetryOptions(options);
|
|
162
|
+
const { retries, timeoutMs } = options;
|
|
163
|
+
let lastError;
|
|
164
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
let timeout;
|
|
167
|
+
const deadline = new Promise((_, reject) => {
|
|
168
|
+
timeout = setTimeout(() => {
|
|
169
|
+
const error = new Error(`OCR request timed out after ${timeoutMs}ms`);
|
|
170
|
+
error.name = "TimeoutError";
|
|
171
|
+
reject(error);
|
|
172
|
+
controller.abort(error);
|
|
173
|
+
}, timeoutMs);
|
|
174
|
+
});
|
|
175
|
+
try {
|
|
176
|
+
return await Promise.race([fn(controller.signal), deadline]);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
179
|
+
if (lastError instanceof InputValidationError || lastError instanceof HttpError && !lastError.retryable) {
|
|
180
|
+
throw lastError;
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
}
|
|
185
|
+
if (attempt < retries) {
|
|
186
|
+
const delay2 = Math.min(500 * Math.pow(2, attempt), 5e3);
|
|
187
|
+
await new Promise((r) => setTimeout(r, delay2));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
throw lastError ?? new Error("All retries exhausted");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/local-server.ts
|
|
194
|
+
var import_node_child_process = require("child_process");
|
|
195
|
+
var import_node_net = require("net");
|
|
196
|
+
var import_node_fs = require("fs");
|
|
197
|
+
var import_node_path = require("path");
|
|
198
|
+
var import_node_url = require("url");
|
|
199
|
+
var import_promises = require("timers/promises");
|
|
200
|
+
var import_meta = {};
|
|
201
|
+
function resolvePackageDir() {
|
|
202
|
+
try {
|
|
203
|
+
if (typeof import_meta?.url === "string") {
|
|
204
|
+
return (0, import_node_path.join)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(import_meta.url)), "..");
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
}
|
|
208
|
+
if (typeof __dirname === "string") {
|
|
209
|
+
return (0, import_node_path.join)(__dirname, "..");
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Cannot resolve document-ocr package directory");
|
|
212
|
+
}
|
|
213
|
+
var HEALTH_POLL_INTERVAL_MS = 500;
|
|
214
|
+
var HEALTH_TIMEOUT_MS = 12e4;
|
|
215
|
+
function findFreePort() {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const srv = (0, import_node_net.createServer)();
|
|
218
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
219
|
+
const addr = srv.address();
|
|
220
|
+
if (addr && typeof addr === "object") {
|
|
221
|
+
const port = addr.port;
|
|
222
|
+
srv.close(() => resolve(port));
|
|
223
|
+
} else {
|
|
224
|
+
srv.close(() => reject(new Error("Could not determine free port")));
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
srv.on("error", reject);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
var LocalServer = class {
|
|
231
|
+
process = null;
|
|
232
|
+
ready = null;
|
|
233
|
+
startupAbort = null;
|
|
234
|
+
cleanupHandler = null;
|
|
235
|
+
/**
|
|
236
|
+
* Ensure the local Python server is running and return its endpoint URL.
|
|
237
|
+
*/
|
|
238
|
+
async ensureRunning() {
|
|
239
|
+
if (this.ready) {
|
|
240
|
+
return this.ready;
|
|
241
|
+
}
|
|
242
|
+
const controller = new AbortController();
|
|
243
|
+
this.startupAbort = controller;
|
|
244
|
+
const ready = this._start(controller);
|
|
245
|
+
this.ready = ready;
|
|
246
|
+
ready.catch(() => {
|
|
247
|
+
if (this.ready === ready) this.ready = null;
|
|
248
|
+
});
|
|
249
|
+
return ready;
|
|
250
|
+
}
|
|
251
|
+
async _start(controller) {
|
|
252
|
+
const packageDir = resolvePackageDir();
|
|
253
|
+
const pythonDir = (0, import_node_path.join)(packageDir, "python");
|
|
254
|
+
const venvDir = (0, import_node_path.join)(packageDir, ".venv");
|
|
255
|
+
const markerFile = (0, import_node_path.join)(venvDir, ".setup-complete");
|
|
256
|
+
if (!(0, import_node_fs.existsSync)(markerFile)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
'document-ocr: Python environment not set up. Run "npm rebuild document-ocr" or ensure Python 3.14 and uv are installed. Check that the postinstall script ran successfully.'
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
const isWindows = process.platform === "win32";
|
|
262
|
+
const pythonExe = isWindows ? (0, import_node_path.join)(venvDir, "Scripts", "python.exe") : (0, import_node_path.join)(venvDir, "bin", "python");
|
|
263
|
+
if (!(0, import_node_fs.existsSync)(pythonExe)) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`document-ocr: Python executable not found at ${pythonExe}. Run "npm rebuild document-ocr" to re-create the virtual environment.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const port = await findFreePort();
|
|
269
|
+
controller.signal.throwIfAborted();
|
|
270
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
271
|
+
const args = [
|
|
272
|
+
"-m",
|
|
273
|
+
"uvicorn",
|
|
274
|
+
"server:app",
|
|
275
|
+
"--host",
|
|
276
|
+
"127.0.0.1",
|
|
277
|
+
"--port",
|
|
278
|
+
String(port),
|
|
279
|
+
"--log-level",
|
|
280
|
+
"warning"
|
|
281
|
+
];
|
|
282
|
+
const child = (0, import_node_child_process.spawn)(pythonExe, args, {
|
|
283
|
+
cwd: pythonDir,
|
|
284
|
+
env: {
|
|
285
|
+
...process.env,
|
|
286
|
+
PYTHONPATH: pythonDir,
|
|
287
|
+
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK: "True",
|
|
288
|
+
// Suppress paddle logging noise
|
|
289
|
+
GLOG_minloglevel: "2"
|
|
290
|
+
},
|
|
291
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
292
|
+
// Detach on Windows to avoid blocking
|
|
293
|
+
detached: isWindows
|
|
294
|
+
});
|
|
295
|
+
this.process = child;
|
|
296
|
+
let processLogs = "";
|
|
297
|
+
const appendLogs = (chunk) => {
|
|
298
|
+
processLogs += chunk.toString();
|
|
299
|
+
if (processLogs.length > 4096) {
|
|
300
|
+
processLogs = processLogs.slice(-4096);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
child.stdout?.on("data", appendLogs);
|
|
304
|
+
child.stderr?.on("data", appendLogs);
|
|
305
|
+
const cleanup = () => child.kill("SIGTERM");
|
|
306
|
+
this.cleanupHandler = cleanup;
|
|
307
|
+
process.once("exit", cleanup);
|
|
308
|
+
const exitPromise = new Promise((_, reject) => {
|
|
309
|
+
const failed = (error) => {
|
|
310
|
+
process.removeListener("exit", cleanup);
|
|
311
|
+
if (this.cleanupHandler === cleanup) this.cleanupHandler = null;
|
|
312
|
+
if (this.process === child) {
|
|
313
|
+
this.ready = null;
|
|
314
|
+
this.process = null;
|
|
315
|
+
}
|
|
316
|
+
controller.abort(error);
|
|
317
|
+
reject(error);
|
|
318
|
+
};
|
|
319
|
+
child.once("error", failed);
|
|
320
|
+
child.once("exit", (code, signal) => failed(new Error(
|
|
321
|
+
`document-ocr: Python server exited (code=${code}, signal=${signal}).
|
|
322
|
+
${processLogs}`
|
|
323
|
+
)));
|
|
324
|
+
});
|
|
325
|
+
try {
|
|
326
|
+
return await Promise.race([this._waitForReady(endpoint, controller.signal), exitPromise]);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (this.process === child) await this.stop();
|
|
329
|
+
throw error;
|
|
330
|
+
} finally {
|
|
331
|
+
if (controller.signal.aborted) process.removeListener("exit", cleanup);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async _waitForReady(endpoint, signal) {
|
|
335
|
+
const readinessUrl = `${endpoint}/ready`;
|
|
336
|
+
const startTime = Date.now();
|
|
337
|
+
while (Date.now() - startTime < HEALTH_TIMEOUT_MS) {
|
|
338
|
+
signal?.throwIfAborted();
|
|
339
|
+
let terminalFailure = null;
|
|
340
|
+
const probeController = new AbortController();
|
|
341
|
+
const cancelProbe = () => probeController.abort(signal?.reason);
|
|
342
|
+
signal?.addEventListener("abort", cancelProbe, { once: true });
|
|
343
|
+
const timeout = setTimeout(() => probeController.abort(), 2e3);
|
|
344
|
+
try {
|
|
345
|
+
const res = await fetch(readinessUrl, {
|
|
346
|
+
signal: probeController.signal
|
|
347
|
+
});
|
|
348
|
+
const body = await res.json().catch(() => null);
|
|
349
|
+
signal?.throwIfAborted();
|
|
350
|
+
if (res.ok && body?.status === "ready") {
|
|
351
|
+
return endpoint;
|
|
352
|
+
}
|
|
353
|
+
if (body?.status === "model_init_failed") {
|
|
354
|
+
const detail = typeof body.error === "string" ? body.error.replace(/\s+/g, " ").slice(0, 500) : "configured OCR model initialization failed";
|
|
355
|
+
terminalFailure = detail;
|
|
356
|
+
}
|
|
357
|
+
} catch {
|
|
358
|
+
signal?.throwIfAborted();
|
|
359
|
+
} finally {
|
|
360
|
+
clearTimeout(timeout);
|
|
361
|
+
signal?.removeEventListener("abort", cancelProbe);
|
|
362
|
+
}
|
|
363
|
+
if (terminalFailure) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`document-ocr: OCR model initialization failed: ${terminalFailure}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
await (0, import_promises.setTimeout)(HEALTH_POLL_INTERVAL_MS, void 0, { signal });
|
|
369
|
+
}
|
|
370
|
+
throw new Error(
|
|
371
|
+
`document-ocr: Python server did not become ready within ${HEALTH_TIMEOUT_MS / 1e3}s. This might happen on first run while models are being downloaded.`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Stop the local Python server if running.
|
|
376
|
+
*/
|
|
377
|
+
async stop() {
|
|
378
|
+
if (this.cleanupHandler) {
|
|
379
|
+
process.removeListener("exit", this.cleanupHandler);
|
|
380
|
+
this.cleanupHandler = null;
|
|
381
|
+
}
|
|
382
|
+
this.startupAbort?.abort(new Error("document-ocr: local server stopped"));
|
|
383
|
+
this.startupAbort = null;
|
|
384
|
+
this.ready = null;
|
|
385
|
+
if (this.process) {
|
|
386
|
+
const proc = this.process;
|
|
387
|
+
this.process = null;
|
|
388
|
+
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
|
389
|
+
await new Promise((resolve) => {
|
|
390
|
+
const done = () => {
|
|
391
|
+
clearTimeout(forceKill);
|
|
392
|
+
clearTimeout(deadline);
|
|
393
|
+
proc.removeListener("exit", done);
|
|
394
|
+
proc.removeListener("error", done);
|
|
395
|
+
resolve();
|
|
396
|
+
};
|
|
397
|
+
const forceKill = setTimeout(() => {
|
|
398
|
+
try {
|
|
399
|
+
proc.kill("SIGKILL");
|
|
400
|
+
} catch {
|
|
401
|
+
done();
|
|
402
|
+
}
|
|
403
|
+
}, 5e3);
|
|
404
|
+
const deadline = setTimeout(done, 6e3);
|
|
405
|
+
proc.once("exit", done);
|
|
406
|
+
proc.once("error", done);
|
|
407
|
+
try {
|
|
408
|
+
if (!proc.kill("SIGTERM")) done();
|
|
409
|
+
} catch {
|
|
410
|
+
done();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
var _instance = null;
|
|
417
|
+
function getLocalServer() {
|
|
418
|
+
if (!_instance) {
|
|
419
|
+
_instance = new LocalServer();
|
|
420
|
+
}
|
|
421
|
+
return _instance;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/client.ts
|
|
425
|
+
var MAX_LAMBDA_PAYLOAD_BYTES = 6 * 1024 * 1024;
|
|
426
|
+
var DocumentOCR = class {
|
|
427
|
+
mode;
|
|
428
|
+
endpoint;
|
|
429
|
+
functionName;
|
|
430
|
+
timeoutMs;
|
|
431
|
+
retries;
|
|
432
|
+
apiKey;
|
|
433
|
+
authHeaders;
|
|
434
|
+
constructor(options = {}) {
|
|
435
|
+
this.mode = options.mode ?? "local";
|
|
436
|
+
this.endpoint = options.endpoint;
|
|
437
|
+
this.functionName = options.functionName;
|
|
438
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
439
|
+
this.retries = options.retries ?? 2;
|
|
440
|
+
this.apiKey = options.apiKey;
|
|
441
|
+
this.authHeaders = options.authHeaders;
|
|
442
|
+
validateRetryOptions({ retries: this.retries, timeoutMs: this.timeoutMs });
|
|
443
|
+
if (this.mode === "http" && !this.endpoint) throw new Error("endpoint is required for http mode");
|
|
444
|
+
if (this.mode === "lambda" && !this.functionName) throw new Error("functionName is required for lambda mode");
|
|
445
|
+
if (this.mode === "local" && this.endpoint) {
|
|
446
|
+
console.warn('document-ocr: endpoint is ignored in local mode. Use mode: "http" if you want to connect to an external server.');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async scan(image, options = {}) {
|
|
450
|
+
const parameters = scanParameters(options);
|
|
451
|
+
return this.retry(async (signal) => {
|
|
452
|
+
if (this.mode === "lambda") {
|
|
453
|
+
const base64 = await normalizeToBase64(image, signal);
|
|
454
|
+
return this.invokeLambda({ image_base64: base64, ...parameters }, signal);
|
|
455
|
+
}
|
|
456
|
+
const form = new FormData();
|
|
457
|
+
form.append("image", await normalizeToBlob(image, signal), "document");
|
|
458
|
+
appendParameters(form, parameters);
|
|
459
|
+
const { status, data } = await this.request("/scan", signal, { method: "POST", body: form });
|
|
460
|
+
return parseScanResponse(status, data);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
async scanBatch(images, options = {}) {
|
|
464
|
+
return this.uploadMany("/scan/batch", images, options);
|
|
465
|
+
}
|
|
466
|
+
async scanDocument(images, options = {}) {
|
|
467
|
+
return this.uploadMany("/scan/document", images, options);
|
|
468
|
+
}
|
|
469
|
+
async uploadMany(path, images, options) {
|
|
470
|
+
this.requireHttp(path === "/scan/batch" ? "scanBatch" : "scanDocument");
|
|
471
|
+
if (!Array.isArray(images) || images.length === 0) throw new InputValidationError("scanBatch requires at least one image");
|
|
472
|
+
const parameters = scanParameters(options);
|
|
473
|
+
return this.retry(async (signal) => {
|
|
474
|
+
const form = new FormData();
|
|
475
|
+
for (const [index, image] of images.entries()) {
|
|
476
|
+
form.append("images", await normalizeToBlob(image, signal), `document-${index}`);
|
|
477
|
+
}
|
|
478
|
+
appendParameters(form, parameters);
|
|
479
|
+
const { status, data } = await this.request(path, signal, { method: "POST", body: form });
|
|
480
|
+
const body = asObject(data);
|
|
481
|
+
const valid = body !== null && ["success", "partial", "failure"].includes(String(body.status)) && Array.isArray(body.results) && body.results.every(isScanResult) && Array.isArray(body.errors);
|
|
482
|
+
checkStatus(status, data, status === 422 && valid);
|
|
483
|
+
if (!valid) throw new Error("Invalid OCR response: expected a batch scan result");
|
|
484
|
+
if (path === "/scan/document" && (!asObject(body.documentFields) || !Array.isArray(body.conflicts))) {
|
|
485
|
+
throw new Error("Invalid OCR response: expected grouped document fields and conflicts");
|
|
486
|
+
}
|
|
487
|
+
return data;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async documents() {
|
|
491
|
+
this.requireHttp("documents");
|
|
492
|
+
return this.retry(async (signal) => {
|
|
493
|
+
const { status, data } = await this.request("/documents", signal);
|
|
494
|
+
checkStatus(status, data);
|
|
495
|
+
const body = asObject(data);
|
|
496
|
+
if (!body || typeof body.schemaVersion !== "number" || !Array.isArray(body.documents) || !asObject(body.capabilities)) {
|
|
497
|
+
throw new Error("Invalid OCR response: expected a document catalog");
|
|
498
|
+
}
|
|
499
|
+
return data;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
async enqueueJob(images, options = {}) {
|
|
503
|
+
this.requireHttp("enqueueJob");
|
|
504
|
+
if (!Array.isArray(images) || images.length === 0) throw new InputValidationError("enqueueJob requires at least one image");
|
|
505
|
+
const parameters = scanParameters(options);
|
|
506
|
+
for (const key of ["grouped", "notify"]) {
|
|
507
|
+
if (options[key] !== void 0) {
|
|
508
|
+
if (typeof options[key] !== "boolean") throw new InputValidationError(`${key} must be boolean`);
|
|
509
|
+
parameters[key] = options[key];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return withRetry(async (signal) => {
|
|
513
|
+
const form = new FormData();
|
|
514
|
+
for (const [index, image] of images.entries()) form.append("images", await normalizeToBlob(image, signal), `document-${index}`);
|
|
515
|
+
appendParameters(form, parameters);
|
|
516
|
+
const { status, data } = await this.request("/jobs", signal, { method: "POST", body: form });
|
|
517
|
+
checkStatus(status, data);
|
|
518
|
+
return parseJob(data);
|
|
519
|
+
}, { retries: 0, timeoutMs: this.timeoutMs });
|
|
520
|
+
}
|
|
521
|
+
async job(id) {
|
|
522
|
+
this.requireHttp("job");
|
|
523
|
+
const path = jobPath(id);
|
|
524
|
+
return this.retry(async (signal) => {
|
|
525
|
+
const { status, data } = await this.request(path, signal);
|
|
526
|
+
checkStatus(status, data);
|
|
527
|
+
return parseJob(data);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async deleteJob(id) {
|
|
531
|
+
this.requireHttp("deleteJob");
|
|
532
|
+
const path = jobPath(id);
|
|
533
|
+
return this.retry(async (signal) => {
|
|
534
|
+
const { status, data } = await this.request(path, signal, { method: "DELETE" });
|
|
535
|
+
checkStatus(status, data);
|
|
536
|
+
if (asObject(data)?.deleted !== true) throw new Error("Invalid OCR response: expected deletion confirmation");
|
|
537
|
+
return { deleted: true };
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async scanS3(image, options = {}) {
|
|
541
|
+
if (this.mode !== "lambda") throw new InputValidationError("scanS3 is available only in lambda mode");
|
|
542
|
+
if (!image || typeof image.bucket !== "string" || !image.bucket.trim() || typeof image.key !== "string" || !image.key.trim() || image.versionId !== void 0 && (typeof image.versionId !== "string" || !image.versionId)) {
|
|
543
|
+
throw new InputValidationError("scanS3 requires bucket, key, and an optional non-empty versionId");
|
|
544
|
+
}
|
|
545
|
+
const parameters = scanParameters(options);
|
|
546
|
+
const s3 = { bucket: image.bucket, key: image.key, ...image.versionId === void 0 ? {} : { version_id: image.versionId } };
|
|
547
|
+
return this.retry((signal) => this.invokeLambda({ s3, ...parameters }, signal));
|
|
548
|
+
}
|
|
549
|
+
async stop() {
|
|
550
|
+
if (this.mode === "local") await getLocalServer().stop();
|
|
551
|
+
}
|
|
552
|
+
requireHttp(method) {
|
|
553
|
+
if (this.mode === "lambda") throw new InputValidationError(`${method} requires local or http mode`);
|
|
554
|
+
}
|
|
555
|
+
retry(operation) {
|
|
556
|
+
return withRetry(operation, { retries: this.retries, timeoutMs: this.timeoutMs });
|
|
557
|
+
}
|
|
558
|
+
async request(path, signal, init = {}) {
|
|
559
|
+
const endpoint = this.mode === "local" ? await getLocalServer().ensureRunning() : this.endpoint;
|
|
560
|
+
const url = `${endpoint.replace(/\/+$/, "")}${path}`;
|
|
561
|
+
signal.throwIfAborted();
|
|
562
|
+
const headers = new Headers(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {});
|
|
563
|
+
if (this.authHeaders) {
|
|
564
|
+
new Headers(await this.authHeaders(url, signal)).forEach((value, key) => headers.set(key, value));
|
|
565
|
+
}
|
|
566
|
+
signal.throwIfAborted();
|
|
567
|
+
const response = await fetch(url, { ...init, headers, signal });
|
|
568
|
+
const data = await response.json().catch(() => null);
|
|
569
|
+
return { status: response.status, data };
|
|
570
|
+
}
|
|
571
|
+
async invokeLambda(event, signal) {
|
|
572
|
+
const payload = JSON.stringify(event);
|
|
573
|
+
if (new TextEncoder().encode(payload).byteLength > MAX_LAMBDA_PAYLOAD_BYTES) {
|
|
574
|
+
throw new InputValidationError("Lambda payload exceeds 6 MiB; upload the image to S3 and use scanS3()");
|
|
575
|
+
}
|
|
576
|
+
signal.throwIfAborted();
|
|
577
|
+
const { LambdaClient, InvokeCommand } = await import("@aws-sdk/client-lambda");
|
|
578
|
+
signal.throwIfAborted();
|
|
579
|
+
const client = new LambdaClient({});
|
|
580
|
+
const command = new InvokeCommand({ FunctionName: this.functionName, Payload: payload });
|
|
581
|
+
try {
|
|
582
|
+
const response = await client.send(command, { abortSignal: signal });
|
|
583
|
+
if (response.FunctionError) throw new Error(`Lambda invocation failed: ${response.FunctionError}`);
|
|
584
|
+
const result = JSON.parse(new TextDecoder().decode(response.Payload));
|
|
585
|
+
if (typeof result?.statusCode === "number") {
|
|
586
|
+
let body = result.body;
|
|
587
|
+
if (typeof body === "string") {
|
|
588
|
+
try {
|
|
589
|
+
body = JSON.parse(body);
|
|
590
|
+
} catch {
|
|
591
|
+
body = null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return parseScanResponse(result.statusCode, body);
|
|
595
|
+
}
|
|
596
|
+
return parseScanResponse(200, result);
|
|
597
|
+
} finally {
|
|
598
|
+
client.destroy();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
function scanParameters(options) {
|
|
603
|
+
if (!options || typeof options !== "object") throw new InputValidationError("scan options must be an object");
|
|
604
|
+
const result = {};
|
|
605
|
+
for (const [key, wireKey] of [["documentType", "document_type"], ["country", "country"]]) {
|
|
606
|
+
const value = options[key];
|
|
607
|
+
if (value !== void 0) {
|
|
608
|
+
if (typeof value !== "string" || !value.trim()) throw new InputValidationError(`${key} must be a non-empty string`);
|
|
609
|
+
result[wireKey] = value;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (options.includeEvidence !== void 0) {
|
|
613
|
+
if (typeof options.includeEvidence !== "boolean") throw new InputValidationError("includeEvidence must be boolean");
|
|
614
|
+
result.include_evidence = options.includeEvidence;
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
function appendParameters(form, parameters) {
|
|
619
|
+
for (const [key, value] of Object.entries(parameters)) form.append(key, String(value));
|
|
620
|
+
}
|
|
621
|
+
function jobPath(id) {
|
|
622
|
+
if (typeof id !== "string" || !/^[A-Za-z0-9_-]+$/.test(id)) throw new InputValidationError("job id must contain letters, digits, hyphens, or underscores");
|
|
623
|
+
return `/jobs/${encodeURIComponent(id)}`;
|
|
624
|
+
}
|
|
625
|
+
function parseJob(data) {
|
|
626
|
+
const body = asObject(data);
|
|
627
|
+
if (!body || typeof body.id !== "string" || !["queued", "running", "succeeded", "failed"].includes(String(body.status)) || typeof body.createdAt !== "number" || typeof body.expiresAt !== "number" || typeof body.attempts !== "number" || body.error !== null && typeof body.error !== "string") throw new Error("Invalid OCR response: expected a job");
|
|
628
|
+
return data;
|
|
629
|
+
}
|
|
630
|
+
function asObject(data) {
|
|
631
|
+
return data !== null && typeof data === "object" && !Array.isArray(data) ? data : null;
|
|
632
|
+
}
|
|
633
|
+
function isScanResult(data) {
|
|
634
|
+
const body = asObject(data);
|
|
635
|
+
return body !== null && ["success", "failure", "unsupported_page"].includes(String(body.status)) && typeof body.documentType === "string" && typeof body.pageType === "string" && Array.isArray(body.errors);
|
|
636
|
+
}
|
|
637
|
+
function checkStatus(status, data, allow422 = false) {
|
|
638
|
+
if (status >= 200 && status < 300 || allow422) return;
|
|
639
|
+
const body = asObject(data);
|
|
640
|
+
const message = typeof body?.error === "string" ? body.error : typeof body?.detail === "string" ? body.detail : `OCR request failed: ${status}`;
|
|
641
|
+
throw new HttpError(status, message);
|
|
642
|
+
}
|
|
643
|
+
function parseScanResponse(status, data) {
|
|
644
|
+
const valid = isScanResult(data);
|
|
645
|
+
checkStatus(status, data, status === 422 && valid);
|
|
646
|
+
if (!valid) throw new Error("Invalid OCR response: expected a document scan result");
|
|
647
|
+
return data;
|
|
648
|
+
}
|
|
649
|
+
var PassportOCR = DocumentOCR;
|
|
650
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
651
|
+
0 && (module.exports = {
|
|
652
|
+
DocumentOCR,
|
|
653
|
+
HttpError,
|
|
654
|
+
InputValidationError,
|
|
655
|
+
LocalServer,
|
|
656
|
+
PassportOCR,
|
|
657
|
+
getLocalServer
|
|
658
|
+
});
|
|
659
|
+
//# sourceMappingURL=index.cjs.map
|