diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63913fb1..60676672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: macos-12 steps: - uses: actions/checkout@v2 - - name: Select Xcode 14.0 - run: sudo xcode-select -s /Applications/Xcode_14.0.app + - name: Select Xcode 14.1 + run: sudo xcode-select -s /Applications/Xcode_14.1.app - name: Run tests run: make test diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index e8245e71..1b0862a7 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -9,12 +9,12 @@ jobs: runs-on: macos-12 steps: - uses: actions/checkout@v2 - - name: Select Xcode 13.4.1 - run: sudo xcode-select -s /Applications/Xcode_13.4.1.app + - name: Select Xcode 14.1 + run: sudo xcode-select -s /Applications/Xcode_14.1.app - name: Tap run: brew tap pointfreeco/formulae - name: Install - run: brew install Formulae/swift-format@5.6 + run: brew install Formulae/swift-format@5.7 - name: Format run: make format - uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/MarkdownUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/MarkdownUI.xcscheme index bf48b40a..0d309541 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/MarkdownUI.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/MarkdownUI.xcscheme @@ -40,7 +40,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/Makefile b/Makefile index 98dab71f..a6d98967 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ test-macos: test-ios: xcodebuild test \ -scheme MarkdownUI \ - -destination platform="iOS Simulator,name=iPhone 8" + -destination platform="iOS Simulator,name=iPhone SE (3rd generation)" test-tvos: xcodebuild test \ @@ -18,7 +18,7 @@ readme-images: xcodebuild test \ "OTHER_SWIFT_FLAGS=${inherited} -D README_IMAGES" \ -scheme MarkdownUI \ - -destination platform="iOS Simulator,name=iPhone 8" \ + -destination platform="iOS Simulator,name=iPhone SE (3rd generation)" \ -only-testing "MarkdownUITests/ReadMeImagesTests" || true mv Tests/MarkdownUITests/__Snapshots__/ReadMeImagesTests Images sips -Z 400 Images/*.png diff --git a/Package.swift b/Package.swift index 8a3c59d8..227a800c 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,10 @@ let package = Package( .target(name: "cmark-gfm"), .target( name: "MarkdownUI", - dependencies: ["cmark-gfm"] + dependencies: [ + "cmark-gfm", + .product(name: "CombineSchedulers", package: "combine-schedulers"), + ] ), .testTarget( name: "MarkdownUITests", diff --git a/Sources/MarkdownUI/Document/Inlines/AnyInline.swift b/Sources/MarkdownUI/Document/Inlines/AnyInline.swift index 369b1d39..b0e18f98 100644 --- a/Sources/MarkdownUI/Document/Inlines/AnyInline.swift +++ b/Sources/MarkdownUI/Document/Inlines/AnyInline.swift @@ -49,4 +49,53 @@ extension AnyInline { return nil } } + + var text: String { + switch self { + case .text(let content): + return content + case .softBreak: + return " " + case .lineBreak: + return "\n" + case .code(let content): + return content + case .html(let content): + return content + case .emphasis(let children): + return children.text + case .strong(let children): + return children.text + case .strikethrough(let children): + return children.text + case .link(_, let children): + return children.text + case .image(_, _, let children): + return children.text + } + } +} + +extension Array where Element == AnyInline { + var text: String { + map(\.text).joined() + } +} + +extension AnyInline { + var image: (source: String?, alt: String)? { + guard case let .image(source, _, children) = self else { + return nil + } + return (source, children.text) + } + + 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 { + return nil + } + return (source, alt, destination) + } } diff --git a/Sources/MarkdownUI/Theme/Styles/ImageStyle.swift b/Sources/MarkdownUI/Theme/Styles/ImageStyle.swift new file mode 100644 index 00000000..0375b961 --- /dev/null +++ b/Sources/MarkdownUI/Theme/Styles/ImageStyle.swift @@ -0,0 +1,38 @@ +import SwiftUI + +public struct ImageStyle { + public struct Configuration { + public struct Content: View { + init(_ content: C) { + self.body = AnyView(content) + } + + public let body: AnyView + } + + public let content: Content + } + + let makeBody: (Configuration) -> AnyView + + public init(@ViewBuilder makeBody: @escaping (Configuration) -> Body) where Body: View { + self.makeBody = { configuration in + AnyView(makeBody(configuration)) + } + } +} + +extension ImageStyle { + public static var `default`: Self { + .init { $0.content } + } + + public static func alignment(_ alignment: HorizontalAlignment) -> Self { + .init { configuration in + ZStack { + configuration.content + } + .frame(maxWidth: .infinity, alignment: .init(horizontal: alignment, vertical: .center)) + } + } +} diff --git a/Sources/MarkdownUI/Theme/Theme.swift b/Sources/MarkdownUI/Theme/Theme.swift index ab4390ae..0413d76b 100644 --- a/Sources/MarkdownUI/Theme/Theme.swift +++ b/Sources/MarkdownUI/Theme/Theme.swift @@ -4,6 +4,8 @@ public struct Theme { // MARK: - Metrics public var paragraphSpacing: CGFloat + public var horizontalImageSpacing: CGFloat + public var verticalImageSpacing: CGFloat // MARK: - Inline styles @@ -14,29 +16,38 @@ public struct Theme { public var strong: InlineStyle public var strikethrough: InlineStyle public var link: InlineStyle + public var image: ImageStyle } extension Theme { private enum Defaults { static let paragraphSpacing = Font.TextStyle.body.pointSize + static let horizontalImageSpacing = floor(Font.TextStyle.body.pointSize / 4) + static let verticalImageSpacing = floor(Font.TextStyle.body.pointSize / 4) } public init( paragraphSpacing: CGFloat? = nil, + horizontalImageSpacing: CGFloat? = nil, + verticalImageSpacing: CGFloat? = nil, baseFont: Font = .body, inlineCode: InlineStyle, emphasis: InlineStyle, strong: InlineStyle, strikethrough: InlineStyle, - link: InlineStyle + link: InlineStyle, + image: ImageStyle ) { self.paragraphSpacing = paragraphSpacing ?? Defaults.paragraphSpacing + self.horizontalImageSpacing = horizontalImageSpacing ?? Defaults.horizontalImageSpacing + self.verticalImageSpacing = verticalImageSpacing ?? Defaults.verticalImageSpacing self.baseFont = baseFont self.inlineCode = inlineCode self.emphasis = emphasis self.strong = strong self.strikethrough = strikethrough self.link = link + self.image = image } } @@ -47,7 +58,8 @@ extension Theme { emphasis: .italic, strong: .bold, strikethrough: .strikethrough, - link: .default + link: .default, + image: .default ) } } diff --git a/Sources/MarkdownUI/Views/Blocks/AnyBlock+View.swift b/Sources/MarkdownUI/Views/Blocks/AnyBlock+View.swift index 365d1019..15cadbc7 100644 --- a/Sources/MarkdownUI/Views/Blocks/AnyBlock+View.swift +++ b/Sources/MarkdownUI/Views/Blocks/AnyBlock+View.swift @@ -4,7 +4,15 @@ extension AnyBlock: View { public var body: some View { switch self { case .paragraph(let inlines): - ParagraphView(inlines) + if let singleImageParagraph = SingleImageParagraphView(inlines) { + singleImageParagraph + } else if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *), + let imageParagraph = ImageParagraphView(inlines) + { + imageParagraph + } else { + ParagraphView(inlines) + } } } } diff --git a/Sources/MarkdownUI/Views/Blocks/ImageParagraphView.swift b/Sources/MarkdownUI/Views/Blocks/ImageParagraphView.swift new file mode 100644 index 00000000..6b7a8a12 --- /dev/null +++ b/Sources/MarkdownUI/Views/Blocks/ImageParagraphView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +struct ImageParagraphView: View { + private enum Item: Hashable { + case image(source: String?, alt: String, destination: String? = nil) + case lineBreak + } + + @Environment(\.theme.paragraphSpacing) private var paragraphSpacing + @Environment(\.theme.horizontalImageSpacing) private var horizontalSpacing + @Environment(\.theme.verticalImageSpacing) private var verticalSpacing + + private let items: [Identified] + + var body: some View { + Flow(horizontalSpacing: self.horizontalSpacing, verticalSpacing: self.verticalSpacing) { + ForEach(self.items) { item in + switch item.value { + case let .image(source, alt, destination): + ImageView(source: source, alt: alt, destination: destination) + case .lineBreak: + Spacer() + } + } + } + .preference(key: BlockSpacingPreference.self, value: paragraphSpacing) + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension ImageParagraphView { + init?(_ inlines: [AnyInline]) { + var items: [Item] = [] + + for inline in inlines { + switch inline { + case let .text(text) where text.isEmpty: + continue + case .softBreak: + continue + case .lineBreak: + items.append(.lineBreak) + 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 { + return nil + } + items.append(.image(source: source, alt: alt, destination: destination)) + default: + return nil + } + } + + self.items = items.identified() + } +} diff --git a/Sources/MarkdownUI/Views/Blocks/SingleImageParagraphView.swift b/Sources/MarkdownUI/Views/Blocks/SingleImageParagraphView.swift new file mode 100644 index 00000000..7009e60e --- /dev/null +++ b/Sources/MarkdownUI/Views/Blocks/SingleImageParagraphView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct SingleImageParagraphView: View { + @Environment(\.theme.paragraphSpacing) private var paragraphSpacing + + private let content: ImageView + + private init(content: ImageView) { + self.content = content + } + + var body: some View { + self.content + .preference(key: BlockSpacingPreference.self, value: paragraphSpacing) + } +} + +extension SingleImageParagraphView { + init?(_ inlines: [AnyInline]) { + guard let content = ImageView(inlines) else { + return nil + } + self.init(content: content) + } +} diff --git a/Sources/MarkdownUI/Views/Common/Flow.swift b/Sources/MarkdownUI/Views/Common/Flow.swift new file mode 100644 index 00000000..d63796de --- /dev/null +++ b/Sources/MarkdownUI/Views/Common/Flow.swift @@ -0,0 +1,93 @@ +import SwiftUI + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +struct Flow: Layout { + let horizontalSpacing: CGFloat + let verticalSpacing: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let rows = self.computeLayout(for: proposal, subviews: subviews) + return self.sizeThatFits(rows: rows) + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void + ) { + let rows = self.computeLayout(for: proposal, subviews: subviews) + var position = bounds.origin + + for row in rows { + for item in row.items { + // align to bottom + let itemBounds = CGRect(origin: position, size: item.size) + .offsetBy(dx: 0, dy: row.size.height - item.size.height) + subviews[item.index].place(at: itemBounds.origin, proposal: .init(itemBounds.size)) + position.x += item.size.width + self.horizontalSpacing + } + + position.x = bounds.origin.x + position.y += row.size.height + self.verticalSpacing + } + } +} + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +extension Flow { + private struct Item { + let index: Int + let size: CGSize + } + + private struct Row { + var size: CGSize = .zero + var items: [Item] = [] + } + + private func computeLayout(for proposal: ProposedViewSize, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var currentRow = Row() + + for (index, view) in zip(subviews.indices, subviews) { + // propose the remainder of the width for low prioriy views, otherwise the full width + // this way we can use a spacer view for hard line breaks + let proposedWidth = + view.priority < 0 ? proposal.width.map { $0 - currentRow.size.width } : proposal.width + let item = Item( + index: index, + size: view.sizeThatFits(.init(width: proposedWidth, height: nil)) + ) + + if currentRow.size.width > 0, + currentRow.size.width + item.size.width > (proposal.width ?? .infinity) + { + // Remove the spacing for the last item + currentRow.size.width -= self.horizontalSpacing + rows.append(currentRow) + currentRow = Row() + } + + currentRow.items.append(item) + currentRow.size.width += item.size.width + self.horizontalSpacing + currentRow.size.height = max(item.size.height, currentRow.size.height) + } + + if !currentRow.items.isEmpty { + // Remove the spacing for the last item + currentRow.size.width -= self.horizontalSpacing + rows.append(currentRow) + } + + return rows + } + + private func sizeThatFits(rows: [Row]) -> CGSize { + zip(rows.indices, rows).reduce(CGSize.zero) { size, tuple in + let (index, row) = tuple + let spacing = index < rows.endIndex - 1 ? self.verticalSpacing : 0 + return CGSize( + width: max(size.width, row.size.width), + height: size.height + row.size.height + spacing + ) + } + } +} diff --git a/Sources/MarkdownUI/Views/Common/LinkModifier.swift b/Sources/MarkdownUI/Views/Common/LinkModifier.swift new file mode 100644 index 00000000..eb3c9cac --- /dev/null +++ b/Sources/MarkdownUI/Views/Common/LinkModifier.swift @@ -0,0 +1,27 @@ +import SwiftUI + +private struct LinkModifier: ViewModifier { + @Environment(\.markdownBaseURL) private var baseURL + @Environment(\.openURL) private var openURL + + let destination: String? + + func body(content: Content) -> some View { + if let url = self.destination.flatMap(URL.init(string:))?.relativeTo(self.baseURL) { + Button { + self.openURL(url) + } label: { + content + } + .buttonStyle(.plain) + } else { + content + } + } +} + +extension View { + func link(destination: String?) -> some View { + self.modifier(LinkModifier(destination: destination)) + } +} diff --git a/Sources/MarkdownUI/Views/Common/ResizeToFit.swift b/Sources/MarkdownUI/Views/Common/ResizeToFit.swift new file mode 100644 index 00000000..4029ce06 --- /dev/null +++ b/Sources/MarkdownUI/Views/Common/ResizeToFit.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct ResizeToFit: View where Content: View { + private let idealSize: CGSize + private let content: Content + + init(idealSize: CGSize, @ViewBuilder content: () -> Content) { + self.idealSize = idealSize + self.content = content() + } + + var body: some View { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + ResizeToFit2 { self.content } + } else { + ResizeToFit1(idealSize: self.idealSize, content: self.content) + } + } +} + +// MARK: - Geometry reader based + +private struct ResizeToFit1: View where Content: View { + @State private var size: CGSize? + + let idealSize: CGSize + let content: Content + + var body: some View { + GeometryReader { proxy in + let size = self.sizeThatFits(proposal: proxy.size) + self.content + .frame(width: size.width, height: size.height) + .preference(key: SizePreference.self, value: size) + } + .frame(width: size?.width, height: size?.height) + .onPreferenceChange(SizePreference.self) { size in + self.size = size + } + } + + private func sizeThatFits(proposal: CGSize) -> CGSize { + guard proposal.width < idealSize.width else { + return idealSize + } + + let aspectRatio = idealSize.width / idealSize.height + return CGSize(width: proposal.width, height: proposal.width / aspectRatio) + } +} + +private struct SizePreference: PreferenceKey { + static let defaultValue: CGSize? = nil + + static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) { + value = value ?? nextValue() + } +} + +// MARK: - Layout based + +@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) +private struct ResizeToFit2: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + guard let view = subviews.first else { + return .zero + } + + var size = view.sizeThatFits(.unspecified) + + if let width = proposal.width, size.width > width { + let aspectRatio = size.width / size.height + size.width = width + size.height = width / aspectRatio + } + return size + } + + func placeSubviews( + in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () + ) { + guard let view = subviews.first else { return } + view.place(at: bounds.origin, proposal: .init(bounds.size)) + } +} diff --git a/Sources/MarkdownUI/Views/Environment.swift b/Sources/MarkdownUI/Views/Environment.swift index 71f44448..207d551e 100644 --- a/Sources/MarkdownUI/Views/Environment.swift +++ b/Sources/MarkdownUI/Views/Environment.swift @@ -49,6 +49,39 @@ private struct TextTransformKey: EnvironmentKey { static var defaultValue: TextTransform? = nil } +// MARK: - Image environment + +extension View { + public func markdownImageLoader( + _ imageLoader: ImageLoader?, + forURLScheme urlScheme: String + ) -> some View { + environment(\.imageLoaderRegistry[urlScheme], imageLoader) + } +} + +extension EnvironmentValues { + var imageLoaderRegistry: [String: ImageLoader] { + get { self[ImageLoaderRegistryKey.self] } + set { self[ImageLoaderRegistryKey.self] = newValue } + } +} + +private struct ImageLoaderRegistryKey: EnvironmentKey { + static var defaultValue: [String: ImageLoader] = [:] +} + +extension EnvironmentValues { + var imageTransaction: Transaction { + get { self[ImageTransactionKey.self] } + set { self[ImageTransactionKey.self] = newValue } + } +} + +private struct ImageTransactionKey: EnvironmentKey { + static var defaultValue = Transaction() +} + // MARK: - Block environment extension EnvironmentValues { diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/Image+PlatformImage.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/Image+PlatformImage.swift new file mode 100644 index 00000000..b722c3cc --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/Image+PlatformImage.swift @@ -0,0 +1,11 @@ +import SwiftUI + +extension SwiftUI.Image { + init(platformImage: ImageLoader.PlatformImage) { + #if os(iOS) || os(tvOS) || os(watchOS) + self.init(uiImage: platformImage) + #elseif os(macOS) + self.init(nsImage: platformImage) + #endif + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageCache.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageCache.swift new file mode 100644 index 00000000..f30464b2 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageCache.swift @@ -0,0 +1,23 @@ +import Foundation + +struct ImageCache { + let image: (URL) -> ImageLoader.PlatformImage? + let setImage: (ImageLoader.PlatformImage, URL) -> Void +} + +extension ImageCache { + static var `default`: Self { + let nsCache = NSCache() + return .init { url in + nsCache.object(forKey: url as NSURL) + } setImage: { image, url in + nsCache.setObject(image, forKey: url as NSURL) + } + } + + #if DEBUG + static var noop: Self { + .init(image: { _ in nil }, setImage: { _, _ in }) + } + #endif +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageDecoding.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageDecoding.swift new file mode 100644 index 00000000..3a6c2bc1 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageDecoding.swift @@ -0,0 +1,34 @@ +import SwiftUI + +#if os(iOS) || os(tvOS) || os(watchOS) + extension UIImage { + static func decode(from data: Data) -> UIImage? { + guard let image = UIImage(data: data) else { + return nil + } + + // Inflates the underlying compressed image data to be backed by an uncompressed bitmap representation. + _ = image.cgImage?.dataProvider?.data + + return image + } + } +#elseif os(macOS) + extension NSImage { + static func decode(from data: Data) -> NSImage? { + guard let bitmapImageRep = NSBitmapImageRep(data: data) else { + return nil + } + + let image = NSImage( + size: NSSize( + width: bitmapImageRep.pixelsWide, + height: bitmapImageRep.pixelsHigh + ) + ) + + image.addRepresentation(bitmapImageRep) + return image + } + } +#endif diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Asset.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Asset.swift new file mode 100644 index 00000000..b73d3614 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Asset.swift @@ -0,0 +1,29 @@ +import Combine +import SwiftUI + +extension ImageLoader { + public static func asset( + imageName: @escaping (URL) -> String = \.lastPathComponent, + in bundle: Bundle? = nil + ) -> Self { + .init { url in + let image: PlatformImage? + #if os(macOS) + if let bundle = bundle, bundle != .main { + image = bundle.image(forResource: imageName(url)) + } else { + image = NSImage(named: imageName(url)) + } + #elseif os(iOS) || os(tvOS) || os(watchOS) + image = UIImage(named: imageName(url), in: bundle, with: nil) + #endif + guard let image else { + return Fail(error: URLError(.fileDoesNotExist)) + .eraseToAnyPublisher() + } + return Just(image) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Default.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Default.swift new file mode 100644 index 00000000..383e75f3 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Default.swift @@ -0,0 +1,69 @@ +import Combine +import Foundation + +extension ImageLoader { + public static let `default`: ImageLoader = .default() + + static func `default`( + urlSession: URLSession = .imageLoading, cache: ImageCache = .default + ) -> Self { + `default`( + data: { url in + urlSession.dataTaskPublisher(for: url).eraseToAnyPublisher() + }, + cache: cache + ) + } + + static func `default`( + data: @escaping (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError>, + cache: ImageCache = .default + ) -> Self { + .init { url in + if let image = cache.image(url) { + return Just(image) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + return data(url) + .tryMap { data, response in + guard let statusCode = (response as? HTTPURLResponse)?.statusCode, + 200..<300 ~= statusCode + else { + throw URLError(.badServerResponse) + } + guard let image = PlatformImage.decode(from: data) else { + throw URLError(.cannotDecodeContentData) + } + + return image + } + .handleEvents(receiveOutput: { image in + cache.setImage(image, url) + }) + .eraseToAnyPublisher() + } + } +} + +extension URLSession { + fileprivate static var imageLoading: URLSession { + enum Constants { + static let memoryCapacity = 10 * 1024 * 1024 + static let diskCapacity = 100 * 1024 * 1024 + static let timeoutInterval: TimeInterval = 15 + } + + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.urlCache = URLCache( + memoryCapacity: Constants.memoryCapacity, + diskCapacity: Constants.diskCapacity + ) + configuration.timeoutIntervalForRequest = Constants.timeoutInterval + configuration.httpAdditionalHeaders = ["Accept": "image/*"] + + return URLSession(configuration: configuration) + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Stub.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Stub.swift new file mode 100644 index 00000000..d982d950 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader+Stub.swift @@ -0,0 +1,26 @@ +import Combine +import Foundation +import XCTestDynamicOverlay + +#if DEBUG + extension ImageLoader { + public static var failing: Self { + .init { _ in + XCTFail("\(Self.self).image is unimplemented") + return Empty().eraseToAnyPublisher() + } + } + + public func stub(url matchingURL: URL, with result: Result) -> Self { + var stub = self + stub.image = { url in + if url == matchingURL { + return result.publisher.eraseToAnyPublisher() + } else { + return self.image(url) + } + } + return stub + } + } +#endif diff --git a/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader.swift b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader.swift new file mode 100644 index 00000000..04aa93ec --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageLoader/ImageLoader.swift @@ -0,0 +1,15 @@ +import Combine +import SwiftUI + +public struct ImageLoader { + #if os(iOS) || os(tvOS) || os(watchOS) + public typealias PlatformImage = UIImage + #elseif os(macOS) + public typealias PlatformImage = NSImage + #endif + public var image: (URL) -> AnyPublisher + + public init(image: @escaping (URL) -> AnyPublisher) { + self.image = image + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift new file mode 100644 index 00000000..2a3eacf4 --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct ImageView: View { + @Environment(\.markdownBaseURL) private var baseURL + @Environment(\.imageLoaderRegistry) private var imageLoaderRegistry + @Environment(\.imageTransaction) private var imageTransaction + @Environment(\.theme.image) private var style + + @StateObject private var viewModel = ImageViewModel() + + private let source: String? + private let alt: String + private let destination: String? + + init(source: String?, alt: String, destination: String? = nil) { + self.source = source + self.alt = alt + self.destination = destination + } + + var body: some View { + self.stateBody + .onAppear { + self.viewModel.onAppear( + source: self.source, + environment: .init( + baseURL: self.baseURL, + imageLoaderRegistry: self.imageLoaderRegistry, + imageTransaction: self.imageTransaction + ) + ) + } + } + + @ViewBuilder private var stateBody: some View { + switch self.viewModel.state { + case .notRequested, .loading, .failure: + Color.clear + .frame(width: 0, height: 0) + case let .success(image, size): + self.style.makeBody( + .init( + content: .init( + ResizeToFit(idealSize: size) { + image + .resizable() + .link(destination: self.destination) + } + ) + ) + ) + .accessibilityLabel(self.alt) + } + } +} + +extension ImageView { + init?(_ inlines: [AnyInline]) { + guard inlines.count == 1, let inline = inlines.first 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 + } + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageViewModel.swift b/Sources/MarkdownUI/Views/Inlines/ImageViewModel.swift new file mode 100644 index 00000000..1a8f885c --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/ImageViewModel.swift @@ -0,0 +1,48 @@ +import Combine +import CombineSchedulers +import SwiftUI + +final class ImageViewModel: ObservableObject { + struct Environment { + let baseURL: URL? + let imageLoaderRegistry: [String: ImageLoader] + let imageTransaction: Transaction + } + + enum State: Equatable { + case notRequested + case loading + case success(SwiftUI.Image, CGSize) + case failure + } + + @Published private(set) var state: State = .notRequested + private var cancellable: AnyCancellable? + + func onAppear(source: String?, environment: Environment) { + guard case .notRequested = state else { + return + } + + guard let absoluteURL = source.flatMap(URL.init(string:))?.relativeTo(environment.baseURL), + let scheme = absoluteURL.scheme + else { + cancellable = nil + state = .failure + return + } + + state = .loading + let imageLoader = environment.imageLoaderRegistry[scheme] ?? .default + + cancellable = imageLoader.image(absoluteURL) + .map { .success(.init(platformImage: $0), $0.size) } + .replaceError(with: .failure) + .receive(on: UIScheduler.shared) + .sink { [weak self] state in + withTransaction(environment.imageTransaction) { + self?.state = state + } + } + } +} diff --git a/Tests/MarkdownUITests/ImageLoaderTests.swift b/Tests/MarkdownUITests/ImageLoaderTests.swift new file mode 100644 index 00000000..2ed21e60 --- /dev/null +++ b/Tests/MarkdownUITests/ImageLoaderTests.swift @@ -0,0 +1,167 @@ +import Combine +import XCTest + +@testable import MarkdownUI + +final class ImageLoaderTests: XCTestCase { + private enum Fixtures { + static let anyImageURL = URL(string: "https://picsum.photos/id/237/300/200")! + + static let anyImageResponse = Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" + )! + static let anyResponse = Data(base64Encoded: "Z29uemFsZXpyZWFs")! + + static let anyImage = ImageLoader.PlatformImage.decode(from: anyImageResponse)! + } + + private var cancellables = Set() + + override func tearDownWithError() throws { + cancellables.removeAll() + } + + func testLoadsAndCachesImage() throws { + // given + let imageCache = ImageCache.default + let imageLoader = ImageLoader.default( + data: { url in + XCTAssertEqual(url, Fixtures.anyImageURL) + return Just( + ( + data: Fixtures.anyImageResponse, + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + ) + ) + .setFailureType(to: URLError.self) + .eraseToAnyPublisher() + }, + cache: imageCache + ) + + // when + var result: ImageLoader.PlatformImage? + imageLoader.image(Fixtures.anyImageURL) + .assertNoFailure() + .sink(receiveValue: { + result = $0 + }) + .store(in: &cancellables) + + // then + let unwrappedResult = try XCTUnwrap(result) + XCTAssertTrue(unwrappedResult.isEqual(imageCache.image(Fixtures.anyImageURL))) + } + + func testReturnsCachedImageIfAvailable() throws { + // given + let imageCache = ImageCache.default + let imageLoader = ImageLoader.default( + data: { _ in + XCTFail() + return Empty().eraseToAnyPublisher() + }, + cache: imageCache + ) + imageCache.setImage(Fixtures.anyImage, Fixtures.anyImageURL) + + // when + var result: ImageLoader.PlatformImage? + imageLoader.image(Fixtures.anyImageURL) + .assertNoFailure() + .sink(receiveValue: { + result = $0 + }) + .store(in: &cancellables) + + // then + let unwrappedResult = try XCTUnwrap(result) + XCTAssertTrue(unwrappedResult.isEqual(Fixtures.anyImage)) + } + + func testFailsWithBadServerResponse() throws { + // given + let imageLoader = ImageLoader.default( + data: { url in + XCTAssertEqual(url, Fixtures.anyImageURL) + return Just( + ( + data: .init(), + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 500, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + ) + ) + .setFailureType(to: URLError.self) + .eraseToAnyPublisher() + }, + cache: .noop + ) + + // when + var result: Error? + imageLoader.image(Fixtures.anyImageURL) + .sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + result = error + } + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + + // then + let unwrappedResult = try XCTUnwrap(result as? URLError) + XCTAssertEqual(unwrappedResult, URLError(.badServerResponse)) + } + + func testImageFailsWithCannotDecodeContentData() throws { + // given + let imageLoader = ImageLoader.default( + data: { url in + XCTAssertEqual(url, Fixtures.anyImageURL) + return Just( + ( + data: Fixtures.anyResponse, + response: HTTPURLResponse( + url: Fixtures.anyImageURL, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + ) + ) + .setFailureType(to: URLError.self) + .eraseToAnyPublisher() + }, + cache: .noop + ) + + // when + var result: Error? + imageLoader.image(Fixtures.anyImageURL) + .sink( + receiveCompletion: { completion in + if case let .failure(error) = completion { + result = error + } + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + + // then + let unwrappedResult = try XCTUnwrap(result as? URLError) + XCTAssertEqual(unwrappedResult, URLError(.cannotDecodeContentData)) + } +} diff --git a/Tests/MarkdownUITests/MarkdownImageTests.swift b/Tests/MarkdownUITests/MarkdownImageTests.swift new file mode 100644 index 00000000..d51c4302 --- /dev/null +++ b/Tests/MarkdownUITests/MarkdownImageTests.swift @@ -0,0 +1,167 @@ +#if os(iOS) + import SnapshotTesting + import SwiftUI + import XCTest + + import MarkdownUI + + final class MarkdownImageTests: XCTestCase { + private let layout = SwiftUISnapshotLayout.device(config: .iPhone8) + + func testFailingImage() { + let view = Markdown { + #""" + An image that fails to load: + + ![](https://picsum.photos/500/300) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader( + .failing.stub( + url: URL(string: "https://picsum.photos/500/300")!, + with: .failure(URLError(.badServerResponse)) + ), + forURLScheme: "https" + ) + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testRelativeImage() { + let view = Markdown(baseURL: URL(string: "https://example.com/picsum")) { + #""" + 500x300 image: + + ![](500/300) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader( + .failing.stub( + url: URL(string: "https://example.com/picsum/500/300")!, + with: .success(UIImage(named: "237-500x300", in: .module, with: nil)!) + ), + forURLScheme: "https" + ) + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testAssetImageLoader() { + let view = Markdown { + #""" + 100x150 image: + + ![](asset:///237-100x150) + + 500x300 image: + + ![](asset:///237-500x300) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testImageLink() { + let view = Markdown { + #""" + A link that contains an image instead of text: + + [![](asset:///237-100x150)](https://example.com) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testImageStyle() { + let view = Markdown { + #""" + A link that contains an image instead of text: + + [![](asset:///237-100x150)](https://example.com) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + .markdownTheme(\.image, .alignment(.center)) + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testMultipleImages() { + let view = Markdown { + #""" + [![](asset:///237-100x150)](https://example.com) + ![](asset:///237-125x75) + ![](asset:///237-500x300) + ![](asset:///237-100x150)\#u{20}\#u{20} + ![](asset:///237-125x75) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testMultipleImagesSize() { + let view = Markdown { + #""" + ![](asset:///237-100x150) + ![](asset:///237-125x75) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + + func testImageStyleWithMultipleImages() { + let view = Markdown { + #""" + ![](asset:///237-100x150) + ![](asset:///237-125x75) + ![](asset:///237-500x300) + ![](asset:///237-100x150) + + ― Photo by André Spieker + """# + } + .border(Color.accentColor) + .padding() + .markdownImageLoader(.asset(in: .module), forURLScheme: "asset") + .markdownTheme(\.image, .alignment(.center)) + + assertSnapshot(matching: view, as: .image(layout: layout)) + } + } +#endif diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/237-100x150.jpg b/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/237-100x150.jpg new file mode 100644 index 00000000..e5080be3 Binary files /dev/null and b/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/237-100x150.jpg differ diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/Contents.json b/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/Contents.json new file mode 100644 index 00000000..cad31266 --- /dev/null +++ b/Tests/MarkdownUITests/Resources/Images.xcassets/237-100x150.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "237-100x150.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/237-125x75.jpg b/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/237-125x75.jpg new file mode 100644 index 00000000..3f017b8e Binary files /dev/null and b/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/237-125x75.jpg differ diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/Contents.json b/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/Contents.json new file mode 100644 index 00000000..435d52ca --- /dev/null +++ b/Tests/MarkdownUITests/Resources/Images.xcassets/237-125x75.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "237-125x75.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/237-500x300.jpg b/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/237-500x300.jpg new file mode 100644 index 00000000..15d0887a Binary files /dev/null and b/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/237-500x300.jpg differ diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/Contents.json b/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/Contents.json new file mode 100644 index 00000000..43accab0 --- /dev/null +++ b/Tests/MarkdownUITests/Resources/Images.xcassets/237-500x300.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "237-500x300.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/MarkdownUITests/Resources/Images.xcassets/Contents.json b/Tests/MarkdownUITests/Resources/Images.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Tests/MarkdownUITests/Resources/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testAssetImageLoader.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testAssetImageLoader.1.png new file mode 100644 index 00000000..6b371232 Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testAssetImageLoader.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testFailingImage.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testFailingImage.1.png new file mode 100644 index 00000000..1895bdbf Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testFailingImage.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageLink.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageLink.1.png new file mode 100644 index 00000000..8f1e2140 Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageLink.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyle.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyle.1.png new file mode 100644 index 00000000..3a47698f Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyle.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyleWithMultipleImages.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyleWithMultipleImages.1.png new file mode 100644 index 00000000..1f82e9d0 Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testImageStyleWithMultipleImages.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImages.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImages.1.png new file mode 100644 index 00000000..142a8646 Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImages.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImagesSize.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImagesSize.1.png new file mode 100644 index 00000000..70f39c5f Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testMultipleImagesSize.1.png differ diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testRelativeImage.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testRelativeImage.1.png new file mode 100644 index 00000000..f275fb6b Binary files /dev/null and b/Tests/MarkdownUITests/__Snapshots__/MarkdownImageTests/testRelativeImage.1.png differ