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/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "kelra",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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)!)