import Foundation #if canImport(AppKit) import AppKit #endif final class ImageProcessor { struct ImageOutput: Hashable { let source: String let width: Int let desiredHeight: Int? var ratio: Float? { guard let desiredHeight = desiredHeight else { return nil } return Float(desiredHeight) / Float(width) } func hasSimilarRatio(as other: ImageOutput) -> Bool { guard let other = other.ratio, let ratio = ratio else { return true } return abs(other - ratio) < 0.1 } } let inputFolder: URL let outputFolder: URL init(inputFolder: URL, outputFolder: URL) { self.inputFolder = inputFolder self.outputFolder = outputFolder } private var tasks: [String : ImageOutput] = [:] @discardableResult func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil, createDoubleVersion: Bool = false) throws -> NSSize { let output = ImageOutput( source: source, width: width, desiredHeight: desiredHeight) return try requireImage(output, for: destination, createDoubleVersion: createDoubleVersion) } private func insert(_ image: ImageOutput, for destination: String) throws -> NSSize { let sourceUrl = inputFolder.appendingPathComponent(image.source) guard sourceUrl.exists else { throw GenerationError.missingImage(sourceUrl.path) } guard let imageSize = NSImage(contentsOfFile: sourceUrl.path)?.size else { throw GenerationError.failedToGenerateImage(sourceUrl.path) } let scaledSize = getScaledSize(of: imageSize, to: CGFloat(image.width)) guard let existing = tasks[destination] else { //print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)") tasks[destination] = image return scaledSize } guard existing.source == image.source else { throw GenerationError.conflictingImageSources( output: destination, in1: existing.source, in2: image.source) } guard existing.hasSimilarRatio(as: image) else { throw GenerationError.conflictingImageRatios( output: destination, in1: existing.source, in2: image.source) } if image.width > existing.width { //print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)") tasks[destination] = image } return scaledSize } @discardableResult func requireImage(_ image: ImageOutput, for destination: String, createDoubleVersion: Bool = false) throws -> NSSize { let size = try insert(image, for: destination) guard createDoubleVersion else { return size } _ = try requireImage( source: image.source, destination: destination.insert("@2x", beforeLast: "."), width: image.width * 2, desiredHeight: image.desiredHeight.unwrapped { $0 * 2 } ) // Return 1x size return size } func createImages() throws { for (destination, image) in tasks { try createImageIfNeeded(image, for: destination) } } private func createImageIfNeeded(_ image: ImageOutput, for destination: String) throws { let source = inputFolder.appendingPathComponent(image.source) guard source.exists else { throw GenerationError.missingImage(source.path) } let destination = outputFolder.appendingPathComponent(destination) #warning("Check if source image has changed since last run") guard !destination.exists else { return } // Just copy SVG files guard destination.pathExtension.lowercased() != "svg" else { try FileSystem.copy(source, to: destination) return } #if canImport(AppKit) try createImage( destination, from: source, with: CGFloat(image.width), and: image.desiredHeight.unwrapped(CGFloat.init)) #else throw GenerationError.failedToGenerateImage(destination.path) #endif } #if canImport(AppKit) private func createImage(_ destination: URL, from source: URL, with desiredWidth: CGFloat, and desiredHeight: CGFloat?) throws { guard let sourceImage = NSImage(contentsOfFile: source.path) else { throw GenerationError.failedToGenerateImage(source.path) } let destinationSize = getScaledSize(of: sourceImage.size, to: desiredWidth) let scaledImage = scale(image: sourceImage, to: destinationSize) let scaledSize = scaledImage.size if abs(scaledImage.size.width - desiredWidth) > 2 { print("[WARN] Image \(destination.path) scaled incorrectly (wanted width \(desiredWidth), is \(scaledSize.width))") } if abs(destinationSize.height - scaledImage.size.height) > 2 { print("[WARN] Image \(destination.path) scaled incorrectly (wanted height \(destinationSize.height), is \(scaledSize.height))") } if let desiredHeight = desiredHeight { let desiredRatio = desiredHeight / desiredWidth let adjustedDesiredHeight = scaledSize.width * desiredRatio if abs(adjustedDesiredHeight - scaledSize.height) > 5 { print("[WARN] Image \(source.path): Desired height \(adjustedDesiredHeight) (actually \(desiredHeight)), got \(scaledSize.height) after reduction") throw GenerationError.imageRatioMismatch(destination.path) } } if scaledSize.width > desiredWidth { print("[WARN] Image \(source.path) is too large (expected width \(desiredWidth), got \(scaledSize.width)") } try saveImage(scaledImage, atUrl: destination) guard let savedImage = NSImage(contentsOfFile: destination.path) else { throw GenerationError.failedToGenerateImage(source.path) } let savedSize = savedImage.size if destination.lastPathComponent.hasSuffix("@2x.jpg") { if abs(savedSize.height - destinationSize.height/2) > 2 || abs(savedSize.width - destinationSize.width/2) > 2 { print("[WARN] Image \(destination.path) (2x): Expected (\(destinationSize.width/2),\(destinationSize.height/2)), got (\(savedSize.width),\(savedSize.height))") } } else if abs(savedSize.height - destinationSize.height) > 2 || abs(savedSize.width - destinationSize.width) > 2 { print("[WARN] Image \(destination.path): Expected (\(destinationSize.width),\(destinationSize.height)), got (\(savedSize.width),\(savedSize.height))") } // print("Source (\(sourceWidth),\(sourceHeight))") // print("Desired (\(desiredWidth),\(desiredHeight!))") // print("Expected (\(expectedScaledWidth),\(expectedScaledHeight))") // print("Scaled (\(scaledWidth),\(scaledImage.size.height))") // print("Saved (\(savedWidth),\(savedHeight))") // print(NSScreen.main!.backingScaleFactor) } private func saveImage(_ image: NSImage, atUrl url: URL) throws { guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else { print("Failed to get jpg data for image \(url.path)") throw GenerationError.failedToGenerateImage(url.path) } guard let jpgData = tiffData.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(0.7)]) else { print("Failed to get jpg data for image \(url.path)") throw GenerationError.failedToGenerateImage(url.path) } try jpgData.createFolderAndWrite(to: url) } #endif } private extension Int { func multiply(by factor: Int) -> Int { self * factor } } private func getScaledSize(of source: NSSize, to desiredWidth: CGFloat) -> NSSize { if source.width == desiredWidth { return source } if source.width < desiredWidth { // Keep existing image if image is too small already return source //print("Image \(destination.path) too small (wanted width \(desiredWidth), has only \(sourceWidth))") } let height = source.height * desiredWidth / source.width return NSSize(width: desiredWidth, height: height) } private func scale(image: NSImage, to size: NSSize) -> NSImage { guard image.size.width > size.width else { return image } //resize image return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in image.draw(in: resizedRect) return true } } private extension NSSize { var ratio: CGFloat { width / height } }