@arcships/light-ocr 0.3.0 → 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.
@@ -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/js/exif.cjs ADDED
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ // Minimal JPEG EXIF orientation parser (D106, D-N1-5).
4
+ // Reads the orientation tag from the JPEG APP1 segment and applies the
5
+ // corresponding pixel transform. Zero-dependency, stb-style.
6
+ //
7
+ // Only orientation (tag 0x0112) is read. Other EXIF fields are skipped.
8
+ // PNG eXIf is not handled here; PNG has no EXIF orientation in v1.
9
+ //
10
+ // References:
11
+ // - JEITA CP-3451C (Exif 2.3) section 4.6.4 (APP1 structure)
12
+ // - TIFF tag 0x0112 Orientation
13
+
14
+ // Parse the EXIF orientation value from a JPEG buffer.
15
+ // Returns 1..8 if found, or 1 (normal) if not present / not a JPEG.
16
+ function parseExifOrientation(buffer) {
17
+ if (!buffer || buffer.length < 4) return 1;
18
+ // JPEG must start with SOI marker 0xFFD8
19
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return 1;
20
+
21
+ let offset = 2;
22
+ while (offset + 1 < buffer.length) {
23
+ // Each marker: 0xFF then marker code
24
+ if (buffer[offset] !== 0xff) return 1;
25
+ const marker = buffer[offset + 1];
26
+ offset += 2;
27
+
28
+ // SOI (D8), EOI (D9), RSTn (D0-D7), TEM (01): no payload
29
+ if (marker === 0xd8 || marker === 0xd9) return 1;
30
+ if (marker >= 0xd0 && marker <= 0xd7) continue;
31
+ if (marker === 0x01) continue;
32
+
33
+ // SOS (DA): start of scan — EXIF would be before this
34
+ if (marker === 0xda) return 1;
35
+
36
+ // All other markers have a 2-byte length (including the length bytes)
37
+ if (offset + 1 >= buffer.length) return 1;
38
+ const length = (buffer[offset] << 8) | buffer[offset + 1];
39
+ if (length < 2 || offset + length > buffer.length) return 1;
40
+
41
+ // APP1 marker is 0xFFE1
42
+ if (marker === 0xe1) {
43
+ const orientation = tryParseApp1(buffer, offset, length);
44
+ if (orientation) return orientation;
45
+ }
46
+
47
+ offset += length;
48
+ }
49
+ return 1;
50
+ }
51
+
52
+ // Try to parse APP1 as EXIF. Returns orientation 1..8 or 0 if not EXIF.
53
+ function tryParseApp1(buffer, dataOffset, segmentLength) {
54
+ // APP1 data starts after the 2-byte length field
55
+ // EXIF header: "Exif\0\0" (6 bytes)
56
+ const exifHeader = dataOffset + 2;
57
+ if (exifHeader + 6 > dataOffset + segmentLength) return 0;
58
+ if (buffer[exifHeader] !== 0x45 || buffer[exifHeader + 1] !== 0x78 ||
59
+ buffer[exifHeader + 2] !== 0x69 || buffer[exifHeader + 3] !== 0x66 ||
60
+ buffer[exifHeader + 4] !== 0x00 || buffer[exifHeader + 5] !== 0x00) {
61
+ return 0; // not EXIF (could be XMP)
62
+ }
63
+
64
+ // TIFF header starts here
65
+ const tiffStart = exifHeader + 6;
66
+ if (tiffStart + 8 > dataOffset + segmentLength) return 0;
67
+
68
+ // Byte order: II (little-endian) or MM (big-endian)
69
+ const littleEndian = buffer[tiffStart] === 0x49 && buffer[tiffStart + 1] === 0x49;
70
+ const bigEndian = buffer[tiffStart] === 0x4d && buffer[tiffStart + 1] === 0x4d;
71
+ if (!littleEndian && !bigEndian) return 0;
72
+ const le = littleEndian;
73
+
74
+ // Magic number 42 (0x002A)
75
+ const magic = readU16(buffer, tiffStart + 2, le);
76
+ if (magic !== 0x002a) return 0;
77
+
78
+ // Offset to IFD0 from TIFF start
79
+ const ifdOffset = tiffStart + readU32(buffer, tiffStart + 4, le);
80
+ if (ifdOffset + 2 > dataOffset + segmentLength) return 0;
81
+
82
+ const entryCount = readU16(buffer, ifdOffset, le);
83
+ for (let i = 0; i < entryCount; i++) {
84
+ const entryOffset = ifdOffset + 2 + i * 12;
85
+ if (entryOffset + 12 > dataOffset + segmentLength) break;
86
+ const tag = readU16(buffer, entryOffset, le);
87
+ if (tag === 0x0112) { // Orientation
88
+ const type = readU16(buffer, entryOffset + 2, le);
89
+ const count = readU32(buffer, entryOffset + 4, le);
90
+ if (type === 3 && count === 1) { // SHORT
91
+ const value = readU16(buffer, entryOffset + 8, le);
92
+ if (value >= 1 && value <= 8) return value;
93
+ }
94
+ return 0;
95
+ }
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ function readU16(buffer, offset, littleEndian) {
101
+ if (littleEndian) return buffer[offset] | (buffer[offset + 1] << 8);
102
+ return (buffer[offset] << 8) | buffer[offset + 1];
103
+ }
104
+
105
+ function readU32(buffer, offset, littleEndian) {
106
+ if (littleEndian) {
107
+ return (buffer[offset]) |
108
+ (buffer[offset + 1] << 8) |
109
+ (buffer[offset + 2] << 16) |
110
+ (buffer[offset + 3] << 24);
111
+ }
112
+ return (buffer[offset] << 24) |
113
+ (buffer[offset + 1] << 16) |
114
+ (buffer[offset + 2] << 8) |
115
+ (buffer[offset + 3]);
116
+ }
117
+
118
+ // Apply EXIF orientation to RGB pixel data.
119
+ // input: { data: Uint8Array (RGB), width, height }
120
+ // orientation: 1..8
121
+ // Returns { data, width, height } in pageSpace (orientation-corrected).
122
+ function applyOrientation(pixels, orientation) {
123
+ if (orientation === 1) return pixels; // normal, no transform
124
+
125
+ const { data, width: w, height: h } = pixels;
126
+ const channels = 3;
127
+
128
+ switch (orientation) {
129
+ case 2: // flip horizontal
130
+ return flipHorizontal(data, w, h, channels);
131
+ case 3: // rotate 180
132
+ return rotate180(data, w, h, channels);
133
+ case 4: // flip vertical
134
+ return flipVertical(data, w, h, channels);
135
+ case 5: // transpose (flip horizontal + rotate 270 CW)
136
+ return transpose(data, w, h, channels);
137
+ case 6: // rotate 90 CW
138
+ return rotate90CW(data, w, h, channels);
139
+ case 7: // transverse (flip horizontal + rotate 90 CW)
140
+ return transverse(data, w, h, channels);
141
+ case 8: // rotate 90 CCW (= 270 CW)
142
+ return rotate90CCW(data, w, h, channels);
143
+ default:
144
+ return pixels;
145
+ }
146
+ }
147
+
148
+ function alloc(w, h, channels) {
149
+ return { data: new Uint8Array(w * h * channels), width: w, height: h };
150
+ }
151
+
152
+ function flipHorizontal(data, w, h, c) {
153
+ const out = alloc(w, h, c);
154
+ for (let y = 0; y < h; y++) {
155
+ for (let x = 0; x < w; x++) {
156
+ const src = (y * w + x) * c;
157
+ const dst = (y * w + (w - 1 - x)) * c;
158
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function flipVertical(data, w, h, c) {
165
+ const out = alloc(w, h, c);
166
+ for (let y = 0; y < h; y++) {
167
+ for (let x = 0; x < w; x++) {
168
+ const src = (y * w + x) * c;
169
+ const dst = ((h - 1 - y) * w + x) * c;
170
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function rotate180(data, w, h, c) {
177
+ const out = alloc(w, h, c);
178
+ for (let y = 0; y < h; y++) {
179
+ for (let x = 0; x < w; x++) {
180
+ const src = (y * w + x) * c;
181
+ const dst = ((h - 1 - y) * w + (w - 1 - x)) * c;
182
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function rotate90CW(data, w, h, c) {
189
+ // new dimensions: h x w
190
+ const out = alloc(h, w, c);
191
+ for (let y = 0; y < h; y++) {
192
+ for (let x = 0; x < w; x++) {
193
+ const src = (y * w + x) * c;
194
+ // (x, y) -> (h-1-y, x) in the new w'=h, h'=w grid
195
+ const dst = (x * h + (h - 1 - y)) * c;
196
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ function rotate90CCW(data, w, h, c) {
203
+ const out = alloc(h, w, c);
204
+ for (let y = 0; y < h; y++) {
205
+ for (let x = 0; x < w; x++) {
206
+ const src = (y * w + x) * c;
207
+ // (x, y) -> (y, w-1-x)
208
+ const dst = ((w - 1 - x) * h + y) * c;
209
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function transpose(data, w, h, c) {
216
+ // transpose: (x,y) -> (y,x)
217
+ const out = alloc(h, w, c);
218
+ for (let y = 0; y < h; y++) {
219
+ for (let x = 0; x < w; x++) {
220
+ const src = (y * w + x) * c;
221
+ const dst = (x * h + y) * c;
222
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function transverse(data, w, h, c) {
229
+ // transverse: (x,y) -> (h-1-y, w-1-x)
230
+ const out = alloc(h, w, c);
231
+ for (let y = 0; y < h; y++) {
232
+ for (let x = 0; x < w; x++) {
233
+ const src = (y * w + x) * c;
234
+ const dst = ((h - 1 - y) * h + (w - 1 - x)) * c;
235
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ module.exports = { parseExifOrientation, applyOrientation };
package/js/index.cjs CHANGED
@@ -131,6 +131,10 @@ class OcrEngineImpl {
131
131
  return this.#recognize('recognizeEncoded', data, options);
132
132
  }
133
133
 
134
+ detect(data, options = {}) {
135
+ return this.#recognize('detect', data, options);
136
+ }
137
+
134
138
  #recognize(nativeMethod, image, options) {
135
139
  let signal;
136
140
  let nativeOptions;
package/js/index.d.ts CHANGED
@@ -69,9 +69,17 @@ export interface RecognizeOptions {
69
69
  readonly signal?: AbortSignal;
70
70
  readonly useTextlineOrientation?: boolean;
71
71
  readonly detectionMaxSide?: number;
72
+ readonly applyExif?: boolean;
73
+ readonly region?: Rect;
72
74
  }
73
75
 
74
76
  export interface Point { readonly x: number; readonly y: number }
77
+ export interface Rect {
78
+ readonly x: number;
79
+ readonly y: number;
80
+ readonly width: number;
81
+ readonly height: number;
82
+ }
75
83
  export interface OcrLine {
76
84
  readonly text: string;
77
85
  readonly confidence: number;
@@ -256,7 +264,21 @@ export interface OcrEngine {
256
264
  readonly info: EngineInfo;
257
265
  recognize(image: RawImage, options?: RecognizeOptions): Promise<OcrResult>;
258
266
  recognizeEncoded(data: Uint8Array, options?: RecognizeOptions): Promise<OcrResult>;
267
+ detect(data: Uint8Array, options?: RecognizeOptions): Promise<DetectionResult>;
259
268
  close(): Promise<void>;
260
269
  }
261
270
 
271
+ export interface DetectionBox {
272
+ readonly score: number;
273
+ readonly box: readonly [Point, Point, Point, Point];
274
+ }
275
+
276
+ export interface DetectionResult {
277
+ readonly boxes: readonly DetectionBox[];
278
+ readonly imageWidth: number;
279
+ readonly imageHeight: number;
280
+ readonly modelBundleId: string;
281
+ readonly timingUs: TimingUs;
282
+ }
283
+
262
284
  export function createEngine(options?: CreateEngineOptions): Promise<OcrEngine>;
@@ -17,6 +17,7 @@ function platformIdentity() {
17
17
  const identities = {
18
18
  'darwin-arm64': { id: 'macos-arm64', os: 'darwin', architecture: 'arm64' },
19
19
  'darwin-x64': { id: 'macos-x64', os: 'darwin', architecture: 'x86_64' },
20
+ 'win32-arm64': { id: 'windows-arm64', os: 'win32', architecture: 'arm64' },
20
21
  'win32-x64': { id: 'windows-x64', os: 'win32', architecture: 'x86_64' },
21
22
  };
22
23
  if (key === 'linux-x64') {
@@ -30,6 +31,17 @@ function platformIdentity() {
30
31
  key,
31
32
  );
32
33
  }
34
+ if (key === 'linux-arm64') {
35
+ const report = process.report?.getReport?.();
36
+ if (report?.header?.glibcVersionRuntime) {
37
+ return { id: 'linux-arm64', os: 'linux', architecture: 'arm64', libc: 'glibc' };
38
+ }
39
+ throw adapterError(
40
+ 'unsupported_platform',
41
+ 'light-ocr currently supports Linux arm64 with glibc only',
42
+ key,
43
+ );
44
+ }
33
45
  const identity = identities[key];
34
46
  if (!identity) {
35
47
  throw adapterError('unsupported_platform', `light-ocr does not support ${key}`, key);
@@ -41,8 +53,10 @@ function platformPackage() {
41
53
  const packages = {
42
54
  'macos-arm64': '@arcships/light-ocr-darwin-arm64',
43
55
  'macos-x64': '@arcships/light-ocr-darwin-x64',
56
+ 'windows-arm64': '@arcships/light-ocr-win32-arm64',
44
57
  'windows-x64': '@arcships/light-ocr-win32-x64',
45
58
  'linux-x64': '@arcships/light-ocr-linux-x64-gnu',
59
+ 'linux-arm64': '@arcships/light-ocr-linux-arm64-gnu',
46
60
  };
47
61
  return packages[platformIdentity().id];
48
62
  }
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.0"
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,10 +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.0",
40
- "@arcships/light-ocr-darwin-x64": "0.3.0",
41
- "@arcships/light-ocr-linux-x64-gnu": "0.3.0",
42
- "@arcships/light-ocr-win32-x64": "0.3.0"
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"
43
49
  },
44
50
  "publishConfig": {
45
51
  "access": "public",
@@ -51,5 +57,5 @@
51
57
  },
52
58
  "type": "commonjs",
53
59
  "types": "./js/index.d.ts",
54
- "version": "0.3.0"
60
+ "version": "0.3.3"
55
61
  }