CHGenerator/WebsiteGenerator/ImageProcessor.swift
Christoph Hagen 14b935249f First version
2022-08-16 10:39:05 +02:00

238 lines
8.8 KiB
Swift

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
}
}