Generate pages, image descriptions

This commit is contained in:
Christoph Hagen
2024-12-06 21:59:36 +01:00
parent 18eb64f289
commit 5fb689ac7c
42 changed files with 1653 additions and 273 deletions

View File

@ -1,238 +0,0 @@
import Foundation
import AppKit
import SDWebImageAVIFCoder
import SDWebImageWebPCoder
private struct ImageJob {
let image: String
let version: String
let maximumWidth: CGFloat
let maximumHeight: CGFloat
let quality: CGFloat
let type: ImageType
}
final class ImageGenerator {
private let storage: Storage
private let inputImageFolder: URL
private let relativeImageOutputPath: String
private var generatedImages: [String : [String]] = [:]
private var jobs: [ImageJob] = []
init(storage: Storage, inputImageFolder: URL, relativeImageOutputPath: String) {
self.storage = storage
self.inputImageFolder = inputImageFolder
self.relativeImageOutputPath = relativeImageOutputPath
self.generatedImages = storage.loadListOfGeneratedImages()
}
func prepareForGeneration() -> Bool {
inOutputImagesFolder { imagesFolder in
do {
try imagesFolder.ensureFolderExistence()
return true
} catch {
print("Failed to create output images folder: \(error)")
return false
}
}
}
func runJobs(callback: (String) -> Void) -> Bool {
for job in jobs {
callback("Generating image \(job.version)")
guard generate(job: job) else {
return false
}
}
return true
}
func save() -> Bool {
storage.save(listOfGeneratedImages: generatedImages)
}
private func versionFileName(image: String, type: ImageType, width: CGFloat, height: CGFloat) -> String {
let fileName = image.fileNameAndExtension.fileName
let prefix = "\(fileName)@\(Int(width))x\(Int(height))"
return "\(prefix).\(type.fileExtension)"
}
func generateVersion(for image: String, type: ImageType, maximumWidth: CGFloat, maximumHeight: CGFloat) -> String {
let version = versionFileName(image: image, type: type, width: maximumWidth, height: maximumHeight)
let fullPath = "/" + relativeImageOutputPath + "/" + version
if exists(version) {
hasNowGenerated(version: version, for: image)
return fullPath
}
if hasPreviouslyGenerated(version: version, for: image), exists(version) {
// Don't add job again
return fullPath
}
let job = ImageJob(
image: image,
version: version,
maximumWidth: maximumWidth,
maximumHeight: maximumHeight,
quality: 0.7,
type: type)
jobs.append(job)
return fullPath
}
private func hasPreviouslyGenerated(version: String, for image: String) -> Bool {
guard let versions = generatedImages[image] else {
return false
}
return versions.contains(version)
}
private func hasNowGenerated(version: String, for image: String) {
guard var versions = generatedImages[image] else {
generatedImages[image] = [version]
return
}
versions.append(version)
generatedImages[image] = versions
}
private func removeVersions(for image: String) {
generatedImages[image] = nil
}
// MARK: Image operations
private func generate(job: ImageJob) -> Bool {
if hasPreviouslyGenerated(version: job.version, for: job.image), exists(job.version) {
return true
}
let inputPath = inputImageFolder.appendingPathComponent(job.image)
#warning("TODO: Read through security scope")
guard inputPath.exists else {
print("Missing image \(inputPath.path())")
return false
}
let data: Data
do {
data = try Data(contentsOf: inputPath)
} catch {
print("Failed to load image \(inputPath.path()): \(error)")
return false
}
guard let originalImage = NSImage(data: data) else {
print("Failed to load image")
return false
}
let sourceRep = originalImage.representations[0]
let sourceSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh)
let maximumSize = NSSize(width: job.maximumWidth, height: job.maximumHeight)
let destinationSize = sourceSize.scaledToFit(in: maximumSize)
// create NSBitmapRep manually, if using cgImage, the resulting size is wrong
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(destinationSize.width),
pixelsHigh: Int(destinationSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: NSColorSpaceName.deviceRGB,
bytesPerRow: Int(destinationSize.width) * 4,
bitsPerPixel: 32)!
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
originalImage.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height))
ctx?.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
guard let data = create(image: rep, type: job.type, quality: job.quality) else {
print("Failed to get data for type \(job.type)")
return false
}
let result = inOutputImagesFolder { folder in
let url = folder.appendingPathComponent(job.version)
if job.type == .avif {
let out = url.path()
let input = out.replacingOccurrences(of: ".avif", with: ".jpg")
print("avifenc -q 70 \(input) \(out)")
return true
}
do {
try data.write(to: url)
return true
} catch {
print("Failed to write image \(job.version): \(error)")
return false
}
}
guard result else {
return false
}
hasNowGenerated(version: job.version, for: job.image)
return true
}
private func exists(_ relativePath: String) -> Bool {
inOutputImagesFolder { folder in
folder.appendingPathComponent(relativePath).exists
}
}
private func inOutputImagesFolder(perform operation: (URL) -> Bool) -> Bool {
storage.write(in: .outputPath) { outputFolder in
let imagesFolder = outputFolder.appendingPathComponent(relativeImageOutputPath)
return operation(imagesFolder)
}
}
// MARK: Avif images
private func create(image: NSBitmapImageRep, type: ImageType, quality: CGFloat) -> Data? {
switch type {
case .jpg:
return image.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(value: 0.6)])
case .png:
return image.representation(using: .png, properties: [.compressionFactor: NSNumber(value: 0.6)])
case .avif:
return createAvif(image: image, quality: 0.7)
case .webp:
return createWebp(image: image, quality: 0.8)
case .gif:
return image.representation(using: .gif, properties: [.compressionFactor: NSNumber(value: quality)])
}
}
private func createAvif(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
return Data()
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageAVIFCoder.shared.encodedData(with: newImage, format: .AVIF, options: [.encodeCompressionQuality: quality])
}
private func createWebp(image: NSBitmapImageRep, quality: CGFloat) -> Data? {
let newImage = NSImage(size: image.size)
newImage.addRepresentation(image)
return SDImageWebPCoder.shared.encodedData(with: newImage, format: .webP, options: [.encodeCompressionQuality: quality])
}
}

