Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions Examples/Demo/Demo/Assets.xcassets/smallDog.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "237-30x40.jpg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
8 changes: 7 additions & 1 deletion Examples/Demo/Demo/ImageProvidersView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand All @@ -36,6 +41,7 @@ struct ImageProvidersView: View {
Section("Image Assets") {
Markdown(self.otherContent)
.markdownImageProvider(.asset)
.markdownInlineImageProvider(.asset)
}
}
}
Expand Down
24 changes: 15 additions & 9 deletions Examples/Demo/Demo/ImagesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
24 changes: 14 additions & 10 deletions Sources/MarkdownUI/Content/Inlines/Inline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 2 additions & 8 deletions Sources/MarkdownUI/Content/Inlines/InlineImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")!)
/// "."
/// }
/// }
/// ```
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions Sources/MarkdownUI/Extensions/AssetInlineImageProvider.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
29 changes: 29 additions & 0 deletions Sources/MarkdownUI/Extensions/DefaultInlineImageProvider.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
16 changes: 16 additions & 0 deletions Sources/MarkdownUI/Extensions/InlineImageProvider.swift
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions Sources/MarkdownUI/Views/Blocks/ImageFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 2 additions & 8 deletions Sources/MarkdownUI/Views/Inlines/ImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
58 changes: 45 additions & 13 deletions Sources/MarkdownUI/Views/Inlines/InlineText.swift
Original file line number Diff line number Diff line change
@@ -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]) {
Expand All @@ -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
}
}
}
36 changes: 36 additions & 0 deletions Sources/MarkdownUI/Views/Inlines/Text+Inline.swift
Original file line number Diff line number Diff line change
@@ -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()
)
}
}
}
Loading