@arcships/light-ocr-runtime 0.1.0

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/src/exif.cjs ADDED
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ // Minimal JPEG EXIF orientation parser (D106, D-N1-5).
4
+ // Reads the orientation tag from the JPEG APP1 segment and applies the
5
+ // corresponding pixel transform. Zero-dependency, stb-style.
6
+ //
7
+ // Only orientation (tag 0x0112) is read. Other EXIF fields are skipped.
8
+ // PNG eXIf is not handled here; PNG has no EXIF orientation in v1.
9
+ //
10
+ // References:
11
+ // - JEITA CP-3451C (Exif 2.3) section 4.6.4 (APP1 structure)
12
+ // - TIFF tag 0x0112 Orientation
13
+
14
+ // Parse the EXIF orientation value from a JPEG buffer.
15
+ // Returns 1..8 if found, or 1 (normal) if not present / not a JPEG.
16
+ function parseExifOrientation(buffer) {
17
+ if (!buffer || buffer.length < 4) return 1;
18
+ // JPEG must start with SOI marker 0xFFD8
19
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return 1;
20
+
21
+ let offset = 2;
22
+ while (offset + 1 < buffer.length) {
23
+ // Each marker: 0xFF then marker code
24
+ if (buffer[offset] !== 0xff) return 1;
25
+ const marker = buffer[offset + 1];
26
+ offset += 2;
27
+
28
+ // SOI (D8), EOI (D9), RSTn (D0-D7), TEM (01): no payload
29
+ if (marker === 0xd8 || marker === 0xd9) return 1;
30
+ if (marker >= 0xd0 && marker <= 0xd7) continue;
31
+ if (marker === 0x01) continue;
32
+
33
+ // SOS (DA): start of scan — EXIF would be before this
34
+ if (marker === 0xda) return 1;
35
+
36
+ // All other markers have a 2-byte length (including the length bytes)
37
+ if (offset + 1 >= buffer.length) return 1;
38
+ const length = (buffer[offset] << 8) | buffer[offset + 1];
39
+ if (length < 2 || offset + length > buffer.length) return 1;
40
+
41
+ // APP1 marker is 0xFFE1
42
+ if (marker === 0xe1) {
43
+ const orientation = tryParseApp1(buffer, offset, length);
44
+ if (orientation) return orientation;
45
+ }
46
+
47
+ offset += length;
48
+ }
49
+ return 1;
50
+ }
51
+
52
+ // Try to parse APP1 as EXIF. Returns orientation 1..8 or 0 if not EXIF.
53
+ function tryParseApp1(buffer, dataOffset, segmentLength) {
54
+ // APP1 data starts after the 2-byte length field
55
+ // EXIF header: "Exif\0\0" (6 bytes)
56
+ const exifHeader = dataOffset + 2;
57
+ if (exifHeader + 6 > dataOffset + segmentLength) return 0;
58
+ if (buffer[exifHeader] !== 0x45 || buffer[exifHeader + 1] !== 0x78 ||
59
+ buffer[exifHeader + 2] !== 0x69 || buffer[exifHeader + 3] !== 0x66 ||
60
+ buffer[exifHeader + 4] !== 0x00 || buffer[exifHeader + 5] !== 0x00) {
61
+ return 0; // not EXIF (could be XMP)
62
+ }
63
+
64
+ // TIFF header starts here
65
+ const tiffStart = exifHeader + 6;
66
+ if (tiffStart + 8 > dataOffset + segmentLength) return 0;
67
+
68
+ // Byte order: II (little-endian) or MM (big-endian)
69
+ const littleEndian = buffer[tiffStart] === 0x49 && buffer[tiffStart + 1] === 0x49;
70
+ const bigEndian = buffer[tiffStart] === 0x4d && buffer[tiffStart + 1] === 0x4d;
71
+ if (!littleEndian && !bigEndian) return 0;
72
+ const le = littleEndian;
73
+
74
+ // Magic number 42 (0x002A)
75
+ const magic = readU16(buffer, tiffStart + 2, le);
76
+ if (magic !== 0x002a) return 0;
77
+
78
+ // Offset to IFD0 from TIFF start
79
+ const ifdOffset = tiffStart + readU32(buffer, tiffStart + 4, le);
80
+ if (ifdOffset + 2 > dataOffset + segmentLength) return 0;
81
+
82
+ const entryCount = readU16(buffer, ifdOffset, le);
83
+ for (let i = 0; i < entryCount; i++) {
84
+ const entryOffset = ifdOffset + 2 + i * 12;
85
+ if (entryOffset + 12 > dataOffset + segmentLength) break;
86
+ const tag = readU16(buffer, entryOffset, le);
87
+ if (tag === 0x0112) { // Orientation
88
+ const type = readU16(buffer, entryOffset + 2, le);
89
+ const count = readU32(buffer, entryOffset + 4, le);
90
+ if (type === 3 && count === 1) { // SHORT
91
+ const value = readU16(buffer, entryOffset + 8, le);
92
+ if (value >= 1 && value <= 8) return value;
93
+ }
94
+ return 0;
95
+ }
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ function readU16(buffer, offset, littleEndian) {
101
+ if (littleEndian) return buffer[offset] | (buffer[offset + 1] << 8);
102
+ return (buffer[offset] << 8) | buffer[offset + 1];
103
+ }
104
+
105
+ function readU32(buffer, offset, littleEndian) {
106
+ if (littleEndian) {
107
+ return (buffer[offset]) |
108
+ (buffer[offset + 1] << 8) |
109
+ (buffer[offset + 2] << 16) |
110
+ (buffer[offset + 3] << 24);
111
+ }
112
+ return (buffer[offset] << 24) |
113
+ (buffer[offset + 1] << 16) |
114
+ (buffer[offset + 2] << 8) |
115
+ (buffer[offset + 3]);
116
+ }
117
+
118
+ // Apply EXIF orientation to RGB pixel data.
119
+ // input: { data: Uint8Array (RGB), width, height }
120
+ // orientation: 1..8
121
+ // Returns { data, width, height } in pageSpace (orientation-corrected).
122
+ function applyOrientation(pixels, orientation) {
123
+ if (orientation === 1) return pixels; // normal, no transform
124
+
125
+ const { data, width: w, height: h } = pixels;
126
+ const channels = 3;
127
+
128
+ switch (orientation) {
129
+ case 2: // flip horizontal
130
+ return flipHorizontal(data, w, h, channels);
131
+ case 3: // rotate 180
132
+ return rotate180(data, w, h, channels);
133
+ case 4: // flip vertical
134
+ return flipVertical(data, w, h, channels);
135
+ case 5: // transpose (flip horizontal + rotate 270 CW)
136
+ return transpose(data, w, h, channels);
137
+ case 6: // rotate 90 CW
138
+ return rotate90CW(data, w, h, channels);
139
+ case 7: // transverse (flip horizontal + rotate 90 CW)
140
+ return transverse(data, w, h, channels);
141
+ case 8: // rotate 90 CCW (= 270 CW)
142
+ return rotate90CCW(data, w, h, channels);
143
+ default:
144
+ return pixels;
145
+ }
146
+ }
147
+
148
+ function alloc(w, h, channels) {
149
+ return { data: new Uint8Array(w * h * channels), width: w, height: h };
150
+ }
151
+
152
+ function flipHorizontal(data, w, h, c) {
153
+ const out = alloc(w, h, c);
154
+ for (let y = 0; y < h; y++) {
155
+ for (let x = 0; x < w; x++) {
156
+ const src = (y * w + x) * c;
157
+ const dst = (y * w + (w - 1 - x)) * c;
158
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+
164
+ function flipVertical(data, w, h, c) {
165
+ const out = alloc(w, h, c);
166
+ for (let y = 0; y < h; y++) {
167
+ for (let x = 0; x < w; x++) {
168
+ const src = (y * w + x) * c;
169
+ const dst = ((h - 1 - y) * w + x) * c;
170
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function rotate180(data, w, h, c) {
177
+ const out = alloc(w, h, c);
178
+ for (let y = 0; y < h; y++) {
179
+ for (let x = 0; x < w; x++) {
180
+ const src = (y * w + x) * c;
181
+ const dst = ((h - 1 - y) * w + (w - 1 - x)) * c;
182
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ function rotate90CW(data, w, h, c) {
189
+ // new dimensions: h x w
190
+ const out = alloc(h, w, c);
191
+ for (let y = 0; y < h; y++) {
192
+ for (let x = 0; x < w; x++) {
193
+ const src = (y * w + x) * c;
194
+ // (x, y) -> (h-1-y, x) in the new w'=h, h'=w grid
195
+ const dst = (x * h + (h - 1 - y)) * c;
196
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ function rotate90CCW(data, w, h, c) {
203
+ const out = alloc(h, w, c);
204
+ for (let y = 0; y < h; y++) {
205
+ for (let x = 0; x < w; x++) {
206
+ const src = (y * w + x) * c;
207
+ // (x, y) -> (y, w-1-x)
208
+ const dst = ((w - 1 - x) * h + y) * c;
209
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
210
+ }
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function transpose(data, w, h, c) {
216
+ // transpose: (x,y) -> (y,x)
217
+ const out = alloc(h, w, c);
218
+ for (let y = 0; y < h; y++) {
219
+ for (let x = 0; x < w; x++) {
220
+ const src = (y * w + x) * c;
221
+ const dst = (x * h + y) * c;
222
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function transverse(data, w, h, c) {
229
+ // transverse: (x,y) -> (h-1-y, w-1-x)
230
+ const out = alloc(h, w, c);
231
+ for (let y = 0; y < h; y++) {
232
+ for (let x = 0; x < w; x++) {
233
+ const src = (y * w + x) * c;
234
+ const dst = ((h - 1 - y) * h + (w - 1 - x)) * c;
235
+ for (let i = 0; i < c; i++) out.data[dst + i] = data[src + i];
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+
241
+ module.exports = { parseExifOrientation, applyOrientation };
package/src/facade.cjs ADDED
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const runtime = require('./index.cjs');
7
+
8
+ function createModelFacade(config) {
9
+ if (!config || typeof config !== 'object') {
10
+ throw new TypeError('model facade config must be an object');
11
+ }
12
+ const {
13
+ model,
14
+ modelPackage,
15
+ compatibleBundleIds,
16
+ appleBundleIds = compatibleBundleIds,
17
+ developmentBundlePath,
18
+ profile,
19
+ } = config;
20
+ if (typeof model !== 'string' || model === '') {
21
+ throw new TypeError('model facade config requires model');
22
+ }
23
+ if (typeof modelPackage !== 'string' || modelPackage === '') {
24
+ throw new TypeError('model facade config requires modelPackage');
25
+ }
26
+ if (!Array.isArray(compatibleBundleIds) || compatibleBundleIds.length === 0) {
27
+ throw new TypeError('model facade config requires compatibleBundleIds');
28
+ }
29
+ if (!profile || typeof profile !== 'object' || profile.model !== model) {
30
+ throw new TypeError('model facade config requires a matching profile');
31
+ }
32
+
33
+ function resolveBuiltInBundle(selectedModel, requireApple) {
34
+ if (selectedModel !== model) {
35
+ throw new runtime.OcrError(
36
+ 'invalid_argument',
37
+ `model must be ${JSON.stringify(model)}`,
38
+ );
39
+ }
40
+
41
+ let manifestPath;
42
+ if (developmentBundlePath) {
43
+ const candidate = path.resolve(developmentBundlePath, 'manifest.json');
44
+ if (fs.existsSync(candidate)) manifestPath = candidate;
45
+ }
46
+ if (!manifestPath) {
47
+ try {
48
+ manifestPath = require.resolve(`${modelPackage}/bundle/manifest.json`);
49
+ } catch (cause) {
50
+ throw new runtime.OcrError(
51
+ 'package_load_failed',
52
+ `Unable to locate the built-in ${model} model`,
53
+ `Reinstall ${modelPackage}; ${cause instanceof Error ? cause.message : String(cause)}`,
54
+ );
55
+ }
56
+ }
57
+
58
+ let manifest;
59
+ try {
60
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
61
+ } catch (cause) {
62
+ throw new runtime.OcrError(
63
+ 'package_load_failed',
64
+ 'Unable to read the built-in model manifest',
65
+ cause instanceof Error ? cause.message : String(cause),
66
+ );
67
+ }
68
+ const accepted = requireApple ? appleBundleIds : compatibleBundleIds;
69
+ if (!accepted.includes(manifest.bundleId)) {
70
+ throw new runtime.OcrError(
71
+ 'package_load_failed',
72
+ `The installed model package is incompatible with the ${model} facade`,
73
+ `expected ${accepted.join(' or ')}, received ${String(manifest.bundleId)}`,
74
+ );
75
+ }
76
+ return path.dirname(manifestPath);
77
+ }
78
+
79
+ function resolveCreateOptions(options) {
80
+ if (options === undefined) options = {};
81
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
82
+ throw new runtime.OcrError('invalid_argument', 'createEngine options must be an object');
83
+ }
84
+ const hasModel = Object.prototype.hasOwnProperty.call(options, 'model');
85
+ const hasBundlePath = Object.prototype.hasOwnProperty.call(options, 'bundlePath');
86
+ if (hasModel && hasBundlePath) {
87
+ throw new runtime.OcrError(
88
+ 'invalid_argument',
89
+ 'model and bundlePath cannot be used together',
90
+ );
91
+ }
92
+ if (hasBundlePath) return options;
93
+ const selectedModel = hasModel ? options.model : model;
94
+ const requireApple = options.execution?.provider === 'apple';
95
+ const resolved = {
96
+ ...options,
97
+ bundlePath: resolveBuiltInBundle(selectedModel, requireApple),
98
+ };
99
+ delete resolved.model;
100
+ return resolved;
101
+ }
102
+
103
+ async function createEngine(options) {
104
+ return runtime.createEngine(resolveCreateOptions(options));
105
+ }
106
+
107
+ const modelProfile = Object.freeze({
108
+ ...profile,
109
+ excludedLanguages: Object.freeze([...(profile?.excludedLanguages || [])]),
110
+ });
111
+
112
+ return Object.freeze({
113
+ createEngine,
114
+ OcrError: runtime.OcrError,
115
+ modelProfile,
116
+ });
117
+ }
118
+
119
+ module.exports = { createModelFacade };
package/src/index.cjs ADDED
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ const { loadNative } = require('./load-native.cjs');
4
+
5
+ class OcrError extends Error {
6
+ constructor(code, message, detail) {
7
+ super(message);
8
+ this.name = 'OcrError';
9
+ this.code = code;
10
+ if (detail !== undefined && detail !== '') this.detail = detail;
11
+ }
12
+ }
13
+
14
+ function normalizeNativeError(error) {
15
+ if (error && error.name === 'OcrError') {
16
+ Object.setPrototypeOf(error, OcrError.prototype);
17
+ }
18
+ return error;
19
+ }
20
+
21
+ function deepFreeze(value) {
22
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
23
+ for (const child of Object.values(value)) deepFreeze(child);
24
+ return Object.freeze(value);
25
+ }
26
+
27
+ function validateSignal(signal) {
28
+ if (
29
+ signal === null ||
30
+ typeof signal !== 'object' ||
31
+ typeof signal.aborted !== 'boolean' ||
32
+ typeof signal.addEventListener !== 'function' ||
33
+ typeof signal.removeEventListener !== 'function'
34
+ ) {
35
+ throw new OcrError('invalid_argument', 'signal must be an AbortSignal');
36
+ }
37
+ }
38
+
39
+ function abortReason(signal) {
40
+ return signal.reason === undefined
41
+ ? new DOMException('The operation was aborted', 'AbortError')
42
+ : signal.reason;
43
+ }
44
+
45
+ function resolveCreateOptions(options) {
46
+ if (options === undefined) options = {};
47
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
48
+ throw new OcrError('invalid_argument', 'createEngine options must be an object');
49
+ }
50
+ const hasModel = Object.prototype.hasOwnProperty.call(options, 'model');
51
+ const hasBundlePath = Object.prototype.hasOwnProperty.call(options, 'bundlePath');
52
+ if (hasModel) {
53
+ throw new OcrError(
54
+ 'invalid_argument',
55
+ '@arcships/light-ocr-runtime is model-free; pass bundlePath or use a model facade',
56
+ );
57
+ }
58
+ if (!hasBundlePath || typeof options.bundlePath !== 'string' || options.bundlePath === '') {
59
+ throw new OcrError(
60
+ 'invalid_argument',
61
+ '@arcships/light-ocr-runtime requires a non-empty bundlePath',
62
+ );
63
+ }
64
+ return options;
65
+ }
66
+
67
+ class OcrEngineImpl {
68
+ #native;
69
+ #closePromise;
70
+
71
+ constructor(nativeEngine) {
72
+ this.#native = nativeEngine;
73
+ this.info = deepFreeze(nativeEngine.info);
74
+ Object.defineProperty(this, 'info', { writable: false, configurable: false });
75
+ }
76
+
77
+ recognize(image, options = {}) {
78
+ return this.#recognize('recognize', image, options);
79
+ }
80
+
81
+ recognizeEncoded(data, options = {}) {
82
+ return this.#recognize('recognizeEncoded', data, options);
83
+ }
84
+
85
+ detect(data, options = {}) {
86
+ return this.#recognize('detect', data, options);
87
+ }
88
+
89
+ #recognize(nativeMethod, image, options) {
90
+ let signal;
91
+ let nativeOptions;
92
+ try {
93
+ if (options === null || typeof options !== 'object' || Array.isArray(options)) {
94
+ throw new OcrError('invalid_argument', 'recognize options must be an object');
95
+ }
96
+ signal = options.signal;
97
+ if (signal !== undefined) {
98
+ validateSignal(signal);
99
+ if (signal.aborted) return Promise.reject(abortReason(signal));
100
+ }
101
+ nativeOptions = { ...options };
102
+ delete nativeOptions.signal;
103
+ } catch (error) {
104
+ return Promise.reject(normalizeNativeError(error));
105
+ }
106
+
107
+ let operation;
108
+ try {
109
+ operation = this.#native[nativeMethod](image, nativeOptions);
110
+ } catch (error) {
111
+ return Promise.reject(normalizeNativeError(error));
112
+ }
113
+
114
+ return new Promise((resolve, reject) => {
115
+ let settled = false;
116
+ const cleanup = () => {
117
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort);
118
+ };
119
+ const settle = (callback, value) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ cleanup();
123
+ callback(value);
124
+ };
125
+ const onAbort = () => {
126
+ if (settled) return;
127
+ try {
128
+ this.#native.cancel(operation.requestId);
129
+ } catch {
130
+ // Public cancellation still wins. Native teardown owns any remaining work.
131
+ }
132
+ settle(reject, abortReason(signal));
133
+ };
134
+
135
+ operation.promise.then(
136
+ (value) => settle(resolve, value),
137
+ (error) => settle(reject, normalizeNativeError(error)),
138
+ );
139
+
140
+ if (signal !== undefined) {
141
+ signal.addEventListener('abort', onAbort, { once: true });
142
+ if (signal.aborted) onAbort();
143
+ }
144
+ });
145
+ }
146
+
147
+ close() {
148
+ if (this.#closePromise === undefined) {
149
+ try {
150
+ this.#closePromise = Promise.resolve(this.#native.close()).catch((error) => {
151
+ throw normalizeNativeError(error);
152
+ });
153
+ } catch (error) {
154
+ this.#closePromise = Promise.reject(normalizeNativeError(error));
155
+ }
156
+ }
157
+ return this.#closePromise;
158
+ }
159
+ }
160
+
161
+ let nativeRuntime;
162
+
163
+ async function createEngine(options) {
164
+ try {
165
+ const resolvedOptions = resolveCreateOptions(options);
166
+ if (!nativeRuntime) nativeRuntime = loadNative();
167
+ const nativeEngine = await nativeRuntime.binding.createEngine(
168
+ resolvedOptions,
169
+ nativeRuntime.runtimePolicy,
170
+ );
171
+ return new OcrEngineImpl(nativeEngine);
172
+ } catch (error) {
173
+ throw normalizeNativeError(error);
174
+ }
175
+ }
176
+
177
+ module.exports = { createEngine, OcrError };