@arcships/light-ocr 0.3.4 → 0.5.1

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/js/exif.cjs DELETED
@@ -1,241 +0,0 @@
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/js/index.cjs DELETED
@@ -1,226 +0,0 @@
1
- 'use strict';
2
-
3
- const fs = require('node:fs');
4
- const path = require('node:path');
5
-
6
- const { loadNative } = require('./load-native.cjs');
7
-
8
- const DEFAULT_MODEL = 'ppocrv6-small';
9
- const MODEL_PACKAGE = '@arcships/light-ocr-model-ppocrv6-small';
10
- const CPU_BUNDLE_ID = 'ppocrv6-small-onnx-20260714.2';
11
- const APPLE_BUNDLE_ID = 'ppocrv6-small-apple-20260715.1';
12
- const WEBGPU_BUNDLE_ID = 'ppocrv6-small-webgpu-20260719.1';
13
- const NATIVE_BUNDLE_ID = 'ppocrv6-small-native-20260719.1';
14
-
15
- class OcrError extends Error {
16
- constructor(code, message, detail) {
17
- super(message);
18
- this.name = 'OcrError';
19
- this.code = code;
20
- if (detail !== undefined && detail !== '') this.detail = detail;
21
- }
22
- }
23
-
24
- function normalizeNativeError(error) {
25
- if (error && error.name === 'OcrError') {
26
- Object.setPrototypeOf(error, OcrError.prototype);
27
- }
28
- return error;
29
- }
30
-
31
- function deepFreeze(value) {
32
- if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
33
- for (const child of Object.values(value)) deepFreeze(child);
34
- return Object.freeze(value);
35
- }
36
-
37
- function validateSignal(signal) {
38
- if (
39
- signal === null ||
40
- typeof signal !== 'object' ||
41
- typeof signal.aborted !== 'boolean' ||
42
- typeof signal.addEventListener !== 'function' ||
43
- typeof signal.removeEventListener !== 'function'
44
- ) {
45
- throw new OcrError('invalid_argument', 'signal must be an AbortSignal');
46
- }
47
- }
48
-
49
- function abortReason(signal) {
50
- return signal.reason === undefined
51
- ? new DOMException('The operation was aborted', 'AbortError')
52
- : signal.reason;
53
- }
54
-
55
- function resolveBuiltInBundle(model, requireApple) {
56
- if (model !== DEFAULT_MODEL) {
57
- throw new OcrError(
58
- 'invalid_argument',
59
- `model must be ${JSON.stringify(DEFAULT_MODEL)}`,
60
- );
61
- }
62
- let manifestPath;
63
- try {
64
- manifestPath = require.resolve(`${MODEL_PACKAGE}/bundle/manifest.json`);
65
- } catch (cause) {
66
- throw new OcrError(
67
- 'package_load_failed',
68
- `Unable to locate the built-in ${DEFAULT_MODEL} model`,
69
- `Reinstall ${MODEL_PACKAGE}; ${cause instanceof Error ? cause.message : String(cause)}`,
70
- );
71
- }
72
- let manifest;
73
- try {
74
- manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
75
- } catch (cause) {
76
- throw new OcrError(
77
- 'package_load_failed',
78
- 'Unable to read the built-in model manifest',
79
- cause instanceof Error ? cause.message : String(cause),
80
- );
81
- }
82
- const compatibleBundleIds = requireApple
83
- ? [APPLE_BUNDLE_ID, NATIVE_BUNDLE_ID]
84
- : [CPU_BUNDLE_ID, APPLE_BUNDLE_ID, WEBGPU_BUNDLE_ID, NATIVE_BUNDLE_ID];
85
- if (!compatibleBundleIds.includes(manifest.bundleId)) {
86
- throw new OcrError(
87
- 'package_load_failed',
88
- 'The installed model package is incompatible with this light-ocr release',
89
- `expected ${compatibleBundleIds.join(' or ')}, received ${String(manifest.bundleId)}`,
90
- );
91
- }
92
- return path.dirname(manifestPath);
93
- }
94
-
95
- function resolveCreateOptions(options) {
96
- if (options === undefined) options = {};
97
- if (options === null || typeof options !== 'object' || Array.isArray(options)) {
98
- throw new OcrError('invalid_argument', 'createEngine options must be an object');
99
- }
100
- const hasModel = Object.prototype.hasOwnProperty.call(options, 'model');
101
- const hasBundlePath = Object.prototype.hasOwnProperty.call(options, 'bundlePath');
102
- if (hasModel && hasBundlePath) {
103
- throw new OcrError(
104
- 'invalid_argument',
105
- 'model and bundlePath cannot be used together',
106
- );
107
- }
108
- if (hasBundlePath) return options;
109
- const model = hasModel ? options.model : DEFAULT_MODEL;
110
- const requireApple = options.execution?.provider === 'apple';
111
- const resolved = { ...options, bundlePath: resolveBuiltInBundle(model, requireApple) };
112
- delete resolved.model;
113
- return resolved;
114
- }
115
-
116
- class OcrEngineImpl {
117
- #native;
118
- #closePromise;
119
-
120
- constructor(nativeEngine) {
121
- this.#native = nativeEngine;
122
- this.info = deepFreeze(nativeEngine.info);
123
- Object.defineProperty(this, 'info', { writable: false, configurable: false });
124
- }
125
-
126
- recognize(image, options = {}) {
127
- return this.#recognize('recognize', image, options);
128
- }
129
-
130
- recognizeEncoded(data, options = {}) {
131
- return this.#recognize('recognizeEncoded', data, options);
132
- }
133
-
134
- detect(data, options = {}) {
135
- return this.#recognize('detect', data, options);
136
- }
137
-
138
- #recognize(nativeMethod, image, options) {
139
- let signal;
140
- let nativeOptions;
141
- try {
142
- if (options === null || typeof options !== 'object' || Array.isArray(options)) {
143
- throw new OcrError('invalid_argument', 'recognize options must be an object');
144
- }
145
- signal = options.signal;
146
- if (signal !== undefined) {
147
- validateSignal(signal);
148
- if (signal.aborted) return Promise.reject(abortReason(signal));
149
- }
150
- nativeOptions = { ...options };
151
- delete nativeOptions.signal;
152
- } catch (error) {
153
- return Promise.reject(normalizeNativeError(error));
154
- }
155
-
156
- let operation;
157
- try {
158
- operation = this.#native[nativeMethod](image, nativeOptions);
159
- } catch (error) {
160
- return Promise.reject(normalizeNativeError(error));
161
- }
162
-
163
- return new Promise((resolve, reject) => {
164
- let settled = false;
165
- const cleanup = () => {
166
- if (signal !== undefined) signal.removeEventListener('abort', onAbort);
167
- };
168
- const settle = (callback, value) => {
169
- if (settled) return;
170
- settled = true;
171
- cleanup();
172
- callback(value);
173
- };
174
- const onAbort = () => {
175
- if (settled) return;
176
- try {
177
- this.#native.cancel(operation.requestId);
178
- } catch {
179
- // Public cancellation still wins. Native teardown owns any remaining work.
180
- }
181
- settle(reject, abortReason(signal));
182
- };
183
-
184
- operation.promise.then(
185
- (value) => settle(resolve, value),
186
- (error) => settle(reject, normalizeNativeError(error)),
187
- );
188
-
189
- if (signal !== undefined) {
190
- signal.addEventListener('abort', onAbort, { once: true });
191
- if (signal.aborted) onAbort();
192
- }
193
- });
194
- }
195
-
196
- close() {
197
- if (this.#closePromise === undefined) {
198
- try {
199
- this.#closePromise = Promise.resolve(this.#native.close()).catch((error) => {
200
- throw normalizeNativeError(error);
201
- });
202
- } catch (error) {
203
- this.#closePromise = Promise.reject(normalizeNativeError(error));
204
- }
205
- }
206
- return this.#closePromise;
207
- }
208
- }
209
-
210
- let nativeRuntime;
211
-
212
- async function createEngine(options) {
213
- try {
214
- const resolvedOptions = resolveCreateOptions(options);
215
- if (!nativeRuntime) nativeRuntime = loadNative();
216
- const nativeEngine = await nativeRuntime.binding.createEngine(
217
- resolvedOptions,
218
- nativeRuntime.runtimePolicy,
219
- );
220
- return new OcrEngineImpl(nativeEngine);
221
- } catch (error) {
222
- throw normalizeNativeError(error);
223
- }
224
- }
225
-
226
- module.exports = { createEngine, OcrError };