@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
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+
3
+ const { OcrGgml, QvacErrorAddonOcrGgml, ERR_CODES } = require('../..')
4
+ const test = require('brittle')
5
+ const { isMobile, getImagePath, ensureDoctrModels } = require('./utils')
6
+
7
+ const MOBILE_TIMEOUT = 600 * 1000
8
+ const DESKTOP_TIMEOUT = 30 * 1000
9
+ const TEST_TIMEOUT = isMobile ? MOBILE_TIMEOUT : DESKTOP_TIMEOUT
10
+
11
+ let DOCTR_DETECTOR
12
+ let DOCTR_RECOGNIZER
13
+
14
+ test('DocTR param validation - download models', { timeout: 180 * 1000 }, async function (t) {
15
+ const models = await ensureDoctrModels()
16
+ DOCTR_DETECTOR = models.db_mobilenet_v3_large
17
+ DOCTR_RECOGNIZER = models.crnn_mobilenet_v3_small
18
+ t.ok(DOCTR_DETECTOR, 'db_mobilenet model available')
19
+ t.ok(DOCTR_RECOGNIZER, 'crnn_mobilenet model available')
20
+ })
21
+
22
+ test('DocTR load() rejects when pathRecognizer is missing', { timeout: TEST_TIMEOUT }, async function (t) {
23
+ const ocrGgml = new OcrGgml({
24
+ params: {
25
+ pathDetector: DOCTR_DETECTOR,
26
+ langList: ['en'],
27
+ pipelineType: 'doctr'
28
+ }
29
+ })
30
+
31
+ try {
32
+ await ocrGgml.load()
33
+ t.fail('Should have thrown for missing pathRecognizer in doctr mode')
34
+ } catch (err) {
35
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
36
+ t.is(err.code, ERR_CODES.MISSING_REQUIRED_PARAMETER, 'Error code should be MISSING_REQUIRED_PARAMETER')
37
+ t.ok(err.message.includes('pathRecognizer'), 'Error message should mention pathRecognizer')
38
+ t.pass('Correctly rejected missing pathRecognizer in doctr mode')
39
+ }
40
+ })
41
+
42
+ test('DocTR load() rejects when pathDetector is missing', { timeout: TEST_TIMEOUT }, async function (t) {
43
+ const ocrGgml = new OcrGgml({
44
+ params: {
45
+ pathRecognizer: DOCTR_RECOGNIZER,
46
+ langList: ['en'],
47
+ pipelineType: 'doctr'
48
+ }
49
+ })
50
+
51
+ try {
52
+ await ocrGgml.load()
53
+ t.fail('Should have thrown for missing pathDetector in doctr mode')
54
+ } catch (err) {
55
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
56
+ t.is(err.code, ERR_CODES.MISSING_REQUIRED_PARAMETER, 'Error code should be MISSING_REQUIRED_PARAMETER')
57
+ t.ok(err.message.includes('pathDetector'), 'Error message should mention pathDetector')
58
+ t.pass('Correctly rejected missing pathDetector in doctr mode')
59
+ }
60
+ })
61
+
62
+ test('DocTR run() before load() throws error', { timeout: TEST_TIMEOUT }, async function (t) {
63
+ const ocrGgml = new OcrGgml({
64
+ params: {
65
+ pathDetector: DOCTR_DETECTOR,
66
+ pathRecognizer: DOCTR_RECOGNIZER,
67
+ langList: ['en'],
68
+ pipelineType: 'doctr'
69
+ }
70
+ })
71
+
72
+ try {
73
+ await ocrGgml.run({
74
+ path: getImagePath('/test/images/basic_test.bmp'),
75
+ options: { paragraph: false }
76
+ })
77
+ t.fail('Should have thrown when running before load')
78
+ } catch (err) {
79
+ t.ok(err, 'Should throw an error when running before load')
80
+ t.comment('Error: ' + err.message)
81
+ t.pass('Correctly prevented run before load')
82
+ }
83
+ })
@@ -0,0 +1,302 @@
1
+ 'use strict'
2
+
3
+ const { OcrGgml, QvacErrorAddonOcrGgml, ERR_CODES } = require('../..')
4
+ const test = require('brittle')
5
+ const fs = require('bare-fs')
6
+ const path = require('bare-path')
7
+ const { isMobile } = require('./utils')
8
+
9
+ const MOBILE_TIMEOUT = 600 * 1000
10
+ const DESKTOP_TIMEOUT = 60 * 1000
11
+ const TEST_TIMEOUT = isMobile ? MOBILE_TIMEOUT : DESKTOP_TIMEOUT
12
+
13
+ const TMP_DIR = isMobile
14
+ ? path.join(global.testDir || '/tmp', 'test-tmp')
15
+ : path.resolve('.', 'test', 'tmp')
16
+
17
+ function setupTmpDir () {
18
+ if (!fs.existsSync(TMP_DIR)) {
19
+ fs.mkdirSync(TMP_DIR, { recursive: true })
20
+ }
21
+ }
22
+
23
+ function writeTmpFile (name, buffer) {
24
+ setupTmpDir()
25
+ const filePath = path.join(TMP_DIR, name)
26
+ fs.writeFileSync(filePath, buffer)
27
+ return filePath
28
+ }
29
+
30
+ function cleanupTmpDir () {
31
+ if (fs.existsSync(TMP_DIR)) {
32
+ const files = fs.readdirSync(TMP_DIR)
33
+ for (const file of files) {
34
+ fs.unlinkSync(path.join(TMP_DIR, file))
35
+ }
36
+ fs.rmdirSync(TMP_DIR)
37
+ }
38
+ }
39
+
40
+ function createOcrInstance () {
41
+ return new OcrGgml({
42
+ params: {
43
+ pathDetector: 'models/craft_mlt_25k.gguf',
44
+ pathRecognizer: 'models/latin_g2.gguf',
45
+ langList: ['en']
46
+ }
47
+ })
48
+ }
49
+
50
+ // --- Unsupported Image Formats ---
51
+
52
+ test('_readImage rejects GIF format with UNSUPPORTED_IMAGE_FORMAT', { timeout: TEST_TIMEOUT }, async function (t) {
53
+ const onnxOcr = createOcrInstance()
54
+ // GIF magic bytes: GIF89a
55
+ const gifBuffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00])
56
+ const filePath = writeTmpFile('test.gif', gifBuffer)
57
+
58
+ try {
59
+ onnxOcr._readImage(filePath)
60
+ t.fail('Should have thrown for GIF format')
61
+ } catch (err) {
62
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
63
+ t.is(err.code, ERR_CODES.UNSUPPORTED_IMAGE_FORMAT, 'Error code should be UNSUPPORTED_IMAGE_FORMAT')
64
+ t.pass('GIF correctly rejected')
65
+ } finally {
66
+ cleanupTmpDir()
67
+ }
68
+ })
69
+
70
+ test('_readImage rejects WebP format with UNSUPPORTED_IMAGE_FORMAT', { timeout: TEST_TIMEOUT }, async function (t) {
71
+ const onnxOcr = createOcrInstance()
72
+ // WebP: RIFF....WEBP
73
+ const webpBuffer = Buffer.alloc(16)
74
+ webpBuffer.write('RIFF', 0)
75
+ webpBuffer.writeUInt32LE(8, 4)
76
+ webpBuffer.write('WEBP', 8)
77
+ const filePath = writeTmpFile('test.webp', webpBuffer)
78
+
79
+ try {
80
+ onnxOcr._readImage(filePath)
81
+ t.fail('Should have thrown for WebP format')
82
+ } catch (err) {
83
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
84
+ t.is(err.code, ERR_CODES.UNSUPPORTED_IMAGE_FORMAT, 'Error code should be UNSUPPORTED_IMAGE_FORMAT')
85
+ t.pass('WebP correctly rejected')
86
+ } finally {
87
+ cleanupTmpDir()
88
+ }
89
+ })
90
+
91
+ test('_readImage rejects TIFF format with UNSUPPORTED_IMAGE_FORMAT', { timeout: TEST_TIMEOUT }, async function (t) {
92
+ const onnxOcr = createOcrInstance()
93
+ // TIFF little-endian: II + 42
94
+ const tiffBuffer = Buffer.from([0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00])
95
+ const filePath = writeTmpFile('test.tiff', tiffBuffer)
96
+
97
+ try {
98
+ onnxOcr._readImage(filePath)
99
+ t.fail('Should have thrown for TIFF format')
100
+ } catch (err) {
101
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
102
+ t.is(err.code, ERR_CODES.UNSUPPORTED_IMAGE_FORMAT, 'Error code should be UNSUPPORTED_IMAGE_FORMAT')
103
+ t.pass('TIFF correctly rejected')
104
+ } finally {
105
+ cleanupTmpDir()
106
+ }
107
+ })
108
+
109
+ // --- Corrupt / Invalid Image Files ---
110
+
111
+ test('_readImage rejects zero-byte file', { timeout: TEST_TIMEOUT }, async function (t) {
112
+ const onnxOcr = createOcrInstance()
113
+ const filePath = writeTmpFile('empty.bmp', Buffer.alloc(0))
114
+
115
+ try {
116
+ onnxOcr._readImage(filePath)
117
+ t.fail('Should have thrown for zero-byte file')
118
+ } catch (err) {
119
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
120
+ t.is(err.code, ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA, 'Error code should be INVALID_IMAGE_OR_INSUFFICIENT_DATA')
121
+ t.pass('Zero-byte file correctly rejected')
122
+ } finally {
123
+ cleanupTmpDir()
124
+ }
125
+ })
126
+
127
+ test('_readImage rejects file with less than 4 bytes', { timeout: TEST_TIMEOUT }, async function (t) {
128
+ const onnxOcr = createOcrInstance()
129
+ const filePath = writeTmpFile('tiny.bin', Buffer.from([0x42, 0x4D, 0x01]))
130
+
131
+ try {
132
+ onnxOcr._readImage(filePath)
133
+ t.fail('Should have thrown for too-small file')
134
+ } catch (err) {
135
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
136
+ t.is(err.code, ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA, 'Error code should be INVALID_IMAGE_OR_INSUFFICIENT_DATA')
137
+ t.pass('Too-small file correctly rejected')
138
+ } finally {
139
+ cleanupTmpDir()
140
+ }
141
+ })
142
+
143
+ test('_readImage rejects non-existent file', { timeout: TEST_TIMEOUT }, async function (t) {
144
+ const onnxOcr = createOcrInstance()
145
+
146
+ try {
147
+ onnxOcr._readImage('/nonexistent/path/image.bmp')
148
+ t.fail('Should have thrown for non-existent file')
149
+ } catch (err) {
150
+ t.ok(err, 'Should throw an error for non-existent file')
151
+ t.pass('Non-existent file correctly rejected')
152
+ }
153
+ })
154
+
155
+ // --- BMP Parsing Edge Cases ---
156
+
157
+ test('getImageFromBmp rejects BMP with incomplete header', { timeout: TEST_TIMEOUT }, async function (t) {
158
+ const onnxOcr = createOcrInstance()
159
+ // Valid BMP magic, infoHeaderSize says 40 but buffer is only 24 bytes (< 14 + 40 = 54)
160
+ const buf = Buffer.alloc(24)
161
+ buf[0] = 0x42 // B
162
+ buf[1] = 0x4D // M
163
+ buf.writeUInt32LE(40, 14) // infoHeaderSize = 40
164
+ const filePath = writeTmpFile('incomplete_header.bmp', buf)
165
+
166
+ try {
167
+ onnxOcr._readImage(filePath)
168
+ t.fail('Should have thrown for incomplete BMP header')
169
+ } catch (err) {
170
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
171
+ t.is(err.code, ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA, 'Error code should be INVALID_IMAGE_OR_INSUFFICIENT_DATA')
172
+ t.pass('Incomplete BMP header correctly rejected')
173
+ } finally {
174
+ cleanupTmpDir()
175
+ }
176
+ })
177
+
178
+ test('getImageFromBmp rejects BMP with unsupported header size', { timeout: TEST_TIMEOUT }, async function (t) {
179
+ const onnxOcr = createOcrInstance()
180
+ // BMP magic + infoHeaderSize of 8 (less than minimum 12), buffer must hold at least 14+8=22 bytes
181
+ const buf = Buffer.alloc(22)
182
+ buf[0] = 0x42
183
+ buf[1] = 0x4D
184
+ buf.writeUInt32LE(8, 14) // infoHeaderSize = 8 (unsupported)
185
+ const filePath = writeTmpFile('bad_header_size.bmp', buf)
186
+
187
+ try {
188
+ onnxOcr._readImage(filePath)
189
+ t.fail('Should have thrown for unsupported BMP header size')
190
+ } catch (err) {
191
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
192
+ t.is(err.code, ERR_CODES.UNSUPPORTED_IMAGE_FORMAT, 'Error code should be UNSUPPORTED_IMAGE_FORMAT')
193
+ t.pass('Unsupported BMP header size correctly rejected')
194
+ } finally {
195
+ cleanupTmpDir()
196
+ }
197
+ })
198
+
199
+ test('getImageFromBmp rejects BMP with truncated pixel data', { timeout: TEST_TIMEOUT }, async function (t) {
200
+ const onnxOcr = createOcrInstance()
201
+ // Valid BMP header for 10x10 24-bit image, but pixel data is incomplete
202
+ const headerSize = 54
203
+ const buf = Buffer.alloc(headerSize + 10) // way too little pixel data for 10x10
204
+ buf[0] = 0x42 // B
205
+ buf[1] = 0x4D // M
206
+ buf.writeUInt32LE(headerSize, 10) // pixel data offset
207
+ buf.writeUInt32LE(40, 14) // infoHeaderSize (BITMAPINFOHEADER)
208
+ buf.writeInt32LE(10, 18) // width = 10
209
+ buf.writeInt32LE(10, 22) // height = 10
210
+ buf.writeUInt16LE(1, 26) // planes
211
+ buf.writeUInt16LE(24, 28) // bitsPerPixel = 24
212
+ const filePath = writeTmpFile('truncated_pixels.bmp', buf)
213
+
214
+ try {
215
+ onnxOcr._readImage(filePath)
216
+ t.fail('Should have thrown for truncated pixel data')
217
+ } catch (err) {
218
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Should throw QvacErrorAddonOcrGgml')
219
+ t.is(err.code, ERR_CODES.INVALID_IMAGE_OR_INSUFFICIENT_DATA, 'Error code should be INVALID_IMAGE_OR_INSUFFICIENT_DATA')
220
+ t.pass('Truncated pixel data correctly rejected')
221
+ } finally {
222
+ cleanupTmpDir()
223
+ }
224
+ })
225
+
226
+ test('getImageFromBmp handles negative height (top-down BMP)', { timeout: TEST_TIMEOUT }, async function (t) {
227
+ const onnxOcr = createOcrInstance()
228
+ const width = 2
229
+ const height = -2 // top-down BMP
230
+ const bpp = 24
231
+ const headerSize = 54
232
+ const rowSize = Math.ceil((width * (bpp / 8)) / 4) * 4
233
+ const pixelDataSize = Math.abs(height) * rowSize
234
+ const buf = Buffer.alloc(headerSize + pixelDataSize)
235
+
236
+ buf[0] = 0x42
237
+ buf[1] = 0x4D
238
+ buf.writeUInt32LE(headerSize, 10)
239
+ buf.writeUInt32LE(40, 14)
240
+ buf.writeInt32LE(width, 18)
241
+ buf.writeInt32LE(height, 22) // negative height
242
+ buf.writeUInt16LE(1, 26)
243
+ buf.writeUInt16LE(bpp, 28)
244
+
245
+ // Fill pixel data with recognizable pattern
246
+ for (let i = headerSize; i < buf.length; i++) {
247
+ buf[i] = i % 256
248
+ }
249
+
250
+ const filePath = writeTmpFile('topdown.bmp', buf)
251
+
252
+ try {
253
+ const result = onnxOcr._readImage(filePath)
254
+ t.is(result.width, width, 'Width should be 2')
255
+ t.is(result.height, Math.abs(height), 'Height should be absolute value (2)')
256
+ t.ok(result.data, 'Should return pixel data buffer')
257
+ t.ok(result.data.length > 0, 'Pixel data should not be empty')
258
+ t.pass('Top-down BMP (negative height) parsed successfully')
259
+ } finally {
260
+ cleanupTmpDir()
261
+ }
262
+ })
263
+
264
+ test('_readImage correctly detects JPEG by magic bytes', { timeout: TEST_TIMEOUT }, async function (t) {
265
+ const onnxOcr = createOcrInstance()
266
+ // JPEG magic: 0xFF 0xD8
267
+ const jpegBuffer = Buffer.alloc(16)
268
+ jpegBuffer[0] = 0xFF
269
+ jpegBuffer[1] = 0xD8
270
+ jpegBuffer[2] = 0xFF
271
+ jpegBuffer[3] = 0xE0
272
+ const filePath = writeTmpFile('test.jpg', jpegBuffer)
273
+
274
+ try {
275
+ const result = onnxOcr._readImage(filePath)
276
+ t.ok(result.isEncoded, 'JPEG should be marked as encoded (for C++ decoding)')
277
+ t.ok(result.data, 'Should contain the raw file data')
278
+ t.pass('JPEG magic bytes correctly detected')
279
+ } finally {
280
+ cleanupTmpDir()
281
+ }
282
+ })
283
+
284
+ test('_readImage correctly detects PNG by magic bytes', { timeout: TEST_TIMEOUT }, async function (t) {
285
+ const onnxOcr = createOcrInstance()
286
+ // PNG magic: 0x89 0x50 0x4E 0x47
287
+ const pngBuffer = Buffer.alloc(16)
288
+ pngBuffer[0] = 0x89
289
+ pngBuffer[1] = 0x50
290
+ pngBuffer[2] = 0x4E
291
+ pngBuffer[3] = 0x47
292
+ const filePath = writeTmpFile('test.png', pngBuffer)
293
+
294
+ try {
295
+ const result = onnxOcr._readImage(filePath)
296
+ t.ok(result.isEncoded, 'PNG should be marked as encoded (for C++ decoding)')
297
+ t.ok(result.data, 'Should contain the raw file data')
298
+ t.pass('PNG magic bytes correctly detected')
299
+ } finally {
300
+ cleanupTmpDir()
301
+ }
302
+ })
@@ -0,0 +1,292 @@
1
+ 'use strict'
2
+
3
+ const { OcrGgml, QvacErrorAddonOcrGgml, ERR_CODES, binding } = require('../..')
4
+ const test = require('brittle')
5
+ const { isMobile, ensureModelPath, getImagePath } = require('./utils')
6
+
7
+ const MOBILE_TIMEOUT = 600 * 1000
8
+ const DESKTOP_TIMEOUT = 120 * 1000
9
+ const TEST_TIMEOUT = isMobile ? MOBILE_TIMEOUT : DESKTOP_TIMEOUT
10
+
11
+ function createMinimalOcr () {
12
+ return new OcrGgml({
13
+ params: {
14
+ pathDetector: 'models/craft_mlt_25k.gguf',
15
+ pathRecognizer: 'models/latin_g2.gguf',
16
+ langList: ['en']
17
+ }
18
+ })
19
+ }
20
+
21
+ async function loadOcr () {
22
+ const detectorPath = await ensureModelPath('detector_craft')
23
+ const recognizerPath = await ensureModelPath('recognizer_latin')
24
+
25
+ const ocrGgml = new OcrGgml({
26
+ params: {
27
+ pathDetector: detectorPath,
28
+ pathRecognizer: recognizerPath,
29
+ langList: ['en']
30
+ }
31
+ })
32
+
33
+ await ocrGgml.load()
34
+ return ocrGgml
35
+ }
36
+
37
+ // =============================================
38
+ // Static methods & properties (index.js)
39
+ // =============================================
40
+
41
+ test('getModelKey returns ocr-ggml', function (t) {
42
+ t.is(OcrGgml.getModelKey(), 'ocr-ggml')
43
+ })
44
+
45
+ test('inferenceManagerConfig.noAdditionalDownload is true', function (t) {
46
+ t.is(OcrGgml.inferenceManagerConfig.noAdditionalDownload, true)
47
+ })
48
+
49
+ test('createJobHandler is initialized', function (t) {
50
+ const ocrGgml = createMinimalOcr()
51
+ t.ok(ocrGgml._job, 'should have _job handler')
52
+ t.is(ocrGgml._job.active, null, 'no active response initially')
53
+ })
54
+
55
+ // =============================================
56
+ // _addonOutputCallback event mapping (index.js)
57
+ // =============================================
58
+
59
+ test('_addonOutputCallback routes stats object (with totalTime) to _job.end', function (t) {
60
+ const ocrGgml = createMinimalOcr()
61
+ ocrGgml.opts = { stats: true }
62
+
63
+ // Start a job so there's an active response
64
+ const response = ocrGgml._job.start()
65
+ let ended = false
66
+ let receivedStats = null
67
+ response.on('stats', s => { receivedStats = s })
68
+ response.on('end', () => { ended = true })
69
+
70
+ const statsData = { totalTime: 1.5, detectionTime: 0.5, recognitionTime: 1.0 }
71
+ ocrGgml._addonOutputCallback(null, 'SomeMangledType', statsData, null)
72
+
73
+ t.ok(ended, 'response should be ended')
74
+ t.alike(receivedStats, statsData, 'stats should be forwarded')
75
+ t.is(ocrGgml._job.active, null, 'active response should be cleared')
76
+ })
77
+
78
+ test('_addonOutputCallback routes Error event to _job.fail', function (t) {
79
+ const ocrGgml = createMinimalOcr()
80
+
81
+ const response = ocrGgml._job.start()
82
+ let receivedError = null
83
+ response.onError(err => { receivedError = err })
84
+
85
+ ocrGgml._addonOutputCallback(null, 'SomethingError', 'error payload', 'the error')
86
+
87
+ t.ok(receivedError, 'error should be received')
88
+ t.is(ocrGgml._job.active, null, 'active response should be cleared')
89
+ })
90
+
91
+ test('_addonOutputCallback routes array data to _job.output', function (t) {
92
+ const ocrGgml = createMinimalOcr()
93
+
94
+ const response = ocrGgml._job.start()
95
+ let receivedData = null
96
+ response.onUpdate(d => { receivedData = d })
97
+
98
+ const outputData = [['box1', 'text1'], ['box2', 'text2']]
99
+ ocrGgml._addonOutputCallback(null, 'PipelineResult', outputData, null)
100
+
101
+ t.alike(receivedData, outputData, 'output data should be routed')
102
+ t.ok(ocrGgml._job.active, 'active response should still exist (not ended)')
103
+ })
104
+
105
+ test('_addonOutputCallback ignores unmapped non-array non-stats events', function (t) {
106
+ const ocrGgml = createMinimalOcr()
107
+
108
+ const response = ocrGgml._job.start()
109
+ let outputCalled = false
110
+ response.onUpdate(() => { outputCalled = true })
111
+
112
+ ocrGgml._addonOutputCallback(null, 'CustomEvent', 'string data', null)
113
+
114
+ t.not(outputCalled, 'output should not be called for unmapped string data')
115
+ t.ok(ocrGgml._job.active, 'active response should still exist')
116
+ })
117
+
118
+ // =============================================
119
+ // addonLogging.js coverage
120
+ // =============================================
121
+
122
+ test('addonLogging exports setLogger and releaseLogger as functions', function (t) {
123
+ const { addonLogging: logging } = require('../..')
124
+ t.is(typeof logging.setLogger, 'function')
125
+ t.is(typeof logging.releaseLogger, 'function')
126
+ })
127
+
128
+ test('addonLogging setLogger and releaseLogger execute without error', function (t) {
129
+ const { addonLogging: logging } = require('../..')
130
+ logging.setLogger(function () {})
131
+ t.pass('setLogger accepted a callback')
132
+ logging.releaseLogger()
133
+ t.pass('releaseLogger completed')
134
+ })
135
+
136
+ // =============================================
137
+ // Optional performance params: defaultRotationAngles, contrastRetry
138
+ // =============================================
139
+
140
+ test('load() accepts defaultRotationAngles and contrastRetry', { timeout: TEST_TIMEOUT }, async function (t) {
141
+ const detectorPath = await ensureModelPath('detector_craft')
142
+ const recognizerPath = await ensureModelPath('recognizer_latin')
143
+ const imagePath = getImagePath('/test/images/basic_test.bmp')
144
+
145
+ const ocrGgml = new OcrGgml({
146
+ params: {
147
+ pathDetector: detectorPath,
148
+ pathRecognizer: recognizerPath,
149
+ langList: ['en'],
150
+ defaultRotationAngles: [90, 180, 270],
151
+ contrastRetry: true
152
+ }
153
+ })
154
+
155
+ await ocrGgml.load()
156
+ t.pass('Loaded with defaultRotationAngles + contrastRetry')
157
+
158
+ try {
159
+ const response = await ocrGgml.run({
160
+ path: imagePath,
161
+ options: { paragraph: false }
162
+ })
163
+
164
+ await response
165
+ .onUpdate(function (output) {
166
+ t.ok(Array.isArray(output), 'Output should be an array')
167
+ })
168
+ .onError(function (error) {
169
+ t.fail('Unexpected error: ' + JSON.stringify(error))
170
+ })
171
+ .await()
172
+
173
+ t.pass('Inference completed with extra params')
174
+ } finally {
175
+ await ocrGgml.unload()
176
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
177
+ }
178
+ })
179
+
180
+ // =============================================
181
+ // OcrGgmlInterface catch blocks via monkey-patching
182
+ // (ocr-ggml.js: activate, runJob, destroy)
183
+ // =============================================
184
+
185
+ test('activate catch wraps error as FAILED_TO_ACTIVATE', { timeout: TEST_TIMEOUT }, async function (t) {
186
+ const ocrGgml = await loadOcr()
187
+ const addonRef = ocrGgml.addon
188
+
189
+ const original = binding.activate
190
+ binding.activate = function () { throw new Error('simulated activate failure') }
191
+
192
+ try {
193
+ await addonRef.activate()
194
+ t.fail('Should have thrown')
195
+ } catch (err) {
196
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Error is QvacErrorAddonOcrGgml')
197
+ t.is(err.code, ERR_CODES.FAILED_TO_ACTIVATE)
198
+ t.ok(err.message.includes('simulated activate failure'))
199
+ } finally {
200
+ binding.activate = original
201
+ await ocrGgml.unload()
202
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
203
+ }
204
+ })
205
+
206
+ test('runJob catch wraps error as FAILED_TO_RUN_JOB', { timeout: TEST_TIMEOUT }, async function (t) {
207
+ const ocrGgml = await loadOcr()
208
+ const addonRef = ocrGgml.addon
209
+
210
+ const original = binding.runJob
211
+ binding.runJob = function () { throw new Error('simulated runJob failure') }
212
+
213
+ try {
214
+ await addonRef.runJob({ type: 'image', input: {}, options: {} })
215
+ t.fail('Should have thrown')
216
+ } catch (err) {
217
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Error is QvacErrorAddonOcrGgml')
218
+ t.is(err.code, ERR_CODES.FAILED_TO_RUN_JOB)
219
+ t.ok(err.message.includes('simulated runJob failure'))
220
+ } finally {
221
+ binding.runJob = original
222
+ await ocrGgml.unload()
223
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
224
+ }
225
+ })
226
+
227
+ test('destroy catch wraps error as FAILED_TO_DESTROY', { timeout: TEST_TIMEOUT }, async function (t) {
228
+ const ocrGgml = await loadOcr()
229
+ const addonRef = ocrGgml.addon
230
+
231
+ const original = binding.destroyInstance
232
+ binding.destroyInstance = function () { throw new Error('simulated destroy failure') }
233
+
234
+ try {
235
+ await addonRef.destroy()
236
+ t.fail('Should have thrown')
237
+ } catch (err) {
238
+ t.ok(err instanceof QvacErrorAddonOcrGgml, 'Error is QvacErrorAddonOcrGgml')
239
+ t.is(err.code, ERR_CODES.FAILED_TO_DESTROY)
240
+ t.ok(err.message.includes('simulated destroy failure'))
241
+ } finally {
242
+ binding.destroyInstance = original
243
+ try { await addonRef.destroy() } catch (e) { /* real cleanup */ }
244
+ ocrGgml.addon = null
245
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
246
+ }
247
+ })
248
+
249
+ // =============================================
250
+ // OcrGgmlInterface.cancel and .unload delegation
251
+ // (ocr-ggml.js)
252
+ // =============================================
253
+
254
+ test('cancel calls binding.cancel', { timeout: TEST_TIMEOUT }, async function (t) {
255
+ const ocrGgml = await loadOcr()
256
+ const addonRef = ocrGgml.addon
257
+
258
+ let cancelCalled = false
259
+ const original = binding.cancel
260
+ binding.cancel = function () { cancelCalled = true }
261
+
262
+ try {
263
+ await addonRef.cancel()
264
+ t.ok(cancelCalled, 'binding.cancel was called')
265
+ } finally {
266
+ binding.cancel = original
267
+ await ocrGgml.unload()
268
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
269
+ }
270
+ })
271
+
272
+ test('OcrGgmlInterface.unload delegates to destroy', { timeout: TEST_TIMEOUT }, async function (t) {
273
+ const ocrGgml = await loadOcr()
274
+ const addonRef = ocrGgml.addon
275
+
276
+ let destroyCalled = false
277
+ const original = binding.destroyInstance
278
+ binding.destroyInstance = function (handle) {
279
+ destroyCalled = true
280
+ return original(handle)
281
+ }
282
+
283
+ try {
284
+ await addonRef.unload()
285
+ t.ok(destroyCalled, 'unload delegated to destroy')
286
+ t.is(addonRef._handle, null, 'Handle set to null after unload')
287
+ } finally {
288
+ binding.destroyInstance = original
289
+ ocrGgml.addon = null
290
+ await new Promise(function (resolve) { setTimeout(resolve, 1000) })
291
+ }
292
+ })