@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 +276 -13
- package/package.json +5 -4
- package/src/cli.cjs +32 -2
- package/src/document-cli.cjs +219 -0
- package/src/document.cjs +447 -0
- package/src/index.cjs +7 -1
- package/src/index.d.ts +109 -0
- package/src/index.mjs +4 -0
package/README.md
CHANGED
|
@@ -1,36 +1,299 @@
|
|
|
1
1
|
# @arcships/light-ocr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@arcships/light-ocr)
|
|
4
|
+
[](https://nodejs.org/)
|
|
5
|
+
[](https://github.com/arcships/light-ocr/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+

|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
light-ocr-document report.pdf --format jsonl
|
|
275
|
+
light-ocr doctor --json
|
|
32
276
|
```
|
|
33
277
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
10
|
+
"@arcships/light-ocr-runtime": "0.1.6"
|
|
11
11
|
},
|
|
12
|
-
"description": "Offline
|
|
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.
|
|
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
|
-
|
|
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 };
|
package/src/document.cjs
ADDED
|
@@ -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
|
-
|
|
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;
|