Improve video and image handling in markdown

This commit is contained in:
Christoph Hagen 2022-08-17 10:36:21 +02:00
parent d4ed30ad80
commit a444c51697
8 changed files with 198 additions and 83 deletions

View File

@ -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 = "<group>"; };
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = "<group>"; };
E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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;

View File

@ -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<String> = [
"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
}

View File

@ -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<String, Error> {
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 {
"""
<span class="image">
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
</span>
"""
}
private func subtitle(left: String?, right: String?) -> String {
guard left != nil || right != nil else {
return ""
}
let leftCode = left.unwrapped { "<span class=\"left\">\($0)</span>" } ?? ""
let rightCode = right.unwrapped { "<span class=\"right\">\($0)</span>" } ?? ""
return """
<div class="subtitle">
\(leftCode)
\(rightCode)
</div>
"""
}
private func fullImageCode(image: String, image2x: String, width: CGFloat, height: CGFloat, subtitle: String) -> String {
"""
<span class="image">
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
\(subtitle)
</span>
"""
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
return factory.video.generate(sources: sources, options: options)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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<T>(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 {
"""
<source src="\(source.url)" type="\(source.type.htmlType)">
"""
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,12 @@
import Foundation
enum VideoType: String {
case mp4
var htmlType: String {
switch self {
case .mp4:
return "video/mp4"
}
}
}