@arcships/light-ocr 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,36 +1,299 @@
1
1
  # @arcships/light-ocr
2
2
 
3
- The stable PP-OCRv6 Small entry for local image OCR on Node.js 22 and 24.
3
+ [![npm version](https://img.shields.io/npm/v/%40arcships%2Flight-ocr?color=CB3837)](https://www.npmjs.com/package/@arcships/light-ocr)
4
+ [![Node.js 22 and 24](https://img.shields.io/badge/Node.js-22%20%7C%2024-339933)](https://nodejs.org/)
5
+ [![Apache-2.0](https://img.shields.io/npm/l/%40arcships%2Flight-ocr)](https://github.com/arcships/light-ocr/blob/main/LICENSE)
6
+
7
+ ![light-ocr](https://raw.githubusercontent.com/arcships/light-ocr/main/docs/assets/light-ocr-banner.png)
8
+
9
+ **Offline image and PDF OCR for Node.js — prebuilt, typed, and
10
+ self-contained.**
11
+
12
+ `@arcships/light-ocr` is the stable PP-OCRv6 Small package. It recognizes
13
+ JPEG, PNG, PDF, decoded pixels, and multi-page image jobs without Python,
14
+ cloud APIs, postinstall downloads, or local native compilation.
15
+
16
+ | | |
17
+ | --- | --- |
18
+ | **Input** | JPEG, PNG, PDF, `Uint8Array`, or decoded pixel buffers |
19
+ | **Output** | text lines in reading order, confidence, quadrilateral boxes, and timing |
20
+ | **Runtime** | CommonJS + ESM + bundled TypeScript declarations |
21
+ | **Platforms** | macOS, Linux glibc, and Windows on x64 and ARM64 |
22
+ | **Node.js** | 22 and 24 |
23
+
24
+ ## Install
4
25
 
5
26
  ```bash
6
27
  npm install @arcships/light-ocr
28
+ ```
29
+
30
+ That single command installs the Small model, shared JavaScript runtime, and
31
+ the native OCR/PDF package matching the current platform. The package has no
32
+ install script and remains installable with scripts disabled.
33
+
34
+ ## Quick start
35
+
36
+ ### Recognize an image
37
+
38
+ ```js
39
+ import { createEngine } from "@arcships/light-ocr";
40
+ import { readFile } from "node:fs/promises";
41
+
42
+ const engine = await createEngine();
43
+
44
+ try {
45
+ const result = await engine.recognizeEncoded(
46
+ await readFile("receipt.png"),
47
+ );
48
+
49
+ for (const line of result.lines) {
50
+ console.log(line.text, line.confidence, line.box);
51
+ }
52
+ } finally {
53
+ await engine.close();
54
+ }
55
+ ```
56
+
57
+ CommonJS uses the same API:
58
+
59
+ ```js
60
+ const { createEngine } = require("@arcships/light-ocr");
61
+ ```
62
+
63
+ ### Stream pages from a PDF
64
+
65
+ ```js
66
+ import { recognizeDocument } from "@arcships/light-ocr";
67
+
68
+ for await (const page of recognizeDocument("report.pdf", {
69
+ dpi: 200,
70
+ pageRange: { start: 1, end: 10 },
71
+ })) {
72
+ console.log(`page ${page.index + 1}`);
73
+ for (const line of page.lines) console.log(line.text);
74
+ }
75
+ ```
76
+
77
+ `recognizeDocument()` accepts a file path, PDF/image bytes, or an array of
78
+ image paths and byte buffers. Pages are yielded as they finish, so callers do
79
+ not need to retain the complete document result.
80
+
81
+ ### Use the CLI
82
+
83
+ The `light-ocr` command is included:
84
+
85
+ ```bash
86
+ # Image OCR
7
87
  light-ocr image.png --format text
88
+
89
+ # PDF OCR; PDFium and the fallback font are already installed
90
+ light-ocr report.pdf --pages 1-10 --format jsonl
91
+
92
+ # Multiple images as one document
93
+ light-ocr document scan-1.png scan-2.png scan-3.png --format text
94
+
95
+ # Detection only
96
+ light-ocr detect screenshot.png
97
+
98
+ # Voluntary hardware/provider diagnostics
8
99
  light-ocr doctor --json
9
100
  ```
10
101
 
11
- The package exact-pins one model-free runtime, the Small model, and the native
12
- component for the current platform. It has no install script and its complete
13
- release closure is tested with npm offline and scripts disabled.
102
+ ## Everything needed for PDF OCR is included
103
+
104
+ The package does not defer essential files to an installer or first-run
105
+ download:
106
+
107
+ | Component | Distribution |
108
+ | --- | --- |
109
+ | PP-OCRv6 Small model | exact-pinned required npm dependency |
110
+ | JavaScript runtime and types | exact-pinned required npm dependency |
111
+ | Native OCR addon and runtime libraries | matching platform npm dependency |
112
+ | PDFium addon and shared library | inside the matching platform package |
113
+ | Noto Sans SC fallback font and OFL license | inside the matching platform package |
114
+
115
+ The bundled, checksum-pinned fallback font is used when a PDF references a
116
+ common Chinese font without embedding its glyphs. PDFium therefore renders the
117
+ page correctly before OCR instead of handing missing-glyph boxes to the model.
118
+
119
+ There is no postinstall fetch, runtime font/model/PDFium download, GitHub
120
+ access, compiler requirement, or system Chinese-font requirement on customer
121
+ machines.
122
+
123
+ ## Node.js API
124
+
125
+ ### Main exports
126
+
127
+ | Export | Purpose |
128
+ | --- | --- |
129
+ | `createEngine(options?)` | Create a reusable image OCR engine |
130
+ | `recognizeDocument(source, options?)` | Stream PDF or image pages with automatic cleanup |
131
+ | `createDocumentEngine(options?)` | Reuse one engine across multiple document jobs |
132
+ | `hasPdfSupport()` | Check that the bundled PDF renderer can be loaded |
133
+ | `modelProfile` | Inspect the selected model tier and language metadata |
134
+ | `OcrError` | Stable typed error with a machine-readable `code` |
135
+
136
+ ### Recognize decoded pixels
137
+
138
+ Applications that already decode images can avoid re-encoding:
139
+
140
+ ```js
141
+ const result = await engine.recognize({
142
+ data: rgbaBytes,
143
+ width,
144
+ height,
145
+ stride: width * 4,
146
+ pixelFormat: "rgba8",
147
+ });
148
+ ```
149
+
150
+ Supported pixel formats are `gray8`, `rgb8`, `bgr8`, and `rgba8`.
151
+
152
+ ### Region, cancellation, and execution provider
14
153
 
15
154
  ```js
16
- const { createEngine } = require('@arcships/light-ocr');
155
+ const controller = new AbortController();
156
+
157
+ const engine = await createEngine({
158
+ execution: { provider: "auto" },
159
+ queueCapacity: 4,
160
+ });
17
161
 
18
- const engine = await createEngine();
19
162
  try {
20
- const result = await engine.recognizeEncoded(imageBytes);
163
+ const result = await engine.recognizeEncoded(imageBytes, {
164
+ region: { x: 100, y: 80, width: 640, height: 320 },
165
+ applyExif: true,
166
+ signal: controller.signal,
167
+ });
168
+
21
169
  console.log(result.lines);
170
+ console.log(engine.info.execution.selectionTrace);
22
171
  } finally {
23
172
  await engine.close();
24
173
  }
25
174
  ```
26
175
 
27
- PDF and multi-page processing are intentionally separate:
176
+ Recognition runs on a dedicated worker instead of blocking the JavaScript main
177
+ thread. Queues are bounded, `AbortSignal` is supported, and engines must be
178
+ closed explicitly when the application is finished with them.
179
+
180
+ ### Document resource limits
181
+
182
+ PDF and multi-page calls apply conservative defaults before or during
183
+ rendering:
184
+
185
+ | Option | Default |
186
+ | --- | ---: |
187
+ | `dpi` | 150 |
188
+ | `maxFileBytes` | 100 MiB per input |
189
+ | `maxPages` | 100 |
190
+ | `maxPagePixels` | 4096 × 4096 |
191
+ | `maxTotalPixels` | 100 Mi pixels |
192
+
193
+ Limits are configurable per call. Violations reject with
194
+ `OcrError.code === "resource_limit_exceeded"`.
195
+
196
+ ### Result shape
197
+
198
+ Image OCR returns one line entry per recognized line:
199
+
200
+ ```json
201
+ {
202
+ "text": "TOTAL 42.00",
203
+ "confidence": 0.98,
204
+ "box": [
205
+ { "x": 24, "y": 80 },
206
+ { "x": 190, "y": 80 },
207
+ { "x": 190, "y": 108 },
208
+ { "x": 24, "y": 108 }
209
+ ]
210
+ }
211
+ ```
212
+
213
+ Coordinates use `pageSpace`: top-left origin, positive x to the right, and
214
+ positive y downward after EXIF correction. Document pages add a stable page
215
+ index, source metadata, dimensions, applied PDF transforms, and timing.
216
+
217
+ ## CLI reference
218
+
219
+ | Command | Purpose |
220
+ | --- | --- |
221
+ | `light-ocr [recognize] <image>` | Full image OCR; `recognize` is optional |
222
+ | `light-ocr <document.pdf>` | Stream a PDF through document OCR |
223
+ | `light-ocr document <source...>` | Process a PDF or multiple page images |
224
+ | `light-ocr detect <image>` | Return text-region boxes without recognition |
225
+ | `light-ocr info --version` | Print package/Core version information |
226
+ | `light-ocr info --model-info` | Print model and execution information |
227
+ | `light-ocr doctor --json` | Print voluntary system/provider diagnostics |
228
+
229
+ Image OCR supports `--format json|jsonl|text`, `--region x,y,w,h`,
230
+ `--provider auto|cpu|apple|webgpu`, `--stdin`, and automatic EXIF correction.
231
+ Document OCR adds `--pages N-M`, `--dpi`, and the page/file/pixel limit flags.
232
+
233
+ `detect` always emits structured JSON and does not accept `--format`.
234
+ Diagnostics do not include the hostname or a stable device identifier.
235
+
236
+ ## Platform acceleration
237
+
238
+ The default `provider: "auto"` chooses a qualified accelerator when available
239
+ and ends with CPU as the stable fallback:
240
+
241
+ | Platform | Auto path |
242
+ | --- | --- |
243
+ | macOS 15+ on Apple Silicon | Core ML, then CPU |
244
+ | macOS on Intel | CPU |
245
+ | Linux x64 with glibc | WebGPU through Vulkan, then CPU |
246
+ | Linux arm64 with glibc | CPU |
247
+ | Windows x64 | WebGPU through D3D12, then CPU |
248
+ | Windows arm64 | CPU |
249
+
250
+ Explicit `cpu`, `apple`, and `webgpu` requests fail closed when the requested
251
+ provider is unavailable; they do not silently switch providers.
252
+
253
+ ## Model tiers
254
+
255
+ This package is the stable Small tier. Tiny and Medium are opt-in previews with
256
+ the same API, types, result schema, and error model:
257
+
258
+ | Tier | Install | Model payload | Status |
259
+ | --- | --- | ---: | --- |
260
+ | Small | `npm install @arcships/light-ocr` | about 30 MB | stable default |
261
+ | Tiny | `npm install @arcships/light-ocr-tiny@next` | about 6.3 MB | preview; 49 languages, no Japanese |
262
+ | Medium | `npm install @arcships/light-ocr-medium@next` | about 139 MB | preview; quality-first |
263
+
264
+ Each facade installs only its selected model.
265
+
266
+ ## Errors and diagnostics
267
+
268
+ Expected failures reject with `OcrError` and a stable `code`, including
269
+ `invalid_argument`, `invalid_image`, `resource_limit_exceeded`,
270
+ `package_load_failed`, and `inference_failed`.
271
+
272
+ For environment reports:
28
273
 
29
274
  ```bash
30
- npm install @arcships/light-ocr-document@next
31
- light-ocr-document report.pdf --format jsonl
275
+ light-ocr doctor --json
32
276
  ```
33
277
 
34
- The Document package is preview software and runs its pinned PDF renderer's
35
- prebuild installer. Keeping that dependency explicit prevents PDF tooling from
36
- changing the stable image package's installation contract.
278
+ For package/PDF capability checks:
279
+
280
+ ```js
281
+ import { hasPdfSupport, modelProfile } from "@arcships/light-ocr";
282
+
283
+ console.log(hasPdfSupport());
284
+ console.log(modelProfile);
285
+ ```
286
+
287
+ ## Documentation
288
+
289
+ - [Project overview](https://github.com/arcships/light-ocr)
290
+ - [Node.js and CLI reference](https://github.com/arcships/light-ocr/blob/main/bindings/node/README.md)
291
+ - [PDF 0.5.6 release notes](https://github.com/arcships/light-ocr/blob/main/docs/releases/npm-0.5.6.en.md)
292
+ - [Agent Skill](https://github.com/arcships/light-ocr/blob/main/.agents/skills/local-ocr/SKILL.md)
293
+ - [Changelog](https://github.com/arcships/light-ocr/blob/main/CHANGELOG.md)
294
+ - [Issues](https://github.com/arcships/light-ocr/issues)
295
+
296
+ ## License
297
+
298
+ Apache-2.0. The package also carries the applicable licenses and notices for
299
+ the bundled PP-OCRv6 model, native runtimes, PDFium, and Noto Sans SC font.
package/package.json CHANGED
@@ -7,9 +7,9 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@arcships/light-ocr-model-ppocrv6-small": "0.3.4",
10
- "@arcships/light-ocr-runtime": "0.1.4"
10
+ "@arcships/light-ocr-runtime": "0.1.6"
11
11
  },
12
- "description": "Offline PP-OCRv6 Small OCR for Node.js — the stable default tier",
12
+ "description": "Offline image and PDF OCR for Node.js — the stable PP-OCRv6 Small tier",
13
13
  "engines": {
14
14
  "node": "^22.0.0 || ^24.0.0"
15
15
  },
@@ -18,7 +18,8 @@
18
18
  "import": "./src/index.mjs",
19
19
  "require": "./src/index.cjs",
20
20
  "types": "./src/index.d.ts"
21
- }
21
+ },
22
+ "./document-cli": "./src/document-cli.cjs"
22
23
  },
23
24
  "files": [
24
25
  "src/",
@@ -41,5 +42,5 @@
41
42
  },
42
43
  "type": "commonjs",
43
44
  "types": "./src/index.d.ts",
44
- "version": "0.5.4"
45
+ "version": "0.5.6"
45
46
  }
package/src/cli.cjs CHANGED
@@ -35,10 +35,40 @@ const cli = createCli({
35
35
  loadNative,
36
36
  });
37
37
 
38
+ function shouldUseDocumentCli(argv) {
39
+ if (argv[0] === 'document') return true;
40
+ const source = argv[0] === 'recognize' ? argv[1] : argv[0];
41
+ return typeof source === 'string' && /\.pdf$/i.test(source);
42
+ }
43
+
44
+ async function main(argv) {
45
+ if (shouldUseDocumentCli(argv)) {
46
+ const selectedArgs = argv[0] === 'document' ? argv.slice(1) : argv;
47
+ return require('./document-cli.cjs').main(selectedArgs);
48
+ }
49
+ const code = await cli.main(argv);
50
+ if (
51
+ code === cli.EXIT.success
52
+ && argv.includes('--help')
53
+ && !argv.some((argument) => ['recognize', 'detect', 'info', 'doctor'].includes(argument))
54
+ ) {
55
+ process.stdout.write(
56
+ '\nPDF: light-ocr <document.pdf> [--pages N-M] or '
57
+ + 'light-ocr document <source...> [options]\n',
58
+ );
59
+ }
60
+ return code;
61
+ }
62
+
38
63
  if (require.main === module) {
39
- cli.main(process.argv.slice(2)).then((code) => {
64
+ const argv = process.argv.slice(2);
65
+ main(argv).then((code) => {
40
66
  if (code !== cli.EXIT.success) process.exitCode = code;
41
67
  });
42
68
  }
43
69
 
44
- module.exports = cli;
70
+ module.exports = { ...cli, main };
71
+ Object.defineProperty(module.exports, 'shouldUseDocumentCli', {
72
+ value: shouldUseDocumentCli,
73
+ enumerable: false,
74
+ });
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const {
5
+ createDocumentEngine,
6
+ getVersion,
7
+ hasPdfSupport,
8
+ OcrError,
9
+ } = require('./index.cjs');
10
+
11
+ const USAGE = `light-ocr - local image, PDF, and multi-page OCR
12
+
13
+ Usage:
14
+ light-ocr [recognize] <document.pdf> [options]
15
+ light-ocr document <source...> [options]
16
+
17
+ Options:
18
+ --format <json|jsonl|text> Output format (default: json)
19
+ --pages <N|N-M> Inclusive PDF page range
20
+ --dpi <36-600> PDF raster DPI (default: 150)
21
+ --max-pages <n> Maximum pages (default: 100)
22
+ --max-page-pixels <n> Maximum rendered pixels per page
23
+ --max-total-pixels <n> Maximum rendered pixels for the request
24
+ --max-file-bytes <n> Maximum bytes per input
25
+ --provider <auto|cpu|apple|webgpu>
26
+ --quiet Suppress progress output
27
+ -h, --help Show help
28
+ -v, --version Show version`;
29
+
30
+ const EXIT_CODES = Object.freeze({
31
+ invalid_argument: 65,
32
+ invalid_image: 66,
33
+ unsupported_capability: 67,
34
+ invalid_model_bundle: 68,
35
+ resource_limit_exceeded: 69,
36
+ package_load_failed: 70,
37
+ inference_failed: 71,
38
+ internal_error: 72,
39
+ });
40
+
41
+ function argumentError(message) {
42
+ return new OcrError('invalid_argument', message);
43
+ }
44
+
45
+ function takeValue(args, index, flag) {
46
+ const value = args[index + 1];
47
+ if (value === undefined || value.startsWith('-')) {
48
+ throw argumentError(`${flag} requires a value`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function parseInteger(value, flag) {
54
+ if (!/^[1-9]\d*$/.test(value)) {
55
+ throw argumentError(`${flag} must be a positive integer`);
56
+ }
57
+ const parsed = Number(value);
58
+ if (!Number.isSafeInteger(parsed)) {
59
+ throw argumentError(`${flag} is too large`);
60
+ }
61
+ return parsed;
62
+ }
63
+
64
+ function parseArgs(argv) {
65
+ const args = [...argv];
66
+ if (args[0] === 'recognize') args.shift();
67
+ const sources = [];
68
+ const documentOptions = {};
69
+ let format = 'json';
70
+ let provider = 'auto';
71
+ let quiet = false;
72
+
73
+ for (let index = 0; index < args.length; index++) {
74
+ const arg = args[index];
75
+ if (arg === '--quiet') {
76
+ quiet = true;
77
+ } else if (arg === '--format') {
78
+ format = takeValue(args, index, arg);
79
+ index++;
80
+ if (!['json', 'jsonl', 'text'].includes(format)) {
81
+ throw argumentError('--format must be json, jsonl, or text');
82
+ }
83
+ } else if (arg === '--pages') {
84
+ const value = takeValue(args, index, arg);
85
+ index++;
86
+ const match = /^([1-9]\d*)(?:-([1-9]\d*))?$/.exec(value);
87
+ if (!match) throw argumentError('--pages must be N or N-M');
88
+ const start = parseInteger(match[1], '--pages');
89
+ const end = parseInteger(match[2] ?? match[1], '--pages');
90
+ if (end < start) throw argumentError('--pages end must not precede its start');
91
+ documentOptions.pageRange = { start, end };
92
+ } else if (
93
+ [
94
+ '--dpi',
95
+ '--max-pages',
96
+ '--max-page-pixels',
97
+ '--max-total-pixels',
98
+ '--max-file-bytes',
99
+ ].includes(arg)
100
+ ) {
101
+ const value = parseInteger(takeValue(args, index, arg), arg);
102
+ index++;
103
+ const keys = {
104
+ '--dpi': 'dpi',
105
+ '--max-pages': 'maxPages',
106
+ '--max-page-pixels': 'maxPagePixels',
107
+ '--max-total-pixels': 'maxTotalPixels',
108
+ '--max-file-bytes': 'maxFileBytes',
109
+ };
110
+ documentOptions[keys[arg]] = value;
111
+ } else if (arg === '--provider') {
112
+ provider = takeValue(args, index, arg);
113
+ index++;
114
+ if (!['auto', 'cpu', 'apple', 'webgpu'].includes(provider)) {
115
+ throw argumentError('--provider must be auto, cpu, apple, or webgpu');
116
+ }
117
+ } else if (arg.startsWith('-')) {
118
+ throw argumentError(`unknown option: ${arg}`);
119
+ } else {
120
+ sources.push(arg);
121
+ }
122
+ }
123
+ if (sources.length === 0) throw argumentError('at least one source is required');
124
+ return { documentOptions, format, provider, quiet, sources };
125
+ }
126
+
127
+ function writeLine(stream, value = '') {
128
+ stream.write(`${value}\n`);
129
+ }
130
+
131
+ async function main(
132
+ argv = process.argv.slice(2),
133
+ io = { stdout: process.stdout, stderr: process.stderr },
134
+ ) {
135
+ if (
136
+ argv.length === 0
137
+ || argv[0] === 'help'
138
+ || argv.includes('--help')
139
+ || argv.includes('-h')
140
+ ) {
141
+ writeLine(io.stdout, USAGE);
142
+ return 0;
143
+ }
144
+ if (argv.includes('--version') || argv.includes('-v')) {
145
+ writeLine(io.stdout, getVersion());
146
+ return 0;
147
+ }
148
+ if (argv[0] === 'info') {
149
+ writeLine(io.stdout, JSON.stringify({
150
+ name: '@arcships/light-ocr',
151
+ version: getVersion(),
152
+ pdfSupport: hasPdfSupport(),
153
+ }));
154
+ return 0;
155
+ }
156
+
157
+ let parsed;
158
+ let engine;
159
+ try {
160
+ parsed = parseArgs(argv);
161
+ engine = await createDocumentEngine({
162
+ engineOptions: { execution: { provider: parsed.provider } },
163
+ });
164
+ const source = parsed.sources.length === 1 ? parsed.sources[0] : parsed.sources;
165
+ const pages = parsed.format === 'json' ? [] : undefined;
166
+ let count = 0;
167
+ for await (const page of engine.recognizeDocument(source, parsed.documentOptions)) {
168
+ count++;
169
+ if (pages) pages.push(page);
170
+ if (parsed.format === 'jsonl') writeLine(io.stdout, JSON.stringify(page));
171
+ if (parsed.format === 'text') {
172
+ if (count > 1) writeLine(io.stdout);
173
+ for (const line of page.lines) writeLine(io.stdout, line.text);
174
+ }
175
+ if (!parsed.quiet) io.stderr.write(`\rProcessed page ${count}`);
176
+ }
177
+ if (!parsed.quiet) writeLine(io.stderr);
178
+ if (pages) {
179
+ writeLine(io.stdout, JSON.stringify({
180
+ schemaVersion: 1,
181
+ source: {
182
+ kind: pages[0]?.source.kind === 'pdf' ? 'pdf' : 'page-images',
183
+ mediaType: pages[0]?.source.mediaType ?? 'application/octet-stream',
184
+ identity: {},
185
+ pageCount: pages.length,
186
+ },
187
+ pages,
188
+ }, null, 2));
189
+ }
190
+ return 0;
191
+ } catch (error) {
192
+ if (error?.name === 'AbortError') {
193
+ writeLine(io.stderr, 'The operation was aborted');
194
+ return 72;
195
+ }
196
+ if (error instanceof OcrError) {
197
+ writeLine(io.stderr, `${error.code}: ${error.message}`);
198
+ return EXIT_CODES[error.code] ?? 72;
199
+ }
200
+ writeLine(io.stderr, `internal_error: ${error?.message ?? String(error)}`);
201
+ return 72;
202
+ } finally {
203
+ await engine?.close();
204
+ }
205
+ }
206
+
207
+ if (require.main === module) {
208
+ main().then(
209
+ (code) => {
210
+ process.exitCode = code;
211
+ },
212
+ (error) => {
213
+ console.error(error);
214
+ process.exitCode = 72;
215
+ },
216
+ );
217
+ }
218
+
219
+ module.exports = { main, parseArgs, USAGE };
@@ -0,0 +1,447 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs/promises');
4
+ const path = require('node:path');
5
+
6
+ const { OcrError } = require('@arcships/light-ocr-runtime');
7
+
8
+ const DEFAULTS = Object.freeze({
9
+ dpi: 150,
10
+ maxPages: 100,
11
+ maxPagePixels: 4096 * 4096,
12
+ maxTotalPixels: 100 * 1024 * 1024,
13
+ maxFileBytes: 100 * 1024 * 1024,
14
+ });
15
+
16
+ let pdfium;
17
+ let pdfiumLoaded = false;
18
+ let defaultCreateEngine;
19
+
20
+ function platformPdfiumPackage() {
21
+ const packages = {
22
+ 'darwin-arm64': '@arcships/light-ocr-darwin-arm64',
23
+ 'darwin-x64': '@arcships/light-ocr-darwin-x64',
24
+ 'linux-arm64': '@arcships/light-ocr-linux-arm64-gnu',
25
+ 'linux-x64': '@arcships/light-ocr-linux-x64-gnu',
26
+ 'win32-arm64': '@arcships/light-ocr-win32-arm64',
27
+ 'win32-x64': '@arcships/light-ocr-win32-x64',
28
+ };
29
+ return packages[`${process.platform}-${process.arch}`];
30
+ }
31
+
32
+ function loadPdfium() {
33
+ if (!pdfiumLoaded) {
34
+ pdfiumLoaded = true;
35
+ try {
36
+ const developmentModule = process.env.LIGHT_OCR_PDFIUM_MODULE;
37
+ if (developmentModule) {
38
+ pdfium = require(path.resolve(developmentModule));
39
+ } else {
40
+ const packageName = platformPdfiumPackage();
41
+ if (!packageName) {
42
+ throw new Error(`unsupported platform ${process.platform}-${process.arch}`);
43
+ }
44
+ pdfium = require(`${packageName}/pdfium`);
45
+ }
46
+ } catch {
47
+ // Workspace tests may use the upstream module directly. Published packages
48
+ // do not depend on it: production PDFium lives in the platform npm package.
49
+ try {
50
+ pdfium = require('pdfium-native');
51
+ } catch {
52
+ pdfium = undefined;
53
+ }
54
+ }
55
+ }
56
+ return pdfium;
57
+ }
58
+
59
+ function hasPdfSupport() {
60
+ return loadPdfium() !== undefined;
61
+ }
62
+
63
+ function getVersion() {
64
+ return require('../package.json').version;
65
+ }
66
+
67
+ function invalidArgument(message) {
68
+ return new OcrError('invalid_argument', message);
69
+ }
70
+
71
+ function positiveInteger(value, name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
72
+ if (value === undefined) return fallback;
73
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
74
+ throw invalidArgument(`${name} must be an integer between 1 and ${maximum}`);
75
+ }
76
+ return value;
77
+ }
78
+
79
+ function normalizeOptions(options) {
80
+ if (options === undefined) options = {};
81
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
82
+ throw invalidArgument('document options must be an object');
83
+ }
84
+ const normalized = {
85
+ ...options,
86
+ dpi: positiveInteger(options.dpi, 'dpi', DEFAULTS.dpi, 600),
87
+ maxPages: positiveInteger(options.maxPages, 'maxPages', DEFAULTS.maxPages, 10000),
88
+ maxPagePixels: positiveInteger(
89
+ options.maxPagePixels,
90
+ 'maxPagePixels',
91
+ DEFAULTS.maxPagePixels,
92
+ ),
93
+ maxTotalPixels: positiveInteger(
94
+ options.maxTotalPixels,
95
+ 'maxTotalPixels',
96
+ DEFAULTS.maxTotalPixels,
97
+ ),
98
+ maxFileBytes: positiveInteger(
99
+ options.maxFileBytes,
100
+ 'maxFileBytes',
101
+ DEFAULTS.maxFileBytes,
102
+ ),
103
+ };
104
+ if (normalized.dpi < 36) {
105
+ throw invalidArgument('dpi must be an integer between 36 and 600');
106
+ }
107
+ if (options.pageRange !== undefined) {
108
+ const range = options.pageRange;
109
+ if (
110
+ range === null
111
+ || typeof range !== 'object'
112
+ || Array.isArray(range)
113
+ || !Number.isSafeInteger(range.start)
114
+ || !Number.isSafeInteger(range.end)
115
+ || range.start < 1
116
+ || range.end < range.start
117
+ ) {
118
+ throw invalidArgument('pageRange must contain 1-based integers with start <= end');
119
+ }
120
+ }
121
+ return normalized;
122
+ }
123
+
124
+ function throwIfAborted(signal) {
125
+ if (signal?.aborted) {
126
+ throw signal.reason === undefined
127
+ ? new DOMException('The operation was aborted', 'AbortError')
128
+ : signal.reason;
129
+ }
130
+ }
131
+
132
+ function isBytes(value) {
133
+ return value instanceof Uint8Array;
134
+ }
135
+
136
+ function isPdf(value) {
137
+ return value?.length >= 4
138
+ && value[0] === 0x25
139
+ && value[1] === 0x50
140
+ && value[2] === 0x44
141
+ && value[3] === 0x46;
142
+ }
143
+
144
+ function mediaType(value) {
145
+ if (
146
+ value?.length >= 4
147
+ && value[0] === 0x89
148
+ && value[1] === 0x50
149
+ && value[2] === 0x4e
150
+ && value[3] === 0x47
151
+ ) {
152
+ return 'image/png';
153
+ }
154
+ if (value?.length >= 3 && value[0] === 0xff && value[1] === 0xd8 && value[2] === 0xff) {
155
+ return 'image/jpeg';
156
+ }
157
+ return 'application/octet-stream';
158
+ }
159
+
160
+ async function readInput(source, maxFileBytes) {
161
+ if (typeof source === 'string') {
162
+ const stats = await fs.stat(source);
163
+ if (stats.size > maxFileBytes) {
164
+ throw new OcrError(
165
+ 'resource_limit_exceeded',
166
+ `File size ${stats.size} exceeds maxFileBytes ${maxFileBytes}`,
167
+ );
168
+ }
169
+ return fs.readFile(source);
170
+ }
171
+ if (!isBytes(source)) {
172
+ throw invalidArgument('document inputs must be file paths or Uint8Array values');
173
+ }
174
+ if (source.byteLength > maxFileBytes) {
175
+ throw new OcrError(
176
+ 'resource_limit_exceeded',
177
+ `Input size ${source.byteLength} exceeds maxFileBytes ${maxFileBytes}`,
178
+ );
179
+ }
180
+ return source;
181
+ }
182
+
183
+ function linesFrom(result) {
184
+ return result.lines.map((line, index) => ({
185
+ id: `L${index}`,
186
+ text: line.text,
187
+ confidence: line.confidence,
188
+ box: line.box,
189
+ }));
190
+ }
191
+
192
+ function pageRect(page, box) {
193
+ if (
194
+ box
195
+ && [box.left, box.bottom, box.right, box.top].every(Number.isFinite)
196
+ ) {
197
+ return {
198
+ x: box.left,
199
+ y: box.bottom,
200
+ width: box.right - box.left,
201
+ height: box.top - box.bottom,
202
+ };
203
+ }
204
+ return { x: 0, y: 0, width: page.width, height: page.height };
205
+ }
206
+
207
+ async function* processPdf(engine, input, options) {
208
+ const renderer = loadPdfium();
209
+ if (!renderer) {
210
+ throw new OcrError(
211
+ 'unsupported_capability',
212
+ 'PDF support is unavailable; reinstall @arcships/light-ocr without --omit=optional',
213
+ );
214
+ }
215
+ const pdf = await readInput(input, options.maxFileBytes);
216
+ const document = await renderer.loadDocument(
217
+ Buffer.from(pdf.buffer, pdf.byteOffset, pdf.byteLength),
218
+ );
219
+ let totalPixels = 0;
220
+ try {
221
+ const start = options.pageRange?.start ?? 1;
222
+ const end = Math.min(options.pageRange?.end ?? document.pageCount, document.pageCount);
223
+ const requestedPages = Math.max(0, end - start + 1);
224
+ if (start > document.pageCount) {
225
+ throw invalidArgument(`pageRange starts after the document's ${document.pageCount} pages`);
226
+ }
227
+ if (requestedPages > options.maxPages) {
228
+ throw new OcrError(
229
+ 'resource_limit_exceeded',
230
+ `Page count ${requestedPages} exceeds maxPages ${options.maxPages}`,
231
+ );
232
+ }
233
+
234
+ for (let pageNumber = start; pageNumber <= end; pageNumber++) {
235
+ throwIfAborted(options.signal);
236
+ const page = await document.getPage(pageNumber - 1);
237
+ let result;
238
+ try {
239
+ const scale = options.dpi / 72;
240
+ const renderedWidth = Math.ceil(page.width * scale);
241
+ const renderedHeight = Math.ceil(page.height * scale);
242
+ const renderedPixels = renderedWidth * renderedHeight;
243
+ if (
244
+ !Number.isSafeInteger(renderedPixels)
245
+ || renderedPixels > options.maxPagePixels
246
+ ) {
247
+ throw new OcrError(
248
+ 'resource_limit_exceeded',
249
+ `Page ${pageNumber} rendered pixels ${renderedPixels} `
250
+ + `exceeds maxPagePixels ${options.maxPagePixels}`,
251
+ );
252
+ }
253
+ totalPixels += renderedPixels;
254
+ if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) {
255
+ throw new OcrError(
256
+ 'resource_limit_exceeded',
257
+ `Total rendered pixels ${totalPixels} exceeds maxTotalPixels `
258
+ + `${options.maxTotalPixels}`,
259
+ );
260
+ }
261
+
262
+ const renderStart = performance.now();
263
+ const png = await page.render({ scale });
264
+ const renderUs = Math.round((performance.now() - renderStart) * 1000);
265
+ throwIfAborted(options.signal);
266
+ const ocrStart = performance.now();
267
+ const ocr = await engine.recognizeEncoded(png, {
268
+ ...options.ocrOptions,
269
+ signal: options.signal,
270
+ });
271
+ const ocrUs = Math.round((performance.now() - ocrStart) * 1000);
272
+ result = {
273
+ index: pageNumber - 1,
274
+ width: ocr.imageWidth,
275
+ height: ocr.imageHeight,
276
+ coordinateSpace: 'pageSpace',
277
+ structure: 'ocr-order',
278
+ lines: linesFrom(ocr),
279
+ source: {
280
+ kind: 'pdf',
281
+ mediaType: 'application/pdf',
282
+ identity: { pageIndex: pageNumber - 1 },
283
+ appliedTransforms: {
284
+ pdf: {
285
+ rotation: Number(page.rotation ?? 0) * 90,
286
+ mediaBox: { x: 0, y: 0, width: page.width, height: page.height },
287
+ cropBox: pageRect(page, page.cropBox),
288
+ dpi: options.dpi,
289
+ scale,
290
+ },
291
+ },
292
+ },
293
+ timingUs: { total: renderUs + ocrUs, decode: renderUs, ocr: ocrUs },
294
+ modelBundleId: ocr.modelBundleId,
295
+ };
296
+ } finally {
297
+ await page.close();
298
+ }
299
+ yield result;
300
+ }
301
+ } finally {
302
+ await document.destroy();
303
+ }
304
+ }
305
+
306
+ async function* processImages(engine, inputs, options) {
307
+ if (inputs.length > options.maxPages) {
308
+ throw new OcrError(
309
+ 'resource_limit_exceeded',
310
+ `Page count ${inputs.length} exceeds maxPages ${options.maxPages}`,
311
+ );
312
+ }
313
+ let totalPixels = 0;
314
+ for (let index = 0; index < inputs.length; index++) {
315
+ throwIfAborted(options.signal);
316
+ const image = await readInput(inputs[index], options.maxFileBytes);
317
+ const started = performance.now();
318
+ const ocr = await engine.recognizeEncoded(image, {
319
+ ...options.ocrOptions,
320
+ applyExif: true,
321
+ signal: options.signal,
322
+ });
323
+ const ocrUs = Math.round((performance.now() - started) * 1000);
324
+ const pixels = ocr.imageWidth * ocr.imageHeight;
325
+ if (!Number.isSafeInteger(pixels) || pixels > options.maxPagePixels) {
326
+ throw new OcrError(
327
+ 'resource_limit_exceeded',
328
+ `Image ${index + 1} pixels ${pixels} exceeds maxPagePixels ${options.maxPagePixels}`,
329
+ );
330
+ }
331
+ totalPixels += pixels;
332
+ if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) {
333
+ throw new OcrError(
334
+ 'resource_limit_exceeded',
335
+ `Total image pixels ${totalPixels} exceeds maxTotalPixels ${options.maxTotalPixels}`,
336
+ );
337
+ }
338
+ yield {
339
+ index,
340
+ width: ocr.imageWidth,
341
+ height: ocr.imageHeight,
342
+ coordinateSpace: 'pageSpace',
343
+ structure: 'ocr-order',
344
+ lines: linesFrom(ocr),
345
+ source: {
346
+ kind: 'image',
347
+ mediaType: mediaType(image),
348
+ identity: { index },
349
+ appliedTransforms: {},
350
+ },
351
+ timingUs: { total: ocrUs, decode: 0, ocr: ocrUs },
352
+ modelBundleId: ocr.modelBundleId,
353
+ };
354
+ }
355
+ }
356
+
357
+ class DocumentEngine {
358
+ #engine;
359
+ #ownsEngine;
360
+ #closed = false;
361
+
362
+ constructor(engine, ownsEngine) {
363
+ this.#engine = engine;
364
+ this.#ownsEngine = ownsEngine;
365
+ }
366
+
367
+ async *recognizeDocument(source, options) {
368
+ if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed');
369
+ const normalized = normalizeOptions(options);
370
+ if (Array.isArray(source)) {
371
+ if (source.length === 0) throw invalidArgument('document source array must not be empty');
372
+ yield* processImages(this.#engine, source, normalized);
373
+ return;
374
+ }
375
+ if (typeof source !== 'string' && !isBytes(source)) {
376
+ throw invalidArgument(
377
+ 'document source must be a file path, Uint8Array, or a non-empty array of them',
378
+ );
379
+ }
380
+ if (
381
+ (typeof source === 'string' && path.extname(source).toLowerCase() === '.pdf')
382
+ || (isBytes(source) && isPdf(source))
383
+ ) {
384
+ yield* processPdf(this.#engine, source, normalized);
385
+ return;
386
+ }
387
+ yield* processImages(this.#engine, [source], normalized);
388
+ }
389
+
390
+ recognizePdf(source, options) {
391
+ if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed');
392
+ return processPdf(this.#engine, source, normalizeOptions(options));
393
+ }
394
+
395
+ recognizeImages(sources, options) {
396
+ if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed');
397
+ if (!Array.isArray(sources) || sources.length === 0) {
398
+ throw invalidArgument('image sources must be a non-empty array');
399
+ }
400
+ return processImages(this.#engine, sources, normalizeOptions(options));
401
+ }
402
+
403
+ async close() {
404
+ if (this.#closed) return;
405
+ this.#closed = true;
406
+ if (this.#ownsEngine) await this.#engine.close();
407
+ }
408
+ }
409
+
410
+ async function createDocumentEngine(options = {}) {
411
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
412
+ throw invalidArgument('createDocumentEngine options must be an object');
413
+ }
414
+ const ownsEngine = options.engine === undefined;
415
+ const engine = options.engine ?? await defaultCreateEngine(options.engineOptions);
416
+ return new DocumentEngine(engine, ownsEngine);
417
+ }
418
+
419
+ async function* recognizeDocument(source, options = {}) {
420
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
421
+ throw invalidArgument('recognizeDocument options must be an object');
422
+ }
423
+ const documentEngine = await createDocumentEngine({
424
+ engine: options.engine,
425
+ engineOptions: options.engineOptions,
426
+ });
427
+ try {
428
+ yield* documentEngine.recognizeDocument(source, options);
429
+ } finally {
430
+ await documentEngine.close();
431
+ }
432
+ }
433
+
434
+ function createDocumentApi(createEngine) {
435
+ if (typeof createEngine !== 'function') {
436
+ throw new TypeError('createDocumentApi requires createEngine');
437
+ }
438
+ defaultCreateEngine = createEngine;
439
+ return Object.freeze({
440
+ createDocumentEngine,
441
+ getVersion,
442
+ hasPdfSupport,
443
+ recognizeDocument,
444
+ });
445
+ }
446
+
447
+ module.exports = { createDocumentApi };
package/src/index.cjs CHANGED
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  const { createModelFacade } = require('@arcships/light-ocr-runtime/facade');
4
+ const { createDocumentApi } = require('./document.cjs');
4
5
 
5
- module.exports = createModelFacade({
6
+ const facade = createModelFacade({
6
7
  model: 'ppocrv6-small',
7
8
  modelPackage: '@arcships/light-ocr-model-ppocrv6-small',
8
9
  compatibleBundleIds: [
@@ -25,3 +26,8 @@ module.exports = createModelFacade({
25
26
  maturity: 'stable',
26
27
  },
27
28
  });
29
+
30
+ module.exports = Object.freeze({
31
+ ...facade,
32
+ ...createDocumentApi(facade.createEngine),
33
+ });
package/src/index.d.ts CHANGED
@@ -19,3 +19,112 @@ export const modelProfile: ModelProfile & {
19
19
  readonly model: BuiltInModel;
20
20
  readonly maturity: 'stable';
21
21
  };
22
+
23
+ export interface Point {
24
+ readonly x: number;
25
+ readonly y: number;
26
+ }
27
+
28
+ export interface Rect {
29
+ readonly x: number;
30
+ readonly y: number;
31
+ readonly width: number;
32
+ readonly height: number;
33
+ }
34
+
35
+ export interface DocumentLine {
36
+ readonly id: string;
37
+ readonly text: string;
38
+ readonly confidence: number;
39
+ readonly box: readonly [Point, Point, Point, Point];
40
+ }
41
+
42
+ export interface PdfTransform {
43
+ readonly rotation: number;
44
+ readonly mediaBox: Rect;
45
+ readonly cropBox: Rect;
46
+ readonly dpi: number;
47
+ readonly scale: number;
48
+ }
49
+
50
+ export interface PageSource {
51
+ readonly kind: 'image' | 'pdf';
52
+ readonly mediaType: string;
53
+ readonly identity: Readonly<Record<string, unknown>>;
54
+ readonly appliedTransforms: {
55
+ readonly pdf?: PdfTransform;
56
+ };
57
+ }
58
+
59
+ export interface DocumentPage {
60
+ readonly index: number;
61
+ readonly width: number;
62
+ readonly height: number;
63
+ readonly coordinateSpace: 'pageSpace';
64
+ readonly structure: 'ocr-order';
65
+ readonly lines: ReadonlyArray<DocumentLine>;
66
+ readonly source: PageSource;
67
+ readonly timingUs: {
68
+ readonly total: number;
69
+ readonly decode: number;
70
+ readonly ocr: number;
71
+ };
72
+ readonly modelBundleId?: string;
73
+ }
74
+
75
+ export type DocumentInput = string | Uint8Array;
76
+
77
+ export interface DocumentOptions {
78
+ /** Inclusive, one-based PDF page range. */
79
+ readonly pageRange?: { readonly start: number; readonly end: number };
80
+ /** PDF raster resolution. Must be an integer from 36 through 600. Default: 150. */
81
+ readonly dpi?: number;
82
+ /** Maximum bytes accepted for each input. Default: 100 MiB. */
83
+ readonly maxFileBytes?: number;
84
+ /** Maximum number of pages. Default: 100. */
85
+ readonly maxPages?: number;
86
+ /** Maximum rendered pixels for one page. Default: 4096 × 4096. */
87
+ readonly maxPagePixels?: number;
88
+ /** Maximum rendered pixels across the request. Default: 100 Mi pixels. */
89
+ readonly maxTotalPixels?: number;
90
+ readonly signal?: AbortSignal;
91
+ readonly ocrOptions?: import('@arcships/light-ocr-runtime').RecognizeOptions;
92
+ }
93
+
94
+ export interface DocumentEngine {
95
+ recognizePdf(
96
+ source: DocumentInput,
97
+ options?: DocumentOptions,
98
+ ): AsyncGenerator<DocumentPage>;
99
+ recognizeImages(
100
+ sources: ReadonlyArray<DocumentInput>,
101
+ options?: DocumentOptions,
102
+ ): AsyncGenerator<DocumentPage>;
103
+ recognizeDocument(
104
+ source: DocumentInput | ReadonlyArray<DocumentInput>,
105
+ options?: DocumentOptions,
106
+ ): AsyncGenerator<DocumentPage>;
107
+ close(): Promise<void>;
108
+ }
109
+
110
+ export interface CreateDocumentEngineOptions {
111
+ readonly engine?: OcrEngine;
112
+ readonly engineOptions?: CreateEngineOptions;
113
+ }
114
+
115
+ export interface RecognizeDocumentOptions extends DocumentOptions {
116
+ readonly engine?: OcrEngine;
117
+ readonly engineOptions?: CreateEngineOptions;
118
+ }
119
+
120
+ export function createDocumentEngine(
121
+ options?: CreateDocumentEngineOptions,
122
+ ): Promise<DocumentEngine>;
123
+
124
+ export function recognizeDocument(
125
+ source: DocumentInput | ReadonlyArray<DocumentInput>,
126
+ options?: RecognizeDocumentOptions,
127
+ ): AsyncGenerator<DocumentPage>;
128
+
129
+ export function getVersion(): string;
130
+ export function hasPdfSupport(): boolean;
package/src/index.mjs CHANGED
@@ -3,3 +3,7 @@ import cjs from './index.cjs';
3
3
  export const createEngine = cjs.createEngine;
4
4
  export const OcrError = cjs.OcrError;
5
5
  export const modelProfile = cjs.modelProfile;
6
+ export const createDocumentEngine = cjs.createDocumentEngine;
7
+ export const getVersion = cjs.getVersion;
8
+ export const hasPdfSupport = cjs.hasPdfSupport;
9
+ export const recognizeDocument = cjs.recognizeDocument;