@qvac/ocr-ggml 0.0.1 → 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +179 -0
  2. package/NOTICE +63 -0
  3. package/README.md +330 -0
  4. package/addonLogging.d.ts +7 -0
  5. package/addonLogging.js +4 -0
  6. package/binding.js +21 -0
  7. package/index.d.ts +145 -0
  8. package/index.js +383 -0
  9. package/lib/error.js +80 -0
  10. package/ocr-ggml.js +121 -0
  11. package/package.json +96 -7
  12. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.0_1.so +0 -0
  13. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_1.so +0 -0
  14. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_2.so +0 -0
  15. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.6_1.so +0 -0
  16. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.0_1.so +0 -0
  17. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_1.so +0 -0
  18. package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_2.so +0 -0
  19. package/prebuilds/android-arm64/qvac__ocr-ggml.bare +0 -0
  20. package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare +0 -0
  21. package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare.exports +4277 -0
  22. package/prebuilds/darwin-x64/qvac__ocr-ggml.bare +0 -0
  23. package/prebuilds/darwin-x64/qvac__ocr-ggml.bare.exports +4322 -0
  24. package/prebuilds/ios-arm64/qvac__ocr-ggml.bare +0 -0
  25. package/prebuilds/ios-arm64/qvac__ocr-ggml.bare.exports +4262 -0
  26. package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare +0 -0
  27. package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare.exports +4262 -0
  28. package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare +0 -0
  29. package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare.exports +4311 -0
  30. package/prebuilds/linux-arm64/qvac__ocr-ggml.bare +0 -0
  31. package/prebuilds/linux-x64/qvac__ocr-ggml.bare +0 -0
  32. package/prebuilds/win32-x64/qvac__ocr-ggml.bare +0 -0
  33. package/prebuilds/win32-x64/qvac__ocr-ggml.bare.exports +0 -0
  34. package/test/integration/canvas-size.test.js +75 -0
  35. package/test/integration/doctr-basic.test.js +124 -0
  36. package/test/integration/doctr-clinical-chemistry.test.js +64 -0
  37. package/test/integration/doctr-ct-scan.test.js +66 -0
  38. package/test/integration/doctr-lab-results.test.js +65 -0
  39. package/test/integration/doctr-liver-function.test.js +67 -0
  40. package/test/integration/doctr-models.test.js +132 -0
  41. package/test/integration/doctr-param-validation.test.js +83 -0
  42. package/test/integration/error-handling.test.js +302 -0
  43. package/test/integration/full-coverage.test.js +292 -0
  44. package/test/integration/full-ocr-suite.test.js +83 -0
  45. package/test/integration/image-formats.test.js +168 -0
  46. package/test/integration/large-images.test.js +81 -0
  47. package/test/integration/lifecycle.test.js +269 -0
  48. package/test/integration/ocr-basic.test.js +70 -0
  49. package/test/integration/param-validation.test.js +86 -0
  50. package/test/integration/pipeline.test.js +61 -0
  51. package/test/integration/run-internal-ordering.test.js +75 -0
  52. package/test/integration/run-tests.sh +43 -0
  53. package/test/integration/run-with-exit.js +71 -0
  54. package/test/integration/utils.js +694 -0
  55. package/test/mobile/integration-runtime.cjs +71 -0
  56. package/test/mobile/integration.auto.cjs +102 -0
  57. package/test/mobile/test-groups.json +55 -0
