@sylphx/image-reader-mcp 0.1.1 → 0.1.3

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 CHANGED
@@ -36,6 +36,15 @@ Each instrument is an independent repository (marketplace + stars).
36
36
  ---
37
37
 
38
38
 
39
+
40
+ ## Read images (not vague vision)
41
+
42
+ Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a text-only agent can understand picture architecture without a vision model.
43
+
44
+ Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
45
+
46
+ **Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
47
+
39
48
  ## Product docs
40
49
 
41
50
  | Doc | Purpose |
@@ -6501,9 +6501,11 @@ var listTesseractLanguages = () => {
6501
6501
  }
6502
6502
  const result = spawnSync("tesseract", ["--list-langs"], {
6503
6503
  encoding: "utf8",
6504
- timeout: 5000,
6504
+ timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
6505
6505
  windowsHide: true
6506
6506
  });
6507
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
6508
+ const languages = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available"));
6507
6509
  if (result.status !== 0) {
6508
6510
  const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
6509
6511
  return {
@@ -6512,9 +6514,6 @@ var listTesseractLanguages = () => {
6512
6514
  warning: stderr || "tesseract --list-langs failed"
6513
6515
  };
6514
6516
  }
6515
- const out = `${result.stdout ?? ""}
6516
- ${result.stderr ?? ""}`;
6517
- const languages = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available languages") && !l.startsWith("Error"));
6518
6517
  return { available: true, languages };
6519
6518
  };
6520
6519
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/image-reader-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "mcpName": "io.github.SylphxAI/image-reader-mcp",
5
5
  "description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
6
6
  "type": "module",
@@ -9,6 +9,7 @@ import {
9
9
  } from '../engine/rust-decode.js';
10
10
  import { text, tool, toolError } from '../mcp.js';
11
11
  import { type AgentMediaTwin, readImageArgsSchema } from '../schemas/readImage.js';
12
+ import { applyImageIntelligence } from '../utils/applyImageIntelligence.js';
12
13
  import { ErrorCode, ImageError } from '../utils/errors.js';
13
14
  import { collectTrustWarnings, redactGpsFields } from '../utils/metadata.js';
14
15
  import { runTesseractOcr } from '../utils/ocr.js';
@@ -74,7 +75,7 @@ const readMetadata = async (
74
75
 
75
76
  export const readImage = tool()
76
77
  .description(
77
- 'Evidence-first image reader. Returns an Agent Media Twin with filename, mime, dimensions, metadata, optional OCR lines with bounding boxes, and trust warnings. No generative LLM is used.'
78
+ 'Evidence-first image reader for agents (read, not vague vision). Returns Agent Media Twin: geometry, metadata, OCR lines/words, layout blocks, text agent_map, optional palette, optional non-authority LLM caption. Local-first; generative path off by default.'
78
79
  )
79
80
  .input(readImageArgsSchema)
80
81
  .handler(async ({ input }) => {
@@ -201,9 +202,12 @@ export const readImage = tool()
201
202
  ? { languages_warning: ocr.languages_warning }
202
203
  : {}),
203
204
  ...(ocr.words !== undefined ? { words: ocr.words } : {}),
205
+ ...(ocr.native_blocks !== undefined ? { native_blocks: ocr.native_blocks } : {}),
204
206
  };
205
207
  }
206
208
 
209
+ twin = await applyImageIntelligence(twin, resolvedPath, input, includeOcr);
210
+
207
211
  if (input.region !== undefined) {
208
212
  if (!useRustDecode) {
209
213
  throw new ImageError(
@@ -59,6 +59,28 @@ export const readImageArgsSchema = z.object({
59
59
  .positive()
60
60
  .optional()
61
61
  .describe('Maximum width or height when resizing the cropped region for evidence.'),
62
+ include_layout: z
63
+ .boolean()
64
+ .optional()
65
+ .describe(
66
+ 'When OCR lines exist, cluster them into reading-order text blocks (image architecture). Defaults to true when include_ocr is true.'
67
+ ),
68
+ include_agent_map: z
69
+ .boolean()
70
+ .optional()
71
+ .describe(
72
+ 'Return a text-only agent_map so non-vision models can read image structure. Defaults to true.'
73
+ ),
74
+ include_palette: z
75
+ .boolean()
76
+ .optional()
77
+ .describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
78
+ include_optional_llm: z
79
+ .boolean()
80
+ .optional()
81
+ .describe(
82
+ 'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
83
+ ),
62
84
  });
63
85
 
64
86
  export const agentMediaTwinSchema = z.object({
@@ -88,6 +110,17 @@ export const agentMediaTwinSchema = z.object({
88
110
  })
89
111
  )
90
112
  .optional(),
113
+ native_blocks: z
114
+ .array(
115
+ z.object({
116
+ id: z.string(),
117
+ kind: z.enum(['block', 'paragraph']),
118
+ text: z.string(),
119
+ bbox: boundingBoxSchema,
120
+ confidence: z.number().optional(),
121
+ })
122
+ )
123
+ .optional(),
91
124
  })
92
125
  .optional(),
93
126
  region_evidence: z
@@ -101,6 +134,45 @@ export const agentMediaTwinSchema = z.object({
101
134
  image_base64: z.string().optional(),
102
135
  })
103
136
  .optional(),
137
+ layout: z
138
+ .object({
139
+ policy: z.string(),
140
+ block_count: z.number().int().nonnegative(),
141
+ blocks: z.array(
142
+ z.object({
143
+ id: z.string(),
144
+ kind: z.literal('text_block'),
145
+ text: z.string(),
146
+ bbox: boundingBoxSchema,
147
+ line_count: z.number().int().nonnegative(),
148
+ reading_order: z.number().int().positive(),
149
+ })
150
+ ),
151
+ full_text: z.string(),
152
+ warnings: z.array(z.string()),
153
+ })
154
+ .optional(),
155
+ agent_map: z
156
+ .object({
157
+ policy: z.string(),
158
+ filename: z.string(),
159
+ mime: z.string(),
160
+ dimensions: imageDimensionsSchema,
161
+ outline: z.string(),
162
+ text_present: z.boolean(),
163
+ block_count: z.number().int().nonnegative(),
164
+ palette: z.array(z.object({ hex: z.string(), approx_share: z.number() })).optional(),
165
+ optional_llm: z
166
+ .object({
167
+ available: z.boolean(),
168
+ skipped_reason: z.string().optional(),
169
+ route: z.string().optional(),
170
+ caption: z.string().optional(),
171
+ model: z.string().optional(),
172
+ })
173
+ .optional(),
174
+ })
175
+ .optional(),
104
176
  trust_warnings: z.array(z.string()),
105
177
  });