View File

@ -1,49 +0,0 @@
import Foundation
enum FileType {
case image(ImageType)
case file
case video
case resource
init(fileExtension: String) {
switch fileExtension.lowercased() {
case "jpg", "jpeg":
self = .image(.jpg)
case "png":
self = .image(.png)
case "avif":
self = .image(.avif)
case "webp":
self = .image(.webp)
case "gif":
self = .image(.gif)
case "html", "stl", "f3d", "step", "f3z", "zip", "json", "conf", "css", "js", "cpp", "cddx", "svg", "glb", "mp3", "pdf", "swift":
self = .file
case "mp4":
self = .video
case "key", "psd":
self = .resource
default:
print("Unhandled file type: \(fileExtension)")
self = .resource
}
}
var fileExtension: String {
switch self {
case .image(let imageType): return imageType.fileExtension
default:
return "" // TODO: Fix
}
}
var isImage: Bool {
if case .image = self {
return true
}
return false
}
}

View File

@ -0,0 +1,13 @@
struct ImageDescriptions {
let imageId: String
let german: String?
let english: String?
}
extension ImageDescriptions: Codable {
}

View File

@ -154,7 +154,7 @@ final class Storage {
let contentUrl = pageContentUrl(pageId: pageId, language: language)
guard fm.fileExists(atPath: contentUrl.path()) else {
print("No file at \(contentUrl.path())")
return "New file"
return ""
}
do {
return try String(contentsOf: contentUrl, encoding: .utf8)
@ -235,6 +235,34 @@ final class Storage {
// MARK: Files
private var imageDescriptionFilename: String {
"image-descriptions.json"
}
private var imageDescriptionUrl: URL {
baseFolder.appending(path: "image-descriptions.json")
}
func loadImageDescriptions() -> [ImageDescriptions] {
do {
return try read(relativePath: imageDescriptionFilename)
} catch {
print("Failed to read image descriptions: \(error)")
return []
}
}
@discardableResult
func save(imageDescriptions: [ImageDescriptions]) -> Bool {
do {
try writeIfChanged(imageDescriptions, to: imageDescriptionFilename)
return true
} catch {
print("Failed to write image descriptions: \(error)")
return false
}
}
/// The folder path where other files are stored (by their unique name)
var filesFolder: URL { subFolder("files") }
@ -251,6 +279,26 @@ final class Storage {
return copy(file: url, to: contentUrl, type: "file", id: fileId)
}
func copy(file fileId: String, to relativeOutputPath: String) -> Bool {
do {
try operate(in: .contentPath) { contentPath in
try operate(in: .outputPath) { outputPath in
let output = outputPath.appending(path: relativeOutputPath, directoryHint: .notDirectory)
if output.exists {
return
}
let input = contentPath.appending(path: "files/\(fileId)", directoryHint: .notDirectory)
try output.ensureParentFolderExistence()
try FileManager.default.copyItem(at: input, to: output)
}
}
return true
} catch {
print("Failed to copy file \(fileId) to output folder: \(error)")
return false
}
}
func loadAllFiles() throws -> [String : URL] {
try files(in: filesFolder).reduce(into: [:]) { files, url in
files[url.lastPathComponent] = url
@ -365,6 +413,30 @@ final class Storage {
}
}
private func writeIfChanged<T>(_ value: T, to relativePath: String) throws where T: Encodable {
try operate(in: .contentPath) { contentPath in
let url = contentPath.appending(path: relativePath, directoryHint: .notDirectory)
let data = try encoder.encode(value)
if fm.fileExists(atPath: url.path()) {
// Check if content is the same, to prevent unnecessary writes
do {
let oldData = try Data(contentsOf: url)
if data == oldData {
// File is the same, don't write
return
}
} catch {
print("Failed to read file \(url.path()) for equality check: \(error)")
// No check possible, write file
}
} else {
print("Writing new file \(url.path())")
}
try data.write(to: url)
print("Saved file \(url.path())")
}
}
/**
Encode a value and write it to a file, if the content changed
*/
@ -426,6 +498,14 @@ final class Storage {
return write(data: data, type: type, id: id, to: file)
}
private func read<T>(relativePath: String) throws -> T where T: Decodable {
try operate(in: .contentPath) { baseFolder in
let url = baseFolder.appending(path: relativePath, directoryHint: .notDirectory)
let data = try Data(contentsOf: url)
return try decoder.decode(T.self, from: data)
}
}
private func read<T>(at url: URL) throws -> T where T: Decodable {
let data = try Data(contentsOf: url)
return try decoder.decode(T.self, from: data)

View File

@ -1,174 +0,0 @@
import Foundation
final class WebsiteGenerator {
let language: ContentLanguage
let localizedSettings: LocalizedSettings
private var outputDirectory: URL {
URL(filePath: content.settings.outputDirectoryPath)
}
private var postsPerPage: Int {
content.settings.posts.postsPerPage
}
private var postFeedTitle: String {
localizedSettings.posts.title
}
private var postFeedDescription: String {
localizedSettings.posts.description
}
private var postFeedUrlPrefix: String {
localizedSettings.posts.feedUrlPrefix
}
private var navigationIconPath: String {
content.settings.navigationBar.iconPath
}
private var mainContentMaximumWidth: CGFloat {
content.settings.posts.contentWidth
}
private let content: Content
private let imageGenerator: ImageGenerator
init(content: Content, language: ContentLanguage) {
self.language = language
self.content = content
self.localizedSettings = content.settings.localized(in: language)
self.imageGenerator = ImageGenerator(
storage: content.storage,
inputImageFolder: content.storage.filesFolder,
relativeImageOutputPath: "images")
}
func generateWebsite(callback: (String) -> Void) -> Bool {
guard imageGenerator.prepareForGeneration() else {
return false
}
guard createPostFeedPages() else {
return false
}
guard imageGenerator.runJobs(callback: callback) else {
return false
}
return imageGenerator.save()
}
private func createPostFeedPages() -> Bool {
let totalCount = content.posts.count
guard totalCount > 0 else {
return true
}
let navBarData = createNavigationBarData(
settings: content.settings.navigationBar,
iconDescription: localizedSettings.navigationBarIconDescription)
let numberOfPages = (totalCount + postsPerPage - 1) / postsPerPage // Round up
for pageIndex in 1...numberOfPages {
let startIndex = (pageIndex - 1) * postsPerPage
let endIndex = min(pageIndex * postsPerPage, totalCount)
let postsOnPage = content.posts[startIndex..<endIndex]
guard createPostFeedPage(pageIndex, pageCount: numberOfPages, posts: postsOnPage, bar: navBarData) else {
return false
}
}
return true
}
private func createNavigationBarData(settings: NavigationBarSettings, iconDescription: String) -> NavigationBarData {
let navigationItems: [NavigationBarLink] = settings.tags.map {
let localized = $0.localized(in: language)
return .init(text: localized.name, url: localized.urlComponent)
}
return NavigationBarData(
navigationIconPath: navigationIconPath,
iconDescription: iconDescription,
navigationItems: navigationItems)
}
private func createImageSet(for image: ImageResource) -> FeedEntryData.Image {
let size1x = mainContentMaximumWidth
let size2x = mainContentMaximumWidth * 2
let avif1x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size1x, maximumHeight: size1x)
let avif2x = imageGenerator.generateVersion(for: image.id, type: .avif, maximumWidth: size2x, maximumHeight: size2x)
let webp1x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size1x, maximumHeight: size1x)
let webp2x = imageGenerator.generateVersion(for: image.id, type: .webp, maximumWidth: size2x, maximumHeight: size2x)
let jpg1x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size1x, maximumHeight: size1x)
let jpg2x = imageGenerator.generateVersion(for: image.id, type: .jpg, maximumWidth: size2x, maximumHeight: size2x)
return FeedEntryData.Image(
altText: image.altText.getText(for: language),
avif1x: avif1x,
avif2x: avif2x,
webp1x: webp1x,
webp2x: webp2x,
jpg1x: jpg1x,
jpg2x: jpg2x)
}
private func createPostFeedPage(_ pageIndex: Int, pageCount: Int, posts: ArraySlice<Post>, bar: NavigationBarData) -> Bool {
let posts: [FeedEntryData] = posts.map { post in
let localized: LocalizedPost = post.localized(in: language)
let linkUrl = post.linkedPage.map {
FeedEntryData.Link(url: $0.localized(in: language).relativeUrl, text: "View")
}
return FeedEntryData(
entryId: "\(post.id)",
title: localized.title,
textAboveTitle: post.dateText(in: language),
link: linkUrl,
tags: post.tags.map { $0.data(in: language) },
text: [localized.content], // TODO: Convert from markdown to html
images: localized.images.map(createImageSet))
}
let feed = PageInFeed(
language: language,
title: postFeedTitle,
description: postFeedDescription,
navigationBarData: bar,
pageNumber: pageIndex,
totalPages: pageCount,
posts: posts)
let fileContent = feed.content
if pageIndex == 1 {
return save(fileContent, to: "\(postFeedUrlPrefix).html")
} else {
return save(fileContent, to: "\(postFeedUrlPrefix)-\(pageIndex).html")
}
}
private func save(_ content: String, to relativePath: String) -> Bool {
guard let data = content.data(using: .utf8) else {
print("Failed to create data for \(relativePath)")
return false
}
return save(data, to: relativePath)
}
private func save(_ data: Data, to relativePath: String) -> Bool {
self.content.storage.write(in: .outputPath) { folder in
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: false)
do {
try data.write(to: outputFile)
return true
} catch {
print("Failed to save \(outputFile.path()): \(error)")
return false
}
}
}
}