@arcships/light-ocr 0.3.2 → 0.3.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/bin/light-ocr.cjs +594 -0
- package/package.json +12 -8
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// light-ocr CLI — N1 entry point (cli-design.md §3, D106)
|
|
5
|
+
//
|
|
6
|
+
// Subcommand structure (cli-design.md §2.1):
|
|
7
|
+
// light-ocr recognize <path|--stdin> [flags] # default OCR
|
|
8
|
+
// light-ocr detect <path|--stdin> [flags] # detect only (N1 step 5)
|
|
9
|
+
// light-ocr info --model-info | --version # diagnostics, no image
|
|
10
|
+
// light-ocr image.png ... # implicit recognize
|
|
11
|
+
//
|
|
12
|
+
// stdout = machine results only; stderr = logs/warnings/usage (cli-design.md §5).
|
|
13
|
+
// Exit codes are a stable surface (cli-design.md §10, D106).
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const { createEngine, OcrError } = require('../js/index.cjs');
|
|
19
|
+
const { parseExifOrientation } = require('../js/exif.cjs');
|
|
20
|
+
|
|
21
|
+
const PKG_VERSION = require('../package.json').version;
|
|
22
|
+
const CORE_VERSION = '0.3.3';
|
|
23
|
+
|
|
24
|
+
const SUBCOMMANDS = new Set(['recognize', 'detect', 'info']);
|
|
25
|
+
const EXIT = {
|
|
26
|
+
success: 0,
|
|
27
|
+
usage: 64,
|
|
28
|
+
invalid_argument: 65,
|
|
29
|
+
invalid_image: 66,
|
|
30
|
+
unsupported_capability: 67,
|
|
31
|
+
model: 68,
|
|
32
|
+
resource_limit_exceeded: 69,
|
|
33
|
+
env_package: 70,
|
|
34
|
+
inference_failed: 71,
|
|
35
|
+
internal_error: 72,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const OCR_ERROR_EXIT = {
|
|
39
|
+
invalid_argument: EXIT.invalid_argument,
|
|
40
|
+
invalid_image: EXIT.invalid_argument,
|
|
41
|
+
unsupported_pixel_format: EXIT.invalid_image,
|
|
42
|
+
unsupported_capability: EXIT.unsupported_capability,
|
|
43
|
+
invalid_model_bundle: EXIT.model,
|
|
44
|
+
unsupported_model: EXIT.model,
|
|
45
|
+
model_integrity_failed: EXIT.model,
|
|
46
|
+
runtime_initialization_failed: EXIT.env_package,
|
|
47
|
+
invalid_engine: EXIT.env_package,
|
|
48
|
+
resource_limit_exceeded: EXIT.resource_limit_exceeded,
|
|
49
|
+
inference_failed: EXIT.inference_failed,
|
|
50
|
+
postprocess_failed: EXIT.inference_failed,
|
|
51
|
+
internal_error: EXIT.internal_error,
|
|
52
|
+
bundle_io_failed: EXIT.env_package,
|
|
53
|
+
queue_full: EXIT.internal_error,
|
|
54
|
+
environment_closing: EXIT.internal_error,
|
|
55
|
+
unsupported_platform: EXIT.env_package,
|
|
56
|
+
package_load_failed: EXIT.env_package,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ALLOWED_PROVIDERS = new Set(['auto', 'cpu', 'apple', 'webgpu']);
|
|
60
|
+
const ALLOWED_FORMATS = new Set(['json', 'jsonl', 'text']);
|
|
61
|
+
|
|
62
|
+
function die(stderr, code, message) {
|
|
63
|
+
stderr.write(`light-ocr: ${message}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function useColor(stderr) {
|
|
67
|
+
if (process.env.NO_COLOR) return false;
|
|
68
|
+
return stderr.isTTY === true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- argv parser (D-N1-2: hand-written, zero-dependency) ---
|
|
72
|
+
// Parses `--flag value`, `--flag=value`, `--bool`, and positional args.
|
|
73
|
+
// Returns { subcommand, positionals, flags } or throws { code, message }.
|
|
74
|
+
function parseArgs(argv) {
|
|
75
|
+
const positionals = [];
|
|
76
|
+
const flags = {};
|
|
77
|
+
let i = 0;
|
|
78
|
+
|
|
79
|
+
while (i < argv.length) {
|
|
80
|
+
const arg = argv[i];
|
|
81
|
+
if (arg === '--') {
|
|
82
|
+
positionals.push(...argv.slice(i + 1));
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (arg.startsWith('--')) {
|
|
86
|
+
const eq = arg.indexOf('=');
|
|
87
|
+
if (eq > 2) {
|
|
88
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const name = arg.slice(2);
|
|
93
|
+
const knownBooleans = new Set([
|
|
94
|
+
'stdin', 'no-exif', 'no-color', 'quiet', 'help',
|
|
95
|
+
'model-info', 'version', 'crop',
|
|
96
|
+
]);
|
|
97
|
+
if (knownBooleans.has(name)) {
|
|
98
|
+
flags[name] = true;
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// value flag: consume next arg
|
|
103
|
+
if (i + 1 >= argv.length) {
|
|
104
|
+
throw { code: EXIT.usage, message: `--${name} requires a value` };
|
|
105
|
+
}
|
|
106
|
+
flags[name] = argv[i + 1];
|
|
107
|
+
i += 2;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
positionals.push(arg);
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
return { positionals, flags };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function resolveSubcommand(positionals) {
|
|
117
|
+
if (positionals.length > 0 && SUBCOMMANDS.has(positionals[0])) {
|
|
118
|
+
return { subcommand: positionals[0], rest: positionals.slice(1) };
|
|
119
|
+
}
|
|
120
|
+
// implicit recognize
|
|
121
|
+
return { subcommand: 'recognize', rest: positionals };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- input resolution ---
|
|
125
|
+
function readImageInput(rest, flags, stderr) {
|
|
126
|
+
const useStdin = flags.stdin === true;
|
|
127
|
+
if (useStdin) {
|
|
128
|
+
if (rest.length > 0) {
|
|
129
|
+
throw { code: EXIT.usage, message: 'cannot pass both a file path and --stdin' };
|
|
130
|
+
}
|
|
131
|
+
const type = flags.type;
|
|
132
|
+
if (!type) {
|
|
133
|
+
throw { code: EXIT.usage, message: '--stdin requires --type image/png or --type image/jpeg' };
|
|
134
|
+
}
|
|
135
|
+
if (type !== 'image/png' && type !== 'image/jpeg') {
|
|
136
|
+
throw { code: EXIT.invalid_argument, message: `unsupported --type ${type}; use image/png or image/jpeg` };
|
|
137
|
+
}
|
|
138
|
+
// read all of stdin synchronously (CLI is short-lived)
|
|
139
|
+
return readStdinSync();
|
|
140
|
+
}
|
|
141
|
+
if (rest.length === 0) {
|
|
142
|
+
throw { code: EXIT.usage, message: 'expected a file path or --stdin' };
|
|
143
|
+
}
|
|
144
|
+
if (rest.length > 1) {
|
|
145
|
+
throw { code: EXIT.usage, message: `unexpected extra argument: ${rest[1]}` };
|
|
146
|
+
}
|
|
147
|
+
const file = rest[0];
|
|
148
|
+
try {
|
|
149
|
+
return fs.readFileSync(file);
|
|
150
|
+
} catch (cause) {
|
|
151
|
+
throw {
|
|
152
|
+
code: EXIT.usage,
|
|
153
|
+
message: cause.code === 'ENOENT'
|
|
154
|
+
? `file not found: ${file}`
|
|
155
|
+
: `cannot read file: ${file} (${cause.message})`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readStdinSync() {
|
|
161
|
+
// Node's stdin is async by default; use a blocking read via fs.readFileSync(0).
|
|
162
|
+
// fd 0 is stdin. This throws EBADF on some platforms if stdin is a pipe with
|
|
163
|
+
// no data, but for CLI usage (piped image bytes) it works.
|
|
164
|
+
try {
|
|
165
|
+
return fs.readFileSync(0);
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
throw { code: EXIT.usage, message: `cannot read stdin: ${cause.message}` };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- format options ---
|
|
172
|
+
function resolveFormat(flags, subcommand) {
|
|
173
|
+
// detect does not expose --format (cli-design.md §3.1 detect)
|
|
174
|
+
if (subcommand === 'detect') {
|
|
175
|
+
if (flags.format !== undefined) {
|
|
176
|
+
throw { code: EXIT.invalid_argument, message: 'detect does not accept --format; output is always JSON' };
|
|
177
|
+
}
|
|
178
|
+
return 'json';
|
|
179
|
+
}
|
|
180
|
+
const format = flags.format === undefined ? 'json' : flags.format;
|
|
181
|
+
if (!ALLOWED_FORMATS.has(format)) {
|
|
182
|
+
throw { code: EXIT.invalid_argument, message: `unsupported --format ${format}; use json, jsonl, or text` };
|
|
183
|
+
}
|
|
184
|
+
return format;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveProvider(flags) {
|
|
188
|
+
if (flags.provider === undefined) return undefined;
|
|
189
|
+
const provider = flags.provider;
|
|
190
|
+
if (!ALLOWED_PROVIDERS.has(provider)) {
|
|
191
|
+
throw { code: EXIT.invalid_argument, message: `unsupported --provider ${provider}; use auto, cpu, apple, or webgpu` };
|
|
192
|
+
}
|
|
193
|
+
return provider;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseRegion(flags) {
|
|
197
|
+
if (flags.region === undefined) return undefined;
|
|
198
|
+
const parts = String(flags.region).split(',');
|
|
199
|
+
if (parts.length !== 4) {
|
|
200
|
+
throw { code: EXIT.invalid_argument, message: `--region expects x,y,w,h (got ${flags.region})` };
|
|
201
|
+
}
|
|
202
|
+
const values = parts.map((p) => {
|
|
203
|
+
const n = Number.parseInt(p, 10);
|
|
204
|
+
if (!Number.isInteger(n) || n < 0 || String(n) !== p.trim()) {
|
|
205
|
+
throw { code: EXIT.invalid_argument, message: `--region values must be non-negative integers (got ${p})` };
|
|
206
|
+
}
|
|
207
|
+
return n;
|
|
208
|
+
});
|
|
209
|
+
const [x, y, width, height] = values;
|
|
210
|
+
if (width === 0 || height === 0) {
|
|
211
|
+
throw { code: EXIT.invalid_argument, message: '--region width and height must be positive' };
|
|
212
|
+
}
|
|
213
|
+
return { x, y, width, height };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- subcommand handlers ---
|
|
217
|
+
async function runInfo(rest, flags, stdout, stderr) {
|
|
218
|
+
if (rest.length > 0) {
|
|
219
|
+
throw { code: EXIT.invalid_argument, message: `info does not accept a file path: ${rest[0]}` };
|
|
220
|
+
}
|
|
221
|
+
const hasModelInfo = flags['model-info'] === true;
|
|
222
|
+
const hasVersion = flags.version === true;
|
|
223
|
+
if (!hasModelInfo && !hasVersion) {
|
|
224
|
+
throw { code: EXIT.usage, message: 'info requires --model-info or --version' };
|
|
225
|
+
}
|
|
226
|
+
if (hasModelInfo && hasVersion) {
|
|
227
|
+
throw { code: EXIT.invalid_argument, message: '--model-info and --version are mutually exclusive' };
|
|
228
|
+
}
|
|
229
|
+
// info must not accept image/ocr flags
|
|
230
|
+
for (const blocked of ['stdin', 'type', 'format', 'region', 'no-exif', 'provider', 'crop']) {
|
|
231
|
+
if (flags[blocked] !== undefined) {
|
|
232
|
+
throw { code: EXIT.invalid_argument, message: `info does not accept --${blocked}` };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (hasVersion) {
|
|
236
|
+
// version triple: npm / core / model
|
|
237
|
+
let modelId = '';
|
|
238
|
+
try {
|
|
239
|
+
const engine = await createEngine();
|
|
240
|
+
modelId = engine.info.modelBundleId;
|
|
241
|
+
await engine.close();
|
|
242
|
+
} catch {
|
|
243
|
+
// version should still print even if engine creation fails
|
|
244
|
+
}
|
|
245
|
+
stdout.write(JSON.stringify({ npm: PKG_VERSION, core: CORE_VERSION, model: modelId }) + '\n');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// --model-info
|
|
249
|
+
const engine = await createEngine();
|
|
250
|
+
try {
|
|
251
|
+
stdout.write(JSON.stringify(engine.info, null, 2) + '\n');
|
|
252
|
+
} finally {
|
|
253
|
+
await engine.close();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- DocumentResult envelope (cli-design.md §9, D106) ---
|
|
258
|
+
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
259
|
+
|
|
260
|
+
function buildEnvelope(result, sourceInfo) {
|
|
261
|
+
const lines = result.lines.map((line, index) => ({
|
|
262
|
+
id: `L${index}`,
|
|
263
|
+
text: line.text,
|
|
264
|
+
confidence: line.confidence,
|
|
265
|
+
box: line.box,
|
|
266
|
+
}));
|
|
267
|
+
const page = {
|
|
268
|
+
index: 0,
|
|
269
|
+
width: result.imageWidth,
|
|
270
|
+
height: result.imageHeight,
|
|
271
|
+
coordinateSpace: 'pageSpace',
|
|
272
|
+
structure: 'ocr-order',
|
|
273
|
+
lines,
|
|
274
|
+
modelBundleId: result.modelBundleId,
|
|
275
|
+
timingUs: result.timingUs,
|
|
276
|
+
};
|
|
277
|
+
if (result.diagnostics) page.diagnostics = result.diagnostics;
|
|
278
|
+
return {
|
|
279
|
+
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
280
|
+
source: {
|
|
281
|
+
kind: 'image',
|
|
282
|
+
mediaType: sourceInfo.mediaType || null,
|
|
283
|
+
identity: sourceInfo.identity || {},
|
|
284
|
+
appliedTransforms: sourceInfo.appliedTransforms || { exifApplied: false },
|
|
285
|
+
},
|
|
286
|
+
pages: [page],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildPageRecord(envelope) {
|
|
291
|
+
// JSONL: one page record per line (cli-design.md §9.3)
|
|
292
|
+
const page = envelope.pages[0];
|
|
293
|
+
return {
|
|
294
|
+
schemaVersion: envelope.schemaVersion,
|
|
295
|
+
source: envelope.source.identity,
|
|
296
|
+
pageIndex: page.index,
|
|
297
|
+
status: 'ok',
|
|
298
|
+
page,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveSchemaVersion(flags) {
|
|
303
|
+
if (flags['schema-version'] === undefined) return SUPPORTED_SCHEMA_VERSION;
|
|
304
|
+
const requested = flags['schema-version'];
|
|
305
|
+
// accept integer or string-integer
|
|
306
|
+
const version = Number.isInteger(Number(requested)) ? Number(requested) : NaN;
|
|
307
|
+
if (!Number.isInteger(version) || version !== SUPPORTED_SCHEMA_VERSION) {
|
|
308
|
+
throw {
|
|
309
|
+
code: EXIT.invalid_argument,
|
|
310
|
+
message: `unsupported --schema-version ${requested}; only version ${SUPPORTED_SCHEMA_VERSION} is supported`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return version;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function inferMediaType(filePath, stdinType) {
|
|
317
|
+
if (stdinType) return stdinType;
|
|
318
|
+
if (!filePath) return null;
|
|
319
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
320
|
+
if (ext === '.png') return 'image/png';
|
|
321
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runRecognize(rest, flags, stdout, stderr) {
|
|
326
|
+
// Validate all flags before reading input so parameter errors surface
|
|
327
|
+
// before filesystem/network errors (D106: stable failure ordering).
|
|
328
|
+
const format = resolveFormat(flags, 'recognize');
|
|
329
|
+
const provider = resolveProvider(flags);
|
|
330
|
+
resolveSchemaVersion(flags);
|
|
331
|
+
const region = parseRegion(flags);
|
|
332
|
+
|
|
333
|
+
const data = readImageInput(rest, flags, stderr);
|
|
334
|
+
|
|
335
|
+
const sourceInfo = {
|
|
336
|
+
mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]),
|
|
337
|
+
identity: flags.stdin ? { stdin: true } : { path: rest[0] },
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const engineOptions = {};
|
|
341
|
+
if (provider) engineOptions.execution = { provider };
|
|
342
|
+
const engine = await createEngine(engineOptions);
|
|
343
|
+
try {
|
|
344
|
+
const recognizeOptions = {};
|
|
345
|
+
if (flags['no-exif'] === true) recognizeOptions.applyExif = false;
|
|
346
|
+
if (region) recognizeOptions.region = region;
|
|
347
|
+
|
|
348
|
+
const result = await engine.recognizeEncoded(data, recognizeOptions);
|
|
349
|
+
|
|
350
|
+
// EXIF orientation: C++ decode path applies the pixel transform when
|
|
351
|
+
// applyExif is true (default). Parse the tag in JS for appliedTransforms
|
|
352
|
+
// reporting; the actual pixel rotation happens in C++.
|
|
353
|
+
const noExif = flags['no-exif'] === true;
|
|
354
|
+
const orientation = noExif ? 1 : parseExifOrientation(data);
|
|
355
|
+
const exifApplied = !noExif && orientation !== 1;
|
|
356
|
+
|
|
357
|
+
const sourceInfo = {
|
|
358
|
+
mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]),
|
|
359
|
+
identity: flags.stdin ? { stdin: true } : { path: rest[0] },
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const envelope = buildEnvelope(result, {
|
|
363
|
+
mediaType: sourceInfo.mediaType,
|
|
364
|
+
identity: sourceInfo.identity,
|
|
365
|
+
appliedTransforms: {
|
|
366
|
+
exifOrientation: orientation,
|
|
367
|
+
exifApplied,
|
|
368
|
+
sourceWidth: exifApplied ? result.imageHeight : result.imageWidth,
|
|
369
|
+
sourceHeight: exifApplied ? result.imageWidth : result.imageHeight,
|
|
370
|
+
pageWidth: result.imageWidth,
|
|
371
|
+
pageHeight: result.imageHeight,
|
|
372
|
+
region: region || undefined,
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
writeResult(envelope, format, stdout, 'recognize');
|
|
376
|
+
} finally {
|
|
377
|
+
await engine.close();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildDetectEnvelope(detectionResult, sourceInfo) {
|
|
382
|
+
// detectionResult comes through as OcrResult format: each line has empty
|
|
383
|
+
// text and detection score as confidence. Convert to detections[].
|
|
384
|
+
const detections = detectionResult.lines.map((line, index) => ({
|
|
385
|
+
id: `D${index}`,
|
|
386
|
+
score: line.confidence,
|
|
387
|
+
box: line.box,
|
|
388
|
+
}));
|
|
389
|
+
const page = {
|
|
390
|
+
index: 0,
|
|
391
|
+
width: detectionResult.imageWidth,
|
|
392
|
+
height: detectionResult.imageHeight,
|
|
393
|
+
coordinateSpace: 'pageSpace',
|
|
394
|
+
structure: 'detect',
|
|
395
|
+
detections,
|
|
396
|
+
modelBundleId: detectionResult.modelBundleId,
|
|
397
|
+
timingUs: detectionResult.timingUs,
|
|
398
|
+
};
|
|
399
|
+
return {
|
|
400
|
+
schemaVersion: SUPPORTED_SCHEMA_VERSION,
|
|
401
|
+
source: {
|
|
402
|
+
kind: 'image',
|
|
403
|
+
mediaType: sourceInfo.mediaType || null,
|
|
404
|
+
identity: sourceInfo.identity || {},
|
|
405
|
+
appliedTransforms: sourceInfo.appliedTransforms || { exifApplied: false },
|
|
406
|
+
},
|
|
407
|
+
pages: [page],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function runDetect(rest, flags, stdout, stderr) {
|
|
412
|
+
// Validate all flags before reading input (same ordering as recognize).
|
|
413
|
+
const provider = resolveProvider(flags);
|
|
414
|
+
resolveSchemaVersion(flags);
|
|
415
|
+
const region = parseRegion(flags);
|
|
416
|
+
|
|
417
|
+
const data = readImageInput(rest, flags, stderr);
|
|
418
|
+
|
|
419
|
+
const sourceInfo = {
|
|
420
|
+
mediaType: flags.stdin ? flags.type : inferMediaType(rest[0]),
|
|
421
|
+
identity: flags.stdin ? { stdin: true } : { path: rest[0] },
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const engineOptions = {};
|
|
425
|
+
if (provider) engineOptions.execution = { provider };
|
|
426
|
+
const engine = await createEngine(engineOptions);
|
|
427
|
+
try {
|
|
428
|
+
const detectOptions = {};
|
|
429
|
+
if (flags['no-exif'] === true) detectOptions.applyExif = false;
|
|
430
|
+
if (region) detectOptions.region = region;
|
|
431
|
+
|
|
432
|
+
const result = await engine.detect(data, detectOptions);
|
|
433
|
+
|
|
434
|
+
// EXIF reporting (same as recognize)
|
|
435
|
+
const noExif = flags['no-exif'] === true;
|
|
436
|
+
const orientation = noExif ? 1 : parseExifOrientation(data);
|
|
437
|
+
const exifApplied = !noExif && orientation !== 1;
|
|
438
|
+
|
|
439
|
+
const envelope = buildDetectEnvelope(result, {
|
|
440
|
+
mediaType: sourceInfo.mediaType,
|
|
441
|
+
identity: sourceInfo.identity,
|
|
442
|
+
appliedTransforms: {
|
|
443
|
+
exifOrientation: orientation,
|
|
444
|
+
exifApplied,
|
|
445
|
+
sourceWidth: exifApplied ? result.imageHeight : result.imageWidth,
|
|
446
|
+
sourceHeight: exifApplied ? result.imageWidth : result.imageHeight,
|
|
447
|
+
pageWidth: result.imageWidth,
|
|
448
|
+
pageHeight: result.imageHeight,
|
|
449
|
+
region: region || undefined,
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// detect output is always JSON (no --format flag)
|
|
454
|
+
stdout.write(JSON.stringify(envelope, null, 2) + '\n');
|
|
455
|
+
} finally {
|
|
456
|
+
await engine.close();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function writeResult(envelope, format, stdout, subcommand) {
|
|
461
|
+
if (format === 'text') {
|
|
462
|
+
// text: just the recognized text lines, one per line (cli-design.md §5)
|
|
463
|
+
for (const line of envelope.pages[0].lines) {
|
|
464
|
+
stdout.write(line.text + '\n');
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (format === 'jsonl') {
|
|
469
|
+
// single image = one page record (cli-design.md §9.3)
|
|
470
|
+
stdout.write(JSON.stringify(buildPageRecord(envelope)) + '\n');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// json — full DocumentResult envelope
|
|
474
|
+
stdout.write(JSON.stringify(envelope, null, 2) + '\n');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- help ---
|
|
478
|
+
function printHelp(stdout, verbose) {
|
|
479
|
+
stdout.write(`light-ocr ${PKG_VERSION} — local OCR for Node.js and Agents\n\n`);
|
|
480
|
+
stdout.write('Usage:\n');
|
|
481
|
+
stdout.write(' light-ocr recognize <path|--stdin> [flags] Recognize text in an image (default)\n');
|
|
482
|
+
stdout.write(' light-ocr detect <path|--stdin> [flags] Detect text regions only\n');
|
|
483
|
+
stdout.write(' light-ocr info --model-info | --version Show engine/version info\n');
|
|
484
|
+
stdout.write(' light-ocr <image> [flags] Implicit recognize\n\n');
|
|
485
|
+
stdout.write('Run `light-ocr <subcommand> --help` for flags of that subcommand.\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function printSubcommandHelp(stdout, subcommand) {
|
|
489
|
+
if (subcommand === 'recognize') {
|
|
490
|
+
stdout.write(`light-ocr recognize — recognize text in an image\n\n`);
|
|
491
|
+
stdout.write('Usage:\n light-ocr recognize <path> [flags]\n light-ocr recognize --stdin --type <mime> [flags]\n\n');
|
|
492
|
+
stdout.write('Flags:\n');
|
|
493
|
+
stdout.write(' --format json|jsonl|text Output format (default: json)\n');
|
|
494
|
+
stdout.write(' --region x,y,w,h Restrict recognition to a pageSpace rectangle\n');
|
|
495
|
+
stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n');
|
|
496
|
+
stdout.write(' --no-exif Disable EXIF orientation correction\n');
|
|
497
|
+
stdout.write(' --schema-version 1 Request exact output schema\n');
|
|
498
|
+
stdout.write(' --quiet Suppress non-error stderr\n');
|
|
499
|
+
stdout.write(' --score-threshold <n> Recognition score threshold (advanced)\n');
|
|
500
|
+
stdout.write(' --no-color Disable stderr color (advanced)\n');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (subcommand === 'detect') {
|
|
504
|
+
stdout.write(`light-ocr detect — detect text regions, no recognition\n\n`);
|
|
505
|
+
stdout.write('Usage:\n light-ocr detect <path> [flags]\n light-ocr detect --stdin --type <mime> [flags]\n\n');
|
|
506
|
+
stdout.write('Flags:\n');
|
|
507
|
+
stdout.write(' --region x,y,w,h Restrict detection to a pageSpace rectangle\n');
|
|
508
|
+
stdout.write(' --crop Reserved; currently fails as unsupported\n');
|
|
509
|
+
stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n');
|
|
510
|
+
stdout.write(' --no-exif Disable EXIF orientation correction\n');
|
|
511
|
+
stdout.write(' --schema-version 1 Request exact output schema\n');
|
|
512
|
+
stdout.write(' --quiet Suppress non-error stderr\n');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (subcommand === 'info') {
|
|
516
|
+
stdout.write(`light-ocr info — show engine or version info without reading an image\n\n`);
|
|
517
|
+
stdout.write('Usage:\n light-ocr info --model-info\n light-ocr info --version\n\n');
|
|
518
|
+
stdout.write('Flags (mutually exclusive):\n');
|
|
519
|
+
stdout.write(' --model-info Print EngineInfo JSON\n');
|
|
520
|
+
stdout.write(' --version Print npm/core/model version triple\n');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
printHelp(stdout, false);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// --- main ---
|
|
527
|
+
async function main(argv) {
|
|
528
|
+
const stdout = process.stdout;
|
|
529
|
+
const stderr = process.stderr;
|
|
530
|
+
|
|
531
|
+
let parsed;
|
|
532
|
+
try {
|
|
533
|
+
parsed = parseArgs(argv);
|
|
534
|
+
} catch (e) {
|
|
535
|
+
die(stderr, e.code, e.message);
|
|
536
|
+
return e.code;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (parsed.flags.help) {
|
|
540
|
+
if (parsed.positionals.length > 0 && SUBCOMMANDS.has(parsed.positionals[0])) {
|
|
541
|
+
printSubcommandHelp(stdout, parsed.positionals[0]);
|
|
542
|
+
} else {
|
|
543
|
+
printHelp(stdout, false);
|
|
544
|
+
}
|
|
545
|
+
return EXIT.success;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const { subcommand, rest } = resolveSubcommand(parsed.positionals);
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
if (subcommand === 'info') {
|
|
552
|
+
await runInfo(rest, parsed.flags, stdout, stderr);
|
|
553
|
+
} else if (subcommand === 'recognize') {
|
|
554
|
+
await runRecognize(rest, parsed.flags, stdout, stderr);
|
|
555
|
+
} else if (subcommand === 'detect') {
|
|
556
|
+
// Validate detect flag contract: detect does not expose --format
|
|
557
|
+
resolveFormat(parsed.flags, 'detect');
|
|
558
|
+
if (parsed.flags.crop !== undefined && parsed.flags.crop !== true) {
|
|
559
|
+
throw { code: EXIT.invalid_argument, message: '--crop is a boolean flag' };
|
|
560
|
+
}
|
|
561
|
+
if (parsed.flags.crop === true) {
|
|
562
|
+
throw {
|
|
563
|
+
code: EXIT.unsupported_capability,
|
|
564
|
+
message: '--crop is not available in this release; use detection boxes with --region',
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
await runDetect(rest, parsed.flags, stdout, stderr);
|
|
568
|
+
} else {
|
|
569
|
+
die(stderr, EXIT.usage, `unknown subcommand: ${subcommand}`);
|
|
570
|
+
return EXIT.usage;
|
|
571
|
+
}
|
|
572
|
+
return EXIT.success;
|
|
573
|
+
} catch (e) {
|
|
574
|
+
if (e instanceof OcrError) {
|
|
575
|
+
const code = OCR_ERROR_EXIT[e.code] ?? EXIT.internal_error;
|
|
576
|
+
die(stderr, code, `${e.message}${e.detail ? ` (${e.detail})` : ''}`);
|
|
577
|
+
return code;
|
|
578
|
+
}
|
|
579
|
+
if (e && typeof e.code === 'number') {
|
|
580
|
+
die(stderr, e.code, e.message);
|
|
581
|
+
return e.code;
|
|
582
|
+
}
|
|
583
|
+
die(stderr, EXIT.internal_error, e?.message || String(e));
|
|
584
|
+
return EXIT.internal_error;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (require.main === module) {
|
|
589
|
+
main(process.argv.slice(2)).then((code) => {
|
|
590
|
+
if (code !== EXIT.success) process.exitCode = code;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
module.exports = { main, parseArgs, EXIT, OCR_ERROR_EXIT, buildEnvelope, buildDetectEnvelope, buildPageRecord, resolveSchemaVersion, inferMediaType, parseRegion };
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"light-ocr": "./bin/light-ocr.cjs"
|
|
4
|
+
},
|
|
2
5
|
"bugs": {
|
|
3
6
|
"url": "https://github.com/arcships/light-ocr/issues"
|
|
4
7
|
},
|
|
5
8
|
"dependencies": {
|
|
6
|
-
"@arcships/light-ocr-model-ppocrv6-small": "0.3.
|
|
9
|
+
"@arcships/light-ocr-model-ppocrv6-small": "0.3.3"
|
|
7
10
|
},
|
|
8
11
|
"description": "Offline PP-OCRv6 OCR for Node.js, powered by an embeddable C++ core",
|
|
9
12
|
"engines": {
|
|
@@ -18,6 +21,7 @@
|
|
|
18
21
|
},
|
|
19
22
|
"files": [
|
|
20
23
|
"js/",
|
|
24
|
+
"bin/",
|
|
21
25
|
"README.md",
|
|
22
26
|
"LICENSE",
|
|
23
27
|
"NOTICE"
|
|
@@ -36,12 +40,12 @@
|
|
|
36
40
|
"module": "./js/index.mjs",
|
|
37
41
|
"name": "@arcships/light-ocr",
|
|
38
42
|
"optionalDependencies": {
|
|
39
|
-
"@arcships/light-ocr-darwin-arm64": "0.3.
|
|
40
|
-
"@arcships/light-ocr-darwin-x64": "0.3.
|
|
41
|
-
"@arcships/light-ocr-linux-arm64-gnu": "0.3.
|
|
42
|
-
"@arcships/light-ocr-linux-x64-gnu": "0.3.
|
|
43
|
-
"@arcships/light-ocr-win32-arm64": "0.3.
|
|
44
|
-
"@arcships/light-ocr-win32-x64": "0.3.
|
|
43
|
+
"@arcships/light-ocr-darwin-arm64": "0.3.3",
|
|
44
|
+
"@arcships/light-ocr-darwin-x64": "0.3.3",
|
|
45
|
+
"@arcships/light-ocr-linux-arm64-gnu": "0.3.3",
|
|
46
|
+
"@arcships/light-ocr-linux-x64-gnu": "0.3.3",
|
|
47
|
+
"@arcships/light-ocr-win32-arm64": "0.3.3",
|
|
48
|
+
"@arcships/light-ocr-win32-x64": "0.3.3"
|
|
45
49
|
},
|
|
46
50
|
"publishConfig": {
|
|
47
51
|
"access": "public",
|
|
@@ -53,5 +57,5 @@
|
|
|
53
57
|
},
|
|
54
58
|
"type": "commonjs",
|
|
55
59
|
"types": "./js/index.d.ts",
|
|
56
|
-
"version": "0.3.
|
|
60
|
+
"version": "0.3.3"
|
|
57
61
|
}
|