diff --git a/README.md b/README.md index f13ff05..b17aa28 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ It won't Add the dependency to your package manifest file. ```swift -.package(url: "https://github.com/colinc86/LaTeXSwiftUI", from: "1.2.2") +.package(url: "https://github.com/colinc86/LaTeXSwiftUI", from: "1.2.3") ``` ## ⌨️ Usage diff --git a/Sources/LaTeXSwiftUI/Extensions/Font+Extensions.swift b/Sources/LaTeXSwiftUI/Extensions/Font+Extensions.swift index 7cee210..8dea497 100644 --- a/Sources/LaTeXSwiftUI/Extensions/Font+Extensions.swift +++ b/Sources/LaTeXSwiftUI/Extensions/Font+Extensions.swift @@ -33,6 +33,8 @@ import Cocoa #endif internal extension Font { + + /// The font's text style. func textStyle() -> _Font.TextStyle? { switch self { case .largeTitle, .largeTitle.bold(), .largeTitle.italic(), .largeTitle.monospaced(): return .largeTitle @@ -48,6 +50,12 @@ internal extension Font { default: return nil } } + + /// The font's x-height. + var xHeight: CGFloat { + _Font.preferredFont(from: self).xHeight + } + } internal extension _Font { diff --git a/Sources/LaTeXSwiftUI/Extensions/MathJax+Extensions.swift b/Sources/LaTeXSwiftUI/Extensions/MathJax+Extensions.swift new file mode 100644 index 0000000..621d077 --- /dev/null +++ b/Sources/LaTeXSwiftUI/Extensions/MathJax+Extensions.swift @@ -0,0 +1,41 @@ +// +// MathJax+Extensions.swift +// LaTeXSwiftUI +// +// Copyright (c) 2023 Colin Campbell +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +import Foundation +import MathJaxSwift + +internal extension MathJax { + + static var svgRenderer: MathJax? = { + do { + return try MathJax(preferredOutputFormat: .svg) + } + catch { + NSLog("Error creating MathJax instance: \(error)") + return nil + } + }() + +} diff --git a/Sources/LaTeXSwiftUI/LaTeX.swift b/Sources/LaTeXSwiftUI/LaTeX.swift index 2c3c850..6dc049f 100644 --- a/Sources/LaTeXSwiftUI/LaTeX.swift +++ b/Sources/LaTeXSwiftUI/LaTeX.swift @@ -104,18 +104,18 @@ public struct LaTeX: View { /// The package's shared data cache. public static var dataCache: NSCache { - Renderer.shared.dataCache + Cache.shared.dataCache } #if os(macOS) /// The package's shared image cache. public static var imageCache: NSCache { - Renderer.shared.imageCache + Cache.shared.imageCache } #else /// The package's shared image cache. public static var imageCache: NSCache { - Renderer.shared.imageCache + Cache.shared.imageCache } #endif @@ -156,24 +156,8 @@ public struct LaTeX: View { // MARK: Private properties - /// The view's render state. - @StateObject private var renderState: LaTeXRenderState - - /// Renders the blocks synchronously. - /// - /// This will block whatever thread you call it on. - private var syncBlocks: [ComponentBlock] { - Renderer.shared.render( - blocks: Parser.parse(unencodeHTML ? latex.htmlUnescape() : latex, mode: parsingMode), - font: font ?? .body, - displayScale: displayScale, - texOptions: texOptions) - } - - /// The TeX options to use when submitting requests to the renderer. - private var texOptions: TeXInputProcessorOptions { - TeXInputProcessorOptions(processEscapes: processEscapes, errorMode: errorMode) - } + /// The view's renderer. + @StateObject private var renderer: Renderer // MARK: Initializers @@ -182,33 +166,35 @@ public struct LaTeX: View { /// - Parameter latex: The LaTeX input. public init(_ latex: String) { self.latex = latex - _renderState = StateObject(wrappedValue: LaTeXRenderState(latex: latex)) + _renderer = StateObject(wrappedValue: Renderer(latex: latex)) } // MARK: View body public var body: some View { VStack(spacing: 0) { - if renderState.rendered { - bodyWithBlocks(renderState.blocks) + if renderer.rendered { + // If our blocks have been rendered, display them + bodyWithBlocks(renderer.blocks) + } + else if isCached() { + // If our blocks are cached, display them + bodyWithBlocks(renderSync()) } else { + // The view is not rendered nor cached switch renderingStyle { - case .empty: - Text("") - .task(render) - case .original: - Text(latex) - .task(render) - case .progress: - ProgressView() - .task(render) + case .empty, .original, .progress: + // Render the components asynchronously + loadingView().task(renderAsync) case .wait: - bodyWithBlocks(syncBlocks) + // Render the components synchronously + bodyWithBlocks(renderSync()) } } } - .animation(renderingAnimation, value: renderState.rendered) + .animation(renderingAnimation, value: renderer.rendered) + .environmentObject(renderer) } } @@ -220,7 +206,7 @@ extension LaTeX { /// Preloads the view's SVG and image data. public func preload() { Task { - await render() + await renderAsync() } } } @@ -229,14 +215,45 @@ extension LaTeX { extension LaTeX { + /// Checks the renderer's caches for the current view. + /// + /// If this method returns `true`, then there is no need to do an async + /// render. + /// + /// - Returns: A boolean indicating whether the components to the view are + /// cached. + private func isCached() -> Bool { + renderer.isCached( + unencodeHTML: unencodeHTML, + parsingMode: parsingMode, + processEscapes: processEscapes, + errorMode: errorMode, + font: font ?? .body, + displayScale: displayScale) + } + /// Renders the view's components. - @Sendable private func render() async { - await renderState.render( + @Sendable private func renderAsync() async { + await renderer.render( + unencodeHTML: unencodeHTML, + parsingMode: parsingMode, + processEscapes: processEscapes, + errorMode: errorMode, + font: font ?? .body, + displayScale: displayScale) + } + + /// Renders the view's components synchronously. + /// + /// - Returns: The rendered components. + private func renderSync() -> [ComponentBlock] { + renderer.renderSync( unencodeHTML: unencodeHTML, parsingMode: parsingMode, - font: font, - displayScale: displayScale, - texOptions: texOptions) + processEscapes: processEscapes, + errorMode: errorMode, + font: font ?? .body, + displayScale: displayScale) } /// Creates the view's body based on its block mode. @@ -254,6 +271,19 @@ extension LaTeX { } } + @MainActor @ViewBuilder private func loadingView() -> some View { + switch renderingStyle { + case .empty: + Text("") + case .original: + Text(latex) + case .progress: + ProgressView() + default: + EmptyView() + } + } + } @available(iOS 16.1, *) diff --git a/Sources/LaTeXSwiftUI/Models/Cache.swift b/Sources/LaTeXSwiftUI/Models/Cache.swift new file mode 100644 index 0000000..e87635f --- /dev/null +++ b/Sources/LaTeXSwiftUI/Models/Cache.swift @@ -0,0 +1,146 @@ +// +// Cache.swift +// LaTeXSwiftUI +// +// Copyright (c) 2023 Colin Campbell +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. +// + +import CryptoKit +import Foundation +import MathJaxSwift + +fileprivate protocol CacheKey: Codable { + + /// The key type used to identify the cache key in storage. + static var keyType: String { get } + + /// A key to use if encoding fails. + var fallbackKey: String { get } + +} + +extension CacheKey { + + /// The key to use in the cache. + func key() -> String { + do { + let data = try JSONEncoder().encode(self) + let hashedData = SHA256.hash(data: data) + return hashedData.compactMap { String(format: "%02x", $0) }.joined() + "-" + Self.keyType + } + catch { + return fallbackKey + "-" + Self.keyType + } + } + +} + +internal class Cache { + + // MARK: Types + + /// An SVG cache key. + struct SVGCacheKey: CacheKey { + static let keyType: String = "svg" + let componentText: String + let conversionOptions: ConversionOptions + let texOptions: TeXInputProcessorOptions + internal var fallbackKey: String { componentText } + } + + /// An image cache key. + struct ImageCacheKey: CacheKey { + static let keyType: String = "image" + let svg: SVG + let xHeight: CGFloat + internal var fallbackKey: String { String(data: svg.data, encoding: .utf8) ?? "" } + } + + // MARK: Static properties + + /// The shared cache. + static let shared = Cache() + + // MARK: Public properties + + /// The renderer's data cache. + let dataCache: NSCache = NSCache() + + /// The renderer's image cache. + let imageCache: NSCache = NSCache() + + // MARK: Private properties + + /// Semaphore for thread-safe access to `dataCache`. + let dataCacheSemaphore = DispatchSemaphore(value: 1) + + /// Semaphore for thread-safe access to `imageCache`. + let imageCacheSemaphore = DispatchSemaphore(value: 1) + +} + +// MARK: Public methods + +extension Cache { + + /// Safely access the cache value for the given key. + /// + /// - Parameter key: The key of the value to get. + /// - Returns: A value. + func dataCacheValue(for key: SVGCacheKey) -> Data? { + dataCacheSemaphore.wait() + defer { dataCacheSemaphore.signal() } + return dataCache.object(forKey: key.key() as NSString) as Data? + } + + /// Safely sets the cache value. + /// + /// - Parameters: + /// - value: The value to set. + /// - key: The value's key. + func setDataCacheValue(_ value: Data, for key: SVGCacheKey) { + dataCacheSemaphore.wait() + dataCache.setObject(value as NSData, forKey: key.key() as NSString) + dataCacheSemaphore.signal() + } + + /// Safely access the cache value for the given key. + /// + /// - Parameter key: The key of the value to get. + /// - Returns: A value. + func imageCacheValue(for key: ImageCacheKey) -> _Image? { + imageCacheSemaphore.wait() + defer { imageCacheSemaphore.signal() } + return imageCache.object(forKey: key.key() as NSString) + } + + /// Safely sets the cache value. + /// + /// - Parameters: + /// - value: The value to set. + /// - key: The value's key. + func setImageCacheValue(_ value: _Image, for key: ImageCacheKey) { + imageCacheSemaphore.wait() + imageCache.setObject(value, forKey: key.key() as NSString) + imageCacheSemaphore.signal() + } + +} diff --git a/Sources/LaTeXSwiftUI/Models/Component.swift b/Sources/LaTeXSwiftUI/Models/Component.swift index 20cf9e2..06f62b0 100644 --- a/Sources/LaTeXSwiftUI/Models/Component.swift +++ b/Sources/LaTeXSwiftUI/Models/Component.swift @@ -44,26 +44,6 @@ internal struct ComponentBlock: Hashable, Identifiable { components.count == 1 && !components[0].type.inline } - /// Creates the image view and its size for the given block. - /// - /// If the block isn't an equation block, then this method returns `nil`. - /// - /// - Parameter block: The block. - /// - Returns: The image, its size, and any associated error text. - @MainActor func image( - font: Font, - displayScale: CGFloat, - renderingMode: Image.TemplateRenderingMode - ) -> (Image, CGSize, String?)? { - guard isEquationBlock, let component = components.first else { - return nil - } - return component.convertToImage( - font: font, - displayScale: displayScale, - renderingMode: renderingMode) - } - } /// A LaTeX component. @@ -203,91 +183,3 @@ internal struct Component: CustomStringConvertible, Equatable, Hashable { } } - -// MARK: Methods - -extension Component { - - /// Converts the component to a `Text` view. - /// - /// - Parameters: - /// - font: The font to use. - /// - displayScale: The view's display scale. - /// - renderingMode: The image rendering mode. - /// - errorMode: The error handling mode. - /// - isLastComponentInBlock: Whether or not this is the last component in - /// the block that contains it. - /// - Returns: A text view. - @MainActor func convertToText( - font: Font, - displayScale: CGFloat, - renderingMode: Image.TemplateRenderingMode, - errorMode: LaTeX.ErrorMode, - blockRenderingMode: LaTeX.BlockMode, - isInEquationBlock: Bool - ) -> Text { - // Get the component's text - let text: Text - if let svg = svg { - // Do we have an error? - if let errorText = svg.errorText, errorMode != .rendered { - switch errorMode { - case .original: - // Use the original tex input - text = Text(blockRenderingMode == .alwaysInline ? originalTextTrimmingNewlines : originalText) - case .error: - // Use the error text - text = Text(errorText) - default: - text = Text("") - } - } - else if let (image, _, _) = convertToImage( - font: font, - displayScale: displayScale, - renderingMode: renderingMode - ) { - let xHeight = _Font.preferredFont(from: font).xHeight - let offset = svg.geometry.verticalAlignment.toPoints(xHeight) - text = Text(image).baselineOffset(blockRenderingMode == .alwaysInline || !isInEquationBlock ? offset : 0) - } - else { - text = Text("") - } - } - else if blockRenderingMode == .alwaysInline { - text = Text(originalTextTrimmingNewlines) - } - else { - text = Text(originalText) - } - - return text - } - - /// Converts the component to an image. - /// - /// - Parameters: - /// - font: The font to use. - /// - displayScale: The current display scale. - /// - renderingMode: The current rendering mode. - /// - Returns: Image details. - @MainActor func convertToImage( - font: Font, - displayScale: CGFloat, - renderingMode: Image.TemplateRenderingMode - ) -> (Image, CGSize, String?)? { - guard let svg = svg else { - return nil - } - guard let imageData = Renderer.shared.convertToImage( - svg: svg, - font: font, - displayScale: displayScale, - renderingMode: renderingMode) else { - return nil - } - return (imageData.0, imageData.1, svg.errorText) - } - -} diff --git a/Sources/LaTeXSwiftUI/Models/LaTeXRenderState.swift b/Sources/LaTeXSwiftUI/Models/LaTeXRenderState.swift deleted file mode 100644 index 831dad5..0000000 --- a/Sources/LaTeXSwiftUI/Models/LaTeXRenderState.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// LaTeXRenderState.swift -// LaTeXSwiftUI -// -// Copyright (c) 2023 Colin Campbell -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// - -import MathJaxSwift -import SwiftUI - -/// A `LaTeX` view's render state. -internal class LaTeXRenderState: ObservableObject { - - /// The view's input string. - let latex: String - - /// Whether or not the view's blocks have been rendered. - @MainActor @Published var rendered: Bool = false - - /// Whether or not the receiver is currently rendering. - @MainActor @Published var isRendering: Bool = false - - /// The rendered blocks. - @MainActor @Published var blocks: [ComponentBlock] = [] - - // MARK: Initializers - - /// Initializes a render state with an input string. - /// - /// - Parameter latex: The view's input string. - init(latex: String) { - self.latex = latex - } - -} - -// MARK: Public methods - -extension LaTeXRenderState { - - /// Renders the views components. - /// - /// - Parameters: - /// - unencodeHTML: The `unencodeHTML` environment variable. - /// - parsingMode: The `parsingMode` environment variable. - /// - font: The `font environment` variable. - /// - displayScale: The `displayScale` environment variable. - /// - texOptions: The `texOptions` environment variable. - func render(unencodeHTML: Bool, parsingMode: LaTeX.ParsingMode, font: Font?, displayScale: CGFloat, texOptions: TeXInputProcessorOptions) async { - let isRen = await isRendering - let ren = await rendered - guard !isRen && !ren else { - return - } - await MainActor.run { - isRendering = true - } - - let renderedBlocks = await Renderer.shared.render( - blocks: Parser.parse(unencodeHTML ? latex.htmlUnescape() : latex, mode: parsingMode), - font: font ?? .body, - displayScale: displayScale, - texOptions: texOptions) - - await MainActor.run { - blocks = renderedBlocks - isRendering = false - rendered = true - } - } - -} diff --git a/Sources/LaTeXSwiftUI/Models/Renderer.swift b/Sources/LaTeXSwiftUI/Models/Renderer.swift index fdd33ab..a708c97 100644 --- a/Sources/LaTeXSwiftUI/Models/Renderer.swift +++ b/Sources/LaTeXSwiftUI/Models/Renderer.swift @@ -23,7 +23,6 @@ // IN THE SOFTWARE. // -import CryptoKit import Foundation import MathJaxSwift import SwiftUI @@ -35,88 +34,39 @@ import UIKit import Cocoa #endif -fileprivate protocol Key: Codable { - - /// The key type used to identify the cache key in storage. - static var keyType: String { get } - - /// A key to use if encoding fails. - var fallbackKey: String { get } - -} - -extension Key { - - /// The key to use in the cache. - func key() -> String { - do { - let data = try JSONEncoder().encode(self) - let hashedData = SHA256.hash(data: data) - return hashedData.compactMap { String(format: "%02x", $0) }.joined() + "-" + Self.keyType - } - catch { - return fallbackKey + "-" + Self.keyType - } - } - -} - /// Renders equation components and updates their rendered image and offset /// values. -internal class Renderer { +internal class Renderer: ObservableObject { - // MARK: Types + // MARK: Public properties - /// An SVG cache key. - struct SVGCacheKey: Key { - static let keyType: String = "svg" - let componentText: String - let conversionOptions: ConversionOptions - let texOptions: TeXInputProcessorOptions - internal var fallbackKey: String { componentText } - } + /// The view's input string. + let latex: String - /// An image cache key. - struct ImageCacheKey: Key { - static let keyType: String = "image" - let svg: SVG - let xHeight: CGFloat - internal var fallbackKey: String { String(data: svg.data, encoding: .utf8) ?? "" } - } + /// Whether or not the view's blocks have been rendered. + @MainActor @Published var rendered: Bool = false - // MARK: Static properties + /// Whether or not the receiver is currently rendering. + @MainActor @Published var isRendering: Bool = false - /// The shared renderer. - static let shared = Renderer() + /// The rendered blocks. + @MainActor @Published var blocks: [ComponentBlock] = [] // MARK: Private properties - /// The MathJax instance. - private let mathjax: MathJax? - - /// The renderer's data cache. - internal let dataCache: NSCache = NSCache() + /// The LaTeX input's parsed blocks. + private var _parsedBlocks: [ComponentBlock]? = nil - /// Semaphore for thread-safe access to `dataCache`. - internal let dataCacheSemaphore = DispatchSemaphore(value: 1) - - /// The renderer's image cache. - internal let imageCache: NSCache = NSCache() - - /// Semaphore for thread-safe access to `imageCache`. - internal let imageCacheSemaphore = DispatchSemaphore(value: 1) + /// Semaphore for thread-safe access to `_parsedBlocks`. + private var _parsedBlocksSemaphore = DispatchSemaphore(value: 1) // MARK: Initializers - /// Initializes a renderer with a MathJax instance. - init() { - do { - mathjax = try MathJax(preferredOutputFormat: .svg) - } - catch { - NSLog("Error creating MathJax instance: \(error)") - mathjax = nil - } + /// Initializes a render state with an input string. + /// + /// - Parameter latex: The view's input string. + init(latex: String) { + self.latex = latex } } @@ -125,109 +75,213 @@ internal class Renderer { extension Renderer { - /// Renders the view's component blocks. + /// Returns whether the view's components are cached. /// /// - Parameters: - /// - blocks: The component blocks. - /// - font: The view's font. - /// - displayScale: The display scale to render at. - /// - texOptions: The MathJax Tex input processor options. - /// - Returns: An array of rendered blocks. + /// - unencodeHTML: The `unencodeHTML` environment variable. + /// - parsingMode: The `parsingMode` environment variable. + /// - processEscapes: The `processEscapes` environment variable. + /// - errorMode: The `errorMode` environment variable. + /// - font: The `font environment` variable. + /// - displayScale: The `displayScale` environment variable. + /// - texOptions: The `texOptions` environment variable. + func isCached( + unencodeHTML: Bool, + parsingMode: LaTeX.ParsingMode, + processEscapes: Bool, + errorMode: LaTeX.ErrorMode, + font: Font, + displayScale: CGFloat + ) -> Bool { + let texOptions = TeXInputProcessorOptions(processEscapes: processEscapes, errorMode: errorMode) + return blocksExistInCache( + parsedBlocks(unencodeHTML: unencodeHTML, parsingMode: parsingMode), + font: font, + displayScale: displayScale, + texOptions: texOptions) + } + + /// Renders the view's components synchronously. + /// + /// - Parameters: + /// - unencodeHTML: The `unencodeHTML` environment variable. + /// - parsingMode: The `parsingMode` environment variable. + /// - processEscapes: The `processEscapes` environment variable. + /// - errorMode: The `errorMode` environment variable. + /// - font: The `font environment` variable. + /// - displayScale: The `displayScale` environment variable. + /// - texOptions: The `texOptions` environment variable. + func renderSync( + unencodeHTML: Bool, + parsingMode: LaTeX.ParsingMode, + processEscapes: Bool, + errorMode: LaTeX.ErrorMode, + font: Font, + displayScale: CGFloat + ) -> [ComponentBlock] { + let texOptions = TeXInputProcessorOptions(processEscapes: processEscapes, errorMode: errorMode) + return render( + blocks: parsedBlocks(unencodeHTML: unencodeHTML, parsingMode: parsingMode), + font: font, + displayScale: displayScale, + texOptions: texOptions) + } + + /// Renders the view's components asynchronously. + /// + /// - Parameters: + /// - unencodeHTML: The `unencodeHTML` environment variable. + /// - parsingMode: The `parsingMode` environment variable. + /// - processEscapes: The `processEscapes` environment variable. + /// - errorMode: The `errorMode` environment variable. + /// - font: The `font environment` variable. + /// - displayScale: The `displayScale` environment variable. + /// - texOptions: The `texOptions` environment variable. func render( - blocks: [ComponentBlock], + unencodeHTML: Bool, + parsingMode: LaTeX.ParsingMode, + processEscapes: Bool, + errorMode: LaTeX.ErrorMode, font: Font, - displayScale: CGFloat, - texOptions: TeXInputProcessorOptions - ) async -> [ComponentBlock] { - let xHeight = _Font.preferredFont(from: font).xHeight - var newBlocks = [ComponentBlock]() - for block in blocks { - do { - let newComponents = try await render( - block.components, - xHeight: xHeight, - displayScale: displayScale, - texOptions: texOptions) - - newBlocks.append(ComponentBlock(components: newComponents)) - } - catch { - NSLog("Error rendering block: \(error)") - newBlocks.append(block) - continue - } + displayScale: CGFloat + ) async { + let isRen = await isRendering + let ren = await rendered + guard !isRen && !ren else { + return + } + await MainActor.run { + isRendering = true } - return newBlocks + let texOptions = TeXInputProcessorOptions(processEscapes: processEscapes, errorMode: errorMode) + let renderedBlocks = await render( + blocks: parsedBlocks(unencodeHTML: unencodeHTML, parsingMode: parsingMode), + font: font, + displayScale: displayScale, + texOptions: texOptions) + + await MainActor.run { + blocks = renderedBlocks + isRendering = false + rendered = true + } } - /// Renders the view's component blocks. + /// Converts the component to a `Text` view. /// /// - Parameters: - /// - blocks: The component blocks. - /// - font: The view's font. - /// - displayScale: The display scale to render at. - /// - texOptions: The MathJax Tex input processor options. - /// - Returns: An array of rendered blocks. - func render( - blocks: [ComponentBlock], + /// - component: The component to convert. + /// - font: The font to use. + /// - displayScale: The view's display scale. + /// - renderingMode: The image rendering mode. + /// - errorMode: The error handling mode. + /// - isLastComponentInBlock: Whether or not this is the last component in + /// the block that contains it. + /// - Returns: A text view. + @MainActor func convertToText( + component: Component, font: Font, displayScale: CGFloat, - texOptions: TeXInputProcessorOptions - ) -> [ComponentBlock] { - let xHeight = _Font.preferredFont(from: font).xHeight - var newBlocks = [ComponentBlock]() - for block in blocks { - do { - let newComponents = try render( - block.components, - xHeight: xHeight, - displayScale: displayScale, - texOptions: texOptions) - - newBlocks.append(ComponentBlock(components: newComponents)) + renderingMode: Image.TemplateRenderingMode, + errorMode: LaTeX.ErrorMode, + blockRenderingMode: LaTeX.BlockMode, + isInEquationBlock: Bool + ) -> Text { + // Get the component's text + let text: Text + if let svg = component.svg { + // Do we have an error? + if let errorText = svg.errorText, errorMode != .rendered { + switch errorMode { + case .original: + // Use the original tex input + text = Text(blockRenderingMode == .alwaysInline ? component.originalTextTrimmingNewlines : component.originalText) + case .error: + // Use the error text + text = Text(errorText) + default: + text = Text("") + } } - catch { - NSLog("Error rendering block: \(error)") - newBlocks.append(block) - continue + else if let (image, _, _) = convertToImage( + component: component, + font: font, + displayScale: displayScale, + renderingMode: renderingMode + ) { + let xHeight = _Font.preferredFont(from: font).xHeight + let offset = svg.geometry.verticalAlignment.toPoints(xHeight) + text = Text(image).baselineOffset(blockRenderingMode == .alwaysInline || !isInEquationBlock ? offset : 0) + } + else { + text = Text("") } } + else if blockRenderingMode == .alwaysInline { + text = Text(component.originalTextTrimmingNewlines) + } + else { + text = Text(component.originalText) + } - return newBlocks + return text + } + + /// Creates the image view and its size for the given block. + /// + /// If the block isn't an equation block, then this method returns `nil`. + /// + /// - Parameter block: The block. + /// - Returns: The image, its size, and any associated error text. + @MainActor func convertToImage( + block: ComponentBlock, + font: Font, + displayScale: CGFloat, + renderingMode: Image.TemplateRenderingMode + ) -> (Image, CGSize, String?)? { + guard block.isEquationBlock, let component = block.components.first else { + return nil + } + return convertToImage( + component: component, + font: font, + displayScale: displayScale, + renderingMode: renderingMode) } /// Creates an image from an SVG. /// /// - Parameters: - /// - svg: The SVG. + /// - component: The component to convert. /// - font: The view's font. /// - displayScale: The current display scale. /// - renderingMode: The image's rendering mode. /// - Returns: An image and its size. @MainActor func convertToImage( - svg: SVG, + component: Component, font: Font, displayScale: CGFloat, renderingMode: Image.TemplateRenderingMode - ) -> (Image, CGSize)? { - // Get the image's width, height, and offset - let xHeight = _Font.preferredFont(from: font).xHeight + ) -> (Image, CGSize, String?)? { + guard let svg = component.svg else { + return nil + } // Create our cache key - let cacheKey = ImageCacheKey(svg: svg, xHeight: xHeight) + let cacheKey = Cache.ImageCacheKey(svg: svg, xHeight: font.xHeight) // Check the cache for an image - if let image = imageCacheValue(for: cacheKey) { + if let image = Cache.shared.imageCacheValue(for: cacheKey) { return (Image(image: image) .renderingMode(renderingMode) .antialiased(true) - .interpolation(.high), image.size) + .interpolation(.high), image.size, svg.errorText) } // Continue with getting the image - let width = svg.geometry.width.toPoints(xHeight) - let height = svg.geometry.height.toPoints(xHeight) + let width = svg.geometry.width.toPoints(font.xHeight) + let height = svg.geometry.height.toPoints(font.xHeight) // Render the view let view = SVGView(data: svg.data) @@ -241,74 +295,47 @@ extension Renderer { #endif if let image = image { - setImageCacheValue(image, for: cacheKey) + Cache.shared.setImageCacheValue(image, for: cacheKey) return (Image(image: image) .renderingMode(renderingMode) .antialiased(true) - .interpolation(.high), image.size) + .interpolation(.high), image.size, svg.errorText) } return nil } } -// MARK: Cache access methods +// MARK: Private methods extension Renderer { - /// Safely access the cache value for the given key. - /// - /// - Parameter key: The key of the value to get. - /// - Returns: A value. - private func dataCacheValue(for key: SVGCacheKey) -> Data? { - dataCacheSemaphore.wait() - defer { dataCacheSemaphore.signal() } - return dataCache.object(forKey: key.key() as NSString) as Data? - } - - /// Safely sets the cache value. + /// Gets the LaTeX input's parsed blocks. /// /// - Parameters: - /// - value: The value to set. - /// - key: The value's key. - private func setDataCacheValue(_ value: Data, for key: SVGCacheKey) { - dataCacheSemaphore.wait() - dataCache.setObject(value as NSData, forKey: key.key() as NSString) - dataCacheSemaphore.signal() - } - - /// Safely access the cache value for the given key. - /// - /// - Parameter key: The key of the value to get. - /// - Returns: A value. - private func imageCacheValue(for key: ImageCacheKey) -> _Image? { - imageCacheSemaphore.wait() - defer { imageCacheSemaphore.signal() } - return imageCache.object(forKey: key.key() as NSString) - } - - /// Safely sets the cache value. - /// - /// - Parameters: - /// - value: The value to set. - /// - key: The value's key. - private func setImageCacheValue(_ value: _Image, for key: ImageCacheKey) { - imageCacheSemaphore.wait() - imageCache.setObject(value, forKey: key.key() as NSString) - imageCacheSemaphore.signal() + /// - unencodeHTML: The `unencodeHTML` environment variable. + /// - parsingMode: The `parsingMode` environment variable. + /// - Returns: The parsed blocks. + private func parsedBlocks( + unencodeHTML: Bool, + parsingMode: LaTeX.ParsingMode + ) -> [ComponentBlock] { + _parsedBlocksSemaphore.wait() + defer { _parsedBlocksSemaphore.signal() } + if let _parsedBlocks { + return _parsedBlocks + } + + let blocks = Parser.parse(unencodeHTML ? latex.htmlUnescape() : latex, mode: parsingMode) + _parsedBlocks = blocks + return blocks } -} - -// MARK: Private methods - -extension Renderer { - /// Gets the error text from a possibly non-nil error. /// /// - Parameter error: The error. /// - Returns: The error text. - func getErrorText(from error: Error?) throws -> String? { + private func getErrorText(from error: Error?) throws -> String? { if let mjError = error as? MathJaxError, case .conversionError(let innerError) = mjError { return innerError } @@ -332,9 +359,9 @@ extension Renderer { xHeight: CGFloat, displayScale: CGFloat, texOptions: TeXInputProcessorOptions - ) async throws -> [Component] { + ) throws -> [Component] { // Make sure we have a MathJax instance! - guard let mathjax = mathjax else { + guard let mathjax = MathJax.svgRenderer else { return components } @@ -348,13 +375,13 @@ extension Renderer { } // Create our cache key - let cacheKey = SVGCacheKey( + let cacheKey = Cache.SVGCacheKey( componentText: component.text, conversionOptions: component.conversionOptions, texOptions: texOptions) // Do we have the SVG in the cache? - if let svgData = dataCacheValue(for: cacheKey) { + if let svgData = Cache.shared.dataCacheValue(for: cacheKey) { renderedComponents.append(Component( text: component.text, type: component.type, @@ -364,24 +391,19 @@ extension Renderer { // Perform the conversion var conversionError: Error? - var svgString: String = "" - do { - svgString = try await mathjax.tex2svg( - component.text, - styles: false, - conversionOptions: component.conversionOptions, - inputOptions: texOptions) - } - catch { - conversionError = error - } + let svgString = mathjax.tex2svg( + component.text, + styles: false, + conversionOptions: component.conversionOptions, + inputOptions: texOptions, + error: &conversionError) // Check for a conversion error let errorText = try getErrorText(from: conversionError) // Create and cache the SVG let svg = try SVG(svgString: svgString, errorText: errorText) - setDataCacheValue(try svg.encoded(), for: cacheKey) + Cache.shared.setDataCacheValue(try svg.encoded(), for: cacheKey) // Save the rendered component renderedComponents.append(Component( @@ -394,75 +416,91 @@ extension Renderer { return renderedComponents } - /// Renders the components and stores the new images in a new set of - /// components. + /// Determines and returns whether the blocks are in the renderer's cache. /// /// - Parameters: - /// - components: The components to render. - /// - xHeight: The xHeight of the font to use. - /// - displayScale: The current display scale. - /// - texOptions: The MathJax TeX input processor options. - /// - Returns: An array of components. - private func render( - _ components: [Component], - xHeight: CGFloat, + /// - blocks: The blocks. + /// - font: The `font` environment variable. + /// - displayScale: The `displayScale` environment variable. + /// - texOptions: The `texOptions` environment variable. + /// - Returns: Whether the blocks are in the renderer's cache. + func blocksExistInCache(_ blocks: [ComponentBlock], font: Font, displayScale: CGFloat, texOptions: TeXInputProcessorOptions) -> Bool { + for block in blocks { + for component in block.components where component.type.isEquation { + let dataCacheKey = Cache.SVGCacheKey( + componentText: component.text, + conversionOptions: component.conversionOptions, + texOptions: texOptions) + guard let svgData = Cache.shared.dataCacheValue(for: dataCacheKey) else { + return false + } + + guard let svg = try? SVG(data: svgData) else { + return false + } + + let xHeight = _Font.preferredFont(from: font).xHeight + let imageCacheKey = Cache.ImageCacheKey(svg: svg, xHeight: xHeight) + guard Cache.shared.imageCacheValue(for: imageCacheKey) != nil else { + return false + } + } + } + return true + } + + /// Renders the view's component blocks. + /// + /// - Parameters: + /// - blocks: The component blocks. + /// - font: The view's font. + /// - displayScale: The display scale to render at. + /// - texOptions: The MathJax Tex input processor options. + /// - Returns: An array of rendered blocks. + func render( + blocks: [ComponentBlock], + font: Font, displayScale: CGFloat, texOptions: TeXInputProcessorOptions - ) throws -> [Component] { - // Make sure we have a MathJax instance! - guard let mathjax = mathjax else { - return components - } - - // Iterate through the input components and render - var renderedComponents = [Component]() - for component in components { - // Only render equation components - guard component.type.isEquation else { - renderedComponents.append(component) - continue + ) async -> [ComponentBlock] { + return await withCheckedContinuation({ continuation in + continuation.resume(returning: render(blocks: blocks, font: font, displayScale: displayScale, texOptions: texOptions)) + }) + } + + /// Renders the view's component blocks. + /// + /// - Parameters: + /// - blocks: The component blocks. + /// - font: The view's font. + /// - displayScale: The display scale to render at. + /// - texOptions: The MathJax Tex input processor options. + /// - Returns: An array of rendered blocks. + func render( + blocks: [ComponentBlock], + font: Font, + displayScale: CGFloat, + texOptions: TeXInputProcessorOptions + ) -> [ComponentBlock] { + var newBlocks = [ComponentBlock]() + for block in blocks { + do { + let newComponents = try render( + block.components, + xHeight: font.xHeight, + displayScale: displayScale, + texOptions: texOptions) + + newBlocks.append(ComponentBlock(components: newComponents)) } - - // Create our cache key - let cacheKey = SVGCacheKey( - componentText: component.text, - conversionOptions: component.conversionOptions, - texOptions: texOptions) - - // Do we have the SVG in the cache? - if let svgData = dataCacheValue(for: cacheKey) { - renderedComponents.append(Component( - text: component.text, - type: component.type, - svg: try SVG(data: svgData))) + catch { + NSLog("Error rendering block: \(error)") + newBlocks.append(block) continue } - - // Perform the conversion - var conversionError: Error? - let svgString = mathjax.tex2svg( - component.text, - styles: false, - conversionOptions: component.conversionOptions, - inputOptions: texOptions, - error: &conversionError) - - // Check for a conversion error - let errorText = try getErrorText(from: conversionError) - - // Create and cache the SVG - let svg = try SVG(svgString: svgString, errorText: errorText) - setDataCacheValue(try svg.encoded(), for: cacheKey) - - // Save the rendered component - renderedComponents.append(Component( - text: component.text, - type: component.type, - svg: svg)) } - // All done - return renderedComponents + return newBlocks } } diff --git a/Sources/LaTeXSwiftUI/Views/ComponentBlockText.swift b/Sources/LaTeXSwiftUI/Views/ComponentBlockText.swift index bdd6061..9c5b2a8 100644 --- a/Sources/LaTeXSwiftUI/Views/ComponentBlockText.swift +++ b/Sources/LaTeXSwiftUI/Views/ComponentBlockText.swift @@ -31,6 +31,9 @@ internal struct ComponentBlockText: View { /// The component blocks to display in the view. let block: ComponentBlock + /// The view's renderer. + let renderer: Renderer + // MARK: Private properties /// The rendering mode to use with the rendered MathJax images. @@ -52,7 +55,8 @@ internal struct ComponentBlockText: View { var body: Text { block.components.enumerated().map { i, component in - return component.convertToText( + return renderer.convertToText( + component: component, font: font ?? .body, displayScale: displayScale, renderingMode: imageRenderingMode, @@ -68,6 +72,6 @@ struct ComponentBlockTextPreviews: PreviewProvider { static var previews: some View { ComponentBlockText(block: ComponentBlock(components: [ Component(text: "Hello, World!", type: .text) - ])) + ]), renderer: Renderer(latex: "Hello, World!")) } } diff --git a/Sources/LaTeXSwiftUI/Views/ComponentBlocksText.swift b/Sources/LaTeXSwiftUI/Views/ComponentBlocksText.swift index 840368b..f20157f 100644 --- a/Sources/LaTeXSwiftUI/Views/ComponentBlocksText.swift +++ b/Sources/LaTeXSwiftUI/Views/ComponentBlocksText.swift @@ -34,11 +34,16 @@ internal struct ComponentBlocksText: View { /// Whether inline mode should be forced. var forceInline: Bool = false + // MARK: Private properties + + /// The view's renderer. + @EnvironmentObject private var renderer: Renderer + // MARK: View body var body: some View { blocks.map { block in - let text = ComponentBlockText(block: block).body + let text = ComponentBlockText(block: block, renderer: renderer).body return block.isEquationBlock && !forceInline ? Text("\n") + text + Text("\n") : text @@ -52,5 +57,6 @@ struct ComponentBlocksTextPreviews: PreviewProvider { ComponentBlocksText(blocks: [ComponentBlock(components: [ Component(text: "Hello, World!", type: .text) ])], forceInline: false) + .environmentObject(Renderer(latex: "Hello, World!")) } } diff --git a/Sources/LaTeXSwiftUI/Views/ComponentBlocksViews.swift b/Sources/LaTeXSwiftUI/Views/ComponentBlocksViews.swift index 1e35c6d..375bc2e 100644 --- a/Sources/LaTeXSwiftUI/Views/ComponentBlocksViews.swift +++ b/Sources/LaTeXSwiftUI/Views/ComponentBlocksViews.swift @@ -33,6 +33,9 @@ internal struct ComponentBlocksViews: View { // MARK: Private properties + /// The view's renderer. + @EnvironmentObject private var renderer: Renderer + /// The rendering mode to use with the rendered MathJax images. @Environment(\.imageRenderingMode) private var imageRenderingMode @@ -57,7 +60,7 @@ internal struct ComponentBlocksViews: View { VStack(alignment: .leading, spacing: lineSpacing + 4) { ForEach(blocks, id: \.self) { block in if block.isEquationBlock, - let (image, size, errorText) = block.image(font: font ?? .body, displayScale: displayScale, renderingMode: imageRenderingMode) { + let (image, size, errorText) = renderer.convertToImage(block: block, font: font ?? .body, displayScale: displayScale, renderingMode: imageRenderingMode) { HStack(spacing: 0) { EquationNumber(blockIndex: blocks.filter({ $0.isEquationBlock }).firstIndex(of: block) ?? 0, side: .left) @@ -81,7 +84,7 @@ internal struct ComponentBlocksViews: View { } } else { - ComponentBlockText(block: block) + ComponentBlockText(block: block, renderer: renderer) } } } @@ -94,5 +97,6 @@ struct ComponentBlocksViewsPreviews: PreviewProvider { ComponentBlocksViews(blocks: [ComponentBlock(components: [ Component(text: "Hello, World!", type: .text) ])]) + .environmentObject(Renderer(latex: "Hello, World!")) } }