// ImageExporter.swift // GreenshotMac // // Port of Greenshot's ImageIO: encoding a capture to the configured output format // and writing it to the configured directory using the filename pattern. import AppKit import UniformTypeIdentifiers enum ImageExporter { static func data(from image: CGImage, format: OutputFormat, jpegQuality: Int) -> Data? { let rep = NSBitmapImageRep(cgImage: image) rep.size = CGSize(width: image.width, height: image.height) var properties: [NSBitmapImageRep.PropertyKey: Any] = [:] if format == .jpg { properties[.compressionFactor] = NSNumber(value: Double(jpegQuality) / 100.0) } return rep.representation(using: format.fileType, properties: properties) } /// Flattens an image onto white, needed for formats without an alpha channel. static func flattened(_ image: CGImage) -> CGImage { guard let context = CGContext(data: nil, width: image.width, height: image.height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return image } context.setFillColor(NSColor.white.cgColor) context.fill(CGRect(x: 0, y: 0, width: image.width, height: image.height)) context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) return context.makeImage() ?? image } /// The URL a capture would be saved to with the current configuration. static func suggestedURL(for details: CaptureDetails, format: OutputFormat = Configuration.shared.outputFileFormat) -> URL { let configuration = Configuration.shared let name = FilenamePattern.filename(pattern: configuration.outputFileFilenamePattern, details: details, format: format) let directory = URL(fileURLWithPath: configuration.outputFilePath, isDirectory: true) return uniqueURL(directory.appendingPathComponent(name)) } /// Appends " (2)", " (3)", … until the URL is free. static func uniqueURL(_ url: URL) -> URL { guard FileManager.default.fileExists(atPath: url.path) else { return url } let directory = url.deletingLastPathComponent() let ext = url.pathExtension let base = url.deletingPathExtension().lastPathComponent var index = 2 while index < 1000 { let candidate = directory .appendingPathComponent("\(base) (\(index))") .appendingPathExtension(ext) if !FileManager.default.fileExists(atPath: candidate.path) { return candidate } index += 1 } return url } @discardableResult static func write(_ image: CGImage, to url: URL, format: OutputFormat) throws -> URL { let configuration = Configuration.shared let source = (format == .jpg || format == .bmp) ? flattened(image) : image guard let data = data(from: source, format: format, jpegQuality: configuration.outputFileJpegQuality) else { throw CaptureError.captureFailed("Could not encode the image as \(format.displayName).") } try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: .atomic) return url } /// Reads a single pixel from an image. `point` is in image points with a top-left /// origin; `scale` converts those points to pixels. static func color(at point: CGPoint, in image: CGImage, scale: CGFloat) -> NSColor? { let x = Int((point.x * scale).rounded(.down)) let y = Int((point.y * scale).rounded(.down)) guard x >= 0, y >= 0, x < image.width, y < image.height else { return nil } var pixel: [UInt8] = [0, 0, 0, 0] guard let context = CGContext(data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } // Draw the image shifted so the pixel of interest lands in the 1x1 context. context.draw(image, in: CGRect(x: -CGFloat(x), y: -CGFloat(image.height - 1 - y), width: CGFloat(image.width), height: CGFloat(image.height))) let alpha = CGFloat(pixel[3]) / 255 guard alpha > 0 else { return NSColor.clear } return NSColor(srgbRed: CGFloat(pixel[0]) / 255 / alpha, green: CGFloat(pixel[1]) / 255 / alpha, blue: CGFloat(pixel[2]) / 255 / alpha, alpha: 1) } static func format(forPathExtension pathExtension: String) -> OutputFormat { switch pathExtension.lowercased() { case "jpg", "jpeg": return .jpg case "tif", "tiff": return .tiff case "bmp": return .bmp case "gif": return .gif default: return .png } } }