From a444c5169743a876b0c42b97047a4f58c77d14c6 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Wed, 17 Aug 2022 10:36:21 +0200 Subject: [PATCH] Improve video and image handling in markdown --- WebsiteGenerator.xcodeproj/project.pbxproj | 12 ++ WebsiteGenerator/FileProcessor.swift | 51 ++++++- .../Generators/MarkdownProcessor.swift | 142 +++++++++--------- .../Generators/PageGenerator.swift | 3 +- .../Elements/PageImageTemplate.swift | 18 +++ .../Elements/PageVideoTemplate.swift | 37 +++++ .../Templates/TemplateFactory.swift | 6 + WebsiteGenerator/VideoType.swift | 12 ++ 8 files changed, 198 insertions(+), 83 deletions(-) create mode 100644 WebsiteGenerator/Templates/Elements/PageImageTemplate.swift create mode 100644 WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift create mode 100644 WebsiteGenerator/VideoType.swift diff --git a/WebsiteGenerator.xcodeproj/project.pbxproj b/WebsiteGenerator.xcodeproj/project.pbxproj index 88bf9ec..2dd4701 100644 --- a/WebsiteGenerator.xcodeproj/project.pbxproj +++ b/WebsiteGenerator.xcodeproj/project.pbxproj @@ -51,6 +51,9 @@ E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */; }; E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */; }; E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; }; + E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */; }; + E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */; }; + E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2728ACD84400632026 /* VideoType.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -110,6 +113,9 @@ E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = ""; }; E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = ""; }; E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = ""; }; + E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = ""; }; + E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = ""; }; + E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -151,6 +157,7 @@ E22E876B289D855D00E51191 /* ThumbnailStyle.swift */, E22E8797289EA42C00E51191 /* FileProcessor.swift */, E22E8777289DA0E100E51191 /* GenerationError.swift */, + E2F8FA2728ACD84400632026 /* VideoType.swift */, E22E8794289E81D700E51191 /* FileSystem.swift */, ); path = WebsiteGenerator; @@ -224,6 +231,8 @@ E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */, E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */, E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */, + E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */, + E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */, ); path = Elements; sourceTree = ""; @@ -319,6 +328,7 @@ E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */, E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */, E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */, + E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */, E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */, E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */, E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */, @@ -340,6 +350,7 @@ E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */, E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */, E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */, + E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */, E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */, E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */, E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */, @@ -352,6 +363,7 @@ E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */, E22E877A289DA9F900E51191 /* Site.swift in Sources */, E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */, + E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */, E22E8787289DDF4C00E51191 /* Page.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/WebsiteGenerator/FileProcessor.swift b/WebsiteGenerator/FileProcessor.swift index 11e0c4b..93b9925 100644 --- a/WebsiteGenerator/FileProcessor.swift +++ b/WebsiteGenerator/FileProcessor.swift @@ -5,6 +5,32 @@ import AppKit 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 = [ + "mp4", "mov" + ] + struct ImageOutput: Hashable { let source: String @@ -89,7 +115,10 @@ final class FileProcessor { throw GenerationError.missingImage(sourceUrl.path) } guard let imageSize = NSImage(contentsOfFile: sourceUrl.path)?.size else { - throw GenerationError.failedToGenerateImage(sourceUrl.path) + 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)) @@ -146,8 +175,10 @@ final class FileProcessor { return } - // Just copy SVG files - guard destination.pathExtension.lowercased() != "svg" else { + // 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 } @@ -166,6 +197,7 @@ final class FileProcessor { 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) } @@ -213,16 +245,21 @@ final class FileProcessor { } 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 jpg data for image \(url.path)") + print("Failed to get 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)") + 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 jpgData.createFolderAndWrite(to: url) + try data.createFolderAndWrite(to: url) } #endif } diff --git a/WebsiteGenerator/Generators/MarkdownProcessor.swift b/WebsiteGenerator/Generators/MarkdownProcessor.swift index dccbeb6..77eb2cd 100644 --- a/WebsiteGenerator/Generators/MarkdownProcessor.swift +++ b/WebsiteGenerator/Generators/MarkdownProcessor.swift @@ -3,9 +3,12 @@ import Ink struct PageContentGenerator { + private let factory: TemplateFactory + private let files: FileProcessor - init(files: FileProcessor) { + init(factory: TemplateFactory, files: FileProcessor) { + self.factory = factory self.files = files } @@ -19,11 +22,9 @@ struct PageContentGenerator { var hasCodeContent = false let imageModifier = Modifier(target: .images) { html, markdown in - let result = processMarkdownImage(markdown: markdown, html: html, page: page) - switch result { - case .success(let content): - return content - case .failure(let error): + do { + return try processMarkdownImage(markdown: markdown, html: html, page: page) + } catch { errorToThrow = error return "" } @@ -36,9 +37,13 @@ struct PageContentGenerator { hasCodeContent = true return html } - let parser = MarkdownParser(modifiers: [imageModifier, codeModifier]) + let linkModifier = Modifier(target: .links) { html, markdown in + #warning("Check links in markdown for (missing) files to copy") + return html + } + + let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier]) - #warning("Check links in markdown for (missing) files to copy") if hasCodeContent { #warning("Automatically add hljs hightlighting if code samples are found") } @@ -50,81 +55,68 @@ struct PageContentGenerator { return result } - private func processMarkdownImage(markdown: Substring, html: String, page: Page) -> Result { - let fileAndTitle = markdown - .components(separatedBy: "(").last! - .components(separatedBy: ")").first! + private func processMarkdownImage(markdown: Substring, html: String, page: Page) throws -> String { + // Split the markdown ![alt](file "title") + // For images: ![left_title](file "right_title") + // For videos: ![option...](file) + // For files: ? + let fileAndTitle = markdown.between("(", and: ")") + let file = fileAndTitle.dropAfterFirst(" \"") + let title = fileAndTitle.contains(" \"") ? fileAndTitle.between("\"", and: "\"").nonEmpty : nil + let alt = markdown.between("[", and: "]").nonEmpty - let file = fileAndTitle.components(separatedBy: " \"").first! // Remove title - let rightSubtitle: String? - if fileAndTitle.contains(" \"") { - rightSubtitle = fileAndTitle.dropBeforeFirst("\"").dropAfterLast("\"") - } else { - rightSubtitle = nil + let fileExtension = file.lastComponentAfter(".").lowercased() + switch files.mediaType(forExtension: fileExtension) { + case .image: + return try handleImage(page: page, file: file, rightTitle: title, leftTitle: alt) + case .video: + return try handleVideo(page: page, file: file, optionString: alt) + case .file: + #warning("Handle other files in markdown") + print("[WARN] Unhandled file \(file) with extension \(fileExtension)") + return "" } - let leftSubtitle = markdown - .components(separatedBy: "]").first! - .components(separatedBy: "[").last!.nonEmpty + } + + private func handleImage(page: Page, file: String, rightTitle: String?, leftTitle: String?) throws -> String { + let imagePath = page.pathRelativeToRootForContainedInputFile(file) #warning("Specify page image width in configuration") let pageImageWidth = 748 - let size: NSSize - let imagePath = page.pathRelativeToRootForContainedInputFile(file) - do { - size = try files.requireImage( - source: imagePath, - destination: imagePath, - width: pageImageWidth, - desiredHeight: nil, - createDoubleVersion: true) - } catch { - return .failure(error) - } + let size = try files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth) + + let imagePath2x = imagePath.insert("@2x", beforeLast: ".") let file2x = file.insert("@2x", beforeLast: ".") - #warning("Move HTML code to single location") - let result = articelImage( - image: file, - image2x: file2x, - width: size.width, - height: size.height, - rightSubtitle: rightSubtitle, - leftSubtitle: leftSubtitle) - return .success(result) + try files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth) + + let content: [PageImageTemplate.Key : String] = [ + .image: file, + .image2x: file2x, + .width: "\(Int(size.width))", + .height: "\(Int(size.height))", + .leftText: leftTitle ?? "", + .rightText: rightTitle ?? ""] + return factory.image.generate(content) } - private func articelImage(image: String, image2x: String, width: CGFloat, height: CGFloat, rightSubtitle: String?, leftSubtitle: String?) -> String { - let subtitleCode = subtitle(left: leftSubtitle, right: rightSubtitle) - return fullImageCode(image: image, image2x: image2x, width: width, height: height, subtitle: subtitleCode) - } + private func handleVideo(page: Page, file: String, optionString: String?) throws -> String { + let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in + string.components(separatedBy: " ").compactMap { optionText in + guard let optionText = optionText.trimmed.nonEmpty else { + return nil + } + guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else { + print("[WARN] Unknown video option \(optionText) in page \(page.path)") + return nil + } + return option + } + } ?? [] + #warning("Check page folder for alternative video versions") + let sources: [PageVideoTemplate.VideoSource] = [(url: file, type: .mp4)] - private func articleImageWithoutSubtitle(image: String, image2x: String, width: CGFloat, height: CGFloat) -> String { - """ - - - - """ - } - - private func subtitle(left: String?, right: String?) -> String { - guard left != nil || right != nil else { - return "" - } - let leftCode = left.unwrapped { "\($0)" } ?? "" - let rightCode = right.unwrapped { "\($0)" } ?? "" - return """ -
- \(leftCode) - \(rightCode) -
- """ - } - - private func fullImageCode(image: String, image2x: String, width: CGFloat, height: CGFloat, subtitle: String) -> String { - """ - - - \(subtitle) - - """ + let filePath = page.pathRelativeToRootForContainedInputFile(file) + files.require(file: filePath) + return factory.video.generate(sources: sources, options: options) } } diff --git a/WebsiteGenerator/Generators/PageGenerator.swift b/WebsiteGenerator/Generators/PageGenerator.swift index a1dc9c8..fe8b2d8 100644 --- a/WebsiteGenerator/Generators/PageGenerator.swift +++ b/WebsiteGenerator/Generators/PageGenerator.swift @@ -55,7 +55,8 @@ struct PageGenerator { return factory.placeholder } print("Generated page \(page.path)") - return try PageContentGenerator(files: files).generate(page: page, language: language, at: url) + return try PageContentGenerator(factory: factory.factory, files: files) + .generate(page: page, language: language, at: url) } private func makeHead(page: Page, language: String) throws -> String { diff --git a/WebsiteGenerator/Templates/Elements/PageImageTemplate.swift b/WebsiteGenerator/Templates/Elements/PageImageTemplate.swift new file mode 100644 index 0000000..76e250d --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/PageImageTemplate.swift @@ -0,0 +1,18 @@ +import Foundation + +struct PageImageTemplate: Template { + + enum Key: String, CaseIterable { + case image = "IMAGE" + case image2x = "IMAGE_2X" + case width = "WIDTH" + case height = "HEIGHT" + case leftText = "LEFT_TEXT" + case rightText = "RIGHT_TEXT" + } + + static let templateName = "image.html" + + let raw: String + +} diff --git a/WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift b/WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift new file mode 100644 index 0000000..6678eed --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift @@ -0,0 +1,37 @@ +import Foundation + +struct PageVideoTemplate: Template { + + typealias VideoSource = (url: String, type: VideoType) + + enum Key: String, CaseIterable { + case options = "OPTIONS" + case sources = "SOURCES" + } + + enum VideoOption: String { + case controls + case autoplay + case muted + case loop + case playsinline + case poster + case preload + } + + static let templateName = "video.html" + + let raw: String + + func generate(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption { + let sourcesCode = sources.map(makeSource).joined(separator: "\n") + let optionCode = options.map { $0.rawValue }.joined(separator: " ") + return generate([.sources: sourcesCode, .options: optionCode]) + } + + private func makeSource(_ source: VideoSource) -> String { + """ + + """ + } +} diff --git a/WebsiteGenerator/Templates/TemplateFactory.swift b/WebsiteGenerator/Templates/TemplateFactory.swift index a9fad43..6a3cf89 100644 --- a/WebsiteGenerator/Templates/TemplateFactory.swift +++ b/WebsiteGenerator/Templates/TemplateFactory.swift @@ -47,6 +47,10 @@ final class TemplateFactory { let page: PageTemplate + let image: PageImageTemplate + + let video: PageVideoTemplate + // MARK: Init init(templateFolder: URL) throws { @@ -63,5 +67,7 @@ final class TemplateFactory { self.leftHeader = try .init(in: templateFolder) self.centeredHeader = try .init(in: templateFolder) self.page = try .init(in: templateFolder) + self.image = try .init(in: templateFolder) + self.video = try .init(in: templateFolder) } } diff --git a/WebsiteGenerator/VideoType.swift b/WebsiteGenerator/VideoType.swift new file mode 100644 index 0000000..849d79f --- /dev/null +++ b/WebsiteGenerator/VideoType.swift @@ -0,0 +1,12 @@ +import Foundation + +enum VideoType: String { + case mp4 + + var htmlType: String { + switch self { + case .mp4: + return "video/mp4" + } + } +}