@arcships/light-ocr 0.5.4 → 0.5.5
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 +8 -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,16 +1,18 @@
|
|
|
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
|
+
The stable PP-OCRv6 Small entry for local image and PDF OCR on Node.js 22 and 24.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install @arcships/light-ocr
|
|
7
7
|
light-ocr image.png --format text
|
|
8
|
+
light-ocr report.pdf --pages 1-10 --format jsonl
|
|
8
9
|
light-ocr doctor --json
|
|
9
10
|
```
|
|
10
11
|
|
|
11
12
|
The package exact-pins one model-free runtime, the Small model, and the native
|
|
12
|
-
component for the current platform. It has no install
|
|
13
|
-
release closure is tested with npm offline and scripts
|
|
13
|
+
component and PDFium renderer for the current platform. It has no install
|
|
14
|
+
script and its complete release closure is tested with npm offline and scripts
|
|
15
|
+
disabled.
|
|
14
16
|
|
|
15
17
|
```js
|
|
16
18
|
const { createEngine } = require('@arcships/light-ocr');
|
|
@@ -24,13 +26,6 @@ try {
|
|
|
24
26
|
}
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
npm install @arcships/light-ocr-document@next
|
|
31
|
-
light-ocr-document report.pdf --format jsonl
|
|
32
|
-
```
|
|
33
|
-
|
|
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.
|
|
29
|
+
The main package exports `recognizeDocument()` and `createDocumentEngine()`.
|
|
30
|
+
PDFium's native files are inside the platform npm package, so neither install
|
|
31
|
+
nor runtime performs a secondary download.
|
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.5"
|
|
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.5"
|
|
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;
|