307 lines
11 KiB
Swift
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
|
|
}
|
|
}
|