import CoreGraphics import Foundation // MARK: - SVGPath // // Minimal SVG → CGPath converter for the vendored Lucide icon geometry // (`LucideIcons.geometry`). Exists so the editor can draw a callout icon as a // *stroked vector path* instead of an NSImage: drawing an image on a wrapping, // multi-line TextKit 2 layout fragment wedges that fragment's layout to a // single line, while shape drawing does not (see // docs/investigations/archives/callout-title-wrap-investigation.md). Supports exactly what the // vendored geometry uses: ``, ``, and `` elements, and the // full SVG path-data command set. Coordinates stay in the icons' 24×24, // y-down viewBox space; callers scale to the target size. enum SVGPath { /// Parses a fragment of SVG markup (one or more ``/``/ /// `` elements) into a single CGPath in viewBox coordinates. /// Returns `nil` if nothing parseable is found. static func path(fromGeometry svg: String) -> CGPath? { let result = CGMutablePath() let elementRegex = try! NSRegularExpression(pattern: #"<(path|circle|rect)\b([^>]*?)/?>"#) let attrRegex = try! NSRegularExpression(pattern: #"([\w-]+)="([^"]*)""#) let ns = svg as NSString for m in elementRegex.matches(in: svg, range: NSRange(location: 0, length: ns.length)) { let tag = ns.substring(with: m.range(at: 1)) let attrString = ns.substring(with: m.range(at: 2)) var attrs: [String: String] = [:] let ans = attrString as NSString for am in attrRegex.matches(in: attrString, range: NSRange(location: 0, length: ans.length)) { attrs[ans.substring(with: am.range(at: 1))] = ans.substring(with: am.range(at: 2)) } func num(_ key: String) -> CGFloat? { attrs[key].flatMap { Double($0) }.map { CGFloat($0) } } switch tag { case "path": if let d = attrs["d"], let p = path(fromData: d) { result.addPath(p) } case "circle": if let cx = num("cx"), let cy = num("cy"), let r = num("r") { result.addEllipse(in: CGRect(x: cx - r, y: cy - r, width: 2 * r, height: 2 * r)) } case "rect": if let w = num("width"), let h = num("height") { let rect = CGRect(x: num("x") ?? 0, y: num("y") ?? 0, width: w, height: h) let rx = num("rx") ?? num("ry") ?? 0 let ry = num("ry") ?? rx if rx > 0 || ry > 0 { result.addRoundedRect(in: rect, cornerWidth: rx, cornerHeight: ry) } else { result.addRect(rect) } } default: break } } return result.isEmpty ? nil : result } /// Parses an SVG path-data string (the `d` attribute) into a CGPath. static func path(fromData d: String) -> CGPath? { var scanner = NumberScanner(d) let path = CGMutablePath() var current = CGPoint.zero var subpathStart = CGPoint.zero // Reflection anchors for S/T smooth curves. var lastCubicControl: CGPoint? var lastQuadControl: CGPoint? var lastCommand: Character = " " while let command = scanner.nextCommand() { let relative = command.isLowercase let cmd = Character(command.uppercased()) // Each iteration of the repeat loop consumes one parameter set; // SVG allows implicit command repetition until a new letter. repeat { func point() -> CGPoint? { guard let x = scanner.nextNumber(), let y = scanner.nextNumber() else { return nil } return relative ? CGPoint(x: current.x + x, y: current.y + y) : CGPoint(x: x, y: y) } switch cmd { case "M": guard let p = point() else { return nil } path.move(to: p); current = p; subpathStart = p // Subsequent implicit pairs are LineTos. while scanner.peekNumber() { guard let q = point() else { return nil } path.addLine(to: q); current = q } case "L": guard let p = point() else { return nil } path.addLine(to: p); current = p case "H": guard let x = scanner.nextNumber() else { return nil } current.x = relative ? current.x + x : x path.addLine(to: current) case "V": guard let y = scanner.nextNumber() else { return nil } current.y = relative ? current.y + y : y path.addLine(to: current) case "C": guard let c1 = point(), let c2 = point(), let p = point() else { return nil } path.addCurve(to: p, control1: c1, control2: c2) current = p; lastCubicControl = c2 case "S": // First control point reflects the previous cubic's second // control about the current point (or is the current point). let c1: CGPoint if "CS".contains(lastCommand), let prev = lastCubicControl { c1 = CGPoint(x: 2 * current.x - prev.x, y: 2 * current.y - prev.y) } else { c1 = current } guard let c2 = point(), let p = point() else { return nil } path.addCurve(to: p, control1: c1, control2: c2) current = p; lastCubicControl = c2 case "Q": guard let c = point(), let p = point() else { return nil } path.addQuadCurve(to: p, control: c) current = p; lastQuadControl = c case "T": let c: CGPoint if "QT".contains(lastCommand), let prev = lastQuadControl { c = CGPoint(x: 2 * current.x - prev.x, y: 2 * current.y - prev.y) } else { c = current } guard let p = point() else { return nil } path.addQuadCurve(to: p, control: c) current = p; lastQuadControl = c case "A": guard let rx = scanner.nextNumber(), let ry = scanner.nextNumber(), let rot = scanner.nextNumber(), let largeArc = scanner.nextNumber(), let sweep = scanner.nextNumber(), let end = point() else { return nil } addArc(to: path, from: current, rx: rx, ry: ry, xAxisRotationDegrees: rot, largeArc: largeArc != 0, sweep: sweep != 0, end: end) current = end case "Z": path.closeSubpath() current = subpathStart default: return nil } if !"CS".contains(cmd) { lastCubicControl = nil } if !"QT".contains(cmd) { lastQuadControl = nil } lastCommand = cmd } while cmd != "Z" && scanner.peekNumber() } return path.isEmpty ? nil : path } /// SVG elliptical arc → cubic Béziers, via the endpoint-to-center /// conversion in SVG spec appendix B.2.4, splitting into ≤90° segments. private static func addArc(to path: CGMutablePath, from start: CGPoint, rx: CGFloat, ry: CGFloat, xAxisRotationDegrees: CGFloat, largeArc: Bool, sweep: Bool, end: CGPoint) { if start == end { return } var rx = abs(rx), ry = abs(ry) if rx == 0 || ry == 0 { path.addLine(to: end); return } let phi = xAxisRotationDegrees * .pi / 180 let cosPhi = cos(phi), sinPhi = sin(phi) // (x1', y1'): midpoint vector rotated into the ellipse frame. let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2 let x1p = cosPhi * dx + sinPhi * dy let y1p = -sinPhi * dx + cosPhi * dy // Scale radii up if the endpoints can't be spanned (spec F.6.6). let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) if lambda > 1 { let s = sqrt(lambda) rx *= s; ry *= s } // Center in the ellipse frame (spec F.6.5.2). let rx2 = rx * rx, ry2 = ry * ry, x1p2 = x1p * x1p, y1p2 = y1p * y1p var radicand = (rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2) radicand = max(0, radicand) let coef = (largeArc != sweep ? 1 : -1) * sqrt(radicand) let cxp = coef * (rx * y1p / ry) let cyp = coef * -(ry * x1p / rx) // Center in user space. let cx = cosPhi * cxp - sinPhi * cyp + (start.x + end.x) / 2 let cy = sinPhi * cxp + cosPhi * cyp + (start.y + end.y) / 2 func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat { let dot = ux * vx + uy * vy let len = sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) var a = acos(min(1, max(-1, dot / len))) if ux * vy - uy * vx < 0 { a = -a } return a } let theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry) var delta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry) if !sweep && delta > 0 { delta -= 2 * .pi } if sweep && delta < 0 { delta += 2 * .pi } // Approximate each ≤90° slice with one cubic. let segments = max(1, Int(ceil(abs(delta) / (.pi / 2)))) let segmentDelta = delta / CGFloat(segments) // Control-point distance for a cubic approximating a unit arc. let t = 4 / 3 * tan(segmentDelta / 4) var theta = theta1 for _ in 0.. CGPoint { CGPoint(x: cx + rx * cos(a) * cosPhi - ry * sin(a) * sinPhi, y: cy + rx * cos(a) * sinPhi + ry * sin(a) * cosPhi) } // Derivative (tangent) at the segment endpoints. func tangent(_ a: CGFloat) -> CGPoint { CGPoint(x: -rx * sin(a) * cosPhi - ry * cos(a) * sinPhi, y: -rx * sin(a) * sinPhi + ry * cos(a) * cosPhi) } let p0 = onEllipse(theta), p1 = onEllipse(thetaNext) let t0 = tangent(theta), t1 = tangent(thetaNext) path.addCurve(to: p1, control1: CGPoint(x: p0.x + t * t0.x, y: p0.y + t * t0.y), control2: CGPoint(x: p1.x - t * t1.x, y: p1.y - t * t1.y)) theta = thetaNext } } /// Lexer for SVG path data: commands are single letters; numbers may be /// packed together (`-.5.83` is −0.5 then 0.83 — a second `.` starts a new /// number), separated by whitespace or commas. private struct NumberScanner { private let chars: [Character] private var index = 0 init(_ s: String) { chars = Array(s) } private mutating func skipSeparators() { while index < chars.count, chars[index] == " " || chars[index] == "," || chars[index] == "\n" || chars[index] == "\t" || chars[index] == "\r" { index += 1 } } mutating func nextCommand() -> Character? { skipSeparators() guard index < chars.count, chars[index].isLetter else { return nil } defer { index += 1 } return chars[index] } /// True if a number (not a command letter) comes next. mutating func peekNumber() -> Bool { skipSeparators() guard index < chars.count else { return false } let c = chars[index] return c.isNumber || c == "-" || c == "+" || c == "." } mutating func nextNumber() -> CGFloat? { skipSeparators() var s = "" guard index < chars.count else { return nil } if chars[index] == "-" || chars[index] == "+" { s.append(chars[index]); index += 1 } var seenDot = false while index < chars.count { let c = chars[index] if c.isNumber { s.append(c); index += 1 } else if c == ".", !seenDot { seenDot = true; s.append(c); index += 1 } else { break } } return Double(s).map { CGFloat($0) } } } }