import Foundation #if canImport(AppKit) import AppKit #endif final class FileProcessor { 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 /** The files required by the site. The content are the links to the files relative to the source root folder. The files will be placed at the same path relative to the output folder */ private var requiredFiles: Set = [] private var tasks: [String : ImageOutput] = [:] init(inputFolder: URL, outputFolder: URL) { self.inputFolder = inputFolder self.outputFolder = outputFolder } // MARK: Files /** Add a file as required, so that it will be copied to the output directory. */ func require(file: String) { requiredFiles.insert(file) } func copyRequiredFiles() throws { var missingFiles = [String]() for file in requiredFiles { let sourceUrl = inputFolder.appendingPathComponent(file) guard sourceUrl.exists else { missingFiles.append(file) continue } let destinationUrl = outputFolder.appendingPathComponent(file) try FileSystem.copy(sourceUrl, to: destinationUrl) } } // MARK: Images @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 } }