121 lines
5.6 KiB
Swift
121 lines
5.6 KiB
Swift
// 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
|
|
}
|
|
}
|
|
}
|