Generate pages, image descriptions
This commit is contained in:
18
CHDataManagement/Generator/GenerationResultsHandler.swift
Normal file
18
CHDataManagement/Generator/GenerationResultsHandler.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
final class GenerationResultsHandler {
|
||||
|
||||
var requiredVideoFiles: Set<String> = []
|
||||
|
||||
/// Generic warnings for pages
|
||||
private var pageWarnings: [(message: String, source: String)] = []
|
||||
|
||||
func warning(_ message: String, page: Page) {
|
||||
pageWarnings.append((message, page.id))
|
||||
print("Page: \(page.id): \(message)")
|
||||
}
|
||||
|
||||
func addRequiredVideoFile(fileId: String) {
|
||||
requiredVideoFiles.insert(fileId)
|
||||
}
|
||||
}
|
261
CHDataManagement/Generator/ImageGenerator.swift
Normal file
261
CHDataManagement/Generator/ImageGenerator.swift
Normal file
@ -0,0 +1,261 @@
|
||||
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 {
|
||||
print("Generating \(jobs.count) images...")
|
||||
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 generateImageSet(for image: String, maxWidth: CGFloat, maxHeight: CGFloat, altText: String) -> FeedEntryData.Image {
|
||||
let type = ImageType(fileExtension: image.fileExtension!)!
|
||||
|
||||
let width2x = maxWidth * 2
|
||||
let height2x = maxHeight * 2
|
||||
|
||||
_ = generateVersion(for: image, type: .avif, maximumWidth: maxWidth, maximumHeight: maxHeight)
|
||||
_ = generateVersion(for: image, type: .avif, maximumWidth: width2x, maximumHeight: height2x)
|
||||
|
||||
_ = generateVersion(for: image, type: .webp, maximumWidth: maxWidth, maximumHeight: maxHeight)
|
||||
_ = generateVersion(for: image, type: .webp, maximumWidth: width2x, maximumHeight: height2x)
|
||||
|
||||
_ = generateVersion(for: image, type: type, maximumWidth: maxWidth, maximumHeight: maxHeight)
|
||||
_ = generateVersion(for: image, type: type, maximumWidth: width2x, maximumHeight: height2x)
|
||||
|
||||
let path = "/" + relativeImageOutputPath + "/" + image
|
||||
return .init(rawImagePath: path,
|
||||
width: Int(maxWidth),
|
||||
height: Int(maxHeight),
|
||||
altText: altText)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
445
CHDataManagement/Generator/PageContentGenerator.swift
Normal file
445
CHDataManagement/Generator/PageContentGenerator.swift
Normal file
@ -0,0 +1,445 @@
|
||||
import Foundation
|
||||
import Ink
|
||||
import Splash
|
||||
|
||||
typealias VideoSource = (url: String, type: VideoType)
|
||||
|
||||
final class PageContentParser {
|
||||
|
||||
private let pageLinkMarker = "page:"
|
||||
|
||||
private let largeImageIndicator = "*large*"
|
||||
|
||||
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
||||
|
||||
private let results: GenerationResultsHandler
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let imageGenerator: ImageGenerator
|
||||
|
||||
private let page: Page
|
||||
|
||||
private let language: ContentLanguage
|
||||
|
||||
private var largeImageCount: Int = 0
|
||||
|
||||
init(page: Page, content: Content, language: ContentLanguage, results: GenerationResultsHandler, imageGenerator: ImageGenerator) {
|
||||
self.page = page
|
||||
self.content = content
|
||||
self.language = language
|
||||
self.results = results
|
||||
self.imageGenerator = imageGenerator
|
||||
}
|
||||
|
||||
func generatePage(from content: String) -> String {
|
||||
|
||||
let imageModifier = Modifier(target: .images) { html, markdown in
|
||||
self.processMarkdownImage(markdown: markdown, html: html)
|
||||
}
|
||||
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
|
||||
if markdown.starts(with: "```swift") {
|
||||
let code = markdown.between("```swift", and: "```").trimmed
|
||||
return "<pre><code>" + self.swift.highlight(code) + "</pre></code>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
let linkModifier = Modifier(target: .links) { html, markdown in
|
||||
self.handleLink(html: html, markdown: markdown)
|
||||
}
|
||||
let htmlModifier = Modifier(target: .html) { html, markdown in
|
||||
self.handleHTML(html: html, markdown: markdown)
|
||||
}
|
||||
let headlinesModifier = Modifier(target: .headings) { html, markdown in
|
||||
self.handleHeadlines(html: html, markdown: markdown)
|
||||
}
|
||||
|
||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier, headlinesModifier])
|
||||
return parser.html(from: content)
|
||||
}
|
||||
|
||||
private func handleLink(html: String, markdown: Substring) -> String {
|
||||
let file = markdown.between("(", and: ")")
|
||||
if file.hasPrefix(pageLinkMarker) {
|
||||
// Retain links pointing to elements within a page
|
||||
let textToChange = file.dropAfterFirst("#")
|
||||
let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "")
|
||||
guard let pagePath = content.pageLink(pageId: pageId, language: language) else {
|
||||
// Remove link since the page can't be found
|
||||
return markdown.between("[", and: "]")
|
||||
}
|
||||
// Adjust file path to get the page url
|
||||
// TODO: Calculate relative links to make pages more portable
|
||||
return html.replacingOccurrences(of: textToChange, with: pagePath)
|
||||
}
|
||||
|
||||
// TODO: Check that linked file exists
|
||||
// if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) {
|
||||
// // The target of the page link must be present after generation is complete
|
||||
// results.expect(file: filePath, source: page.path)
|
||||
// }
|
||||
return html
|
||||
}
|
||||
|
||||
private func handleHTML(html: String, markdown: Substring) -> String {
|
||||
// TODO: Check HTML code in markdown for required resources
|
||||
//print("[HTML] Found in page \(page.path):")
|
||||
//print(markdown)
|
||||
// Things to check:
|
||||
// <img src=
|
||||
// <a href=
|
||||
//
|
||||
return html
|
||||
}
|
||||
|
||||
private func handleHeadlines(html: String, markdown: Substring) -> String {
|
||||
let id = markdown
|
||||
.last(after: "#")
|
||||
.trimmed
|
||||
.filter { $0.isNumber || $0.isLetter || $0 == " " }
|
||||
.lowercased()
|
||||
.components(separatedBy: " ")
|
||||
.filter { $0 != "" }
|
||||
.joined(separator: "-")
|
||||
let parts = html.components(separatedBy: ">")
|
||||
return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">")
|
||||
}
|
||||
|
||||
private func processMarkdownImage(markdown: Substring, html: String) -> String {
|
||||
// First, check the content type, then parse the remaining arguments
|
||||
// Notation:
|
||||
// <abc?> -> Optional argument
|
||||
// <abc...> -> Repeated argument (0 or more)
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
guard let argumentList = markdown.between(first: "](", andLast: ")").removingPercentEncoding else {
|
||||
results.warning("Invalid percent encoding for markdown image", page: page)
|
||||
return ""
|
||||
}
|
||||
let arguments = argumentList.components(separatedBy: ";")
|
||||
|
||||
let rawCommand = markdown.between("![", and: "]").trimmed
|
||||
guard rawCommand != "" else {
|
||||
return handleImage(arguments)
|
||||
}
|
||||
|
||||
guard let convertedCommand = rawCommand.removingPercentEncoding,
|
||||
let command = ShorthandMarkdownKey(rawValue: convertedCommand) else {
|
||||
// Treat unknown commands as normal links
|
||||
print("Unknown markdown command: \(rawCommand)")
|
||||
return html
|
||||
}
|
||||
|
||||
switch command {
|
||||
case .image:
|
||||
return handleImage(arguments)
|
||||
case .hikingStatistics:
|
||||
return handleHikingStatistics(arguments)
|
||||
case .downloadButtons:
|
||||
return handleDownloadButtons(arguments)
|
||||
case .video:
|
||||
return handleVideo(arguments)
|
||||
default:
|
||||
print("Unhandled markdown command: \(command)")
|
||||
return ""
|
||||
/*
|
||||
case .externalLink:
|
||||
return handleExternalButtons(content: content)
|
||||
case .includedHtml:
|
||||
return handleExternalHTML(file: content)
|
||||
case .box:
|
||||
return handleSimpleBox(content: content)
|
||||
case .pageLink:
|
||||
return handlePageLink(pageId: content)
|
||||
case .model:
|
||||
return handle3dModel(content: content)
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImage(_ arguments: [String]) -> String {
|
||||
// [image](<imageId>;<caption?>]
|
||||
guard (1...2).contains(arguments.count) else {
|
||||
results.warning("Invalid image arguments: \(arguments)", page: page)
|
||||
return ""
|
||||
}
|
||||
let imageId = arguments[0]
|
||||
|
||||
guard let image = content.image(imageId) else {
|
||||
results.warning("Missing image \(imageId)", page: page)
|
||||
return ""
|
||||
}
|
||||
let caption = arguments.count == 2 ? arguments[1] : nil
|
||||
let altText = image.getDescription(for: language)
|
||||
|
||||
let thumbnailWidth = CGFloat(content.settings.pages.contentWidth)
|
||||
let thumbnail = imageGenerator.generateImageSet(
|
||||
for: imageId,
|
||||
maxWidth: thumbnailWidth, maxHeight: thumbnailWidth,
|
||||
altText: altText)
|
||||
|
||||
let largeImageWidth = CGFloat(1200) // TODO: Move to settings
|
||||
|
||||
let largeImage = imageGenerator.generateImageSet(
|
||||
for: imageId,
|
||||
maxWidth: largeImageWidth, maxHeight: largeImageWidth,
|
||||
altText: altText)
|
||||
|
||||
return PageImage(
|
||||
imageId: imageId.replacingOccurrences(of: ".", with: "-"),
|
||||
thumbnail: thumbnail,
|
||||
largeImage: largeImage,
|
||||
caption: caption).content
|
||||
}
|
||||
|
||||
private func handleHikingStatistics(_ arguments: [String]) -> String {
|
||||
guard (1...5).contains(arguments.count) else {
|
||||
results.warning("Invalid hiking statistic arguments: \(arguments)", page: page)
|
||||
return ""
|
||||
}
|
||||
|
||||
let time = arguments[0].trimmed
|
||||
let elevationUp = arguments.count > 1 ? arguments[1].trimmed : nil
|
||||
let elevationDown = arguments.count > 2 ? arguments[2].trimmed : nil
|
||||
let distance = arguments.count > 3 ? arguments[3].trimmed : nil
|
||||
let calories = arguments.count > 4 ? arguments[4].trimmed : nil
|
||||
|
||||
return HikingStatistics(
|
||||
time: time,
|
||||
elevationUp: elevationUp,
|
||||
elevationDown: elevationDown,
|
||||
distance: distance,
|
||||
calories: calories)
|
||||
.content
|
||||
}
|
||||
|
||||
private func handleDownloadButtons(_ arguments: [String]) -> String {
|
||||
let buttons: [DownloadButtons.Item] = arguments.compactMap { button in
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard (2...3).contains(parts.count) else {
|
||||
results.warning("Invalid download definition with \(parts)", page: page)
|
||||
return nil
|
||||
}
|
||||
let file = parts[0].trimmed
|
||||
let title = parts[1].trimmed
|
||||
let downloadName = parts.count > 2 ? parts[2].trimmed : nil
|
||||
|
||||
// Ensure that file is available
|
||||
guard let filePath = content.pathToFile(file) else {
|
||||
results.warning("Missing download file \(file)", page: page)
|
||||
return nil
|
||||
}
|
||||
|
||||
return DownloadButtons.Item(filePath: filePath, text: title, downloadFileName: downloadName)
|
||||
}
|
||||
return DownloadButtons(items: buttons).content
|
||||
}
|
||||
|
||||
private func handleVideo(_ arguments: [String]) -> String {
|
||||
guard arguments.count >= 1 else {
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0].trimmed
|
||||
|
||||
let options: [VideoOption] = arguments.dropFirst().compactMap { optionText in
|
||||
guard let optionText = optionText.trimmed.nonEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let option = VideoOption(rawValue: optionText) else {
|
||||
results.warning("Unknown video option \(optionText)", page: page)
|
||||
return nil
|
||||
}
|
||||
return option
|
||||
}
|
||||
|
||||
guard let filePath = content.pathToFile(fileId),
|
||||
let file = content.file(id: fileId) else {
|
||||
results.warning("Missing video file \(fileId)", page: page)
|
||||
return ""
|
||||
}
|
||||
guard let videoType = file.type.videoType?.htmlType else {
|
||||
results.warning("Unknown video file type for \(fileId)", page: page)
|
||||
return ""
|
||||
}
|
||||
|
||||
results.addRequiredVideoFile(fileId: fileId)
|
||||
|
||||
return ContentPageVideo(
|
||||
filePath: filePath,
|
||||
videoType: videoType,
|
||||
options: options)
|
||||
.content
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
private func handleGif(file: String, altText: String) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
results.require(file: imagePath, source: page.path)
|
||||
|
||||
guard let size = results.getImageSize(atPath: imagePath, source: page.path) else {
|
||||
return ""
|
||||
}
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
return factory.html.image(file: file, width: width, height: height, altText: altText)
|
||||
}
|
||||
|
||||
private func handleSvg(file: String, area: String?) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
results.require(file: imagePath, source: page.path)
|
||||
|
||||
guard let size = results.getImageSize(atPath: imagePath, source: page.path) else {
|
||||
return "" // Missing image warning already produced
|
||||
}
|
||||
let width = Int(size.width)
|
||||
let height = Int(size.height)
|
||||
|
||||
var altText = "image " + file.lastComponentAfter("/")
|
||||
guard let area = area else {
|
||||
return factory.html.image(file: file, width: width, height: height, altText: altText)
|
||||
}
|
||||
let parts = area.components(separatedBy: ",").map { $0.trimmed }
|
||||
switch parts.count {
|
||||
case 1:
|
||||
return factory.html.image(file: file, width: width, height: height, altText: parts[0])
|
||||
case 4:
|
||||
break
|
||||
case 5:
|
||||
altText = parts[4]
|
||||
default:
|
||||
results.warning("Invalid area string for svg image", source: page.path)
|
||||
return factory.html.image(file: file, width: width, height: height, altText: altText)
|
||||
}
|
||||
guard let x = Int(parts[0]),
|
||||
let y = Int(parts[1]),
|
||||
let partWidth = Int(parts[2]),
|
||||
let partHeight = Int(parts[3]) else {
|
||||
results.warning("Invalid area string for svg image", source: page.path)
|
||||
return factory.html.image(file: file, width: width, height: height, altText: altText)
|
||||
}
|
||||
let part = SVGSelection(x, y, partWidth, partHeight)
|
||||
return factory.html.svgImage(file: file, part: part, altText: altText)
|
||||
}
|
||||
|
||||
private func handleFile(file: String, fileExtension: String) -> String {
|
||||
results.warning("Unhandled file \(file) with extension \(fileExtension)", source: page.path)
|
||||
return ""
|
||||
}
|
||||
|
||||
private func handleExternalButtons(content: String) -> String {
|
||||
let buttons = content
|
||||
.components(separatedBy: ";")
|
||||
.compactMap { button -> (url: String, text: String)? in
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard parts.count == 2 else {
|
||||
results.warning("Invalid external link definition", page: page)
|
||||
return nil
|
||||
}
|
||||
guard let url = parts[0].trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||
results.warning("Invalid external link \(parts[0].trimmed)", source: page.path)
|
||||
return nil
|
||||
}
|
||||
let title = parts[1].trimmed
|
||||
|
||||
return (url, title)
|
||||
}
|
||||
return factory.html.externalButtons(buttons)
|
||||
}
|
||||
|
||||
private func handleExternalHTML(file: String) -> String {
|
||||
let path = page.pathRelativeToRootForContainedInputFile(file)
|
||||
return results.getContentOfRequiredFile(at: path, source: page.path) ?? ""
|
||||
}
|
||||
|
||||
private func handleSimpleBox(content: String) -> String {
|
||||
let parts = content.components(separatedBy: ";")
|
||||
guard parts.count > 1 else {
|
||||
results.warning("Invalid box specification", page: page)
|
||||
return ""
|
||||
}
|
||||
let title = parts[0]
|
||||
let text = parts.dropFirst().joined(separator: ";")
|
||||
return factory.makePlaceholder(title: title, text: text)
|
||||
}
|
||||
|
||||
private func handlePageLink(pageId: String) -> String {
|
||||
guard let linkedPage = siteRoot.find(pageId) else {
|
||||
// Checking the page path will add it to the missing pages
|
||||
_ = results.getPagePath(for: pageId, source: page.path, language: language)
|
||||
// Remove link since the page can't be found
|
||||
return ""
|
||||
}
|
||||
guard linkedPage.state == .standard else {
|
||||
// Prevent linking to unpublished content
|
||||
return ""
|
||||
}
|
||||
var content = [PageLinkTemplate.Key: String]()
|
||||
|
||||
content[.title] = linkedPage.title(for: language)
|
||||
content[.altText] = ""
|
||||
|
||||
let fullThumbnailPath = linkedPage.thumbnailFilePath(for: language).destination
|
||||
// Note: Here we assume that the thumbnail was already used elsewhere, so already generated
|
||||
let relativeImageUrl = page.relativePathToOtherSiteElement(file: fullThumbnailPath)
|
||||
let metadata = linkedPage.localized(for: language)
|
||||
|
||||
if linkedPage.state.hasThumbnailLink {
|
||||
let fullPageUrl = linkedPage.fullPageUrl(for: language)
|
||||
let relativePageUrl = page.relativePathToOtherSiteElement(file: fullPageUrl)
|
||||
content[.url] = "href=\"\(relativePageUrl)\""
|
||||
}
|
||||
|
||||
content[.image] = relativeImageUrl.dropAfterLast(".")
|
||||
if let suffix = metadata.thumbnailSuffix {
|
||||
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
|
||||
} else {
|
||||
content[.title] = metadata.title
|
||||
}
|
||||
|
||||
let path = linkedPage.makePath(language: language, from: siteRoot)
|
||||
content[.path] = factory.pageLink.makePath(components: path)
|
||||
|
||||
content[.description] = metadata.relatedContentText
|
||||
if let parent = linkedPage.findParent(from: siteRoot), parent.thumbnailStyle == .large {
|
||||
content[.className] = " related-page-link-large"
|
||||
}
|
||||
|
||||
// We assume that the thumbnail images are already required by overview pages.
|
||||
return factory.pageLink.generate(content)
|
||||
}
|
||||
|
||||
private func handle3dModel(content: String) -> String {
|
||||
let parts = content.components(separatedBy: ";")
|
||||
guard parts.count > 1 else {
|
||||
results.warning("Invalid 3d model specification", page: page)
|
||||
return ""
|
||||
}
|
||||
let file = parts[0]
|
||||
guard file.hasSuffix(".glb") else {
|
||||
results.warning("Invalid 3d model file \(file) (must be .glb)", page: page)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Ensure that file is available
|
||||
let filePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
results.require(file: filePath, source: page.path)
|
||||
|
||||
// Add required file to head
|
||||
headers.insert(.modelViewer)
|
||||
|
||||
let description = parts.dropFirst().joined(separator: ";")
|
||||
return """
|
||||
<model-viewer alt="\(description)" src="\(file)" ar shadow-intensity="1" camera-controls touch-action="pan-y"></model-viewer>
|
||||
"""
|
||||
}
|
||||
*/
|
||||
}
|
43
CHDataManagement/Generator/PageGenerator.swift
Normal file
43
CHDataManagement/Generator/PageGenerator.swift
Normal file
@ -0,0 +1,43 @@
|
||||
final class PageGenerator {
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let imageGenerator: ImageGenerator
|
||||
|
||||
private let navigationBarData: NavigationBarData
|
||||
|
||||
let results = GenerationResultsHandler()
|
||||
|
||||
init(content: Content, imageGenerator: ImageGenerator, navigationBarData: NavigationBarData) {
|
||||
self.content = content
|
||||
self.imageGenerator = imageGenerator
|
||||
self.navigationBarData = navigationBarData
|
||||
}
|
||||
|
||||
func generate(page: Page, language: ContentLanguage) -> String {
|
||||
let contentGenerator = PageContentParser(
|
||||
page: page,
|
||||
content: content,
|
||||
language: language,
|
||||
results: results,
|
||||
imageGenerator: imageGenerator)
|
||||
|
||||
let rawPageContent = content.storage.pageContent(for: page.id, language: language)
|
||||
|
||||
let pageContent = contentGenerator.generatePage(from: rawPageContent)
|
||||
|
||||
let localized = page.localized(in: language)
|
||||
|
||||
return ContentPage(
|
||||
language: language,
|
||||
dateString: page.dateText(in: language),
|
||||
title: localized.title,
|
||||
tags: page.tags.map { $0.data(in: language) },
|
||||
linkTitle: localized.linkPreviewTitle ?? localized.title,
|
||||
description: localized.linkPreviewDescription ?? "",
|
||||
navigationBarData: navigationBarData,
|
||||
pageContent: pageContent)
|
||||
.content
|
||||
}
|
||||
|
||||
}
|
51
CHDataManagement/Generator/ShorthandMarkdownKey.swift
Normal file
51
CHDataManagement/Generator/ShorthandMarkdownKey.swift
Normal file
@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A string key used in markdown to indicate special elements
|
||||
*/
|
||||
enum ShorthandMarkdownKey: String {
|
||||
|
||||
/// A standard url
|
||||
/// Format: ``
|
||||
case url
|
||||
|
||||
/// An image
|
||||
/// Format: ``
|
||||
|
||||
/// A variable number of download buttons for file downloads
|
||||
/// Format: `[download](<<fileId>,<text>,<download-filename?>;...)`
|
||||
case downloadButtons = "download"
|
||||
|
||||
/// A box with a title and content
|
||||
/// Format: ``
|
||||
case box
|
||||
|
||||
/// A 3D model to display
|
||||
/// Format: ``
|
||||
case model
|
||||
|
||||
/// A pretty link to another page on the site.
|
||||
/// Format: ``
|
||||
case pageLink = "page"
|
||||
|
||||
/// A large button to an external page.
|
||||
/// Format: ``
|
||||
case includedHtml = "html"
|
||||
|
||||
}
|
11
CHDataManagement/Generator/VideoOption.swift
Normal file
11
CHDataManagement/Generator/VideoOption.swift
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
/// HTML video options
|
||||
enum VideoOption: String {
|
||||
case controls
|
||||
case autoplay
|
||||
case muted
|
||||
case loop
|
||||
case playsinline
|
||||
case poster
|
||||
case preload
|
||||
}
|
213
CHDataManagement/Generator/WebsiteGenerator.swift
Normal file
213
CHDataManagement/Generator/WebsiteGenerator.swift
Normal file
@ -0,0 +1,213 @@
|
||||
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 {
|
||||
CGFloat(content.settings.posts.contentWidth)
|
||||
}
|
||||
|
||||
private let content: Content
|
||||
|
||||
private let imageGenerator: ImageGenerator
|
||||
|
||||
private var navigationBarData: NavigationBarData {
|
||||
createNavigationBarData(
|
||||
settings: content.settings.navigationBar,
|
||||
iconDescription: localizedSettings.navigationBarIconDescription)
|
||||
}
|
||||
|
||||
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 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: navigationBarData) 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 {
|
||||
imageGenerator.generateImageSet(
|
||||
for: image.id,
|
||||
maxWidth: mainContentMaximumWidth,
|
||||
maxHeight: mainContentMaximumWidth,
|
||||
altText: image.getDescription(for: language))
|
||||
}
|
||||
|
||||
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: content.pageLink($0, language: language),
|
||||
text: language == .english ? "View" : "Anzeigen") // TODO: Add to settings
|
||||
}
|
||||
|
||||
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 generatePagesFolderIfNeeded() -> Bool {
|
||||
let relativePath = content.settings.pages.pageUrlPrefix
|
||||
|
||||
return content.storage.write(in: .outputPath) { folder in
|
||||
let outputFile = folder.appendingPathComponent(relativePath, isDirectory: true)
|
||||
do {
|
||||
try outputFile.ensureFolderExistence()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generate(page: Page) -> Bool {
|
||||
guard generatePagesFolderIfNeeded() else {
|
||||
print("Failed to generate output folder")
|
||||
return false
|
||||
}
|
||||
let pageGenerator = PageGenerator(content: content, imageGenerator: imageGenerator, navigationBarData: navigationBarData)
|
||||
let content = pageGenerator.generate(page: page, language: language)
|
||||
|
||||
let path = self.content.pageLink(page, language: language) + ".html"
|
||||
guard save(content, to: path) else {
|
||||
print("Failed to save page")
|
||||
return false
|
||||
}
|
||||
guard imageGenerator.runJobs(callback: { _ in }) else {
|
||||
return false
|
||||
}
|
||||
guard copy(requiredVideoFiles: pageGenerator.results.requiredVideoFiles) else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func copy(requiredVideoFiles: Set<String>) -> Bool {
|
||||
print("Copying \(requiredVideoFiles.count) videos...")
|
||||
for fileId in requiredVideoFiles {
|
||||
guard let outputPath = content.pathToFile(fileId) else {
|
||||
return false
|
||||
}
|
||||
guard content.storage.copy(file: fileId, to: outputPath) else {
|
||||
print("Failed to copy video file to output folder")
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user