import AppKit import Foundation enum AnnotationRenderer { static func draw(_ item: AnnotationItem, in context: CGContext) { context.saveGState() context.setStrokeColor(item.color.cgColor) context.setFillColor(item.color.cgColor) context.setLineWidth(item.lineWidth) context.setLineCap(.round) context.setLineJoin(.round) switch item.kind { case .arrow: drawArrow(item, in: context) case .rectangle: drawRectangle(item, in: context) case .ellipse: drawEllipse(item, in: context) case .text: drawText(item) } context.restoreGState() } static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage { guard let srcCG = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return image } let w = srcCG.width let h = srcCG.height let size = image.size guard let cs = srcCG.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), let ctx = CGContext( data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0, space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return image } ctx.draw(srcCG, in: CGRect(x: 0, y: 0, width: w, height: h)) ctx.translateBy(x: 0, y: CGFloat(h)) let sx = CGFloat(w) / size.width let sy = CGFloat(h) / size.height ctx.scaleBy(x: sx, y: -sy) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true) for item in annotations { draw(item, in: ctx) } NSGraphicsContext.restoreGraphicsState() guard let resultCG = ctx.makeImage() else { return image } let bitmapRep = NSBitmapImageRep(cgImage: resultCG) bitmapRep.size = size let result = NSImage(size: size) result.addRepresentation(bitmapRep) return result } // MARK: - Shape drawing private static func drawArrow(_ item: AnnotationItem, in context: CGContext) { let start = item.rect.origin let end = CGPoint(x: item.rect.origin.x + item.rect.size.width, y: item.rect.origin.y + item.rect.size.height) context.move(to: start) context.addLine(to: end) context.strokePath() let angle = atan2(end.y - start.y, end.x - start.x) let headLength = max(item.lineWidth * 4, 14) let headAngle: CGFloat = .pi / 6 let left = CGPoint( x: end.x - headLength * cos(angle - headAngle), y: end.y - headLength * sin(angle - headAngle) ) let right = CGPoint( x: end.x - headLength * cos(angle + headAngle), y: end.y - headLength * sin(angle + headAngle) ) context.move(to: end) context.addLine(to: left) context.addLine(to: right) context.closePath() context.fillPath() } private static func drawRectangle(_ item: AnnotationItem, in context: CGContext) { context.stroke(item.rect, width: item.lineWidth) } private static func drawEllipse(_ item: AnnotationItem, in context: CGContext) { context.strokeEllipse(in: item.rect) } private static func drawText(_ item: AnnotationItem) { guard let text = item.text, !text.isEmpty else { return } let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold) let attributes: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: item.color, ] let string = NSAttributedString(string: text, attributes: attributes) string.draw(at: item.rect.origin) } // MARK: - Hit testing static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool { switch item.kind { case .arrow: let arrowEnd = CGPoint(x: item.rect.origin.x + item.rect.size.width, y: item.rect.origin.y + item.rect.size.height) return distanceToLine(point: point, from: item.rect.origin, to: arrowEnd) < tolerance + item.lineWidth case .rectangle: let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth), dy: -(tolerance + item.lineWidth)) let inner = item.rect.insetBy(dx: tolerance + item.lineWidth, dy: tolerance + item.lineWidth) return outer.contains(point) && !inner.contains(point) case .ellipse: let center = CGPoint(x: item.rect.midX, y: item.rect.midY) let rx = item.rect.width / 2 let ry = item.rect.height / 2 guard rx > 0, ry > 0 else { return false } let dx = (point.x - center.x) / rx let dy = (point.y - center.y) / ry let dist = sqrt(dx * dx + dy * dy) return abs(dist - 1.0) < (tolerance + item.lineWidth) / min(rx, ry) case .text: guard let text = item.text, !text.isEmpty else { return false } let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold) let size = (text as NSString).size(withAttributes: [.font: font]) let textRect = CGRect(origin: item.rect.origin, size: size) return textRect.insetBy(dx: -tolerance, dy: -tolerance).contains(point) } } private static func distanceToLine(point: CGPoint, from a: CGPoint, to b: CGPoint) -> CGFloat { let dx = b.x - a.x let dy = b.y - a.y let lengthSq = dx * dx + dy * dy guard lengthSq > 0 else { return hypot(point.x - a.x, point.y - a.y) } var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq t = max(0, min(1, t)) let projX = a.x + t * dx let projY = a.y + t * dy return hypot(point.x - projX, point.y - projY) } }