mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
211 lines
6.5 KiB
Swift
211 lines
6.5 KiB
Swift
import Foundation
|
|
import AppKit
|
|
|
|
// Force AppKit initialization so all system cursor images are populated
|
|
let _ = NSApplication.shared
|
|
|
|
struct CursorAsset: Codable {
|
|
let dataUrl: String
|
|
let hotspotX: Double
|
|
let hotspotY: Double
|
|
let width: Double
|
|
let height: Double
|
|
}
|
|
|
|
let minimumCursorRenderScale: CGFloat = 12
|
|
|
|
/// Reference pixel height that all cursor images are normalized to after trimming.
|
|
/// Kept high so large in-app cursor scaling still preserves raster detail up to 10x.
|
|
let referencePixelHeight: CGFloat = 1024
|
|
|
|
func bestCursorCGImage(for image: NSImage) -> CGImage? {
|
|
var proposedRect = CGRect(origin: .zero, size: image.size)
|
|
if let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) {
|
|
return cgImage
|
|
}
|
|
|
|
let sortedRepresentations = image.representations.sorted { lhs, rhs in
|
|
let lhsPixels = lhs.pixelsWide * lhs.pixelsHigh
|
|
let rhsPixels = rhs.pixelsWide * rhs.pixelsHigh
|
|
return lhsPixels > rhsPixels
|
|
}
|
|
|
|
for representation in sortedRepresentations {
|
|
if let bitmap = representation as? NSBitmapImageRep, let cgImage = bitmap.cgImage {
|
|
return cgImage
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func makeRetinaImage(from cgImage: CGImage, pointSize: NSSize) -> NSImage {
|
|
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
|
bitmap.size = pointSize
|
|
let image = NSImage(size: pointSize)
|
|
image.addRepresentation(bitmap)
|
|
return image
|
|
}
|
|
|
|
/// Find the bounding box of non-transparent pixels in a bitmap.
|
|
/// Returns (x, y, width, height) in pixel coordinates (origin top-left).
|
|
func findOpaqueBounds(_ bitmap: NSBitmapImageRep) -> (x: Int, y: Int, width: Int, height: Int)? {
|
|
let w = bitmap.pixelsWide
|
|
let h = bitmap.pixelsHigh
|
|
guard let data = bitmap.bitmapData else { return nil }
|
|
let bpr = bitmap.bytesPerRow
|
|
var minX = w, minY = h, maxX = -1, maxY = -1
|
|
for y in 0..<h {
|
|
for x in 0..<w {
|
|
let alpha = data[y * bpr + x * 4 + 3]
|
|
if alpha > 2 {
|
|
if x < minX { minX = x }
|
|
if x > maxX { maxX = x }
|
|
if y < minY { minY = y }
|
|
if y > maxY { maxY = y }
|
|
}
|
|
}
|
|
}
|
|
if maxX < 0 { return nil }
|
|
return (minX, minY, maxX - minX + 1, maxY - minY + 1)
|
|
}
|
|
|
|
func makeAsset(for cursor: NSCursor) -> CursorAsset? {
|
|
let baseImage = cursor.image
|
|
let pointSize = baseImage.size
|
|
guard let sourceCGImage = bestCursorCGImage(for: baseImage) else { return nil }
|
|
let sourcePixelWidth = max(1, sourceCGImage.width)
|
|
let sourcePixelHeight = max(1, sourceCGImage.height)
|
|
let sourceScaleX = CGFloat(sourcePixelWidth) / max(1, pointSize.width)
|
|
let sourceScaleY = CGFloat(sourcePixelHeight) / max(1, pointSize.height)
|
|
let sourceScale = max(sourceScaleX, sourceScaleY)
|
|
let image = makeRetinaImage(from: sourceCGImage, pointSize: pointSize)
|
|
let renderScale = max(
|
|
minimumCursorRenderScale,
|
|
referencePixelHeight / max(1, CGFloat(sourcePixelHeight))
|
|
)
|
|
let pixelWidth = max(1, Int((CGFloat(sourcePixelWidth) * renderScale).rounded(.up)))
|
|
let pixelHeight = max(1, Int((CGFloat(sourcePixelHeight) * renderScale).rounded(.up)))
|
|
|
|
// Render at full resolution
|
|
guard let bitmap = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: pixelWidth,
|
|
pixelsHigh: pixelHeight,
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0
|
|
) else { return nil }
|
|
|
|
bitmap.size = NSSize(width: CGFloat(pixelWidth), height: CGFloat(pixelHeight))
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return nil
|
|
}
|
|
NSGraphicsContext.current = context
|
|
context.imageInterpolation = .none
|
|
context.cgContext.interpolationQuality = .none
|
|
image.draw(
|
|
in: NSRect(x: 0, y: 0, width: CGFloat(pixelWidth), height: CGFloat(pixelHeight)),
|
|
from: .zero,
|
|
operation: .copy,
|
|
fraction: 1
|
|
)
|
|
context.flushGraphics()
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
// Find opaque bounds and crop away transparent padding
|
|
guard let bounds = findOpaqueBounds(bitmap) else { return nil }
|
|
guard let fullCG = bitmap.cgImage else { return nil }
|
|
let cropRect = CGRect(x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height)
|
|
guard let croppedCG = fullCG.cropping(to: cropRect) else { return nil }
|
|
|
|
// Scale cropped image to referencePixelHeight
|
|
let aspect = CGFloat(bounds.width) / CGFloat(bounds.height)
|
|
let outH = Int(referencePixelHeight)
|
|
let outW = max(1, Int((referencePixelHeight * aspect).rounded(.up)))
|
|
|
|
guard let outBitmap = NSBitmapImageRep(
|
|
bitmapDataPlanes: nil,
|
|
pixelsWide: outW,
|
|
pixelsHigh: outH,
|
|
bitsPerSample: 8,
|
|
samplesPerPixel: 4,
|
|
hasAlpha: true,
|
|
isPlanar: false,
|
|
colorSpaceName: .deviceRGB,
|
|
bytesPerRow: 0,
|
|
bitsPerPixel: 0
|
|
) else { return nil }
|
|
|
|
outBitmap.size = NSSize(width: outW, height: outH)
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
guard let outCtx = NSGraphicsContext(bitmapImageRep: outBitmap) else {
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return nil
|
|
}
|
|
NSGraphicsContext.current = outCtx
|
|
outCtx.imageInterpolation = .none
|
|
outCtx.cgContext.interpolationQuality = .none
|
|
let nsImage = NSImage(cgImage: croppedCG, size: NSSize(width: bounds.width, height: bounds.height))
|
|
nsImage.draw(
|
|
in: NSRect(x: 0, y: 0, width: outW, height: outH),
|
|
from: .zero,
|
|
operation: .copy,
|
|
fraction: 1
|
|
)
|
|
outCtx.flushGraphics()
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
guard let pngData = outBitmap.representation(using: .png, properties: [:]) else { return nil }
|
|
let base64 = pngData.base64EncodedString()
|
|
|
|
// Adjust hotspot using the extracted source image scale so 2x cursor reps keep the correct anchor.
|
|
let hotspot = cursor.hotSpot
|
|
let hotspotPxX = hotspot.x * sourceScale * renderScale - CGFloat(bounds.x)
|
|
let hotspotPxY = hotspot.y * sourceScale * renderScale - CGFloat(bounds.y)
|
|
let scaleToOut = referencePixelHeight / CGFloat(bounds.height)
|
|
let outHotspotX = hotspotPxX * scaleToOut
|
|
let outHotspotY = hotspotPxY * scaleToOut
|
|
|
|
return CursorAsset(
|
|
dataUrl: "data:image/png;base64,\(base64)",
|
|
hotspotX: Double(outHotspotX),
|
|
hotspotY: Double(outHotspotY),
|
|
width: Double(outW),
|
|
height: Double(outH)
|
|
)
|
|
}
|
|
|
|
let cursorMap: [String: NSCursor] = [
|
|
"arrow": .arrow,
|
|
"text": .iBeam,
|
|
"pointer": .pointingHand,
|
|
"crosshair": .crosshair,
|
|
"open-hand": .openHand,
|
|
"closed-hand": .closedHand,
|
|
"resize-ew": .resizeLeftRight,
|
|
"resize-ns": .resizeUpDown,
|
|
"not-allowed": .operationNotAllowed,
|
|
]
|
|
|
|
var assets: [String: CursorAsset] = [:]
|
|
for (key, cursor) in cursorMap {
|
|
if let asset = makeAsset(for: cursor) {
|
|
assets[key] = asset
|
|
}
|
|
}
|
|
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
let data = try encoder.encode(assets)
|
|
FileHandle.standardOutput.write(data)
|
|
|