@arcships/light-ocr 0.5.3 → 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 CHANGED
@@ -1,10 +1,31 @@
1
1
  # @arcships/light-ocr
2
2
 
3
- The default PP-OCRv6 Small entry for `light-ocr`. It exact-pins one compatible model-free runtime and one Small model package, and remains the only owner of the `light-ocr` command.
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
- light-ocr info --version
7
+ light-ocr image.png --format text
8
+ light-ocr report.pdf --pages 1-10 --format jsonl
9
+ light-ocr doctor --json
8
10
  ```
9
11
 
10
- `0.4.0` is the N2 topology cutover: the facade contains only Small model configuration and delegates the API, native loading, EXIF handling, and CLI implementation to the shared runtime. Tiny and Medium use separate packages and commands, so installing this package still installs only Small.
12
+ The package exact-pins one model-free runtime, the Small model, and the native
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.
16
+
17
+ ```js
18
+ const { createEngine } = require('@arcships/light-ocr');
19
+
20
+ const engine = await createEngine();
21
+ try {
22
+ const result = await engine.recognizeEncoded(imageBytes);
23
+ console.log(result.lines);
24
+ } finally {
25
+ await engine.close();
26
+ }
27
+ ```
28
+
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.3"
10
+ "@arcships/light-ocr-runtime": "0.1.5"
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/",
@@ -31,9 +32,6 @@
31
32
  "main": "./src/index.cjs",
32
33
  "module": "./src/index.mjs",
33
34
  "name": "@arcships/light-ocr",
34
- "optionalDependencies": {
35
- "pdfium-native": "0.6.1"
36
- },
37
35
  "publishConfig": {
38
36
  "access": "public",
39
37
  "provenance": true
@@ -44,5 +42,5 @@
44
42
  },
45
43
  "type": "commonjs",
46
44
  "types": "./src/index.d.ts",
47
- "version": "0.5.3"
45
+ "version": "0.5.5"
48
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,18 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const path = require('node:path');
4
-
5
- // Try to use workspace dependencies, fallback to local paths
6
- let createModelFacade;
7
- try {
8
- ({ createModelFacade } = require('@arcships/light-ocr-runtime/facade'));
9
- } catch {
10
- // Fallback to local runtime
11
- const facadePath = path.join(__dirname, '..', '..', 'runtime', 'src', 'facade.cjs');
12
- ({ createModelFacade } = require(facadePath));
13
- }
14
-
15
- const fs = require('node:fs');
3
+ const { createModelFacade } = require('@arcships/light-ocr-runtime/facade');
4
+ const { createDocumentApi } = require('./document.cjs');
16
5
 
17
6
  const facade = createModelFacade({
18
7
  model: 'ppocrv6-small',
@@ -38,249 +27,7 @@ const facade = createModelFacade({
38
27
  },
39
28
  });
40
29
 
41
- // Lazy load pdfium-native
42
- let pdfium = null;
43
- let pdfiumLoaded = false;
44
-
45
- function loadPdfium() {
46
- if (pdfiumLoaded) return pdfium;
47
- pdfiumLoaded = true;
48
- try {
49
- pdfium = require('pdfium-native');
50
- } catch {
51
- pdfium = null;
52
- }
53
- return pdfium;
54
- }
55
-
56
- function hasPdfSupport() {
57
- return loadPdfium() !== null;
58
- }
59
-
60
- async function* processPdf(engine, pdfBuffer, options = {}) {
61
- const pdfiumNative = loadPdfium();
62
- if (!pdfiumNative) {
63
- throw new facade.OcrError('unsupported_capability', 'PDF support not available. Install pdfium-native.');
64
- }
65
-
66
- const {
67
- pageRange,
68
- dpi = 150,
69
- maxPages = 100,
70
- maxPagePixels = 4096 * 4096,
71
- maxTotalPixels = 100 * 1024 * 1024,
72
- maxFileBytes = 100 * 1024 * 1024,
73
- signal,
74
- ocrOptions = {}
75
- } = options;
76
-
77
- // Check file size
78
- if (pdfBuffer.byteLength > maxFileBytes) {
79
- throw new facade.OcrError('resource_limit_exceeded',
80
- `File size ${pdfBuffer.byteLength} exceeds maxFileBytes ${maxFileBytes}`);
81
- }
82
-
83
- let totalPixels = 0;
84
-
85
- // Open PDF document
86
- const doc = await pdfiumNative.loadDocument(pdfBuffer);
87
-
88
- try {
89
- const pageCount = doc.pageCount;
90
-
91
- // Apply page range
92
- const start = pageRange?.start ? Math.max(1, pageRange.start) : 1;
93
- const end = pageRange?.end ? Math.min(pageCount, pageRange.end) : pageCount;
94
-
95
- // Check page limits
96
- if (end - start + 1 > maxPages) {
97
- throw new facade.OcrError('resource_limit_exceeded',
98
- `Page count ${end - start + 1} exceeds maxPages ${maxPages}`);
99
- }
100
-
101
- for (let i = start; i <= end; i++) {
102
- // Check abort signal
103
- if (signal?.aborted) {
104
- throw new facade.OcrError('internal_error', 'Operation aborted');
105
- }
106
-
107
- const page = await doc.getPage(i - 1); // 0-indexed
108
-
109
- // Get page dimensions
110
- const { width, height } = page;
111
- const pagePixels = width * height;
112
-
113
- // Check pixel limits
114
- if (pagePixels > maxPagePixels) {
115
- throw new facade.OcrError('resource_limit_exceeded',
116
- `Page ${i} pixels ${pagePixels} exceeds maxPagePixels ${maxPagePixels}`);
117
- }
118
-
119
- totalPixels += pagePixels;
120
- if (totalPixels > maxTotalPixels) {
121
- throw new facade.OcrError('resource_limit_exceeded',
122
- `Total pixels ${totalPixels} exceeds maxTotalPixels ${maxTotalPixels}`);
123
- }
124
-
125
- // Render page to PNG
126
- const renderStart = Date.now();
127
- const scale = dpi / 72; // PDF default is 72 DPI
128
- const pngBuffer = await page.render({ scale });
129
- const renderTime = (Date.now() - renderStart) * 1000;
130
-
131
- // OCR the rendered image
132
- const ocrStart = Date.now();
133
- const ocrResult = await engine.recognizeEncoded(pngBuffer, ocrOptions);
134
- const ocrTime = (Date.now() - ocrStart) * 1000;
135
-
136
- // Close page to free native memory
137
- await page.close();
138
-
139
- // Build page result
140
- const pageResult = {
141
- index: i - 1,
142
- width: ocrResult.imageWidth,
143
- height: ocrResult.imageHeight,
144
- coordinateSpace: 'pageSpace',
145
- structure: 'ocr-order',
146
- lines: ocrResult.lines.map((line, idx) => ({
147
- id: `L${idx}`,
148
- text: line.text,
149
- confidence: line.confidence,
150
- box: line.box
151
- })),
152
- source: {
153
- kind: 'pdf',
154
- mediaType: 'application/pdf',
155
- identity: { pageIndex: i - 1 },
156
- appliedTransforms: {
157
- pdf: {
158
- rotation: 0,
159
- mediaBox: { x: 0, y: 0, width, height },
160
- cropBox: { x: 0, y: 0, width, height },
161
- dpi,
162
- scale: dpi / 72
163
- }
164
- }
165
- },
166
- timingUs: {
167
- total: renderTime + ocrTime,
168
- decode: renderTime,
169
- ocr: ocrTime
170
- },
171
- modelBundleId: ocrResult.modelBundleId
172
- };
173
-
174
- yield pageResult;
175
- }
176
- } finally {
177
- doc.destroy();
178
- }
179
- }
180
-
181
- async function* processImages(engine, imageBuffers, options = {}) {
182
- const { signal, ocrOptions = {} } = options;
183
-
184
- for (let i = 0; i < imageBuffers.length; i++) {
185
- if (signal?.aborted) {
186
- throw new facade.OcrError('internal_error', 'Operation aborted');
187
- }
188
-
189
- const buffer = imageBuffers[i];
190
-
191
- const ocrStart = Date.now();
192
- const ocrResult = await engine.recognizeEncoded(buffer, {
193
- ...ocrOptions,
194
- applyExif: true
195
- });
196
- const ocrTime = (Date.now() - ocrStart) * 1000;
197
-
198
- const pageResult = {
199
- index: i,
200
- width: ocrResult.imageWidth,
201
- height: ocrResult.imageHeight,
202
- coordinateSpace: 'pageSpace',
203
- structure: 'ocr-order',
204
- lines: ocrResult.lines.map((line, idx) => ({
205
- id: `L${idx}`,
206
- text: line.text,
207
- confidence: line.confidence,
208
- box: line.box
209
- })),
210
- source: {
211
- kind: 'image',
212
- mediaType: 'image/png',
213
- identity: { index: i },
214
- appliedTransforms: {
215
- exif: { orientation: 1, applied: false }
216
- }
217
- },
218
- timingUs: {
219
- total: ocrTime,
220
- decode: 0,
221
- ocr: ocrTime
222
- },
223
- modelBundleId: ocrResult.modelBundleId
224
- };
225
-
226
- yield pageResult;
227
- }
228
- }
229
-
230
- async function* recognizeDocument(source, options = {}) {
231
- // Create engine if not provided
232
- let engine = options.engine;
233
- let engineCreated = false;
234
-
235
- if (!engine) {
236
- engine = await facade.createEngine();
237
- engineCreated = true;
238
- }
239
-
240
- try {
241
- let buffers;
242
- let isPdf = false;
243
-
244
- if (Array.isArray(source)) {
245
- // Multiple images
246
- buffers = [];
247
- for (const s of source) {
248
- if (typeof s === 'string') {
249
- buffers.push(fs.readFileSync(s));
250
- } else {
251
- buffers.push(s);
252
- }
253
- }
254
- } else if (typeof source === 'string') {
255
- // File path
256
- const ext = path.extname(source).toLowerCase();
257
- if (ext === '.pdf') {
258
- isPdf = true;
259
- buffers = [fs.readFileSync(source)];
260
- } else {
261
- buffers = [fs.readFileSync(source)];
262
- }
263
- } else {
264
- // Buffer
265
- isPdf = source[0] === 0x25 && source[1] === 0x50 && source[2] === 0x44 && source[3] === 0x46;
266
- buffers = [source];
267
- }
268
-
269
- if (isPdf) {
270
- yield* processPdf(engine, buffers[0], options);
271
- } else {
272
- yield* processImages(engine, buffers, options);
273
- }
274
- } finally {
275
- if (engineCreated) {
276
- await engine.close();
277
- }
278
- }
279
- }
280
-
281
- // Export facade + document capabilities
282
- module.exports = {
30
+ module.exports = Object.freeze({
283
31
  ...facade,
284
- hasPdfSupport,
285
- recognizeDocument
286
- };
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;