macos-vision 0.1.4 → 0.3.0
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/CHANGELOG.md +17 -0
- package/README.md +51 -0
- package/bin/vision-helper +0 -0
- package/dist/cli.js +23 -15
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -4
- package/dist/layout.d.ts +98 -0
- package/dist/layout.js +183 -0
- package/package.json +1 -1
- package/scripts/build-native.js +1 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0](https://github.com/woladi/macos-vision/compare/v0.2.0...v0.3.0) (2026-04-08)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* add inferLayout() — unified reading-order LayoutBlock representation ([aec507e](https://github.com/woladi/macos-vision/commit/aec507eb7cf133ec1e56759c0945563a48d871ee))
|
|
8
|
+
|
|
9
|
+
## [0.2.0](https://github.com/woladi/macos-vision/compare/v0.1.4...v0.2.0) (2026-04-08)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add confidence to VisionBlock and Barcode ([a87df27](https://github.com/woladi/macos-vision/commit/a87df275e51dec4b57fbff6e3bffc4220b96b4d7))
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* correct mkdirSync, CLI error on missing file, execFile timeout, README scope ([1cef2c7](https://github.com/woladi/macos-vision/commit/1cef2c7078430c9182fcd39792cf0c002833203f))
|
|
18
|
+
* replace try? with do/catch in Swift helper — surface Vision errors properly ([f287065](https://github.com/woladi/macos-vision/commit/f2870655225806070be3db462ea15923201fecbf))
|
|
19
|
+
|
|
3
20
|
## 0.1.4 (2026-04-08)
|
package/README.md
CHANGED
|
@@ -22,6 +22,19 @@ npm install macos-vision
|
|
|
22
22
|
|
|
23
23
|
The native Swift binary is compiled automatically on install.
|
|
24
24
|
|
|
25
|
+
## What this is (and isn't)
|
|
26
|
+
|
|
27
|
+
`macos-vision` gives you **raw Apple Vision results** — text, coordinates, bounding boxes, labels.
|
|
28
|
+
|
|
29
|
+
It is **not** a document pipeline. It does not:
|
|
30
|
+
- Convert PDFs or images to Markdown
|
|
31
|
+
- Understand document structure (headings, tables, paragraphs)
|
|
32
|
+
- Chain multiple detections into a final report
|
|
33
|
+
|
|
34
|
+
For those use cases, use the raw output as input to an LLM or a post-processing layer of your own.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
25
38
|
## CLI
|
|
26
39
|
|
|
27
40
|
```bash
|
|
@@ -81,8 +94,46 @@ const doc = await detectDocument('photo.jpg') // DocumentBounds | null
|
|
|
81
94
|
|
|
82
95
|
// Classify image content
|
|
83
96
|
const labels = await classify('photo.jpg')
|
|
97
|
+
|
|
98
|
+
// Layout inference — unified reading-order-sorted representation
|
|
99
|
+
const layout = inferLayout({ textBlocks: blocks, faces, barcodes: codes })
|
|
100
|
+
// layout is LayoutBlock[] — ready to feed into a Markdown renderer or LLM context
|
|
84
101
|
```
|
|
85
102
|
|
|
103
|
+
### Layout inference
|
|
104
|
+
|
|
105
|
+
`inferLayout` merges raw Vision results into a unified `LayoutBlock[]` sorted in reading order (top-to-bottom, left-to-right). Text blocks are grouped into **lines** and **paragraphs** using geometric heuristics.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { ocr, detectFaces, detectBarcodes, inferLayout } from 'macos-vision';
|
|
109
|
+
|
|
110
|
+
const blocks = await ocr('page.png', { format: 'blocks' });
|
|
111
|
+
const faces = await detectFaces('page.png');
|
|
112
|
+
const barcodes = await detectBarcodes('page.png');
|
|
113
|
+
|
|
114
|
+
const layout = inferLayout({ textBlocks: blocks, faces, barcodes });
|
|
115
|
+
|
|
116
|
+
for (const block of layout) {
|
|
117
|
+
if (block.kind === 'text') {
|
|
118
|
+
console.log(`[p${block.paragraphId} l${block.lineId}] ${block.text}`);
|
|
119
|
+
} else {
|
|
120
|
+
console.log(`[${block.kind}] at (${block.x.toFixed(2)}, ${block.y.toFixed(2)})`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`LayoutBlock` is a discriminated union — use `block.kind` to narrow the type:
|
|
126
|
+
|
|
127
|
+
| `kind` | Extra fields |
|
|
128
|
+
|--------|-------------|
|
|
129
|
+
| `'text'` | `text`, `lineId`, `paragraphId` |
|
|
130
|
+
| `'barcode'` | `value`, `type` |
|
|
131
|
+
| `'face'` | — |
|
|
132
|
+
| `'rectangle'` | — |
|
|
133
|
+
| `'document'` | — |
|
|
134
|
+
|
|
135
|
+
> **Note:** Layout inference is a heuristic layer. It does not understand multi-column layouts or rotated text. Treat it as structured input for downstream tools, not as ground truth.
|
|
136
|
+
|
|
86
137
|
## API
|
|
87
138
|
|
|
88
139
|
### `ocr(imagePath, options?)`
|
package/bin/vision-helper
CHANGED
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { resolve
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
2
|
+
import { resolve } from 'path';
|
|
4
3
|
import { ocr, detectFaces, detectBarcodes, detectRectangles, detectDocument, classify, } from './index.js';
|
|
5
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
4
|
const USAGE = `
|
|
7
5
|
Usage: vision-cli [options] <image>
|
|
8
6
|
|
|
@@ -28,9 +26,14 @@ if (rawArgs.includes('--help') || rawArgs.length === 0) {
|
|
|
28
26
|
console.log(USAGE);
|
|
29
27
|
process.exit(0);
|
|
30
28
|
}
|
|
31
|
-
const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
|
|
32
|
-
const fileArgs = rawArgs.filter(a => !a.startsWith('--'));
|
|
33
|
-
|
|
29
|
+
const flags = new Set(rawArgs.filter((a) => a.startsWith('--')));
|
|
30
|
+
const fileArgs = rawArgs.filter((a) => !a.startsWith('--'));
|
|
31
|
+
if (!fileArgs[0]) {
|
|
32
|
+
console.error('Error: no image path provided.\n');
|
|
33
|
+
console.log(USAGE);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const imagePath = resolve(fileArgs[0]);
|
|
34
37
|
const runAll = flags.has('--all');
|
|
35
38
|
const runOcr = runAll || flags.has('--ocr');
|
|
36
39
|
const runBlocks = runAll || flags.has('--blocks');
|
|
@@ -40,9 +43,14 @@ const runRects = runAll || flags.has('--rectangles');
|
|
|
40
43
|
const runDoc = runAll || flags.has('--document');
|
|
41
44
|
const runClassify = runAll || flags.has('--classify');
|
|
42
45
|
// Default: OCR text when no feature flag is given
|
|
43
|
-
const anyFeatureFlag = runAll ||
|
|
44
|
-
flags.has('--
|
|
45
|
-
flags.has('--
|
|
46
|
+
const anyFeatureFlag = runAll ||
|
|
47
|
+
flags.has('--ocr') ||
|
|
48
|
+
flags.has('--blocks') ||
|
|
49
|
+
flags.has('--faces') ||
|
|
50
|
+
flags.has('--barcodes') ||
|
|
51
|
+
flags.has('--rectangles') ||
|
|
52
|
+
flags.has('--document') ||
|
|
53
|
+
flags.has('--classify');
|
|
46
54
|
const useDefault = !anyFeatureFlag;
|
|
47
55
|
async function main() {
|
|
48
56
|
try {
|
|
@@ -51,27 +59,27 @@ async function main() {
|
|
|
51
59
|
console.log(text);
|
|
52
60
|
}
|
|
53
61
|
if (runBlocks) {
|
|
54
|
-
const blocks = await ocr(imagePath, { format: 'blocks' });
|
|
62
|
+
const blocks = (await ocr(imagePath, { format: 'blocks' }));
|
|
55
63
|
console.log(JSON.stringify(blocks, null, 2));
|
|
56
64
|
}
|
|
57
65
|
if (runFaces) {
|
|
58
|
-
const faces = await detectFaces(imagePath);
|
|
66
|
+
const faces = (await detectFaces(imagePath));
|
|
59
67
|
console.log(JSON.stringify(faces, null, 2));
|
|
60
68
|
}
|
|
61
69
|
if (runBarcodes) {
|
|
62
|
-
const barcodes = await detectBarcodes(imagePath);
|
|
70
|
+
const barcodes = (await detectBarcodes(imagePath));
|
|
63
71
|
console.log(JSON.stringify(barcodes, null, 2));
|
|
64
72
|
}
|
|
65
73
|
if (runRects) {
|
|
66
|
-
const rectangles = await detectRectangles(imagePath);
|
|
74
|
+
const rectangles = (await detectRectangles(imagePath));
|
|
67
75
|
console.log(JSON.stringify(rectangles, null, 2));
|
|
68
76
|
}
|
|
69
77
|
if (runDoc) {
|
|
70
|
-
const doc = await detectDocument(imagePath);
|
|
78
|
+
const doc = (await detectDocument(imagePath));
|
|
71
79
|
console.log(JSON.stringify(doc, null, 2));
|
|
72
80
|
}
|
|
73
81
|
if (runClassify) {
|
|
74
|
-
const labels = await classify(imagePath);
|
|
82
|
+
const labels = (await classify(imagePath));
|
|
75
83
|
console.log(JSON.stringify(labels, null, 2));
|
|
76
84
|
}
|
|
77
85
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface VisionBlock {
|
|
|
9
9
|
width: number;
|
|
10
10
|
/** Height, 0–1 relative to image */
|
|
11
11
|
height: number;
|
|
12
|
+
/** OCR transcription confidence, 0–1 */
|
|
13
|
+
confidence: number;
|
|
12
14
|
}
|
|
13
15
|
export interface OcrOptions {
|
|
14
16
|
/** Return plain text (default) or structured blocks with coordinates */
|
|
@@ -46,6 +48,8 @@ export interface Barcode {
|
|
|
46
48
|
width: number;
|
|
47
49
|
/** Height, 0–1 relative to image */
|
|
48
50
|
height: number;
|
|
51
|
+
/** Detection confidence, 0–1 */
|
|
52
|
+
confidence: number;
|
|
49
53
|
}
|
|
50
54
|
export declare function detectBarcodes(imagePath: string): Promise<Barcode[]>;
|
|
51
55
|
export interface Rectangle {
|
|
@@ -83,3 +87,5 @@ export interface Classification {
|
|
|
83
87
|
}
|
|
84
88
|
/** Returns top image classifications sorted by confidence (highest first). */
|
|
85
89
|
export declare function classify(imagePath: string): Promise<Classification[]>;
|
|
90
|
+
export type { BlockKind, BaseBlock, TextBlock, FaceBlock, BarcodeBlock, RectangleBlock, DocumentBlock, LayoutBlock, InferLayoutInput, } from './layout.js';
|
|
91
|
+
export { inferLayout, sortBlocksByReadingOrder } from './layout.js';
|
package/dist/index.js
CHANGED
|
@@ -5,19 +5,31 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const BIN_PATH = resolve(__dirname, '../bin/vision-helper');
|
|
8
|
+
const BINARY_TIMEOUT_MS = 30_000;
|
|
8
9
|
async function run(flag, imagePath) {
|
|
9
|
-
const { stdout } = await execFileAsync(BIN_PATH, [flag, resolve(imagePath)]
|
|
10
|
+
const { stdout } = await execFileAsync(BIN_PATH, [flag, resolve(imagePath)], {
|
|
11
|
+
timeout: BINARY_TIMEOUT_MS,
|
|
12
|
+
});
|
|
10
13
|
return stdout;
|
|
11
14
|
}
|
|
12
15
|
export async function ocr(imagePath, options = {}) {
|
|
13
16
|
const absPath = resolve(imagePath);
|
|
14
17
|
const { format = 'text' } = options;
|
|
15
18
|
if (format === 'blocks') {
|
|
16
|
-
const { stdout } = await execFileAsync(BIN_PATH, ['--json', absPath]
|
|
19
|
+
const { stdout } = await execFileAsync(BIN_PATH, ['--json', absPath], {
|
|
20
|
+
timeout: BINARY_TIMEOUT_MS,
|
|
21
|
+
});
|
|
17
22
|
const raw = JSON.parse(stdout);
|
|
18
|
-
return raw.map((b) => ({
|
|
23
|
+
return raw.map((b) => ({
|
|
24
|
+
text: b.t,
|
|
25
|
+
x: b.x,
|
|
26
|
+
y: b.y,
|
|
27
|
+
width: b.w,
|
|
28
|
+
height: b.h,
|
|
29
|
+
confidence: b.confidence,
|
|
30
|
+
}));
|
|
19
31
|
}
|
|
20
|
-
const { stdout } = await execFileAsync(BIN_PATH, [absPath]);
|
|
32
|
+
const { stdout } = await execFileAsync(BIN_PATH, [absPath], { timeout: BINARY_TIMEOUT_MS });
|
|
21
33
|
return stdout.trim();
|
|
22
34
|
}
|
|
23
35
|
export async function detectFaces(imagePath) {
|
|
@@ -33,6 +45,7 @@ export async function detectBarcodes(imagePath) {
|
|
|
33
45
|
y: b.y,
|
|
34
46
|
width: b.w,
|
|
35
47
|
height: b.h,
|
|
48
|
+
confidence: b.confidence,
|
|
36
49
|
}));
|
|
37
50
|
}
|
|
38
51
|
export async function detectRectangles(imagePath) {
|
|
@@ -49,3 +62,4 @@ export async function classify(imagePath) {
|
|
|
49
62
|
const raw = JSON.parse(await run('--classify', imagePath));
|
|
50
63
|
return raw;
|
|
51
64
|
}
|
|
65
|
+
export { inferLayout, sortBlocksByReadingOrder } from './layout.js';
|
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module layout
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript layout inference layer for macos-vision.
|
|
5
|
+
*
|
|
6
|
+
* Takes raw Vision framework results and produces a unified, reading-order-sorted
|
|
7
|
+
* `LayoutBlock[]` that downstream tools (Markdown generators, LLM pipelines, etc.)
|
|
8
|
+
* can consume directly.
|
|
9
|
+
*
|
|
10
|
+
* **Limitations & intended usage**
|
|
11
|
+
* - This is a heuristic layer, not a full document parser. Line and paragraph
|
|
12
|
+
* grouping uses simple geometric proximity — it will not be perfect for
|
|
13
|
+
* multi-column layouts, rotated text, or unusual document structures.
|
|
14
|
+
* - No LLMs, no external dependencies, no I/O. Pure data-in → data-out.
|
|
15
|
+
* - Treat the output as a structured starting point, not ground truth.
|
|
16
|
+
*/
|
|
17
|
+
import type { VisionBlock, Face, Barcode, Rectangle, DocumentBounds } from './index.js';
|
|
18
|
+
export type BlockKind = 'text' | 'face' | 'barcode' | 'rectangle' | 'document';
|
|
19
|
+
export interface BaseBlock {
|
|
20
|
+
kind: BlockKind;
|
|
21
|
+
/** Horizontal position, 0–1 from left */
|
|
22
|
+
x: number;
|
|
23
|
+
/** Vertical position, 0–1 from top */
|
|
24
|
+
y: number;
|
|
25
|
+
/** Width, 0–1 relative to image */
|
|
26
|
+
width: number;
|
|
27
|
+
/** Height, 0–1 relative to image */
|
|
28
|
+
height: number;
|
|
29
|
+
/** Detection/recognition confidence, 0–1 (omitted when unavailable) */
|
|
30
|
+
confidence?: number;
|
|
31
|
+
}
|
|
32
|
+
export interface TextBlock extends BaseBlock {
|
|
33
|
+
kind: 'text';
|
|
34
|
+
/** Recognized text string */
|
|
35
|
+
text: string;
|
|
36
|
+
/**
|
|
37
|
+
* 0-based index of the visual line this block belongs to.
|
|
38
|
+
* Blocks sharing the same `lineId` are on the same horizontal line.
|
|
39
|
+
*/
|
|
40
|
+
lineId: number;
|
|
41
|
+
/**
|
|
42
|
+
* 0-based index of the paragraph this block belongs to.
|
|
43
|
+
* A new paragraph begins when the vertical gap between lines exceeds
|
|
44
|
+
* ~1.5× the average line height.
|
|
45
|
+
*/
|
|
46
|
+
paragraphId: number;
|
|
47
|
+
}
|
|
48
|
+
export interface FaceBlock extends BaseBlock {
|
|
49
|
+
kind: 'face';
|
|
50
|
+
}
|
|
51
|
+
export interface BarcodeBlock extends BaseBlock {
|
|
52
|
+
kind: 'barcode';
|
|
53
|
+
/** Decoded barcode / QR payload */
|
|
54
|
+
value: string;
|
|
55
|
+
/** Symbology, e.g. 'org.iso.QRCode', 'org.gs1.EAN-13' */
|
|
56
|
+
type: string;
|
|
57
|
+
}
|
|
58
|
+
export interface RectangleBlock extends BaseBlock {
|
|
59
|
+
kind: 'rectangle';
|
|
60
|
+
}
|
|
61
|
+
export interface DocumentBlock extends BaseBlock {
|
|
62
|
+
kind: 'document';
|
|
63
|
+
}
|
|
64
|
+
export type LayoutBlock = TextBlock | FaceBlock | BarcodeBlock | RectangleBlock | DocumentBlock;
|
|
65
|
+
export interface InferLayoutInput {
|
|
66
|
+
textBlocks: VisionBlock[];
|
|
67
|
+
faces?: Face[];
|
|
68
|
+
barcodes?: Barcode[];
|
|
69
|
+
rectangles?: Rectangle[];
|
|
70
|
+
document?: DocumentBounds | null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sort any LayoutBlock array into reading order: top-to-bottom, then
|
|
74
|
+
* left-to-right within blocks that share the same approximate vertical band.
|
|
75
|
+
*
|
|
76
|
+
* Uses a 1% image-height tolerance so that blocks on the same visual row
|
|
77
|
+
* are ordered by `x` rather than by the tiny y differences between them.
|
|
78
|
+
*/
|
|
79
|
+
export declare function sortBlocksByReadingOrder(blocks: LayoutBlock[]): LayoutBlock[];
|
|
80
|
+
/**
|
|
81
|
+
* Merge raw Apple Vision results into a unified, reading-order-sorted
|
|
82
|
+
* `LayoutBlock[]`.
|
|
83
|
+
*
|
|
84
|
+
* Text blocks are grouped into **lines** (`lineId`) and **paragraphs**
|
|
85
|
+
* (`paragraphId`) using simple bounding-box heuristics. All other block types
|
|
86
|
+
* are placed into the sorted sequence by their top-left coordinate.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const blocks = await ocr('page.png', { format: 'blocks' });
|
|
91
|
+
* const faces = await detectFaces('page.png');
|
|
92
|
+
* const barcodes = await detectBarcodes('page.png');
|
|
93
|
+
*
|
|
94
|
+
* const layout = inferLayout({ textBlocks: blocks, faces, barcodes });
|
|
95
|
+
* // Feed `layout` into a Markdown renderer or an LLM context window.
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function inferLayout(input: InferLayoutInput): LayoutBlock[];
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module layout
|
|
3
|
+
*
|
|
4
|
+
* Pure TypeScript layout inference layer for macos-vision.
|
|
5
|
+
*
|
|
6
|
+
* Takes raw Vision framework results and produces a unified, reading-order-sorted
|
|
7
|
+
* `LayoutBlock[]` that downstream tools (Markdown generators, LLM pipelines, etc.)
|
|
8
|
+
* can consume directly.
|
|
9
|
+
*
|
|
10
|
+
* **Limitations & intended usage**
|
|
11
|
+
* - This is a heuristic layer, not a full document parser. Line and paragraph
|
|
12
|
+
* grouping uses simple geometric proximity — it will not be perfect for
|
|
13
|
+
* multi-column layouts, rotated text, or unusual document structures.
|
|
14
|
+
* - No LLMs, no external dependencies, no I/O. Pure data-in → data-out.
|
|
15
|
+
* - Treat the output as a structured starting point, not ground truth.
|
|
16
|
+
*/
|
|
17
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
18
|
+
/** Compute an axis-aligned bounding box from four corner points [x, y]. */
|
|
19
|
+
function cornersToRect(corners) {
|
|
20
|
+
const xs = [
|
|
21
|
+
corners.topLeft[0],
|
|
22
|
+
corners.topRight[0],
|
|
23
|
+
corners.bottomLeft[0],
|
|
24
|
+
corners.bottomRight[0],
|
|
25
|
+
];
|
|
26
|
+
const ys = [
|
|
27
|
+
corners.topLeft[1],
|
|
28
|
+
corners.topRight[1],
|
|
29
|
+
corners.bottomLeft[1],
|
|
30
|
+
corners.bottomRight[1],
|
|
31
|
+
];
|
|
32
|
+
const x = Math.min(...xs);
|
|
33
|
+
const y = Math.min(...ys);
|
|
34
|
+
return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Group text blocks into visual lines using y-center proximity.
|
|
38
|
+
*
|
|
39
|
+
* Two blocks are considered to be on the same line when the distance between
|
|
40
|
+
* their vertical centers is less than 60% of the taller block's height.
|
|
41
|
+
* Blocks within each line are sorted left-to-right by `x`.
|
|
42
|
+
*/
|
|
43
|
+
function groupTextIntoLines(blocks) {
|
|
44
|
+
if (blocks.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const sorted = [...blocks].sort((a, b) => a.y + a.height / 2 - (b.y + b.height / 2));
|
|
47
|
+
const lines = [];
|
|
48
|
+
let currentLine = [sorted[0]];
|
|
49
|
+
let lineYCenter = sorted[0].y + sorted[0].height / 2;
|
|
50
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
51
|
+
const block = sorted[i];
|
|
52
|
+
const blockYCenter = block.y + block.height / 2;
|
|
53
|
+
const threshold = Math.max(block.height, sorted[i - 1].height) * 0.6;
|
|
54
|
+
if (Math.abs(blockYCenter - lineYCenter) <= threshold) {
|
|
55
|
+
currentLine.push(block);
|
|
56
|
+
// Recompute line center as the mean of all members so far.
|
|
57
|
+
lineYCenter =
|
|
58
|
+
currentLine.reduce((sum, b) => sum + b.y + b.height / 2, 0) / currentLine.length;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
lines.push(currentLine.sort((a, b) => a.x - b.x));
|
|
62
|
+
currentLine = [block];
|
|
63
|
+
lineYCenter = blockYCenter;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
lines.push(currentLine.sort((a, b) => a.x - b.x));
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Assign a paragraph index to each line.
|
|
71
|
+
*
|
|
72
|
+
* A new paragraph begins when the vertical gap between the bottom of one line
|
|
73
|
+
* and the top of the next exceeds 1.5× the average line height across all lines.
|
|
74
|
+
*/
|
|
75
|
+
function assignParagraphIds(lines) {
|
|
76
|
+
if (lines.length === 0)
|
|
77
|
+
return [];
|
|
78
|
+
const lineHeights = lines.map((line) => Math.max(...line.map((b) => b.height)));
|
|
79
|
+
const avgLineHeight = lineHeights.reduce((s, h) => s + h, 0) / lineHeights.length;
|
|
80
|
+
const ids = [0];
|
|
81
|
+
let paragraphId = 0;
|
|
82
|
+
for (let i = 1; i < lines.length; i++) {
|
|
83
|
+
const prevBottom = Math.max(...lines[i - 1].map((b) => b.y + b.height));
|
|
84
|
+
const currTop = Math.min(...lines[i].map((b) => b.y));
|
|
85
|
+
const gap = currTop - prevBottom;
|
|
86
|
+
if (gap > avgLineHeight * 1.5)
|
|
87
|
+
paragraphId++;
|
|
88
|
+
ids.push(paragraphId);
|
|
89
|
+
}
|
|
90
|
+
return ids;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sort any LayoutBlock array into reading order: top-to-bottom, then
|
|
94
|
+
* left-to-right within blocks that share the same approximate vertical band.
|
|
95
|
+
*
|
|
96
|
+
* Uses a 1% image-height tolerance so that blocks on the same visual row
|
|
97
|
+
* are ordered by `x` rather than by the tiny y differences between them.
|
|
98
|
+
*/
|
|
99
|
+
export function sortBlocksByReadingOrder(blocks) {
|
|
100
|
+
return [...blocks].sort((a, b) => {
|
|
101
|
+
const dy = a.y - b.y;
|
|
102
|
+
// Treat blocks as being on the same row when y-difference < 1% of image height.
|
|
103
|
+
if (Math.abs(dy) > 0.01)
|
|
104
|
+
return dy;
|
|
105
|
+
return a.x - b.x;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* Merge raw Apple Vision results into a unified, reading-order-sorted
|
|
111
|
+
* `LayoutBlock[]`.
|
|
112
|
+
*
|
|
113
|
+
* Text blocks are grouped into **lines** (`lineId`) and **paragraphs**
|
|
114
|
+
* (`paragraphId`) using simple bounding-box heuristics. All other block types
|
|
115
|
+
* are placed into the sorted sequence by their top-left coordinate.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const blocks = await ocr('page.png', { format: 'blocks' });
|
|
120
|
+
* const faces = await detectFaces('page.png');
|
|
121
|
+
* const barcodes = await detectBarcodes('page.png');
|
|
122
|
+
*
|
|
123
|
+
* const layout = inferLayout({ textBlocks: blocks, faces, barcodes });
|
|
124
|
+
* // Feed `layout` into a Markdown renderer or an LLM context window.
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function inferLayout(input) {
|
|
128
|
+
const result = [];
|
|
129
|
+
// ── Text blocks (with line / paragraph grouping) ──────────────────────────
|
|
130
|
+
const lines = groupTextIntoLines(input.textBlocks);
|
|
131
|
+
const paragraphIds = assignParagraphIds(lines);
|
|
132
|
+
lines.forEach((line, lineId) => {
|
|
133
|
+
const paragraphId = paragraphIds[lineId];
|
|
134
|
+
for (const b of line) {
|
|
135
|
+
result.push({
|
|
136
|
+
kind: 'text',
|
|
137
|
+
x: b.x,
|
|
138
|
+
y: b.y,
|
|
139
|
+
width: b.width,
|
|
140
|
+
height: b.height,
|
|
141
|
+
confidence: b.confidence,
|
|
142
|
+
text: b.text,
|
|
143
|
+
lineId,
|
|
144
|
+
paragraphId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
// ── Faces ─────────────────────────────────────────────────────────────────
|
|
149
|
+
for (const f of input.faces ?? []) {
|
|
150
|
+
result.push({
|
|
151
|
+
kind: 'face',
|
|
152
|
+
x: f.x,
|
|
153
|
+
y: f.y,
|
|
154
|
+
width: f.width,
|
|
155
|
+
height: f.height,
|
|
156
|
+
confidence: f.confidence,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// ── Barcodes ──────────────────────────────────────────────────────────────
|
|
160
|
+
for (const b of input.barcodes ?? []) {
|
|
161
|
+
result.push({
|
|
162
|
+
kind: 'barcode',
|
|
163
|
+
x: b.x,
|
|
164
|
+
y: b.y,
|
|
165
|
+
width: b.width,
|
|
166
|
+
height: b.height,
|
|
167
|
+
confidence: b.confidence,
|
|
168
|
+
value: b.value,
|
|
169
|
+
type: b.type,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// ── Rectangles ────────────────────────────────────────────────────────────
|
|
173
|
+
for (const r of input.rectangles ?? []) {
|
|
174
|
+
const bbox = cornersToRect(r);
|
|
175
|
+
result.push({ kind: 'rectangle', ...bbox, confidence: r.confidence });
|
|
176
|
+
}
|
|
177
|
+
// ── Document boundary ─────────────────────────────────────────────────────
|
|
178
|
+
if (input.document) {
|
|
179
|
+
const bbox = cornersToRect(input.document);
|
|
180
|
+
result.push({ kind: 'document', ...bbox, confidence: input.document.confidence });
|
|
181
|
+
}
|
|
182
|
+
return sortBlocksByReadingOrder(result);
|
|
183
|
+
}
|
package/package.json
CHANGED
package/scripts/build-native.js
CHANGED
|
@@ -13,9 +13,7 @@ if (existsSync(binPath)) {
|
|
|
13
13
|
process.exit(0);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
// dir created
|
|
18
|
-
}
|
|
16
|
+
mkdirSync(binDir, { recursive: true });
|
|
19
17
|
|
|
20
18
|
try {
|
|
21
19
|
execSync(`swiftc -O "${swiftSrc}" -o "${binPath}"`, { stdio: 'inherit' });
|