From 7ec06ff8f8bc304080e201a3bd8bd6a5e1934625 Mon Sep 17 00:00:00 2001 From: alexandre-pod <24512899+alexandre-pod@users.noreply.github.com> Date: Sat, 2 Apr 2022 21:55:46 +0200 Subject: [PATCH] Clean API and add documentation --- Sources/SVGConverter/SVGConverter.swift | 10 +- .../Utils/Result+throwingMap.swift | 0 .../Utils/XMLElement+SetAttribute.swift | 0 .../Internal/WebViewSVGRenderer.swift | 195 ++++++++++++++++ Sources/SVGConverterCore/SVGRenderer.swift | 209 +++++------------- .../SVGConverterCore/SVGRenderingError.swift | 36 +++ .../SVGRenderingWarnings.swift | 17 ++ 7 files changed, 308 insertions(+), 159 deletions(-) rename Sources/SVGConverterCore/{ => Internal}/Utils/Result+throwingMap.swift (100%) rename Sources/SVGConverterCore/{ => Internal}/Utils/XMLElement+SetAttribute.swift (100%) create mode 100644 Sources/SVGConverterCore/Internal/WebViewSVGRenderer.swift create mode 100644 Sources/SVGConverterCore/SVGRenderingError.swift create mode 100644 Sources/SVGConverterCore/SVGRenderingWarnings.swift diff --git a/Sources/SVGConverter/SVGConverter.swift b/Sources/SVGConverter/SVGConverter.swift index 3b62780..775b765 100644 --- a/Sources/SVGConverter/SVGConverter.swift +++ b/Sources/SVGConverter/SVGConverter.swift @@ -33,8 +33,16 @@ struct SVGConverter: AsyncParsableCommand { func run() async throws { let svgData = try Data(contentsOf: inputPath) - let renderer = await SVGRenderer(allowViewBoxFix: !preventMissingViewBoxFix, quiet: quiet) + let configuration = SVGRenderer.Configuration(allowFixingMissingViewBox: !preventMissingViewBoxFix) + let renderer = SVGRenderer( + configuration: configuration, + warningHandler: quiet ? nil : logWarning + ) let pngData = try await renderer.render(svgData: svgData, size: CGSize(width: width, height: height)) try pngData.write(to: outputPath) } } + +func logWarning(_ warning: SVGRenderingWarnings) { + FileHandle.standardError.write(Data("[Warning] \(warning.localizedDescription)\n".utf8)) +} diff --git a/Sources/SVGConverterCore/Utils/Result+throwingMap.swift b/Sources/SVGConverterCore/Internal/Utils/Result+throwingMap.swift similarity index 100% rename from Sources/SVGConverterCore/Utils/Result+throwingMap.swift rename to Sources/SVGConverterCore/Internal/Utils/Result+throwingMap.swift diff --git a/Sources/SVGConverterCore/Utils/XMLElement+SetAttribute.swift b/Sources/SVGConverterCore/Internal/Utils/XMLElement+SetAttribute.swift similarity index 100% rename from Sources/SVGConverterCore/Utils/XMLElement+SetAttribute.swift rename to Sources/SVGConverterCore/Internal/Utils/XMLElement+SetAttribute.swift diff --git a/Sources/SVGConverterCore/Internal/WebViewSVGRenderer.swift b/Sources/SVGConverterCore/Internal/WebViewSVGRenderer.swift new file mode 100644 index 0000000..2815229 --- /dev/null +++ b/Sources/SVGConverterCore/Internal/WebViewSVGRenderer.swift @@ -0,0 +1,195 @@ +// +// WebViewSVGRenderer.swift +// +// +// Created by Alexandre Podlewski on 02/04/2022. +// + +import Foundation +import WebKit + +private extension SVGRenderingWarnings { + static let missingViewBoxAndNoDefinedSize = SVGRenderingWarnings( + "Missing viewBox in svg file, the svg will not be resized" + ) + static let missingViewBoxAndComputedFromSize = SVGRenderingWarnings( + "Missing viewBox in svg file, one was guessed using width and height" + ) +} + +@available(macOS 10.15, *) +final class WebViewSVGRenderer: WKWebView, WKNavigationDelegate { + + // MARK: - Public typealias + + typealias WarningHandler = (SVGRenderingWarnings) -> Void + + // MARK: - Public properties + + var warningHandler: WarningHandler? + + // MARK: - Private Properties + + private let allowFixingMissingViewBox: Bool + private var completion: ((Result) -> Void)? + private var isRendering = false + private var size: CGSize = .zero + + // MARK: - Life cycle + + /// An SVG renderer using a WebView to render the SVG + /// - Parameter allowFixingMissingViewBox: allow the renderer to try adding a viewBox attribute to svg that are lacking of it. + /// Without viewBox the renderer is unable to resize the svg image. + init(allowFixingMissingViewBox: Bool = true) { + self.allowFixingMissingViewBox = allowFixingMissingViewBox + super.init(frame: .zero, configuration: WebViewSVGRenderer.rendererConfiguration) + navigationDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + self.allowFixingMissingViewBox = true + fatalError("init(coder:) has not been implemented") + } + + // MARK: - SVGRenderer + + @available(*, renamed: "render(svgString:svgSize:)") + func render( + svgData: Data, + size: CGSize, + scale: CGFloat = 1.0, + completion: @escaping (Result) -> Void + ) { + guard !isRendering else { + assertionFailure("SVGRenderer can only do one render at a time") + completion(.failure(SVGRenderingError.renderingAlreadyInProgress)) + return + } + isRendering = true + self.completion = completion + let webViewScale = layer?.contentsScale ?? 1 + self.size = NSSize( + width: scale * size.width / webViewScale, + height: scale * size.height / webViewScale + ) + do { + let resizedSVGData = try resizeSVG(svgData, to: self.size) + guard let svgString = String(data: resizedSVGData, encoding: .utf8) else { + throw SVGRenderingError.invalidState + } + loadHTMLString(htmlDocument(forSVG: svgString), baseURL: nil) + } catch { + didComplete(with: .failure(error)) + return + } + } + + @available(macOS 10.15, *) + func render(svgData: Data, size: CGSize, scale: CGFloat = 1.0) async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + render(svgData: svgData, size: size, scale: scale) { result in + continuation.resume(with: result) + } + } + } + + // MARK: - WKNavigationDelegate + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let config = WKSnapshotConfiguration() + config.afterScreenUpdates = true + config.rect = NSRect(origin: .zero, size: size) + + webView.frame.size = size + webView.takeSnapshot(with: config) { [weak self] image, error in + guard let self = self else { return } + let snapshotResult = Result { + if let error = error { + throw error + } + if let image = image { + return image + } + throw SVGRenderingError.invalidState + } + + self.didComplete(with: snapshotResult.throwingMap { image in + guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + throw SVGRenderingError.cgImageConversionFailed + } + let rep = NSBitmapImageRep(cgImage: cgImage) + rep.size = self.size + guard let data = rep.representation(using: .png, properties: [:]) else { + throw SVGRenderingError.pngImageConversionFailed + } + return data + }) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + didComplete(with: .failure(error)) + } + + // MARK: - Private + + private func resizeSVG(_ svg: Data, to size: CGSize) throws -> Data { + let document = try XMLDocument(data: svg) + guard + let svgElement = document.rootElement(), + svgElement.name == "svg" + else { throw SVGRenderingError.invalidSVGData } + + if svgElement.attribute(forName: "viewBox") == nil { + if + allowFixingMissingViewBox, + let width = svgElement.attribute(forName: "width")?.stringValue.flatMap(Double.init), + let height = svgElement.attribute(forName: "height")?.stringValue.flatMap(Double.init) + { + warningHandler?(.missingViewBoxAndComputedFromSize) + svgElement.set(value: "0 0 \(width) \(height)", for: "viewBox") + } else { + warningHandler?(.missingViewBoxAndNoDefinedSize) + } + } + + svgElement.set(value: "\(size.width)", for: "width") + svgElement.set(value: "\(size.height)", for: "height") + return document.xmlData() + } + + private func htmlDocument(forSVG svg: String) -> String { + return """ + + + \(svg) + + """ + } + + private func didComplete(with result: Result) { + let completionReference = completion + isRendering = false + completion = nil + completionReference?(result) + } +} + +@available(macOS 10.15, *) +private extension WebViewSVGRenderer { + + static let rendererConfiguration: WKWebViewConfiguration = { + let pagePreference = WKWebpagePreferences() + let configuration = WKWebViewConfiguration() + if #available(macOS 11.0, *) { + pagePreference.allowsContentJavaScript = false + } else { + configuration.preferences.javaScriptEnabled = false + } + configuration.defaultWebpagePreferences = pagePreference + configuration.websiteDataStore = .nonPersistent() + configuration.suppressesIncrementalRendering = true + return configuration + }() +} diff --git a/Sources/SVGConverterCore/SVGRenderer.swift b/Sources/SVGConverterCore/SVGRenderer.swift index 9da325b..f8542cb 100644 --- a/Sources/SVGConverterCore/SVGRenderer.swift +++ b/Sources/SVGConverterCore/SVGRenderer.swift @@ -6,188 +6,81 @@ // import Foundation -import WebKit @available(macOS 10.15, *) -public final class SVGRenderer: WKWebView, WKNavigationDelegate { +public final class SVGRenderer { - // MARK: - Private Types + // MARK: - Public typealiases - private enum InternalError: Error { - case renderingAlreadyInProgress - case invalidSVGData - case cgImageConversionFailed - case pngImageConversionFailed - case invalidState + public typealias WarningHandler = (SVGRenderingWarnings) -> Void + + // MARK: - Public structures + + public struct Configuration { + + /// Controls wether or not the renderer will try to add viewBox attribute to SVG without one + /// + /// When at true, if there is no viewBox attribute in the SVG and the svg has a width and an height, it will add a viewBox attribute with the value `"0 0 width height"` + public var allowFixingMissingViewBox: Bool + + /// Create a configuration for SVGRenderer + /// - Parameter allowFixingMissingViewBox: Controls wether or not the renderer will try to add viewBox attribute to SVG without one + public init(allowFixingMissingViewBox: Bool = true) { + self.allowFixingMissingViewBox = allowFixingMissingViewBox + } + } + + // MARK: - Public properties + + /// Receives warnings detected by the renderer. Set it to `nil` to ignore those warnings + /// + /// By default to `nil` + public var warningHandler: WarningHandler? = nil { + didSet { + self.renderer.warningHandler = warningHandler + } } // MARK: - Private Properties - private let allowViewBoxFix: Bool - private let quiet: Bool - private var completion: ((Result) -> Void)? - private var isRendering = false - private var size: CGSize = .zero + private let renderer: WebViewSVGRenderer // MARK: - Life cycle - public init(allowViewBoxFix: Bool = true, quiet: Bool = false) { - self.allowViewBoxFix = allowViewBoxFix - self.quiet = quiet - super.init(frame: .zero, configuration: SVGRenderer.rendererConfiguration) - navigationDelegate = self - } + public init( + configuration: Configuration = Configuration(), + warningHandler: WarningHandler? = nil + ) { + self.renderer = WebViewSVGRenderer(allowFixingMissingViewBox: configuration.allowFixingMissingViewBox) - @available(*, unavailable) - required public init?(coder: NSCoder) { - self.allowViewBoxFix = true - self.quiet = false - fatalError("init(coder:) has not been implemented") + self.warningHandler = warningHandler + self.renderer.warningHandler = warningHandler } // MARK: - SVGRenderer - @available(*, renamed: "render(svgString:svgSize:)") + /// Transform SVG Data to PNG data + /// - Parameters: + /// - svgData: The SVG data to render + /// - size: The size of the PNG returned by this method + /// - scale: A multiplicator for the size of the output PNG + /// - completion: The completion handler returning the rendered PNG or an error public func render( svgData: Data, size: CGSize, scale: CGFloat = 1.0, completion: @escaping (Result) -> Void ) { - guard !isRendering else { - assertionFailure("SVGRenderer can only do one render at a time") - completion(.failure(InternalError.renderingAlreadyInProgress)) - return - } - isRendering = true - self.completion = completion - let webViewScale = layer?.contentsScale ?? 1 - self.size = NSSize( - width: scale * size.width / webViewScale, - height: scale * size.height / webViewScale - ) - do { - let resizedSVGData = try resizeSVG(svgData, to: self.size) - guard let svgString = String(data: resizedSVGData, encoding: .utf8) else { - throw InternalError.invalidState - } - loadHTMLString(htmlDocument(forSVG: svgString), baseURL: nil) - } catch { - didComplete(with: .failure(error)) - return - } + renderer.render(svgData: svgData, size: size, scale: scale, completion: completion) } + /// Transform SVG Data to PNG data + /// - Parameters: + /// - svgData: The SVG data to render + /// - size: The size of the PNG returned by this method + /// - scale: A multiplicator for the size of the output PNG @available(macOS 10.15, *) - public func render(svgData: Data,size: CGSize,scale: CGFloat = 1.0) async throws -> Data { - return try await withCheckedThrowingContinuation { continuation in - render(svgData: svgData, size: size, scale: scale) { result in - continuation.resume(with: result) - } - } - } - - // MARK: - WKNavigationDelegate - - public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - let config = WKSnapshotConfiguration() - config.afterScreenUpdates = true - config.rect = NSRect(origin: .zero, size: size) - - webView.frame.size = size - webView.takeSnapshot(with: config) { [weak self] image, error in - guard let self = self else { return } - let snapshotResult = Result { - if let error = error { - throw error - } - if let image = image { - return image - } - throw InternalError.invalidState - } - - self.didComplete(with: snapshotResult.throwingMap { image in - guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - throw InternalError.cgImageConversionFailed - } - let rep = NSBitmapImageRep(cgImage: cgImage) - rep.size = self.size - guard let data = rep.representation(using: .png, properties: [:]) else { - throw InternalError.pngImageConversionFailed - } - return data - }) - } + public func render(svgData: Data, size: CGSize, scale: CGFloat = 1.0) async throws -> Data { + return try await renderer.render(svgData: svgData, size: size, scale: scale) } - - public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - didComplete(with: .failure(error)) - } - - // MARK: - Private - - private func resizeSVG(_ svg: Data, to size: CGSize) throws -> Data { - let document = try XMLDocument(data: svg) - guard - let svgElement = document.rootElement(), - svgElement.name == "svg" - else { throw InternalError.invalidSVGData } - - if svgElement.attribute(forName: "viewBox") == nil { - if - allowViewBoxFix, - let width = svgElement.attribute(forName: "width")?.stringValue.flatMap(Double.init), - let height = svgElement.attribute(forName: "height")?.stringValue.flatMap(Double.init) - { - printWarning("Missing viewBox in svg file, one was guessed using width and height") - svgElement.set(value: "0 0 \(width) \(height)", for: "viewBox") - } else { - printWarning("Missing viewBox in svg file, the svg will not be resized") - } - } - - svgElement.set(value: "\(size.width)", for: "width") - svgElement.set(value: "\(size.height)", for: "height") - return document.xmlData() - } - - private func htmlDocument(forSVG svg: String) -> String { - return """ - - - \(svg) - - """ - } - - private func printWarning(_ message: String) { - guard !quiet else { return } - FileHandle.standardError.write(Data("[Warning] \(message)\n".utf8)) - } - - private func didComplete(with result: Result) { - let completionReference = completion - isRendering = false - completion = nil - completionReference?(result) - } -} - -@available(macOS 10.15, *) -private extension SVGRenderer { - - static let rendererConfiguration: WKWebViewConfiguration = { - let pagePreference = WKWebpagePreferences() - let configuration = WKWebViewConfiguration() - if #available(macOS 11.0, *) { - pagePreference.allowsContentJavaScript = false - } else { - configuration.preferences.javaScriptEnabled = false - } - configuration.defaultWebpagePreferences = pagePreference - configuration.websiteDataStore = .nonPersistent() - configuration.suppressesIncrementalRendering = true - return configuration - }() } diff --git a/Sources/SVGConverterCore/SVGRenderingError.swift b/Sources/SVGConverterCore/SVGRenderingError.swift new file mode 100644 index 0000000..eecea94 --- /dev/null +++ b/Sources/SVGConverterCore/SVGRenderingError.swift @@ -0,0 +1,36 @@ +// +// SVGRenderingError.swift +// +// +// Created by Alexandre Podlewski on 02/04/2022. +// + +import Foundation + +public enum SVGRenderingError: Error { + case renderingAlreadyInProgress + case invalidSVGData + case cgImageConversionFailed + case pngImageConversionFailed + case invalidState +} + +public extension SVGRenderingError { + + // MARK: - Error + + var localizedDescription: String { + switch self { + case .renderingAlreadyInProgress: + return "A rendering is already in progress. An SVGRenderer only supports the rendering of one SVG at a time." + case .invalidSVGData: + return "The SVG data is malformed" + case .cgImageConversionFailed: + return "Internal error, conversion to cgImage failed" + case .pngImageConversionFailed: + return "Internal error, getting png representation from cgImage failed" + case .invalidState: + return "Unexpected error" + } + } +} diff --git a/Sources/SVGConverterCore/SVGRenderingWarnings.swift b/Sources/SVGConverterCore/SVGRenderingWarnings.swift new file mode 100644 index 0000000..cebbd10 --- /dev/null +++ b/Sources/SVGConverterCore/SVGRenderingWarnings.swift @@ -0,0 +1,17 @@ +// +// SVGRenderingWarnings.swift +// +// +// Created by Alexandre Podlewski on 02/04/2022. +// + +import Foundation + +public struct SVGRenderingWarnings: Error { + + public let localizedDescription: String + + internal init(_ message: String) { + self.localizedDescription = message + } +}