@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.
- package/LICENSE +179 -0
- package/NOTICE +63 -0
- package/README.md +330 -0
- package/addonLogging.d.ts +7 -0
- package/addonLogging.js +4 -0
- package/binding.js +21 -0
- package/index.d.ts +145 -0
- package/index.js +383 -0
- package/lib/error.js +80 -0
- package/ocr-ggml.js +121 -0
- package/package.json +96 -7
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.0_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.2_2.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv8.6_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.0_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_1.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml/libqvac-ggml-cpu-android_armv9.2_2.so +0 -0
- package/prebuilds/android-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-arm64/qvac__ocr-ggml.bare.exports +4277 -0
- package/prebuilds/darwin-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/darwin-x64/qvac__ocr-ggml.bare.exports +4322 -0
- package/prebuilds/ios-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-arm64/qvac__ocr-ggml.bare.exports +4262 -0
- package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-arm64-simulator/qvac__ocr-ggml.bare.exports +4262 -0
- package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/ios-x64-simulator/qvac__ocr-ggml.bare.exports +4311 -0
- package/prebuilds/linux-arm64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/linux-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/win32-x64/qvac__ocr-ggml.bare +0 -0
- package/prebuilds/win32-x64/qvac__ocr-ggml.bare.exports +0 -0
- package/test/integration/canvas-size.test.js +75 -0
- package/test/integration/doctr-basic.test.js +124 -0
- package/test/integration/doctr-clinical-chemistry.test.js +64 -0
- package/test/integration/doctr-ct-scan.test.js +66 -0
- package/test/integration/doctr-lab-results.test.js +65 -0
- package/test/integration/doctr-liver-function.test.js +67 -0
- package/test/integration/doctr-models.test.js +132 -0
- package/test/integration/doctr-param-validation.test.js +83 -0
- package/test/integration/error-handling.test.js +302 -0
- package/test/integration/full-coverage.test.js +292 -0
- package/test/integration/full-ocr-suite.test.js +83 -0
- package/test/integration/image-formats.test.js +168 -0
- package/test/integration/large-images.test.js +81 -0
- package/test/integration/lifecycle.test.js +269 -0
- package/test/integration/ocr-basic.test.js +70 -0
- package/test/integration/param-validation.test.js +86 -0
- package/test/integration/pipeline.test.js +61 -0
- package/test/integration/run-internal-ordering.test.js +75 -0
- package/test/integration/run-tests.sh +43 -0
- package/test/integration/run-with-exit.js +71 -0
- package/test/integration/utils.js +694 -0
- package/test/mobile/integration-runtime.cjs +71 -0
- package/test/mobile/integration.auto.cjs +102 -0
- 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
|
+
})
|