@sylphx/image-reader-mcp 0.1.0 → 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/README.md +222 -16
- package/bin/image-reader-mcp +48 -0
- package/bin/native/image-reader-mcp-server +0 -0
- package/dist/doctor-cli.js +6705 -0
- package/package.json +22 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +216 -0
- package/src/engine/rust-decode.ts +145 -0
- package/src/handlers/readImage.ts +252 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readImage.ts +110 -0
- package/src/sdk.ts +27 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/metadata.ts +108 -0
- package/src/utils/ocr.ts +279 -0
- package/src/utils/pathUtils.ts +40 -0
- package/src/utils/safety.ts +29 -0
- package/dist/index.js +0 -624
package/src/utils/ocr.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import type { OcrLine } from '../schemas/readImage.js';
|
|
3
|
+
|
|
4
|
+
const OCR_HEALTHCHECK_TIMEOUT_MS = 2_500;
|
|
5
|
+
const OCR_TIMEOUT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export interface OcrWord {
|
|
8
|
+
text: string;
|
|
9
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
10
|
+
confidence?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OcrResult {
|
|
14
|
+
available: boolean;
|
|
15
|
+
skipped_reason?: string;
|
|
16
|
+
/** Adapter route for evidence contract honesty */
|
|
17
|
+
route?: string;
|
|
18
|
+
languages?: string[];
|
|
19
|
+
languages_warning?: string;
|
|
20
|
+
line_count?: number;
|
|
21
|
+
dropped_low_confidence?: number;
|
|
22
|
+
lines: OcrLine[];
|
|
23
|
+
words?: OcrWord[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ParseTesseractOptions {
|
|
27
|
+
minConfidence?: number;
|
|
28
|
+
includeWords?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pure TSV parser — offline-testable evidence path for OCR bbox lines. */
|
|
32
|
+
export const parseTesseractTsv = (
|
|
33
|
+
raw: string,
|
|
34
|
+
options: ParseTesseractOptions = {}
|
|
35
|
+
): { lines: OcrLine[]; words: OcrWord[]; dropped_low_confidence: number } => {
|
|
36
|
+
const minConfidence = options.minConfidence ?? 0;
|
|
37
|
+
const includeWords = options.includeWords ?? false;
|
|
38
|
+
const linesRaw = raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
39
|
+
if (linesRaw.length <= 1) {
|
|
40
|
+
return { lines: [], words: [], dropped_low_confidence: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = linesRaw.slice(1).map((line) => line.split('\t'));
|
|
44
|
+
const lineMap = new Map<
|
|
45
|
+
number,
|
|
46
|
+
{
|
|
47
|
+
words: Array<{
|
|
48
|
+
text: string;
|
|
49
|
+
left: number;
|
|
50
|
+
top: number;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
conf: number;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
>();
|
|
57
|
+
|
|
58
|
+
let dropped = 0;
|
|
59
|
+
const flatWords: OcrWord[] = [];
|
|
60
|
+
|
|
61
|
+
for (const columns of rows) {
|
|
62
|
+
if (columns.length < 12) continue;
|
|
63
|
+
const level = Number.parseInt(columns[0] ?? '', 10);
|
|
64
|
+
if (level !== 5) continue;
|
|
65
|
+
|
|
66
|
+
const text = columns[11]?.trim() ?? '';
|
|
67
|
+
if (text.length === 0) continue;
|
|
68
|
+
|
|
69
|
+
const lineNum = Number.parseInt(columns[4] ?? '', 10);
|
|
70
|
+
const left = Number.parseInt(columns[6] ?? '', 10);
|
|
71
|
+
const top = Number.parseInt(columns[7] ?? '', 10);
|
|
72
|
+
const width = Number.parseInt(columns[8] ?? '', 10);
|
|
73
|
+
const height = Number.parseInt(columns[9] ?? '', 10);
|
|
74
|
+
const conf = Number.parseFloat(columns[10] ?? '');
|
|
75
|
+
|
|
76
|
+
if (!Number.isFinite(lineNum) || !Number.isFinite(left) || !Number.isFinite(top)) continue;
|
|
77
|
+
|
|
78
|
+
const confVal = Number.isFinite(conf) ? conf : 0;
|
|
79
|
+
if (confVal < minConfidence) {
|
|
80
|
+
dropped += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bucket = lineMap.get(lineNum) ?? { words: [] };
|
|
85
|
+
bucket.words.push({
|
|
86
|
+
text,
|
|
87
|
+
left,
|
|
88
|
+
top,
|
|
89
|
+
width: Number.isFinite(width) ? width : 0,
|
|
90
|
+
height: Number.isFinite(height) ? height : 0,
|
|
91
|
+
conf: confVal,
|
|
92
|
+
});
|
|
93
|
+
lineMap.set(lineNum, bucket);
|
|
94
|
+
|
|
95
|
+
if (includeWords) {
|
|
96
|
+
flatWords.push({
|
|
97
|
+
text,
|
|
98
|
+
bbox: {
|
|
99
|
+
x: left,
|
|
100
|
+
y: top,
|
|
101
|
+
width: Number.isFinite(width) ? width : 0,
|
|
102
|
+
height: Number.isFinite(height) ? height : 0,
|
|
103
|
+
},
|
|
104
|
+
confidence: confVal,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ocrLines: OcrLine[] = [];
|
|
110
|
+
|
|
111
|
+
for (const bucket of lineMap.values()) {
|
|
112
|
+
if (bucket.words.length === 0) continue;
|
|
113
|
+
|
|
114
|
+
const sorted = [...bucket.words].sort((a, b) => a.left - b.left);
|
|
115
|
+
const text = sorted
|
|
116
|
+
.map((word) => word.text)
|
|
117
|
+
.join(' ')
|
|
118
|
+
.trim();
|
|
119
|
+
if (text.length === 0) continue;
|
|
120
|
+
|
|
121
|
+
const left = Math.min(...sorted.map((word) => word.left));
|
|
122
|
+
const top = Math.min(...sorted.map((word) => word.top));
|
|
123
|
+
const right = Math.max(...sorted.map((word) => word.left + word.width));
|
|
124
|
+
const bottom = Math.max(...sorted.map((word) => word.top + word.height));
|
|
125
|
+
const confidenceValues = sorted.map((word) => word.conf).filter((value) => value >= 0);
|
|
126
|
+
const confidence =
|
|
127
|
+
confidenceValues.length > 0
|
|
128
|
+
? confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
ocrLines.push({
|
|
132
|
+
text,
|
|
133
|
+
bbox: {
|
|
134
|
+
x: left,
|
|
135
|
+
y: top,
|
|
136
|
+
width: Math.max(0, right - left),
|
|
137
|
+
height: Math.max(0, bottom - top),
|
|
138
|
+
},
|
|
139
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
lines: ocrLines.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
145
|
+
words: includeWords ? flatWords.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x) : [],
|
|
146
|
+
dropped_low_confidence: dropped,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const isTesseractAvailable = (): boolean => {
|
|
151
|
+
const result = spawnSync('tesseract', ['--version'], {
|
|
152
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
stdio: 'ignore',
|
|
155
|
+
});
|
|
156
|
+
return result.status === 0;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** List installed Tesseract traineddata languages (honest empty when unavailable). */
|
|
160
|
+
export const listTesseractLanguages = (): {
|
|
161
|
+
available: boolean;
|
|
162
|
+
languages: string[];
|
|
163
|
+
warning?: string;
|
|
164
|
+
} => {
|
|
165
|
+
if (!isTesseractAvailable()) {
|
|
166
|
+
return {
|
|
167
|
+
available: false,
|
|
168
|
+
languages: [],
|
|
169
|
+
warning: 'Tesseract is not installed or not available on PATH.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const result = spawnSync('tesseract', ['--list-langs'], {
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
timeout: 5_000,
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
});
|
|
177
|
+
if (result.status !== 0) {
|
|
178
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
179
|
+
return {
|
|
180
|
+
available: false,
|
|
181
|
+
languages: [],
|
|
182
|
+
warning: stderr || 'tesseract --list-langs failed',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
186
|
+
const languages = out
|
|
187
|
+
.split(/\r?\n/)
|
|
188
|
+
.map((l) => l.trim())
|
|
189
|
+
.filter(
|
|
190
|
+
(l) => l && !l.toLowerCase().includes('list of available languages') && !l.startsWith('Error')
|
|
191
|
+
);
|
|
192
|
+
return { available: true, languages };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type RunOcrOptions = {
|
|
196
|
+
languages?: string[];
|
|
197
|
+
minConfidence?: number;
|
|
198
|
+
includeWords?: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const runTesseractOcr = (
|
|
202
|
+
imagePath: string,
|
|
203
|
+
languagesOrOptions: string[] | RunOcrOptions = ['eng']
|
|
204
|
+
): OcrResult => {
|
|
205
|
+
const options: RunOcrOptions = Array.isArray(languagesOrOptions)
|
|
206
|
+
? { languages: languagesOrOptions }
|
|
207
|
+
: languagesOrOptions;
|
|
208
|
+
const languages = options.languages?.length ? options.languages : ['eng'];
|
|
209
|
+
const minConfidence = options.minConfidence ?? 0;
|
|
210
|
+
const includeWords = options.includeWords ?? false;
|
|
211
|
+
|
|
212
|
+
if (!isTesseractAvailable()) {
|
|
213
|
+
return {
|
|
214
|
+
available: false,
|
|
215
|
+
skipped_reason: 'Tesseract is not installed or not available on PATH.',
|
|
216
|
+
route: 'tesseract_tsv',
|
|
217
|
+
languages,
|
|
218
|
+
lines: [],
|
|
219
|
+
line_count: 0,
|
|
220
|
+
dropped_low_confidence: 0,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const installed = listTesseractLanguages();
|
|
225
|
+
const missingLangs = languages.filter(
|
|
226
|
+
(lang) => installed.available && !installed.languages.includes(lang)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const languageArg = languages.join('+');
|
|
230
|
+
const result = spawnSync('tesseract', [imagePath, 'stdout', '-l', languageArg, 'tsv'], {
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
timeout: OCR_TIMEOUT_MS,
|
|
233
|
+
windowsHide: true,
|
|
234
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (result.error) {
|
|
238
|
+
return {
|
|
239
|
+
available: false,
|
|
240
|
+
skipped_reason: `Tesseract failed to start: ${result.error.message}`,
|
|
241
|
+
route: 'tesseract_tsv',
|
|
242
|
+
languages,
|
|
243
|
+
lines: [],
|
|
244
|
+
line_count: 0,
|
|
245
|
+
dropped_low_confidence: 0,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
251
|
+
return {
|
|
252
|
+
available: false,
|
|
253
|
+
skipped_reason:
|
|
254
|
+
stderr.length > 0 ? stderr : `Tesseract exited with status ${String(result.status)}.`,
|
|
255
|
+
route: 'tesseract_tsv',
|
|
256
|
+
languages,
|
|
257
|
+
lines: [],
|
|
258
|
+
line_count: 0,
|
|
259
|
+
dropped_low_confidence: 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
264
|
+
const parsed = parseTesseractTsv(stdout, { minConfidence, includeWords });
|
|
265
|
+
return {
|
|
266
|
+
available: true,
|
|
267
|
+
route: 'tesseract_tsv',
|
|
268
|
+
languages,
|
|
269
|
+
lines: parsed.lines,
|
|
270
|
+
line_count: parsed.lines.length,
|
|
271
|
+
dropped_low_confidence: parsed.dropped_low_confidence,
|
|
272
|
+
...(missingLangs.length
|
|
273
|
+
? {
|
|
274
|
+
languages_warning: `Requested OCR language(s) not listed by tesseract --list-langs: ${missingLangs.join(', ')}. Installed: ${installed.languages.join(', ') || '(none)'}.`,
|
|
275
|
+
}
|
|
276
|
+
: {}),
|
|
277
|
+
...(includeWords ? { words: parsed.words } : {}),
|
|
278
|
+
};
|
|
279
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ErrorCode, ImageError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const PROJECT_ROOT = process.cwd();
|
|
6
|
+
|
|
7
|
+
const canonicalize = (p: string): string => {
|
|
8
|
+
try {
|
|
9
|
+
return fs.realpathSync(p);
|
|
10
|
+
} catch (err: unknown) {
|
|
11
|
+
if (
|
|
12
|
+
typeof err === 'object' &&
|
|
13
|
+
err !== null &&
|
|
14
|
+
'code' in err &&
|
|
15
|
+
(err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
|
16
|
+
) {
|
|
17
|
+
const parent = path.dirname(p);
|
|
18
|
+
if (parent === p) return p;
|
|
19
|
+
return path.join(canonicalize(parent), path.basename(p));
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a user-provided path, accepting both absolute and relative paths.
|
|
27
|
+
* Relative paths are resolved against the current working directory.
|
|
28
|
+
*/
|
|
29
|
+
export const resolvePath = (userPath: string): string => {
|
|
30
|
+
if (typeof userPath !== 'string') {
|
|
31
|
+
throw new ImageError(ErrorCode.InvalidParams, 'Path must be a string.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalizedUserPath = path.normalize(userPath);
|
|
35
|
+
const resolved = path.isAbsolute(normalizedUserPath)
|
|
36
|
+
? normalizedUserPath
|
|
37
|
+
: path.resolve(PROJECT_ROOT, normalizedUserPath);
|
|
38
|
+
|
|
39
|
+
return canonicalize(resolved);
|
|
40
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ErrorCode, ImageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const IMAGE_SAFETY_LIMITS = {
|
|
4
|
+
maxFileBytes: 32 * 1024 * 1024,
|
|
5
|
+
maxPixels: 64 * 1024 * 1024,
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export function validateImageSafety(input: {
|
|
9
|
+
fileSizeBytes: number;
|
|
10
|
+
width?: number | undefined;
|
|
11
|
+
height?: number | undefined;
|
|
12
|
+
}): void {
|
|
13
|
+
if (input.fileSizeBytes > IMAGE_SAFETY_LIMITS.maxFileBytes) {
|
|
14
|
+
throw new ImageError(
|
|
15
|
+
ErrorCode.InvalidRequest,
|
|
16
|
+
`Image exceeds the ${IMAGE_SAFETY_LIMITS.maxFileBytes} byte safety limit.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (input.width !== undefined && input.height !== undefined) {
|
|
21
|
+
const pixels = input.width * input.height;
|
|
22
|
+
if (pixels > IMAGE_SAFETY_LIMITS.maxPixels) {
|
|
23
|
+
throw new ImageError(
|
|
24
|
+
ErrorCode.InvalidRequest,
|
|
25
|
+
`Image exceeds the ${IMAGE_SAFETY_LIMITS.maxPixels} pixel safety budget (${input.width}x${input.height}).`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|