106
178
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Text-only Agent Map: lets a non-vision model "read" the image architecture.
3
+ * Deterministic, local-first. Optional LLM captions are layered elsewhere.
4
+ */
5
+
6
+ import type { ImageLayout } from './layout.js';
7
+
8
+ export type AgentImageMap = {
9
+ policy: 'agent_image_map_v1';
10
+ filename: string;
11
+ mime: string;
12
+ dimensions: { width: number; height: number };
13
+ /** Human/agent readable outline (markdown-ish, not prose hallucination). */
14
+ outline: string;
15
+ text_present: boolean;
16
+ block_count: number;
17
+ palette?: Array<{ hex: string; approx_share: number }>;
18
+ optional_llm?: {
19
+ available: boolean;
20
+ skipped_reason?: string;
21
+ route?: string;
22
+ caption?: string;
23
+ model?: string;
24
+ };
25
+ };
26
+
27
+ export function buildAgentImageMap(input: {
28
+ filename: string;
29
+ mime: string;
30
+ dimensions: { width: number; height: number };
31
+ layout?: ImageLayout;
32
+ ocrLineCount?: number;
33
+ palette?: Array<{ hex: string; approx_share: number }>;
34
+ optionalLlm?: AgentImageMap['optional_llm'];
35
+ }): AgentImageMap {
36
+ const { width, height } = input.dimensions;
37
+ const blocks = input.layout?.blocks ?? [];
38
+ const textPresent = (input.ocrLineCount ?? 0) > 0 || blocks.length > 0;
39
+
40
+ const lines: string[] = [
41
+ `# Image map: ${input.filename}`,
42
+ `- mime: ${input.mime}`,
43
+ `- size: ${width}×${height}px`,
44
+ `- text_present: ${textPresent}`,
45
+ `- layout_blocks: ${blocks.length}`,
46
+ ];
47
+
48
+ if (input.palette && input.palette.length > 0) {
49
+ lines.push(
50
+ `- palette: ${input.palette.map((p) => `${p.hex}~${Math.round(p.approx_share * 100)}%`).join(', ')}`
51
+ );
52
+ }
53
+
54
+ if (blocks.length > 0) {
55
+ lines.push('', '## Reading-order text blocks');
56
+ for (const b of blocks) {
57
+ const preview = b.text.replace(/\s+/g, ' ').slice(0, 160);
58
+ lines.push(
59
+ `### ${b.id} (order ${b.reading_order}, ${b.line_count} lines)`,
60
+ `- bbox: x=${b.bbox.x} y=${b.bbox.y} w=${b.bbox.width} h=${b.bbox.height}`,
61
+ `- text: ${preview}${b.text.length > 160 ? '…' : ''}`,
62
+ ''
63
+ );
64
+ }
65
+ } else if (!textPresent) {
66
+ lines.push(
67
+ '',
68
+ '## Notes',
69
+ '- No OCR text recovered. Enable include_ocr for text architecture.',
70
+ '- Use crop_region / image_probe for geometry; optional LLM caption only if configured.'
71
+ );
72
+ }
73
+
74
+ if (input.optionalLlm?.available && input.optionalLlm.caption) {
75
+ lines.push('', '## Optional LLM caption (non-authority)', input.optionalLlm.caption);
76
+ } else if (input.optionalLlm && !input.optionalLlm.available) {
77
+ lines.push(
78
+ '',
79
+ `## Optional LLM: skipped (${input.optionalLlm.skipped_reason ?? 'not configured'})`
80
+ );
81
+ }
82
+
83
+ return {
84
+ policy: 'agent_image_map_v1',
85
+ filename: input.filename,
86
+ mime: input.mime,
87
+ dimensions: input.dimensions,
88
+ outline: lines.join('\n'),
89
+ text_present: textPresent,
90
+ block_count: blocks.length,
91
+ ...(input.palette ? { palette: input.palette } : {}),
92
+ ...(input.optionalLlm ? { optional_llm: input.optionalLlm } : {}),
93
+ };
94
+ }
@@ -0,0 +1,84 @@
1
+ import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
2
+ import { buildAgentImageMap } from './agentMap.js';
3
+ import { buildBestEffortLayout } from './layout.js';
4
+ import { maybeOptionalImageCaption } from './optionalLlm.js';
5
+ import { samplePalette } from './palette.js';
6
+
7
+ /** Attach layout, palette, optional LLM, and agent_map to an Agent Media Twin. */
8
+ export async function applyImageIntelligence(
9
+ twin: AgentMediaTwin,
10
+ resolvedPath: string,
11
+ input: ReadImageArgs,
12
+ includeOcr: boolean
13
+ ): Promise<AgentMediaTwin> {
14
+ const next: AgentMediaTwin = {
15
+ ...twin,
16
+ trust_warnings: [...twin.trust_warnings],
17
+ };
18
+
19
+ const includeLayout = input.include_layout ?? includeOcr;
20
+ if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
21
+ const ocrAny = next.ocr as {
22
+ lines: Array<{
23
+ text: string;
24
+ bbox: { x: number; y: number; width: number; height: number };
25
+ confidence?: number;
26
+ }>;
27
+ native_blocks?: Array<{
28
+ id: string;
29
+ kind: 'block' | 'paragraph';
30
+ text: string;
31
+ bbox: { x: number; y: number; width: number; height: number };
32
+ confidence?: number;
33
+ }>;
34
+ };
35
+ const layout = buildBestEffortLayout({
36
+ lines: ocrAny.lines.map((line) => ({
37
+ text: line.text,
38
+ bbox: line.bbox,
39
+ ...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
40
+ })),
41
+ ...(ocrAny.native_blocks ? { nativeBlocks: ocrAny.native_blocks } : {}),
42
+ });
43
+ next.layout = layout;
44
+ next.trust_warnings.push(`layout_policy: ${layout.policy}`);
45
+ for (const warning of layout.warnings) {
46
+ next.trust_warnings.push(`layout: ${warning}`);
47
+ }
48
+ }
49
+
50
+ let palette: Array<{ hex: string; approx_share: number }> | undefined;
51
+ if (input.include_palette) {
52
+ try {
53
+ palette = await samplePalette(resolvedPath);
54
+ } catch {
55
+ next.trust_warnings.push('palette: sampling failed; continuing without palette.');
56
+ }
57
+ }
58
+
59
+ const optionalLlm = await maybeOptionalImageCaption({
60
+ path: resolvedPath,
61
+ mime: next.mime,
62
+ enabled: input.include_optional_llm ?? false,
63
+ });
64
+ if (optionalLlm.available) {
65
+ next.trust_warnings.push(
66
+ 'optional_llm caption is non-authority; prefer OCR/layout locators for claims.'
67
+ );
68
+ }
69
+
70
+ const includeAgentMap = input.include_agent_map ?? true;
71
+ if (includeAgentMap) {
72
+ next.agent_map = buildAgentImageMap({
73
+ filename: next.filename,
74
+ mime: next.mime,
75
+ dimensions: next.dimensions,
76
+ ...(next.layout ? { layout: next.layout } : {}),
77
+ ocrLineCount: next.ocr?.line_count ?? next.ocr?.lines?.length ?? 0,
78
+ ...(palette ? { palette } : {}),
79
+ optionalLlm,
80
+ });
81
+ }
82
+
83
+ return next;
84
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Local-first layout: prefer Tesseract native block/paragraph units; fall back to line clustering.
3
+ */
4
+
5
+ export type BBox = { x: number; y: number; width: number; height: number };
6
+
7
+ export type LayoutLine = {
8
+ text: string;
9
+ bbox: BBox;
10
+ confidence?: number;
11
+ };
12
+
13
+ export type LayoutBlock = {
14
+ id: string;
15
+ kind: 'text_block';
16
+ text: string;
17
+ bbox: BBox;
18
+ line_count: number;
19
+ reading_order: number;
20
+ source?: 'tesseract_native' | 'line_cluster';
21
+ };
22
+
23
+ export type ImageLayout = {
24
+ policy: string;
25
+ block_count: number;
26
+ blocks: LayoutBlock[];
27
+ full_text: string;
28
+ warnings: string[];
29
+ };
30
+
31
+ export type NativeBlockInput = {
32
+ id: string;
33
+ kind: 'block' | 'paragraph';
34
+ text: string;
35
+ bbox: BBox;
36
+ confidence?: number;
37
+ };
38
+
39
+ const unionBBox = (boxes: BBox[]): BBox => {
40
+ const minX = Math.min(...boxes.map((b) => b.x));
41
+ const minY = Math.min(...boxes.map((b) => b.y));
42
+ const maxX = Math.max(...boxes.map((b) => b.x + b.width));
43
+ const maxY = Math.max(...boxes.map((b) => b.y + b.height));
44
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
45
+ };
46
+
47
+ const median = (values: number[], fallback: number): number => {
48
+ if (values.length === 0) return fallback;
49
+ const sorted = [...values].sort((a, b) => a - b);
50
+ return sorted[Math.floor(sorted.length / 2)] ?? fallback;
51
+ };
52
+
53
+ const shouldMerge = (prev: LayoutLine, next: LayoutLine, yGap: number, xSlop: number): boolean => {
54
+ const verticalClose = next.bbox.y - (prev.bbox.y + prev.bbox.height) <= yGap;
55
+ const prevRight = prev.bbox.x + prev.bbox.width;
56
+ const nextRight = next.bbox.x + next.bbox.width;
57
+ const horizontalOverlap =
58
+ Math.min(prevRight, nextRight) - Math.max(prev.bbox.x, next.bbox.x) > -xSlop;
59
+ return verticalClose && horizontalOverlap;
60
+ };
61
+
62
+ const toClusterBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
63
+ const ordered = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
64
+ const text = ordered
65
+ .map((l) => l.text.trim())
66
+ .filter(Boolean)
67
+ .join('\n');
68
+ return {
69
+ id: `block-${index + 1}`,
70
+ kind: 'text_block',
71
+ text,
72
+ bbox: unionBBox(ordered.map((l) => l.bbox)),
73
+ line_count: ordered.length,
74
+ reading_order: index + 1,
75
+ source: 'line_cluster',
76
+ };
77
+ };
78
+
79
+ export function buildLayoutFromNativeBlocks(native: NativeBlockInput[]): ImageLayout {
80
+ const usable = native
81
+ .filter((b) => b.text.trim().length > 0 && b.bbox.width > 0 && b.bbox.height > 0)
82
+ .sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
83
+ const blocks: LayoutBlock[] = usable.map((b, index) => ({
84
+ id: b.id || `native-${index + 1}`,
85
+ kind: 'text_block',
86
+ text: b.text.trim(),
87
+ bbox: b.bbox,
88
+ line_count: Math.max(1, b.text.split(/\n/).length),
89
+ reading_order: index + 1,
90
+ source: 'tesseract_native',
91
+ }));
92
+ return {
93
+ policy: 'tesseract_native_layout_v1',
94
+ block_count: blocks.length,
95
+ blocks,
96
+ full_text: blocks.map((b) => b.text).join('\n\n'),
97
+ warnings:
98
+ blocks.length === 0
99
+ ? ['Native Tesseract blocks present but empty text; falling back recommended.']
100
+ : [],
101
+ };
102
+ }
103
+
104
+ export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
105
+ if (lines.length === 0) {
106
+ return {
107
+ policy: 'ocr_line_cluster_v1',
108
+ block_count: 0,
109
+ blocks: [],
110
+ full_text: '',
111
+ warnings: ['No OCR lines available for layout clustering.'],
112
+ };
113
+ }
114
+
115
+ const sorted = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
116
+ const medianHeight = median(
117
+ sorted.map((l) => l.bbox.height).filter((h) => h > 0),
118
+ 16
119
+ );
120
+ const yGap = Math.max(8, medianHeight * 1.4);
121
+ const xSlop = medianHeight * 2;
122
+
123
+ const clusters: LayoutLine[][] = [];
124
+ for (const line of sorted) {
125
+ const last = clusters[clusters.length - 1];
126
+ const prev = last?.[last.length - 1];
127
+ if (last && prev && shouldMerge(prev, line, yGap, xSlop)) {
128
+ last.push(line);
129
+ } else {
130
+ clusters.push([line]);
131
+ }
132
+ }
133
+
134
+ const blocks = clusters.map((cluster, index) => toClusterBlock(cluster, index));
135
+ const warnings: string[] = [];
136
+ if (blocks.length === 1 && lines.length > 8) {
137
+ warnings.push(
138
+ 'Layout collapsed to a single block; image may be dense continuous text or OCR line geometry is coarse.'
139
+ );
140
+ }
141
+
142
+ return {
143
+ policy: 'ocr_line_cluster_v1',
144
+ block_count: blocks.length,
145
+ blocks,
146
+ full_text: blocks.map((b) => b.text).join('\n\n'),
147
+ warnings,
148
+ };
149
+ }
150
+
151
+ /** Local-first frontier layout: native Tesseract structure first, heuristic second. */
152
+ export function buildBestEffortLayout(input: {
153
+ lines: LayoutLine[];
154
+ nativeBlocks?: NativeBlockInput[];
155
+ }): ImageLayout {
156
+ if (input.nativeBlocks?.some((b) => b.text.trim().length > 0)) {
157
+ const native = buildLayoutFromNativeBlocks(input.nativeBlocks);
158
+ if (native.block_count > 0) return native;
159
+ }
160
+ return buildLayoutFromOcrLines(input.lines);
161
+ }
package/src/utils/ocr.ts CHANGED
@@ -10,6 +10,17 @@ export interface OcrWord {
10
10
  confidence?: number;
11
11
  }
