diff --git a/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/237-30x40.jpg b/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/237-30x40.jpg new file mode 100644 index 00000000..b380e62b Binary files /dev/null and b/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/237-30x40.jpg differ diff --git a/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/Contents.json b/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/Contents.json new file mode 100644 index 00000000..6baf171e --- /dev/null +++ b/Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "237-30x40.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Demo/Demo/ImageProvidersView.swift b/Examples/Demo/Demo/ImageProvidersView.swift index ff19982f..e3547614 100644 --- a/Examples/Demo/Demo/ImageProvidersView.swift +++ b/Examples/Demo/Demo/ImageProvidersView.swift @@ -13,18 +13,23 @@ struct ImageProvidersView: View { """ private let otherContent = """ - You can use the built-in `AssetImageProvider` to load images from image assets. + You can use the built-in `AssetImageProvider` and `AssetInlineImageProvider` + to load images from image assets. ```swift Markdown { "![A dog](dog)" + "A ![dog](smallDog) within a line of text." "― Photo by André Spieker" } .markdownImageProvider(.asset) + .markdownInlineImageProvider(.asset) ``` ![A dog](dog) + An image ![dog](smallDog) within a line of text. + ― Photo by André Spieker """ @@ -36,6 +41,7 @@ struct ImageProvidersView: View { Section("Image Assets") { Markdown(self.otherContent) .markdownImageProvider(.asset) + .markdownInlineImageProvider(.asset) } } } diff --git a/Examples/Demo/Demo/ImagesView.swift b/Examples/Demo/Demo/ImagesView.swift index e447ec3a..2d03e484 100644 --- a/Examples/Demo/Demo/ImagesView.swift +++ b/Examples/Demo/Demo/ImagesView.swift @@ -15,25 +15,31 @@ struct ImagesView: View { ― Photo by Jennifer Trovato """ - private let assetContent = """ - You can configure a `Markdown` view to load images from the asset catalog. + private let inlineImageContent = """ + You can also insert images in a line of text, such as + ![](https://picsum.photos/id/237/50/25) or + ![](https://picsum.photos/id/433/50/25). - ```swift - Markdown { - "![This is an image](237-200x300)" - } - .markdownImageProvider(.asset) + ``` + You can also insert images in a line of text, such as + ![](https://picsum.photos/id/237/50/25) or + ![](https://picsum.photos/id/433/50/25). ``` - ![This is an image](dog) + Note that MarkdownUI **cannot** apply any styling to + inline images. - ― Photo by André Spieker + ― Photos by André Spieker and Thomas Lefebvre """ var body: some View { DemoView { Markdown(self.content) + Section("Inline images") { + Markdown(self.inlineImageContent) + } + Section("Customization Example") { Markdown(self.content) } diff --git a/Sources/MarkdownUI/Content/Inlines/Inline.swift b/Sources/MarkdownUI/Content/Inlines/Inline.swift index 762b318d..40efc46a 100644 --- a/Sources/MarkdownUI/Content/Inlines/Inline.swift +++ b/Sources/MarkdownUI/Content/Inlines/Inline.swift @@ -82,19 +82,23 @@ extension Array where Element == Inline { } extension Inline { - var image: (source: String?, alt: String)? { - guard case let .image(source, children) = self else { - return nil - } - return (source, children.text) + struct Image: Hashable { + var source: String? + var alt: String + var destination: String? } - var imageLink: (source: String?, alt: String, destination: String?)? { - guard case let .link(destination, children) = self, children.count == 1, - let (source, alt) = children.first?.image - else { + var image: Image? { + switch self { + case let .image(source, children): + return .init(source: source, alt: children.text) + case let .link(destination, children) where children.count == 1: + guard case let .some(.image(source, children)) = children.first else { + return nil + } + return .init(source: source, alt: children.text, destination: destination) + default: return nil } - return (source, alt, destination) } } diff --git a/Sources/MarkdownUI/Content/Inlines/InlineImage.swift b/Sources/MarkdownUI/Content/Inlines/InlineImage.swift index a0e8c2f0..06824583 100644 --- a/Sources/MarkdownUI/Content/Inlines/InlineImage.swift +++ b/Sources/MarkdownUI/Content/Inlines/InlineImage.swift @@ -4,13 +4,6 @@ import Foundation /// /// You can use an image inline to embed an image in a paragraph. /// -/// Note that even if you can compose images and text as part of the same inline content, the ``Markdown`` -/// view is currently limited to displaying image-only paragraphs and will ignore images composed with other -/// text inlines in the same block. -/// -/// In the following example, the ``Markdown`` view will not display the image in the last paragraph, as it -/// is interleaved with other text inline. -/// /// ```swift /// Markdown { /// Paragraph { @@ -23,8 +16,9 @@ import Foundation /// } /// } /// Paragraph { -/// "The following image will be ignored:" +/// "You can also insert images in a line of text, such as " /// InlineImage(source: URL(string: "https://picsum.photos/id/237/100/150")!) +/// "." /// } /// } /// ``` diff --git a/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage@2x.png b/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage@2x.png index fd4f019a..bc313ba6 100644 Binary files a/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage@2x.png and b/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage@2x.png differ diff --git a/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage~dark@2x.png b/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage~dark@2x.png index 61a710d1..c636d1b6 100644 Binary files a/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage~dark@2x.png and b/Sources/MarkdownUI/Documentation.docc/Resources/InlineImage~dark@2x.png differ diff --git a/Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift b/Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift new file mode 100644 index 00000000..c88f2fe7 --- /dev/null +++ b/Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// An inline image provider that loads images from resources located in an app or a module. +public struct AssetInlineImageProvider: InlineImageProvider { + private let name: (URL) -> String + private let bundle: Bundle? + + /// Creates an asset inline image provider. + /// - Parameters: + /// - name: A closure that extracts the image resource name from the URL in the Markdown content. + /// - bundle: The bundle where the image resources are located. Specify `nil` to search the app’s main bundle. + public init( + name: @escaping (URL) -> String = \.lastPathComponent, + bundle: Bundle? = nil + ) { + self.name = name + self.bundle = bundle + } + + public func image(with url: URL, label: String) async throws -> Image { + .init(self.name(url), bundle: self.bundle, label: Text(label)) + } +} + +extension InlineImageProvider where Self == AssetInlineImageProvider { + /// An inline image provider that loads images from resources located in an app or a module. + /// + /// Use the `markdownInlineImageProvider(_:)` modifier to configure this image provider for a view hierarchy. + public static var asset: Self { + .init() + } +} diff --git a/Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift b/Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift new file mode 100644 index 00000000..da15910c --- /dev/null +++ b/Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The default inline image provider, which loads images from the network. +public struct DefaultInlineImageProvider: InlineImageProvider { + private let urlSession: URLSession + + /// Creates a default inline image provider. + /// - Parameter urlSession: An `URLSession` instance to load images. + public init(urlSession: URLSession = .shared) { + self.urlSession = urlSession + } + + public func image(with url: URL, label: String) async throws -> Image { + try await Image( + platformImage: DefaultImageLoader.shared + .image(with: url, urlSession: self.urlSession) + ) + } +} + +extension InlineImageProvider where Self == DefaultInlineImageProvider { + /// The default inline image provider, which loads images from the network. + /// + /// Use the `markdownInlineImageProvider(_:)` modifier to configure + /// this image provider for a view hierarchy. + public static var `default`: Self { + .init() + } +} diff --git a/Sources/MarkdownUI/Extensions/InlineImageProvider.swift b/Sources/MarkdownUI/Extensions/InlineImageProvider.swift new file mode 100644 index 00000000..06bd9e1c --- /dev/null +++ b/Sources/MarkdownUI/Extensions/InlineImageProvider.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// A type that loads images that are displayed within a line of text. +/// +/// To configure the current inline image provider for a view hierarchy, +/// use the `markdownInlineImageProvider(_:)` modifier. +public protocol InlineImageProvider { + /// Returns an image for the given URL. + /// + /// ``Markdown`` views call this method to load images within a line of text. + /// + /// - Parameters: + /// - url: The URL of the image to display. + /// - label: The accessibility label associated with the image. + func image(with url: URL, label: String) async throws -> Image +} diff --git a/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift b/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift index 273ea5a4..88542f68 100644 --- a/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift +++ b/Sources/MarkdownUI/Views/Blocks/ImageFlow.swift @@ -43,10 +43,10 @@ extension ImageFlow { case let .image(source, children): items.append(.image(source: source, alt: children.text)) case let .link(destination, children) where children.count == 1: - guard let (source, alt) = children.first?.image else { + guard let image = children.first?.image else { return nil } - items.append(.image(source: source, alt: alt, destination: destination)) + items.append(.image(source: image.source, alt: image.alt, destination: destination)) default: return nil } diff --git a/Sources/MarkdownUI/Views/Environment/Environment+InlineImageProvider.swift b/Sources/MarkdownUI/Views/Environment/Environment+InlineImageProvider.swift new file mode 100644 index 00000000..fa1b5918 --- /dev/null +++ b/Sources/MarkdownUI/Views/Environment/Environment+InlineImageProvider.swift @@ -0,0 +1,24 @@ +import SwiftUI + +extension View { + /// Sets the inline image provider for the Markdown inline images in a view hierarchy. + /// - Parameter inlineImageProvider: The inline image provider to set. Use one of the built-in values, like + /// ``InlineImageProvider/default`` or ``InlineImageProvider/asset``, + /// or a custom inline image provider that you define by creating a type that + /// conforms to the ``InlineImageProvider`` protocol. + /// - Returns: A view that uses the specified inline image provider for itself and its child views. + public func markdownInlineImageProvider(_ inlineImageProvider: InlineImageProvider) -> some View { + self.environment(\.inlineImageProvider, inlineImageProvider) + } +} + +extension EnvironmentValues { + var inlineImageProvider: InlineImageProvider { + get { self[InlineImageProviderKey.self] } + set { self[InlineImageProviderKey.self] = newValue } + } +} + +private struct InlineImageProviderKey: EnvironmentKey { + static let defaultValue: InlineImageProvider = .default +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift index ea32549a..1771ecd1 100644 --- a/Sources/MarkdownUI/Views/Inlines/ImageView.swift +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -32,17 +32,11 @@ struct ImageView: View { extension ImageView { init?(_ inlines: [Inline]) { - guard inlines.count == 1, let inline = inlines.first else { + guard inlines.count == 1, let inline = inlines.first, let image = inline.image else { return nil } - if let (source, alt) = inline.image { - self.init(source: source, alt: alt) - } else if let (source, alt, destination) = inline.imageLink { - self.init(source: source, alt: alt, destination: destination) - } else { - return nil - } + self.init(source: image.source, alt: image.alt, destination: image.destination) } } diff --git a/Sources/MarkdownUI/Views/Inlines/InlineText.swift b/Sources/MarkdownUI/Views/Inlines/InlineText.swift index 5e3a1445..5c55d62b 100644 --- a/Sources/MarkdownUI/Views/Inlines/InlineText.swift +++ b/Sources/MarkdownUI/Views/Inlines/InlineText.swift @@ -1,9 +1,13 @@ import SwiftUI struct InlineText: View { + @Environment(\.inlineImageProvider) private var inlineImageProvider @Environment(\.baseURL) private var baseURL + @Environment(\.imageBaseURL) private var imageBaseURL @Environment(\.theme) private var theme + @State private var inlineImages: [String: Image] = [:] + private let inlines: [Inline] init(_ inlines: [Inline]) { @@ -13,21 +17,49 @@ struct InlineText: View { var body: some View { TextStyleAttributesReader { attributes in Text( - AttributedString( - inlines: self.inlines, - environment: .init( - baseURL: self.baseURL, - code: self.theme.code, - emphasis: self.theme.emphasis, - strong: self.theme.strong, - strikethrough: self.theme.strikethrough, - link: self.theme.link - ), - attributes: attributes - ) - .resolvingFonts() + inlines: self.inlines, + images: self.inlineImages, + environment: .init( + baseURL: self.baseURL, + code: self.theme.code, + emphasis: self.theme.emphasis, + strong: self.theme.strong, + strikethrough: self.theme.strikethrough, + link: self.theme.link + ), + attributes: attributes ) } + .task(id: self.inlines) { + self.inlineImages = (try? await self.loadInlineImages()) ?? [:] + } .fixedSize(horizontal: false, vertical: true) } + + private func loadInlineImages() async throws -> [String: Image] { + let images = Set(self.inlines.compactMap(\.image)) + guard !images.isEmpty else { return [:] } + + return try await withThrowingTaskGroup(of: (String, Image).self) { taskGroup in + for image in images { + guard let source = image.source, + let url = URL(string: source, relativeTo: self.imageBaseURL) + else { + continue + } + + taskGroup.addTask { + (source, try await self.inlineImageProvider.image(with: url, label: image.alt)) + } + } + + var inlineImages: [String: Image] = [:] + + for try await result in taskGroup { + inlineImages[result.0] = result.1 + } + + return inlineImages + } + } } diff --git a/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift b/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift new file mode 100644 index 00000000..935bdd9b --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/Text+Inline.swift @@ -0,0 +1,36 @@ +import SwiftUI + +extension Text { + init( + inlines: [Inline], + images: [String: Image], + environment: InlineEnvironment, + attributes: AttributeContainer + ) { + self = inlines.map { inline in + Text(inline: inline, images: images, environment: environment, attributes: attributes) + } + .reduce(.init(""), +) + } + + init( + inline: Inline, + images: [String: Image], + environment: InlineEnvironment, + attributes: AttributeContainer + ) { + switch inline { + case .image(let source, _): + if let image = images[source] { + self.init(image) + } else { + self.init("") + } + default: + self.init( + AttributedString(inline: inline, environment: environment, attributes: attributes) + .resolvingFonts() + ) + } + } +} diff --git a/Tests/MarkdownUITests/MarkdownContentTests.swift b/Tests/MarkdownUITests/MarkdownContentTests.swift index fff0b282..0a42d156 100644 --- a/Tests/MarkdownUITests/MarkdownContentTests.swift +++ b/Tests/MarkdownUITests/MarkdownContentTests.swift @@ -198,7 +198,7 @@ final class MarkdownContentTests: XCTestCase { XCTAssertEqual( MarkdownContent { TextTable { - TextTableColumn(title: "Default", value: \[String].[0]) + TextTableColumn(title: "Default", value: \.[0]) TextTableColumn(alignment: .leading, title: "Leading", value: \.[1]) TextTableColumn(alignment: .center, title: "Center", value: \.[2]) TextTableColumn(alignment: .trailing, title: "Trailing", value: \.[3])