CHGenerator/WebsiteGenerator/FileProcessor.swift
2022-08-17 10:36:21 +02:00

307 lines
11 KiB
Swift

import Foundation
#if canImport(AppKit)
import AppKit
#endif
final class FileProcessor {
enum MediaType {
case image
case video
case file
}
func mediaType(forExtension fileExtension: String) -> MediaType {
if supportedImageExtensions[fileExtension] != nil {
return .image
}
if supportedVideoExtensions.contains(fileExtension) {
return .video
}
return .file
}
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
"jpg" : .jpeg,
"jpeg" : .jpeg,
"png" : .png,
]
private let supportedVideoExtensions: Set<String> = [
"mp4", "mov"
]
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<String> = []
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 {
let height = image.desiredHeight.unwrapped(CGFloat.init)
let width = CGFloat(image.width)
return .init(width: width, height: height ?? width / 16 * 9)
//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
}
// Ensure that image file is supported
let ext = destination.pathExtension.lowercased()
guard supportedImageExtensions[ext] != nil else {
print("Copying file \(source.path)")
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 {
print("Failed to load image \(source.path)")
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 {
let ext = url.pathExtension.lowercased()
guard let type = supportedImageExtensions[ext] else {
print("No image type for \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
print("Failed to get data for image \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
print("Failed to get data for image \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
try data.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
}
}