12
12
 
13
+ /** Native Tesseract layout unit (block or paragraph) — frontier-local structure free with classic OCR. */
14
+ export interface OcrNativeBlock {
15
+ id: string;
16
+ kind: 'block' | 'paragraph';
17
+ text: string;
18
+ bbox: { x: number; y: number; width: number; height: number };
19
+ block_num?: number;
20
+ par_num?: number;
21
+ confidence?: number;
22
+ }
23
+
13
24
  export interface OcrResult {
14
25
  available: boolean;
15
26
  skipped_reason?: string;
@@ -21,6 +32,8 @@ export interface OcrResult {
21
32
  dropped_low_confidence?: number;
22
33
  lines: OcrLine[];
23
34
  words?: OcrWord[];
35
+ /** Prefer for layout when present (Tesseract levels 2/3). */
36
+ native_blocks?: OcrNativeBlock[];
24
37
  }
25
38
 
26
39
  export interface ParseTesseractOptions {
@@ -28,21 +41,31 @@ export interface ParseTesseractOptions {
28
41
  includeWords?: boolean;
29
42
  }
30
43
 
31
- /** Pure TSV parser offline-testable evidence path for OCR bbox lines. */
44
+ const num = (v: string | undefined): number => Number.parseFloat(v ?? '');
45
+
46
+ /**
47
+ * Pure TSV parser — lines (level 4 aggregated from words), words (level 5),
48
+ * and native blocks/paragraphs (levels 2/3) for local-first layout frontier.
49
+ */
32
50
  export const parseTesseractTsv = (
33
51
  raw: string,
34
52
  options: ParseTesseractOptions = {}
35
- ): { lines: OcrLine[]; words: OcrWord[]; dropped_low_confidence: number } => {
53
+ ): {
54
+ lines: OcrLine[];
55
+ words: OcrWord[];
56
+ native_blocks: OcrNativeBlock[];
57
+ dropped_low_confidence: number;
58
+ } => {
36
59
  const minConfidence = options.minConfidence ?? 0;
37
60
  const includeWords = options.includeWords ?? false;
38
61
  const linesRaw = raw.split(/\r?\n/).filter((line) => line.length > 0);
39
62
  if (linesRaw.length <= 1) {
40
- return { lines: [], words: [], dropped_low_confidence: 0 };
63
+ return { lines: [], words: [], native_blocks: [], dropped_low_confidence: 0 };
41
64
  }
42
65
 
43
66
  const rows = linesRaw.slice(1).map((line) => line.split('\t'));
44
67
  const lineMap = new Map<
45
- number,
68
+ string,
46
69
  {
47
70
  words: Array<{
48
71
  text: string;
@@ -57,22 +80,47 @@ export const parseTesseractTsv = (
57
80
 
58
81
  let dropped = 0;
59
82
  const flatWords: OcrWord[] = [];
83
+ const nativeBlocks: OcrNativeBlock[] = [];
60
84
 
61
85
  for (const columns of rows) {
62
86
  if (columns.length < 12) continue;
63
87
  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
-
88
+ const blockNum = Number.parseInt(columns[2] ?? '', 10);
89
+ const parNum = Number.parseInt(columns[3] ?? '', 10);
69
90
  const lineNum = Number.parseInt(columns[4] ?? '', 10);
70
91
  const left = Number.parseInt(columns[6] ?? '', 10);
71
92
  const top = Number.parseInt(columns[7] ?? '', 10);
72
93
  const width = Number.parseInt(columns[8] ?? '', 10);
73
94
  const height = Number.parseInt(columns[9] ?? '', 10);
74
- const conf = Number.parseFloat(columns[10] ?? '');
95
+ const conf = num(columns[10]);
96
+ const text = columns[11]?.trim() ?? '';
97
+
98
+ // Native layout units from Tesseract itself (better than pure heuristic when present)
99
+ if ((level === 2 || level === 3) && Number.isFinite(left) && Number.isFinite(top)) {
100
+ if (text.length > 0 || level === 2) {
101
+ // level 2 blocks often have empty text; still keep geometry if non-zero
102
+ if (width > 0 && height > 0) {
103
+ const nb: OcrNativeBlock = {
104
+ id: level === 2 ? `tess-block-${blockNum}` : `tess-par-${blockNum}-${parNum}`,
105
+ kind: level === 2 ? 'block' : 'paragraph',
106
+ text,
107
+ bbox: {
108
+ x: left,
109
+ y: top,
110
+ width: Number.isFinite(width) ? width : 0,
111
+ height: Number.isFinite(height) ? height : 0,
112
+ },
113
+ };
114
+ if (Number.isFinite(blockNum)) nb.block_num = blockNum;
115
+ if (Number.isFinite(parNum)) nb.par_num = parNum;
116
+ if (Number.isFinite(conf) && conf >= 0) nb.confidence = conf;
117
+ nativeBlocks.push(nb);
118
+ }
119
+ }
120
+ }
75
121
 
122
+ if (level !== 5) continue;
123
+ if (text.length === 0) continue;
76
124
  if (!Number.isFinite(lineNum) || !Number.isFinite(left) || !Number.isFinite(top)) continue;
77
125
 
78
126
  const confVal = Number.isFinite(conf) ? conf : 0;
@@ -81,7 +129,8 @@ export const parseTesseractTsv = (
81
129
  continue;
82
130
  }
83
131
 
84
- const bucket = lineMap.get(lineNum) ?? { words: [] };
132
+ const lineKey = `${blockNum}:${parNum}:${lineNum}`;
133
+ const bucket = lineMap.get(lineKey) ?? { words: [] };
85
134
  bucket.words.push({
86
135
  text,
87
136
  left,
@@ -90,7 +139,7 @@ export const parseTesseractTsv = (
90
139
  height: Number.isFinite(height) ? height : 0,
91
140
  conf: confVal,
92
141
  });
93
- lineMap.set(lineNum, bucket);
142
+ lineMap.set(lineKey, bucket);
94
143
 
95
144
  if (includeWords) {
96
145
  flatWords.push({
@@ -106,18 +155,34 @@ export const parseTesseractTsv = (
106
155
  }
107
156
  }
108
157
 
109
- const ocrLines: OcrLine[] = [];
158
+ // Fill empty native block text from aggregated words in that block
159
+ const wordsByBlock = new Map<number, string[]>();
160
+ for (const [key, bucket] of lineMap) {
161
+ const blockNum = Number.parseInt(key.split(':')[0] ?? '', 10);
162
+ if (!Number.isFinite(blockNum)) continue;
163
+ const list = wordsByBlock.get(blockNum) ?? [];
164
+ const sorted = [...bucket.words].sort((a, b) => a.top - b.top || a.left - b.left);
165
+ list.push(sorted.map((w) => w.text).join(' '));
166
+ wordsByBlock.set(blockNum, list);
167
+ }
168
+ for (const block of nativeBlocks) {
169
+ if (block.text.length > 0) continue;
170
+ if (block.block_num === undefined) continue;
171
+ const parts = wordsByBlock.get(block.block_num);
172
+ if (parts && parts.length > 0) {
173
+ block.text = parts.join('\n').trim();
174
+ }
175
+ }
110
176
 
177
+ const ocrLines: OcrLine[] = [];
111
178
  for (const bucket of lineMap.values()) {
112
179
  if (bucket.words.length === 0) continue;
113
-
114
180
  const sorted = [...bucket.words].sort((a, b) => a.left - b.left);
115
181
  const text = sorted
116
182
  .map((word) => word.text)
117
183
  .join(' ')
118
184
  .trim();
119
185
  if (text.length === 0) continue;
120
-
121
186
  const left = Math.min(...sorted.map((word) => word.left));
122
187
  const top = Math.min(...sorted.map((word) => word.top));
123
188
  const right = Math.max(...sorted.map((word) => word.left + word.width));
@@ -127,7 +192,6 @@ export const parseTesseractTsv = (
127
192
  confidenceValues.length > 0
128
193
  ? confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length
129
194
  : undefined;
130
-
131
195
  ocrLines.push({
132
196
  text,
133
197
  bbox: {
@@ -140,9 +204,20 @@ export const parseTesseractTsv = (
140
204
  });
141
205
  }
142
206
 
207
+ // Prefer paragraph native blocks with text; else blocks
208
+ const paragraphs = nativeBlocks.filter((b) => b.kind === 'paragraph' && b.text.length > 0);
209
+ const blocksOnly = nativeBlocks.filter((b) => b.kind === 'block');
210
+ const preferredNative =
211
+ paragraphs.length > 0
212
+ ? paragraphs
213
+ : blocksOnly.filter((b) => b.text.length > 0).length > 0
214
+ ? blocksOnly.filter((b) => b.text.length > 0)
215
+ : blocksOnly;
216
+
143
217
  return {
144
218
  lines: ocrLines.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
145
219
  words: includeWords ? flatWords.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x) : [],
220
+ native_blocks: preferredNative.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
146
221
  dropped_low_confidence: dropped,
147
222
  };
148
223
  };
@@ -156,7 +231,6 @@ export const isTesseractAvailable = (): boolean => {
156
231
  return result.status === 0;
157
232
  };
158
233
 
159
- /** List installed Tesseract traineddata languages (honest empty when unavailable). */
160
234
  export const listTesseractLanguages = (): {
161
235
  available: boolean;
162
236
  languages: string[];
@@ -171,9 +245,14 @@ export const listTesseractLanguages = (): {
171
245
  }
172
246
  const result = spawnSync('tesseract', ['--list-langs'], {
173
247
  encoding: 'utf8',
174
- timeout: 5_000,
248
+ timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
175
249
  windowsHide: true,
176
250
  });
251
+ const stdout = typeof result.stdout === 'string' ? result.stdout : '';
252
+ const languages = stdout
253
+ .split(/\r?\n/)
254
+ .map((l) => l.trim())
255
+ .filter((l) => l && !l.toLowerCase().includes('list of available'));
177
256
  if (result.status !== 0) {
178
257
  const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
179
258
  return {
@@ -182,13 +261,6 @@ export const listTesseractLanguages = (): {
182
261
  warning: stderr || 'tesseract --list-langs failed',
183
262
  };
184
263
  }
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
264
  return { available: true, languages };
193
265
  };
194
266
 
@@ -205,7 +277,7 @@ export const runTesseractOcr = (
205
277
  const options: RunOcrOptions = Array.isArray(languagesOrOptions)
206
278
  ? { languages: languagesOrOptions }
207
279
  : languagesOrOptions;
208
- const languages = options.languages?.length ? options.languages : ['eng'];
280
+ const languages = options.languages ?? ['eng'];
209
281
  const minConfidence = options.minConfidence ?? 0;
210
282
  const includeWords = options.includeWords ?? false;
211
283
 
@@ -218,6 +290,7 @@ export const runTesseractOcr = (
218
290
  lines: [],
219
291
  line_count: 0,
220
292
  dropped_low_confidence: 0,
293
+ native_blocks: [],
221
294
  };
222
295
  }
223
296
 
@@ -227,22 +300,27 @@ export const runTesseractOcr = (
227
300
  );
228
301
 
229
302
  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
- });
303
+ const result = spawnSync(
304
+ 'tesseract',
305
+ [imagePath, 'stdout', '-l', languageArg, 'tsv', '--psm', '3'],
306
+ {
307
+ encoding: 'utf8',
308
+ timeout: OCR_TIMEOUT_MS,
309
+ windowsHide: true,
310
+ maxBuffer: 20 * 1024 * 1024,
311
+ }
312
+ );
236
313
 
237
314
  if (result.error) {
238
315
  return {
239
316
  available: false,
240
- skipped_reason: `Tesseract failed to start: ${result.error.message}`,
317
+ skipped_reason: result.error.message,
241
318
  route: 'tesseract_tsv',
242
319
  languages,
243
320
  lines: [],
244
321
  line_count: 0,
245
322
  dropped_low_confidence: 0,
323
+ native_blocks: [],
246
324
  };
247
325
  }
248
326
 
@@ -257,6 +335,7 @@ export const runTesseractOcr = (
257
335
  lines: [],
258
336
  line_count: 0,
259
337
  dropped_low_confidence: 0,
338
+ native_blocks: [],
260
339
  };
261
340
  }
262
341
 
@@ -264,11 +343,12 @@ export const runTesseractOcr = (
264
343
  const parsed = parseTesseractTsv(stdout, { minConfidence, includeWords });
265
344
  return {
266
345
  available: true,
267
- route: 'tesseract_tsv',
346
+ route: 'tesseract_tsv_psm3_native_layout',
268
347
  languages,
269
348
  lines: parsed.lines,
270
349
  line_count: parsed.lines.length,
271
350
  dropped_low_confidence: parsed.dropped_low_confidence,
351
+ native_blocks: parsed.native_blocks,
272
352
  ...(missingLangs.length
273
353
  ? {
274
354
  languages_warning: `Requested OCR language(s) not listed by tesseract --list-langs: ${missingLangs.join(', ')}. Installed: ${installed.languages.join(', ') || '(none)'}.`,
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Local-first frontier optional vision caption.
3
+ *
4
+ * Priority:
5
+ * 1) IRIS_OPTIONAL_LLM_URL — custom POST { path, mime, purpose } → { caption }
6
+ * 2) Local Ollama at IRIS_OLLAMA_URL (default http://127.0.0.1:11434) if a vision model is installed
7
+ *
8
+ * Never authority over OCR/layout locators. OFF unless include_optional_llm=true.
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs';
12
+
13
+ export type OptionalLlmResult = {
14
+ available: boolean;
15
+ skipped_reason?: string;
16
+ route?: string;
17
+ caption?: string;
18
+ model?: string;
19
+ };
20
+
21
+ const DEFAULT_OLLAMA = 'http://127.0.0.1:11434';
22
+
23
+ const VISION_MODEL_HINTS = [
24
+ 'llava',
25
+ 'minicpm-v',
26
+ 'minicpm_v',
27
+ 'qwen2-vl',
28
+ 'qwen2.5-vl',
29
+ 'qwen3-vl',
30
+ 'bakllava',
31
+ 'moondream',
32
+ 'gemma3',
33
+ 'llama3.2-vision',
34
+ 'pixtral',
35
+ ];
36
+
37
+ function isVisionModel(name: string): boolean {
38
+ const n = name.toLowerCase();
39
+ return VISION_MODEL_HINTS.some((h) => n.includes(h));
40
+ }
41
+
42
+ async function tryCustomUrl(input: {
43
+ path: string;
44
+ mime: string;
45
+ url: string;
46
+ }): Promise<OptionalLlmResult> {
47
+ try {
48
+ const res = await fetch(input.url, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({
52
+ path: input.path,
53
+ mime: input.mime,
54
+ purpose: 'image_caption',
55
+ }),
56
+ signal: AbortSignal.timeout(20_000),
57
+ });
58
+ if (!res.ok) {
59
+ return {
60
+ available: false,
61
+ skipped_reason: `optional LLM HTTP ${res.status}`,
62
+ route: input.url,
63
+ };
64
+ }
65
+ const body = (await res.json()) as { caption?: string };
66
+ if (!body.caption || typeof body.caption !== 'string') {
67
+ return {
68
+ available: false,
69
+ skipped_reason: 'optional LLM response missing caption string',
70
+ route: input.url,
71
+ };
72
+ }
73
+ return {
74
+ available: true,
75
+ route: `optional-llm:${input.url}`,
76
+ caption: body.caption.slice(0, 4000),
77
+ };
78
+ } catch (error: unknown) {
79
+ const message = error instanceof Error ? error.message : 'optional LLM failed';
80
+ return { available: false, skipped_reason: message, route: input.url };
81
+ }
82
+ }
83
+
84
+ async function listOllamaModels(base: string): Promise<string[]> {
85
+ const res = await fetch(`${base.replace(/\/$/, '')}/api/tags`, {
86
+ signal: AbortSignal.timeout(3_000),
87
+ });
88
+ if (!res.ok) return [];
89
+ const body = (await res.json()) as { models?: Array<{ name?: string }> };
90
+ return (body.models ?? [])
91
+ .map((m) => m.name)
92
+ .filter((n): n is string => typeof n === 'string' && n.length > 0);
93
+ }
94
+
95
+ async function tryOllamaVision(input: {
96
+ path: string;
97
+ baseUrl: string;
98
+ modelHint?: string;
99
+ }): Promise<OptionalLlmResult> {
100
+ const base = input.baseUrl.replace(/\/$/, '');
101
+ let models: string[] = [];
102
+ try {
103
+ models = await listOllamaModels(base);
104
+ } catch {
105
+ return {
106
+ available: false,
107
+ skipped_reason: `Ollama not reachable at ${base}`,
108
+ route: base,
109
+ };
110
+ }
111
+ if (models.length === 0) {
112
+ return {
113
+ available: false,
114
+ skipped_reason: 'Ollama has no models installed',
115
+ route: base,
116
+ };
117
+ }
118
+
119
+ const preferred =
120
+ (input.modelHint &&
121
+ models.find((m) => m === input.modelHint || m.startsWith(`${input.modelHint}:`))) ||
122
+ models.find((m) => isVisionModel(m));
123
+ if (!preferred) {
124
+ return {
125
+ available: false,
126
+ skipped_reason: `No local vision model found in Ollama (have: ${models.slice(0, 6).join(', ')}). Install e.g. llava or minicpm-v.`,
127
+ route: base,
128
+ };
129
+ }
130
+
131
+ let b64: string;
132
+ try {
133
+ b64 = readFileSync(input.path).toString('base64');
134
+ } catch (error: unknown) {
135
+ const message = error instanceof Error ? error.message : 'read failed';
136
+ return { available: false, skipped_reason: message, route: base };
137
+ }
138
+
139
+ // Cap image payload ~4MB base64
140
+ if (b64.length > 5_500_000) {
141
+ return {
142
+ available: false,
143
+ skipped_reason: 'image too large for default Ollama caption path; crop_region first',
144
+ route: `ollama:${preferred}`,
145
+ };
146
+ }
147
+
148
+ try {
149
+ const res = await fetch(`${base}/api/chat`, {
150
+ method: 'POST',
151
+ headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify({
153
+ model: preferred,
154
+ stream: false,
155
+ messages: [
156
+ {
157
+ role: 'user',
158
+ content:
159
+ 'Describe this image for a software agent. Focus on structure, UI/text regions, objects, and layout. Be concise and factual. Do not invent unreadable text.',
160
+ images: [b64],
161
+ },
162
+ ],
163
+ }),
164
+ signal: AbortSignal.timeout(60_000),
165
+ });
166
+ if (!res.ok) {
167
+ return {
168
+ available: false,
169
+ skipped_reason: `Ollama chat HTTP ${res.status}`,
170
+ route: `ollama:${preferred}`,
171
+ model: preferred,
172
+ };
173
+ }
174
+ const body = (await res.json()) as {
175
+ message?: { content?: string };
176
+ response?: string;
177
+ };
178
+ const caption = body.message?.content ?? body.response;
179
+ if (!caption || typeof caption !== 'string') {
180
+ return {
181
+ available: false,
182
+ skipped_reason: 'Ollama response missing message content',
183
+ route: `ollama:${preferred}`,
184
+ model: preferred,
185
+ };
186
+ }
187
+ return {
188
+ available: true,
189
+ route: `ollama-local-vlm:${preferred}`,
190
+ model: preferred,
191
+ caption: caption.slice(0, 4000),
192
+ };
193
+ } catch (error: unknown) {
194
+ const message = error instanceof Error ? error.message : 'Ollama caption failed';
195
+ return {
196
+ available: false,
197
+ skipped_reason: message,
198
+ route: `ollama:${preferred}`,
199
+ model: preferred,
200
+ };
201
+ }
202
+ }
203
+
204
+ export async function maybeOptionalImageCaption(input: {
205
+ path: string;
206
+ mime: string;
207
+ enabled: boolean;
208
+ }): Promise<OptionalLlmResult> {
209
+ if (!input.enabled) {
210
+ return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
211
+ }
212
+
213
+ const custom = process.env['IRIS_OPTIONAL_LLM_URL'];
214
+ if (custom) {
215
+ return tryCustomUrl({ path: input.path, mime: input.mime, url: custom });
216
+ }
217
+
218
+ const ollamaBase = process.env['IRIS_OLLAMA_URL'] ?? process.env['OLLAMA_HOST'] ?? DEFAULT_OLLAMA;
219
+ // OLLAMA_HOST may be host:port without scheme
220
+ const baseUrl = ollamaBase.startsWith('http') ? ollamaBase : `http://${ollamaBase}`;
221
+ const modelHint = process.env['IRIS_OLLAMA_VISION_MODEL'] ?? process.env['OLLAMA_VISION_MODEL'];
222
+
223
+ return tryOllamaVision({
224
+ path: input.path,
225
+ baseUrl,
226
+ ...(modelHint ? { modelHint } : {}),
227
+ });
228
+ }
@@ -0,0 +1,36 @@
1
+ import sharp from 'sharp';
2
+
3
+ /** Cheap local palette sample via sharp stats (not ML segmentation). */
4
+ export async function samplePalette(
5
+ filePath: string,
6
+ maxColors = 5
7
+ ): Promise<Array<{ hex: string; approx_share: number }>> {
8
+ const image = sharp(filePath, { failOn: 'none' }).resize(64, 64, { fit: 'inside' });
9
+ const stats = await image.stats();
10
+ const dominant = stats.dominant;
11
+ if (!dominant) return [];
12
+
13
+ const hex = (r: number, g: number, b: number) =>
14
+ `#${[r, g, b]
15
+ .map((v) =>
16
+ Math.max(0, Math.min(255, Math.round(v)))
17
+ .toString(16)
18
+ .padStart(2, '0')
19
+ )
20
+ .join('')}`;
21
+
22
+ const colors: Array<{ hex: string; approx_share: number }> = [
23
+ { hex: hex(dominant.r, dominant.g, dominant.b), approx_share: 0.55 },
24
+ ];
25
+
26
+ // Channel means as a second honest swatch (not true multi-color clustering).
27
+ const ch = stats.channels;
28
+ if (ch && ch.length >= 3) {
29
+ colors.push({
30
+ hex: hex(ch[0]?.mean ?? dominant.r, ch[1]?.mean ?? dominant.g, ch[2]?.mean ?? dominant.b),
31
+ approx_share: 0.45,
32
+ });
33
+ }
34
+
35
+ return colors.slice(0, maxColors);
36
+ }