kelra 0.0.0-stage → 0.1.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/AGENT_GUIDE.md +34 -0
- package/LICENSE +47 -0
- package/README.md +62 -2
- package/bin/kelra-ocr +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +34 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/developer.d.ts +2 -0
- package/dist/developer.js +14 -0
- package/dist/devicectl.d.ts +5 -0
- package/dist/devicectl.js +45 -0
- package/dist/friendly.d.ts +11 -0
- package/dist/friendly.js +39 -0
- package/dist/guide.d.ts +1 -0
- package/dist/guide.js +13 -0
- package/dist/image.d.ts +27 -0
- package/dist/image.js +59 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +290 -0
- package/dist/license.d.ts +15 -0
- package/dist/license.js +29 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +6 -0
- package/dist/login.d.ts +22 -0
- package/dist/login.js +84 -0
- package/dist/ocr.d.ts +6 -0
- package/dist/ocr.js +40 -0
- package/dist/service.d.ts +7 -0
- package/dist/service.js +32 -0
- package/dist/settle.d.ts +17 -0
- package/dist/settle.js +27 -0
- package/package.json +38 -4
- package/swift/ocr.swift +216 -0
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kelra",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kelra": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"prepack": "cd ../.. && npm run build && test -f packages/helper/dist/cli.js && test -f packages/helper/bin/kelra-ocr",
|
|
11
|
+
"build:ocr": "mkdir -p bin && DD=\"${DEVELOPER_DIR:-$(xcode-select -p)}\" && SDK=\"$DD/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk\" && SW=\"$DD/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc\" && \"$SW\" -O -target arm64-apple-macos13 swift/ocr.swift -o bin/kelra-ocr-arm64 -sdk \"$SDK\" && \"$SW\" -O -target x86_64-apple-macos13 swift/ocr.swift -o bin/kelra-ocr-x64 -sdk \"$SDK\" && lipo -create bin/kelra-ocr-arm64 bin/kelra-ocr-x64 -output bin/kelra-ocr && rm bin/kelra-ocr-arm64 bin/kelra-ocr-x64"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
15
|
+
"pngjs": "^7.0.0",
|
|
16
|
+
"zod": "^3.24.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/pngjs": "^6.0.5",
|
|
20
|
+
"@kelra/protocol": "*"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"!dist/.tsbuildinfo",
|
|
25
|
+
"!dist/**/*.map",
|
|
26
|
+
"swift",
|
|
27
|
+
"bin",
|
|
28
|
+
"README.md",
|
|
29
|
+
"AGENT_GUIDE.md"
|
|
30
|
+
],
|
|
31
|
+
"description": "MCP server that gives AI agents precise iOS UI measurements: measure a reference, build, compare, fix.",
|
|
32
|
+
"os": [
|
|
33
|
+
"darwin"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
38
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
39
|
+
"homepage": "https://kelra.net"
|
|
40
|
+
}
|
package/swift/ocr.swift
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// kelra-ocr: reads text lines from an image with Apple Vision and reports raw measurements.
|
|
2
|
+
// usage: kelra-ocr <image.png> <pixelsPerPoint>
|
|
3
|
+
// prints a JSON array: text, confidence, rectangle in points, the stroke thickness and ink height measured in the image,
|
|
4
|
+
// and for each system-font weight the size whose width matches plus that candidate's own stroke thickness.
|
|
5
|
+
// It makes no decisions: which weight and size a line really is gets decided by the kelra service.
|
|
6
|
+
import Foundation
|
|
7
|
+
import Vision
|
|
8
|
+
import AppKit
|
|
9
|
+
import CoreText
|
|
10
|
+
|
|
11
|
+
struct Candidate: Codable { var weight: String; var sizePt: Double; var coverage: Double; var stemPx: Double }
|
|
12
|
+
struct RawLine: Codable {
|
|
13
|
+
var text: String
|
|
14
|
+
var confidence: Float
|
|
15
|
+
var x: Double, y: Double, w: Double, h: Double
|
|
16
|
+
var cropStemPx: Double
|
|
17
|
+
var inkHeightPt: Double
|
|
18
|
+
var cropCoverage: Double
|
|
19
|
+
var candidates: [Candidate]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let weights: [(String, NSFont.Weight)] = [("regular", .regular), ("medium", .medium), ("semibold", .semibold), ("bold", .bold)]
|
|
23
|
+
|
|
24
|
+
func fail(_ msg: String) -> Never {
|
|
25
|
+
FileHandle.standardError.write((msg + "\n").data(using: .utf8)!)
|
|
26
|
+
exit(1)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let args = CommandLine.arguments
|
|
30
|
+
guard args.count >= 3, let scale = Double(args[2]), scale > 0 else { fail("usage: kelra-ocr <image> <pixelsPerPoint>") }
|
|
31
|
+
guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: args[1]) as CFURL, nil),
|
|
32
|
+
let image = CGImageSourceCreateImageAtIndex(src, 0, nil) else { fail("cannot read image \(args[1])") }
|
|
33
|
+
let W = image.width, H = image.height
|
|
34
|
+
|
|
35
|
+
// luminance of the whole image, row-major, top row first
|
|
36
|
+
var gray = [UInt8](repeating: 0, count: W * H)
|
|
37
|
+
gray.withUnsafeMutableBytes { buf in
|
|
38
|
+
let ctx = CGContext(data: buf.baseAddress, width: W, height: H, bitsPerComponent: 8, bytesPerRow: W,
|
|
39
|
+
space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue)!
|
|
40
|
+
ctx.draw(image, in: CGRect(x: 0, y: 0, width: W, height: H))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// how much of a box is covered by ink: 0 (empty) to 1 (solid), relative to the strongest contrast in the box
|
|
44
|
+
func cropCoverage(x: Int, y: Int, w: Int, h: Int) -> Double {
|
|
45
|
+
guard w > 2, h > 2 else { return 0 }
|
|
46
|
+
var border = [Int]()
|
|
47
|
+
for i in 0..<w { border.append(Int(gray[y * W + x + i])); border.append(Int(gray[(y + h - 1) * W + x + i])) }
|
|
48
|
+
for j in 0..<h { border.append(Int(gray[(y + j) * W + x])); border.append(Int(gray[(y + j) * W + x + w - 1])) }
|
|
49
|
+
border.sort()
|
|
50
|
+
let bg = Double(border[border.count / 2])
|
|
51
|
+
var farthest = 0.0
|
|
52
|
+
for j in 0..<h { for i in 0..<w { farthest = max(farthest, abs(Double(gray[(y + j) * W + x + i]) - bg)) } }
|
|
53
|
+
guard farthest > 20 else { return 0 }
|
|
54
|
+
var sum = 0.0
|
|
55
|
+
for j in 0..<h { for i in 0..<w { sum += min(1, abs(Double(gray[(y + j) * W + x + i]) - bg) / farthest) } }
|
|
56
|
+
return sum / Double(w * h)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Height in pixels of the drawn ink (first to last row that has any), not of Vision's looser text box.
|
|
60
|
+
func inkHeight(_ buf: [UInt8], stride: Int, x: Int, y: Int, w: Int, h: Int) -> Double {
|
|
61
|
+
guard w > 2, h > 2 else { return 0 }
|
|
62
|
+
var border = [Int]()
|
|
63
|
+
for i in 0..<w { border.append(Int(buf[y * stride + x + i])); border.append(Int(buf[(y + h - 1) * stride + x + i])) }
|
|
64
|
+
border.sort()
|
|
65
|
+
let bg = Double(border[border.count / 2])
|
|
66
|
+
var farthest = 0.0
|
|
67
|
+
for j in 0..<h { for i in 0..<w { farthest = max(farthest, abs(Double(buf[(y + j) * stride + x + i]) - bg)) } }
|
|
68
|
+
guard farthest > 20 else { return 0 }
|
|
69
|
+
var top = -1, bottom = -1
|
|
70
|
+
for j in 0..<h {
|
|
71
|
+
let hasInk = (0..<w).contains { i in abs(Double(buf[(y + j) * stride + x + i]) - bg) > 0.35 * farthest }
|
|
72
|
+
if hasInk { if top < 0 { top = j }; bottom = j }
|
|
73
|
+
}
|
|
74
|
+
return top < 0 ? 0 : Double(bottom - top + 1)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Typical stroke thickness in pixels: the median, over rows and ink runs, of each run's darkness-weighted length.
|
|
78
|
+
/// Measured the same way on the screenshot crop and on rendered candidates, so no assumption about the other text is needed.
|
|
79
|
+
func stemWidth(_ buf: [UInt8], stride: Int, x: Int, y: Int, w: Int, h: Int) -> Double {
|
|
80
|
+
guard w > 2, h > 2 else { return 0 }
|
|
81
|
+
var border = [Int]()
|
|
82
|
+
for i in 0..<w { border.append(Int(buf[y * stride + x + i])); border.append(Int(buf[(y + h - 1) * stride + x + i])) }
|
|
83
|
+
for j in 0..<h { border.append(Int(buf[(y + j) * stride + x])); border.append(Int(buf[(y + j) * stride + x + w - 1])) }
|
|
84
|
+
border.sort()
|
|
85
|
+
let bg = Double(border[border.count / 2])
|
|
86
|
+
var farthest = 0.0
|
|
87
|
+
for j in 0..<h { for i in 0..<w { farthest = max(farthest, abs(Double(buf[(y + j) * stride + x + i]) - bg)) } }
|
|
88
|
+
guard farthest > 20 else { return 0 }
|
|
89
|
+
var runs = [Double]()
|
|
90
|
+
for j in 0..<h {
|
|
91
|
+
var sum = 0.0, len = 0
|
|
92
|
+
for i in 0...w {
|
|
93
|
+
let d = i < w ? min(1, abs(Double(buf[(y + j) * stride + x + i]) - bg) / farthest) : 0
|
|
94
|
+
if d > 0.2 { sum += d; len += 1 } else if len > 0 { runs.append(sum); sum = 0; len = 0 }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
guard !runs.isEmpty else { return 0 }
|
|
98
|
+
runs.sort()
|
|
99
|
+
return runs[runs.count / 2]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func textWidth(_ s: String, size: CGFloat, weight: NSFont.Weight) -> CGFloat {
|
|
103
|
+
(s as NSString).size(withAttributes: [.font: NSFont.systemFont(ofSize: size, weight: weight)]).width
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// the font size, in points, at which the string is `targetPt` wide
|
|
107
|
+
func solveSize(_ s: String, targetPt: Double, weight: NSFont.Weight) -> Double {
|
|
108
|
+
var size = 17.0
|
|
109
|
+
for _ in 0..<8 {
|
|
110
|
+
let w = Double(textWidth(s, size: CGFloat(size), weight: weight))
|
|
111
|
+
if w <= 0 { break }
|
|
112
|
+
size *= targetPt / w
|
|
113
|
+
}
|
|
114
|
+
return size
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// coverage of the string rendered in a system font, in a box of the same pixel size
|
|
118
|
+
func renderCoverage(_ s: String, sizePt: Double, weight: NSFont.Weight, boxW: Int, boxH: Int) -> (coverage: Double, stem: Double) {
|
|
119
|
+
guard boxW > 2, boxH > 2 else { return (0, 0) }
|
|
120
|
+
var buf = [UInt8](repeating: 255, count: boxW * boxH)
|
|
121
|
+
var total = 0.0
|
|
122
|
+
buf.withUnsafeMutableBytes { p in
|
|
123
|
+
let ctx = CGContext(data: p.baseAddress, width: boxW, height: boxH, bitsPerComponent: 8, bytesPerRow: boxW,
|
|
124
|
+
space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.none.rawValue)!
|
|
125
|
+
let font = NSFont.systemFont(ofSize: CGFloat(sizePt * scale), weight: weight)
|
|
126
|
+
let attr = NSAttributedString(string: s, attributes: [.font: font, .foregroundColor: NSColor.black])
|
|
127
|
+
let line = CTLineCreateWithAttributedString(attr)
|
|
128
|
+
var asc: CGFloat = 0, desc: CGFloat = 0, lead: CGFloat = 0
|
|
129
|
+
let lw = CTLineGetTypographicBounds(line, &asc, &desc, &lead)
|
|
130
|
+
ctx.textPosition = CGPoint(x: (CGFloat(boxW) - CGFloat(lw)) / 2, y: (CGFloat(boxH) - (asc + desc)) / 2 + desc)
|
|
131
|
+
CTLineDraw(line, ctx)
|
|
132
|
+
}
|
|
133
|
+
for v in buf { total += (255 - Double(v)) / 255 }
|
|
134
|
+
return (total / Double(boxW * boxH), stemWidth(buf, stride: boxW, x: 0, y: 0, w: boxW, h: boxH))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let request = VNRecognizeTextRequest()
|
|
138
|
+
request.recognitionLevel = .accurate
|
|
139
|
+
request.usesLanguageCorrection = false // UI strings like "£3.45" or "Sep 29" must not be "corrected"
|
|
140
|
+
do { try VNImageRequestHandler(cgImage: image, options: [:]).perform([request]) } catch { fail("vision failed: \(error)") }
|
|
141
|
+
|
|
142
|
+
/// column ranges (relative to the box) of the word-token groups in a box. Each boundary between tokens is matched to the
|
|
143
|
+
/// ink gap nearest to where it should fall by character position, so a gap inside a word (a comma) is not mistaken for a space.
|
|
144
|
+
func tokenGroups(x: Int, y: Int, w: Int, h: Int, tokenLengths: [Int]) -> [(Int, Int)] {
|
|
145
|
+
var border = [Int]()
|
|
146
|
+
for i in 0..<w { border.append(Int(gray[y * W + x + i])); border.append(Int(gray[(y + h - 1) * W + x + i])) }
|
|
147
|
+
border.sort()
|
|
148
|
+
let bg = Double(border[border.count / 2])
|
|
149
|
+
var farthest = 0.0
|
|
150
|
+
for j in 0..<h { for i in 0..<w { farthest = max(farthest, abs(Double(gray[(y + j) * W + x + i]) - bg)) } }
|
|
151
|
+
guard farthest > 20 else { return [] }
|
|
152
|
+
let ink = (0..<w).map { i -> Bool in (0..<h).contains { j in abs(Double(gray[(y + j) * W + x + i]) - bg) > 0.35 * farthest } }
|
|
153
|
+
guard let first = ink.firstIndex(of: true), let last = ink.lastIndex(of: true) else { return [] }
|
|
154
|
+
var gaps = [(start: Int, len: Int)](); var i = first
|
|
155
|
+
while i <= last { if !ink[i] { var j = i; while j <= last && !ink[j] { j += 1 }; gaps.append((i, j - i)); i = j } else { i += 1 } }
|
|
156
|
+
let total = Double(tokenLengths.reduce(0, +) + tokenLengths.count - 1)
|
|
157
|
+
var cuts = [(start: Int, len: Int)](), used = Set<Int>(), chars = 0.0
|
|
158
|
+
for k in 0..<(tokenLengths.count - 1) {
|
|
159
|
+
chars += Double(tokenLengths[k]) + 0.5
|
|
160
|
+
let expected = Double(first) + chars / total * Double(last - first)
|
|
161
|
+
guard let g = gaps.enumerated().filter({ !used.contains($0.offset) }).min(by: { abs(Double($0.element.start) + Double($0.element.len) / 2 - expected) < abs(Double($1.element.start) + Double($1.element.len) / 2 - expected) }),
|
|
162
|
+
abs(Double(g.element.start) + Double(g.element.len) / 2 - expected) < 0.22 * Double(last - first) else { return [] }
|
|
163
|
+
used.insert(g.offset); cuts.append(g.element)
|
|
164
|
+
}
|
|
165
|
+
cuts.sort { $0.start < $1.start }
|
|
166
|
+
var groups = [(Int, Int)](); var from = first
|
|
167
|
+
for c in cuts { groups.append((from, c.start - 1)); from = c.start + c.len }
|
|
168
|
+
groups.append((from, last))
|
|
169
|
+
return groups
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func isWord(_ t: Substring) -> Bool { t.contains { $0.isLetter || $0.isNumber } }
|
|
173
|
+
|
|
174
|
+
struct Pending { var text: String; var conf: Float; var px: Int, py: Int, pw: Int, ph: Int; var crop: Double; var cropStem: Double; var inkH: Double; var fits: [Candidate] }
|
|
175
|
+
var pending = [Pending]()
|
|
176
|
+
for obs in request.results ?? [] {
|
|
177
|
+
guard let cand = obs.topCandidates(1).first else { continue }
|
|
178
|
+
let bb = obs.boundingBox // normalized, origin at bottom-left
|
|
179
|
+
var text = cand.string
|
|
180
|
+
var px = Int((bb.minX * Double(W)).rounded()), pw = Int((bb.width * Double(W)).rounded())
|
|
181
|
+
let ph = Int((bb.height * Double(H)).rounded())
|
|
182
|
+
let py = Int(((1 - bb.maxY) * Double(H)).rounded())
|
|
183
|
+
guard px >= 0, py >= 0, px + pw <= W, py + ph <= H, pw > 2, ph > 2 else { continue }
|
|
184
|
+
// a trailing chevron or a neighboring icon is swept into the line: keep only the groups of word tokens
|
|
185
|
+
let tokens = cand.string.split(separator: " ", omittingEmptySubsequences: true)
|
|
186
|
+
if let first = tokens.firstIndex(where: isWord), let last = tokens.lastIndex(where: isWord), (first > 0 || last < tokens.count - 1) {
|
|
187
|
+
let groups = tokenGroups(x: px, y: py, w: pw, h: ph, tokenLengths: tokens.map { $0.count })
|
|
188
|
+
if groups.count == tokens.count {
|
|
189
|
+
let from = groups[first].0, to = groups[last].1
|
|
190
|
+
text = tokens[first...last].joined(separator: " ")
|
|
191
|
+
px += from; pw = to - from + 1
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
let targetPt = Double(pw) / scale
|
|
195
|
+
let crop = cropCoverage(x: px, y: py, w: pw, h: ph)
|
|
196
|
+
var fits = [Candidate]()
|
|
197
|
+
let cropStem = stemWidth(gray, stride: W, x: px, y: py, w: pw, h: ph)
|
|
198
|
+
for (name, w) in weights {
|
|
199
|
+
let size = solveSize(text, targetPt: targetPt, weight: w)
|
|
200
|
+
let r = renderCoverage(text, sizePt: size, weight: w, boxW: pw, boxH: ph)
|
|
201
|
+
fits.append(Candidate(weight: name, sizePt: (size * 1000).rounded() / 1000, coverage: (r.coverage * 1000).rounded() / 1000, stemPx: (r.stem * 100).rounded() / 100))
|
|
202
|
+
}
|
|
203
|
+
pending.append(Pending(text: text, conf: cand.confidence, px: px, py: py, pw: pw, ph: ph, crop: crop, cropStem: cropStem, inkH: inkHeight(gray, stride: W, x: px, y: py, w: pw, h: ph), fits: fits))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var lines = [RawLine]()
|
|
207
|
+
for p in pending {
|
|
208
|
+
lines.append(RawLine(text: p.text, confidence: p.conf,
|
|
209
|
+
x: (Double(p.px) / scale * 10).rounded() / 10, y: (Double(p.py) / scale * 10).rounded() / 10,
|
|
210
|
+
w: (Double(p.pw) / scale * 10).rounded() / 10, h: (Double(p.ph) / scale * 10).rounded() / 10,
|
|
211
|
+
cropStemPx: (p.cropStem * 100).rounded() / 100, inkHeightPt: (p.inkH / scale * 100).rounded() / 100,
|
|
212
|
+
cropCoverage: (p.crop * 1000).rounded() / 1000, candidates: p.fits))
|
|
213
|
+
}
|
|
214
|
+
lines.sort { ($0.y, $0.x) < ($1.y, $1.x) }
|
|
215
|
+
let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
216
|
+
print(String(data: try! enc.encode(lines), encoding: .utf8)!)
|