package/index.d.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { QvacResponse } from '@qvac/infer-base'
2
+
3
+ /**
4
+ * OCR pipeline backing the addon.
5
+ * - `easyocr` (default): CRAFT detector + CRNN gen-2 recognizer.
6
+ * Uses `langList`, `magRatio`, `defaultRotationAngles`, `contrastRetry`,
7
+ * `lowConfidenceThreshold`, `recognizerBatchSize`.
8
+ * - `doctr`: DBNet detector + doctr recognizer.
9
+ * Language-agnostic; EasyOCR-specific knobs (`magRatio`, etc.) are
10
+ * ignored.
11
+ */
12
+ export type OcrGgmlPipelineType = 'easyocr' | 'doctr'
13
+
14
+ export interface OcrGgmlParams {
15
+ /**
16
+ * Path to the detector GGUF file.
17
+ * - easyocr: CRAFT model (e.g. `craft_mlt_25k.gguf`)
18
+ * - doctr: DBNet model (e.g. `db_mobilenet_v3_large.gguf`)
19
+ */
20
+ pathDetector: string
21
+ /**
22
+ * Path to the recognizer GGUF file.
23
+ * - easyocr: CRNN gen-2 (e.g. `english_g2.gguf`, `latin_g2.gguf`)
24
+ * - doctr: doctr recognition model (e.g. `crnn_mobilenet_v3_small.gguf`)
25
+ */
26
+ pathRecognizer: string
27
+ /** Languages handled by the recognizer (e.g. `['en']`, `['en', 'fr']`). */
28
+ langList: string[]
29
+
30
+ /** Pipeline backing the addon. Default: `'easyocr'`. */
31
+ pipelineType?: OcrGgmlPipelineType
32
+ /** Detection magnification ratio (easyocr only). Default: 1.5. */
33
+ magRatio?: number
34
+ /**
35
+ * EasyOCR `canvas_size`: detection canvas cap (long side, px) applied after
36
+ * `magRatio` scaling. Bounds CRAFT peak memory on dense/high-resolution
37
+ * pages; lower it (e.g. 1280) on memory-constrained targets such as mobile.
38
+ * Default: 2560 (matches `@qvac/ocr-onnx` / EasyOCR). easyocr only.
39
+ */
40
+ canvasSize?: number
41
+ /** Rotation angles tried when the primary pass is low-confidence (easyocr only). Default: [90, 270]. */
42
+ defaultRotationAngles?: number[]
43
+ /** Retry low-confidence boxes with contrast adjustment (easyocr only). Default: false. */
44
+ contrastRetry?: boolean
45
+ /** Threshold below which contrast-retry kicks in (easyocr only). Default: 0.4. */
46
+ lowConfidenceThreshold?: number
47
+ /** Recognizer batch size (easyocr only). Default: 32. */
48
+ recognizerBatchSize?: number
49
+ /**
50
+ * GGML CPU thread count:
51
+ * - `0` (default): auto-detect physical cores (hardware_concurrency / 2, floor 1)
52
+ * - `> 0`: explicit override
53
+ * - `< 0`: leave GGML's CPU backend default unchanged
54
+ */
55
+ nThreads?: number
56
+ /** Directory holding ggml backend shared libraries. Default: `<package>/prebuilds`. */
57
+ backendsDir?: string
58
+ }
59
+
60
+ export interface OcrGgmlArgs {
61
+ params: OcrGgmlParams
62
+ opts?: { stats?: boolean }
63
+ logger?: any
64
+ }
65
+
66
+ export interface OcrGgmlRunOptions {
67
+ /** Merge nearby boxes into paragraph-style regions. Default: false. */
68
+ paragraph?: boolean
69
+ /** Extra padding around detected boxes, as a fraction of box size. Default: 0.1. */
70
+ boxMarginMultiplier?: number
71
+ /** Override `defaultRotationAngles` for this single call. */
72
+ rotationAngles?: number[]
73
+ }
74
+
75
+ export interface OcrGgmlRunInput {
76
+ /** Path to a JPEG, PNG, or BMP file. */
77
+ path: string
78
+ options?: OcrGgmlRunOptions
79
+ }
80
+
81
+ /**
82
+ * One detected text region. Shape matches `@qvac/ocr-onnx` so downstream
83
+ * consumers can swap backends without changing data handling code.
84
+ */
85
+ export type InferredText = [
86
+ /** Bounding box: [[x,y], [x,y], [x,y], [x,y]]. */
87
+ [[number, number], [number, number], [number, number], [number, number]],
88
+ /** Recognized text. */
89
+ string,
90
+ /** Confidence in [0, 1]. */
91
+ number
92
+ ]
93
+
94
+ export interface InferenceClientState {
95
+ configLoaded: boolean
96
+ weightsLoaded: boolean
97
+ destroyed: boolean
98
+ }
99
+
100
+ export interface RuntimeStats {
101
+ /** Total wall-clock time for the run (seconds). */
102
+ totalTime: number
103
+ /** Detection step duration (seconds). */
104
+ detectionTime: number
105
+ /** Recognition step duration (seconds). */
106
+ recognitionTime: number
107
+ /** Number of detected boxes (aligned + unaligned). */
108
+ numBoxes: number
109
+ }
110
+
111
+ export class OcrGgml {
112
+ constructor(args: OcrGgmlArgs)
113
+ getState(): InferenceClientState
114
+ load(): Promise<void>
115
+ run(input: OcrGgmlRunInput): Promise<QvacResponse<InferredText[]>>
116
+ unload(): Promise<void>
117
+ destroy(): Promise<void>
118
+
119
+ static readonly inferenceManagerConfig: { noAdditionalDownload: boolean }
120
+ static getModelKey(): string
121
+ }
122
+
123
+ export const modelClass: typeof OcrGgml
124
+ export const modelFile: unknown
125
+
126
+ export const ERR_CODES: {
127
+ readonly FAILED_TO_LOAD_WEIGHTS: number
128
+ readonly FAILED_TO_CANCEL: number
129
+ readonly FAILED_TO_RUN_JOB: number
130
+ readonly FAILED_TO_GET_STATUS: number
131
+ readonly FAILED_TO_DESTROY: number
132
+ readonly FAILED_TO_ACTIVATE: number
133
+ readonly MISSING_REQUIRED_PARAMETER: number
134
+ readonly UNSUPPORTED_LANGUAGE: number
135
+ readonly INVALID_IMAGE_OR_INSUFFICIENT_DATA: number
136
+ readonly UNSUPPORTED_IMAGE_FORMAT: number
137
+ readonly NOT_LOADED: number
138
+ }
139
+
140
+ export class QvacErrorAddonOcrGgml extends Error {
141
+ code: number
142
+ }
143
+
144
+ export const binding: unknown
145
+ export const addonLogging: unknown
package/index.js ADDED
@@ -0,0 +1,383 @@
1
+ 'use strict'
2
+
3
+ const path = require('bare-path')
4
+ const fs = require('bare-fs')
5
+ const QvacLogger = require('@qvac/logging')
6
+ const { createJobHandler, exclusiveRunQueue } = require('@qvac/infer-base')
7
+
8
+ const { QvacErrorAddonOcrGgml, ERR_CODES } = require('./lib/error')
9
+
10
+ /**
11
+ * GGML-backed OCR implementation.
12
+ *
13
+ * Public surface matches `@qvac/ocr-onnx` so a downstream caller can switch
14
+ * inference engines by swapping the import.
15
+ */
16
+ class OcrGgml {
17
+ /**
18
+ * @param {Object} args
19
+ * @param {Object} args.params
20
+ * @param {string} args.params.pathDetector - path to the CRAFT .gguf
21
+ * @param {string} args.params.pathRecognizer - path to the recognizer .gguf
22
+ * @param {string[]} args.params.langList - e.g. `['en']`
23
+ * @param {number} [args.params.magRatio=1.5]
24
+ * @param {number} [args.params.canvasSize=2560] - EasyOCR `canvas_size`: detection canvas cap (long side, px). Lower it (e.g. 1280) on memory-constrained targets to bound CRAFT peak memory. EasyOCR only.
25
+ * @param {number[]} [args.params.defaultRotationAngles]
26
+ * @param {boolean} [args.params.contrastRetry]
27
+ * @param {number} [args.params.lowConfidenceThreshold]
28
+ * @param {number} [args.params.recognizerBatchSize]
29
+ * @param {number} [args.params.nThreads] - 0=auto (physical cores), >0=explicit, <0=leave default
30
+ * @param {string} [args.params.backendsDir] - override directory for ggml backend shared libs
31
+ * @param {Object} [args.opts]
32
+ * @param {boolean} [args.opts.stats] - emit timing stats on finish
33
+ * @param {Object} [args.logger]
34
+ */
35
+ constructor ({ params, opts = {}, logger = null }) {
36
+ this.opts = opts
37
+ this.logger = new QvacLogger(logger)
38
+ this.addon = null
39
+ this.params = params
40
+ this._packageName = '@qvac/ocr-ggml'
41
+ this._packageVersion = require('./package.json').version
42
+ this._job = createJobHandler({ cancel: () => this.addon && this.addon.cancel() })
43
+ this._run = exclusiveRunQueue()
44
+
45
+ this.state = {
46
+ configLoaded: false,
47
+ weightsLoaded: false,
48
+ destroyed: false
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @returns {{configLoaded: boolean, weightsLoaded: boolean, destroyed: boolean}}
54
+ */
55
+ getState () {
56
+ return this.state
57
+ }
58
+
59
+ async load () {
60
+ if (this.state.configLoaded || this.state.weightsLoaded) {
61
+ this.logger.info('Reload requested - unloading existing model first')
62
+ await this.unload()
63
+ }
64
+ await this._load()
65
+ }
66
+
67
+ /**
68
+ * @param {{ path: string, options?: Object }} input
69
+ * @returns {Promise<import('@qvac/infer-base').QvacResponse>}
70
+ */
71
+ async run (input) {
72
+ return this._run(() => this._runInternal(input))
73
+ }
74
+
75
+ async unload () {
76
+ if (this.addon) {
77
+ await this.addon.destroy()
78
+ this.addon = null
79
+ }
80
+ this.state.configLoaded = false
81
+ this.state.weightsLoaded = false
82
+ }
83
+
84
+ async destroy () {
85
+ await this.unload()
86
+ this.state.destroyed = true
87
+ }
88
+
89
+ async _load () {
90
+ if (!this.params || typeof this.params !== 'object') {
91
+ throw new QvacErrorAddonOcrGgml({
92
+ code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
93
+ adds: 'params object is required'
94
+ })
95
+ }
96
+
97
+ if (!this.params.pathDetector) {
98
+ throw new QvacErrorAddonOcrGgml({
99
+ code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
100
+ adds: 'pathDetector'
101
+ })
102
+ }
103
+ if (!this.params.pathRecognizer) {
104
+ throw new QvacErrorAddonOcrGgml({
105
+ code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
106
+ adds: 'pathRecognizer'
107
+ })
108
+ }
109
+ if (!Array.isArray(this.params.langList) || this.params.langList.length === 0) {
110
+ throw new QvacErrorAddonOcrGgml({
111
+ code: ERR_CODES.MISSING_REQUIRED_PARAMETER,
112
+ adds: 'langList (non-empty array)'
113
+ })
114
+ }
115
+
116
+ const SUPPORTED_LANGUAGES = new Set(['en'])
117
+ const hasSupported = this.params.langList.some(l => SUPPORTED_LANGUAGES.has(l))
118
+ if (!hasSupported) {
119
+ throw new QvacErrorAddonOcrGgml({
120
+ code: ERR_CODES.UNSUPPORTED_LANGUAGE,
121
+ adds: `none of the requested languages are supported: ${this.params.langList.join(', ')}`
122
+ })
123
+ }
124
+
125
+ const configurationParams = {
126
+ pathDetector: this.params.pathDetector,
127
+ pathRecognizer: this.params.pathRecognizer,
128
+ langList: this.params.langList
129
+ }
130
+
131
+ // Forward optional config knobs only when explicitly set so the C++
132
+ // defaults (in OcrConfig) win otherwise.
133
+ const optionalFields = [
134
+ 'magRatio',
135
+ 'canvasSize',
136
+ 'defaultRotationAngles',
137
+ 'contrastRetry',
138
+ 'lowConfidenceThreshold',
139
+ 'recognizerBatchSize',
140
+ 'nThreads',
141
+ 'pipelineType'
142
+ ]
143
+ for (const field of optionalFields) {
144
+ if (this.params[field] !== undefined) {
145
+ configurationParams[field] = this.params[field]
146
+ }
147
+ }
148
+
149
+ configurationParams.backendsDir =
150
+ this.params.backendsDir !== undefined
151
+ ? this.params.backendsDir
152
+ : path.join(__dirname, 'prebuilds')
153
+
154
+ this.logger.info('Creating ocr-ggml addon')
155
+ this.addon = this._createAddon(configurationParams)
156
+ await this.addon.activate()
157
+ this.state.configLoaded = true
158
+ this.state.weightsLoaded = true
159
+ this.logger.info('ocr-ggml model loaded')
160
+ }
161
+
162
+ _createAddon (configurationParams) {
163
+ const binding = require('./binding')
164
+ const { OcrGgmlInterface } = require('./ocr-ggml')
165
+ return new OcrGgmlInterface(
166
+ binding,
167
+ configurationParams,
168
+ this._addonOutputCallback.bind(this),
169
+ this.logger
170
+ )
171
+ }
172
+
173
+ async _runInternal (input) {
174
+ this.logger.info('Starting OCR inference')
175
+
176
+ if (!this.addon) {
177
+ throw new QvacErrorAddonOcrGgml({
178
+ code: ERR_CODES.NOT_LOADED,
179
+ adds: 'call load() before run()'
180
+ })
181
+ }
182
+
183
+ const response = this._job.start()
184
+ try {
185
+ const imageInput = this._readImage(input.path)
186
+ await this.addon.runJob({
187
+ type: 'image',
188
+ input: imageInput,
189
+ options: input.options
190
+ })
191
+ } catch (err) {
192
+ this._job.fail(err)
193
+ throw err
194
+ }
195
+ return response
196
+ }
197
+
198
+ /**
199
+ * Read an image file from disk and prepare it for the addon. Mirrors the
200
+ * ocr-onnx convention: JPEG / PNG are passed encoded to the addon (decoded
201
+ * by OpenCV in C++); BMP is decoded in JS to raw RGB.
202
+ *
203
+ * @param {string} imagePath
204
+ * @returns {{ data: Buffer, isEncoded?: boolean, width?: number, height?: number, bitsPerPixel?: number }}
205
+ */
206
+ _readImage (imagePath) {
207
+ this.logger.debug('Reading image from path:', imagePath)
208
+ const contents = fs.readFileSync(imagePath)
209
+ if (!contents || contents.length < 4) {
210
+ this.logger.error('Invalid image file or insufficient data')
211
+ throw new QvacErrorAddonOcrGgml({
212
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
213
+ adds: imagePath
214
+ })
215
+ }
216
+
217
+ // JPEG: starts with 0xFF 0xD8
218
+ if (contents[0] === 0xFF && contents[1] === 0xD8) {
219
+ return { data: contents, isEncoded: true }
220
+ }
221
+ // PNG: 0x89 P N G
222
+ if (contents[0] === 0x89 && contents[1] === 0x50 && contents[2] === 0x4E && contents[3] === 0x47) {
223
+ return { data: contents, isEncoded: true }
224
+ }
225
+ // BMP: 'BM'
226
+ if (contents[0] === 0x42 && contents[1] === 0x4D) {
227
+ return this._decodeBmp(contents, imagePath)
228
+ }
229
+
230
+ this.logger.error('Unsupported image format')
231
+ throw new QvacErrorAddonOcrGgml({
232
+ code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
233
+ adds: imagePath
234
+ })
235
+ }
236
+
237
+ _decodeBmp (contents, imagePath) {
238
+ if (contents.length < 14 + 4) {
239
+ throw new QvacErrorAddonOcrGgml({
240
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
241
+ adds: imagePath
242
+ })
243
+ }
244
+
245
+ const infoHeaderSize = contents.readUInt32LE(14)
246
+ if (contents.length < 14 + infoHeaderSize) {
247
+ throw new QvacErrorAddonOcrGgml({
248
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
249
+ adds: imagePath
250
+ })
251
+ }
252
+
253
+ let width, height, bitsPerPixel
254
+ if (infoHeaderSize >= 40) {
255
+ width = contents.readInt32LE(18)
256
+ height = contents.readInt32LE(22)
257
+ bitsPerPixel = contents.readUInt16LE(28)
258
+ } else if (infoHeaderSize >= 12) {
259
+ width = contents.readUInt16LE(18)
260
+ height = contents.readInt16LE(20)
261
+ bitsPerPixel = 24
262
+ } else {
263
+ throw new QvacErrorAddonOcrGgml({
264
+ code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
265
+ adds: `BMP header size ${infoHeaderSize}`
266
+ })
267
+ }
268
+
269
+ if (width <= 0 || height === 0) {
270
+ throw new QvacErrorAddonOcrGgml({
271
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
272
+ adds: `${imagePath} (invalid BMP dimensions ${width}x${height})`
273
+ })
274
+ }
275
+
276
+ const SUPPORTED_BITS = new Set([8, 24, 32])
277
+ if (!SUPPORTED_BITS.has(bitsPerPixel)) {
278
+ throw new QvacErrorAddonOcrGgml({
279
+ code: ERR_CODES.UNSUPPORTED_IMAGE_FORMAT,
280
+ adds: `BMP bitsPerPixel ${bitsPerPixel} (supported: 8, 24, 32)`
281
+ })
282
+ }
283
+
284
+ const pixelDataOffset = contents.readUInt32LE(10)
285
+ if (pixelDataOffset >= contents.length) {
286
+ throw new QvacErrorAddonOcrGgml({
287
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
288
+ adds: `${imagePath} (BMP pixelDataOffset ${pixelDataOffset} out of range)`
289
+ })
290
+ }
291
+ const pixelDataBuffer = contents.slice(pixelDataOffset)
292
+
293
+ const bytesPerPixel = bitsPerPixel / 8
294
+ const unpaddedRowSize = width * bytesPerPixel
295
+ const paddedRowSize = Math.ceil(unpaddedRowSize / 4) * 4
296
+ const rows = Math.abs(height)
297
+
298
+ if (pixelDataBuffer.length < rows * paddedRowSize) {
299
+ throw new QvacErrorAddonOcrGgml({
300
+ code: ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA,
301
+ adds: imagePath
302
+ })
303
+ }
304
+
305
+ const unpaddedData = Buffer.alloc(rows * unpaddedRowSize)
306
+ for (let row = 0; row < rows; row++) {
307
+ const srcStart = row * paddedRowSize
308
+ const srcEnd = srcStart + unpaddedRowSize
309
+ let destRow = row
310
+ if (height > 0) destRow = rows - row - 1
311
+ const destStart = destRow * unpaddedRowSize
312
+ pixelDataBuffer.copy(unpaddedData, destStart, srcStart, srcEnd)
313
+ }
314
+
315
+ return { width, height: rows, data: unpaddedData, bitsPerPixel }
316
+ }
317
+
318
+ _addonOutputCallback (addon, event, data, error) {
319
+ if (event && event.includes('Error')) {
320
+ return this._job.fail(error)
321
+ }
322
+
323
+ // JobEnded may arrive with stats payload, with null (stats disabled), or
324
+ // not at all on some platforms - handle the event name explicitly so
325
+ // await() doesn't hang when data is null.
326
+ if (event === 'JobEnded') {
327
+ const isStatsObject =
328
+ typeof data === 'object' &&
329
+ data !== null &&
330
+ !Array.isArray(data) &&
331
+ ('totalTime' in data || 'detectionTime' in data)
332
+ if (isStatsObject) {
333
+ this.logger.info('OCR inference completed. Stats:', JSON.stringify(data))
334
+ }
335
+ return this._job.end(this.opts?.stats && isStatsObject ? data : null)
336
+ }
337
+
338
+ // Some addon paths surface stats without a 'JobEnded' event name; keep
339
+ // the legacy heuristic as a fallback so we still close the job.
340
+ const isStatsObject =
341
+ typeof data === 'object' &&
342
+ data !== null &&
343
+ !Array.isArray(data) &&
344
+ ('totalTime' in data || 'detectionTime' in data)
345
+ if (isStatsObject) {
346
+ this.logger.info('OCR inference completed. Stats:', JSON.stringify(data))
347
+ return this._job.end(this.opts?.stats ? data : null)
348
+ }
349
+
350
+ if (Array.isArray(data)) {
351
+ return this._job.output(data)
352
+ }
353
+ }
354
+
355
+ /** Inference Manager diagnostics hook (parity with ocr-onnx). */
356
+ _getDiagnosticsJSON () {
357
+ return JSON.stringify({
358
+ status: this.state.destroyed
359
+ ? 'destroyed'
360
+ : (this.state.configLoaded ? 'loaded' : 'not_loaded'),
361
+ params: this.params
362
+ })
363
+ }
364
+
365
+ /** Inference Manager hooks (parity with ocr-onnx) */
366
+ static inferenceManagerConfig = {
367
+ noAdditionalDownload: true
368
+ }
369
+
370
+ static getModelKey () {
371
+ return 'ocr-ggml'
372
+ }
373
+ }
374
+
375
+ module.exports = {
376
+ OcrGgml,
377
+ modelClass: OcrGgml,
378
+ get modelFile () { return require.addon.resolve('.') },
379
+ QvacErrorAddonOcrGgml,
380
+ ERR_CODES,
381
+ get binding () { return require('./binding') },
382
+ get addonLogging () { return require('./addonLogging') }
383
+ }
package/lib/error.js ADDED
@@ -0,0 +1,80 @@
1
+ 'use strict'
2
+
3
+ const { QvacErrorBase, addCodes } = require('@qvac/error')
4
+ const { name, version } = require('../package.json')
5
+
6
+ class QvacErrorAddonOcrGgml extends QvacErrorBase {}
7
+
8
+ // Allocated error code range: 8101..8200
9
+ // (translation-nmtcpp uses 8001..9000 broadly; ocr-onnx claims its own block.)
10
+ const ERR_CODES = Object.freeze({
11
+ FAILED_TO_LOAD_WEIGHTS: 8101,
12
+ FAILED_TO_CANCEL: 8102,
13
+ FAILED_TO_RUN_JOB: 8103,
14
+ FAILED_TO_GET_STATUS: 8104,
15
+ FAILED_TO_DESTROY: 8105,
16
+ FAILED_TO_ACTIVATE: 8106,
17
+ MISSING_REQUIRED_PARAMETER: 8107,
18
+ UNSUPPORTED_LANGUAGE: 8108,
19
+ INVALID_IMAGE_OR_INSUFFICIENT_DATA: 8109,
20
+ UNSUPPORTED_IMAGE_FORMAT: 8110,
21
+ NOT_LOADED: 8111
22
+ })
23
+
24
+ addCodes(
25
+ {
26
+ [ERR_CODES.FAILED_TO_LOAD_WEIGHTS]: {
27
+ name: 'FAILED_TO_LOAD_WEIGHTS',
28
+ message: message => `Failed to load weights, error: ${message}`
29
+ },
30
+ [ERR_CODES.FAILED_TO_CANCEL]: {
31
+ name: 'FAILED_TO_CANCEL',
32
+ message: message => `Failed to cancel inference, error: ${message}`
33
+ },
34
+ [ERR_CODES.FAILED_TO_RUN_JOB]: {
35
+ name: 'FAILED_TO_RUN_JOB',
36
+ message: message => `Failed to run OCR job, error: ${message}`
37
+ },
38
+ [ERR_CODES.FAILED_TO_GET_STATUS]: {
39
+ name: 'FAILED_TO_GET_STATUS',
40
+ message: message => `Failed to get addon status, error: ${message}`
41
+ },
42
+ [ERR_CODES.FAILED_TO_DESTROY]: {
43
+ name: 'FAILED_TO_DESTROY',
44
+ message: message => `Failed to destroy instance, error: ${message}`
45
+ },
46
+ [ERR_CODES.FAILED_TO_ACTIVATE]: {
47
+ name: 'FAILED_TO_ACTIVATE',
48
+ message: message => `Failed to activate model, error: ${message}`
49
+ },
50
+ [ERR_CODES.MISSING_REQUIRED_PARAMETER]: {
51
+ name: 'MISSING_REQUIRED_PARAMETER',
52
+ message: message => `Missing required parameter: ${message}`
53
+ },
54
+ [ERR_CODES.UNSUPPORTED_LANGUAGE]: {
55
+ name: 'UNSUPPORTED_LANGUAGE',
56
+ message: message => `Unsupported language(s): ${message}`
57
+ },
58
+ [ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA]: {
59
+ name: 'INVALID_IMAGE_OR_INSUFFICIENT_DATA',
60
+ message: message => `Invalid image file or insufficient data: ${message}`
61
+ },
62
+ [ERR_CODES.UNSUPPORTED_IMAGE_FORMAT]: {
63
+ name: 'UNSUPPORTED_IMAGE_FORMAT',
64
+ message: message => `Unsupported image format: ${message}`
65
+ },
66
+ [ERR_CODES.NOT_LOADED]: {
67
+ name: 'NOT_LOADED',
68
+ message: message => `OCR model is not loaded: ${message}`
69
+ }
70
+ },
71
+ {
72
+ name,
73
+ version
74
+ }
75
+ )
76
+
77
+ module.exports = {
78
+ ERR_CODES,
79
+ QvacErrorAddonOcrGgml
80
+ }