From d8e0552d917c2b9a4127a86655a632d6d79aca13 Mon Sep 17 00:00:00 2001 From: Paul Bancarel Date: Fri, 20 Dec 2024 23:38:19 +0100 Subject: [PATCH 1/5] Support for image resize inside markdown --- Examples/Demo/Demo/ImagesView.swift | 5 ++- Package.swift | 5 ++- .../Utility/InlineNode+RawImageData.swift | 38 ++++++++++++++++ .../MarkdownUI/Views/Inlines/ImageView.swift | 45 ++++++++++++++++--- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/Examples/Demo/Demo/ImagesView.swift b/Examples/Demo/Demo/ImagesView.swift index 3b8fab36..71901acb 100644 --- a/Examples/Demo/Demo/ImagesView.swift +++ b/Examples/Demo/Demo/ImagesView.swift @@ -7,10 +7,10 @@ struct ImagesView: View { Then wrap the link for the image in parentheses `()`. ``` - ![This is an image](https://picsum.photos/id/91/400/300) + ![This is an image](https://picsum.photos/id/91/400/300){width=50px} ``` - ![This is an image](https://picsum.photos/id/91/400/300) + ![This is an image](https://picsum.photos/id/91/400/300){width=50px} ― Photo by Jennifer Trovato """ @@ -45,6 +45,7 @@ struct ImagesView: View { } .markdownBlockStyle(\.image) { configuration in configuration.label + .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) .shadow(radius: 8, y: 8) .markdownMargin(top: .em(1.6), bottom: .em(1.6)) diff --git a/Package.swift b/Package.swift index 5fb61bc9..77bd2665 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.8 import PackageDescription @@ -29,6 +29,9 @@ let package = Package( .product(name: "cmark-gfm", package: "swift-cmark"), .product(name: "cmark-gfm-extensions", package: "swift-cmark"), .product(name: "NetworkImage", package: "NetworkImage"), + ], + swiftSettings: [ + .enableUpcomingFeature("BareSlashRegexLiterals"), ] ), .testTarget( diff --git a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift index 11f77d3f..7d0529d0 100644 --- a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift +++ b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift @@ -20,3 +20,41 @@ extension InlineNode { } } } + +extension InlineNode { + @available(iOS 16.0, macOS 13.0, tvOS 13.0, watchOS 6.0, *) + var size: MarkdownImageSize? { + switch self { + case .text(let input): + let pattern = /{(?:width\s*=\s*(\d+)px\s*)?(?:height\s*=\s*(\d+)px\s*)?(?:width\s*=\s*(\d+)px\s*)?(?:height\s*=\s*(\d+)px\s*)?\}/ + + if let match = input.wholeMatch(of: pattern) { + let widthParts = [match.output.1, match.output.3].compactMap { $0 } + let heightParts = [match.output.2, match.output.4].compactMap { $0 } + + let width = widthParts.compactMap { Float(String($0)) }.last + let height = heightParts.compactMap { Float(String($0)) }.last + + return MarkdownImageSize(width: width.map(CGFloat.init), height: height.map(CGFloat.init)) + } + + return nil + default: + return nil + } + } +} + +/// A value type representating an image size suffix. +/// +/// Example: `![This is an image](https://foo/bar.png){width=50px}` +/// +/// Suffix can be either +/// - {width=50px} +/// - {height=50px} +/// - {width=50px height=100px} +/// - {height=50px width=100px} +struct MarkdownImageSize { + let width: CGFloat? + let height: CGFloat? +} diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift index 90f97e0a..e2f85f3f 100644 --- a/Sources/MarkdownUI/Views/Inlines/ImageView.swift +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -6,9 +6,11 @@ struct ImageView: View { @Environment(\.imageBaseURL) private var baseURL private let data: RawImageData + private let size: MarkdownImageSize? - init(data: RawImageData) { + init(data: RawImageData, size: MarkdownImageSize? = nil) { self.data = data + self.size = size } var body: some View { @@ -18,6 +20,7 @@ struct ImageView: View { content: .init(block: self.content) ) ) + .frame(size: size) } private var label: some View { @@ -49,12 +52,16 @@ struct ImageView: View { } extension ImageView { - init?(_ inlines: [InlineNode]) { - guard inlines.count == 1, let data = inlines.first?.imageData else { - return nil + init?(_ inlines: [InlineNode]) { + if inlines.count == 2, #available(iOS 16.0, macOS 13.0, tvOS 16.0, *), let data = inlines.first?.imageData, let size = inlines.last?.size { + self.init(data: data, size: size) + } + else if inlines.count == 1, let data = inlines.first?.imageData { + self.init(data: data) + } else { + return nil + } } - self.init(data: data) - } } extension View { @@ -88,3 +95,29 @@ private struct LinkModifier: ViewModifier { } } } + +extension View { + fileprivate func frame(size: MarkdownImageSize?) -> some View { + self.modifier(ImageViewFrameModifier(size: size)) + } +} + +private struct ImageViewFrameModifier: ViewModifier { + let size: MarkdownImageSize? + + func body(content: Content) -> some View { + if let size { + if let width = size.width, let height = size.height { + content.frame(width: width, height: height) + } else if let width = size.width, size.height == nil { + content.frame(width: width) + } else if let height = size.height, size.width == nil { + content.frame(height: height) + } else { + content + } + } else { + content + } + } +} From 3b4c9e20da5406993b14b2b60cc165ba8d481199 Mon Sep 17 00:00:00 2001 From: Paul Bancarel Date: Fri, 20 Dec 2024 23:57:15 +0100 Subject: [PATCH 2/5] Use NSRegularExpression to support below iOS 16 --- Package.swift | 5 +-- .../Utility/InlineNode+RawImageData.swift | 31 +++++++++++++------ .../MarkdownUI/Views/Inlines/ImageView.swift | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/Package.swift b/Package.swift index 77bd2665..5fb61bc9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.8 +// swift-tools-version:5.6 import PackageDescription @@ -29,9 +29,6 @@ let package = Package( .product(name: "cmark-gfm", package: "swift-cmark"), .product(name: "cmark-gfm-extensions", package: "swift-cmark"), .product(name: "NetworkImage", package: "NetworkImage"), - ], - swiftSettings: [ - .enableUpcomingFeature("BareSlashRegexLiterals"), ] ), .testTarget( diff --git a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift index 7d0529d0..ad32f31a 100644 --- a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift +++ b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift @@ -22,23 +22,36 @@ extension InlineNode { } extension InlineNode { - @available(iOS 16.0, macOS 13.0, tvOS 13.0, watchOS 6.0, *) var size: MarkdownImageSize? { switch self { case .text(let input): - let pattern = /{(?:width\s*=\s*(\d+)px\s*)?(?:height\s*=\s*(\d+)px\s*)?(?:width\s*=\s*(\d+)px\s*)?(?:height\s*=\s*(\d+)px\s*)?\}/ + let pattern = "\\{(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?\\}" - if let match = input.wholeMatch(of: pattern) { - let widthParts = [match.output.1, match.output.3].compactMap { $0 } - let heightParts = [match.output.2, match.output.4].compactMap { $0 } + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + + let range = NSRange(input.startIndex.. Date: Sat, 11 Jan 2025 14:26:43 +0100 Subject: [PATCH 3/5] Simplify usage of frame modifiers --- Sources/MarkdownUI/Views/Inlines/ImageView.swift | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift index c6587943..62a55e19 100644 --- a/Sources/MarkdownUI/Views/Inlines/ImageView.swift +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -106,18 +106,6 @@ private struct ImageViewFrameModifier: ViewModifier { let size: MarkdownImageSize? func body(content: Content) -> some View { - if let size { - if let width = size.width, let height = size.height { - content.frame(width: width, height: height) - } else if let width = size.width, size.height == nil { - content.frame(width: width) - } else if let height = size.height, size.width == nil { - content.frame(height: height) - } else { - content - } - } else { - content - } + content.frame(width: size?.width, height: size?.height) } } From 14b521edb0967ba1efafac6eac17816a3277e7da Mon Sep 17 00:00:00 2001 From: Paul Bancarel Date: Sat, 11 Jan 2025 14:40:02 +0100 Subject: [PATCH 4/5] Handle relative size using containerRelativeFrame modifier --- Examples/Demo/Demo/ImagesView.swift | 17 ++-- .../Utility/InlineNode+RawImageData.swift | 89 +++++++++++++------ .../MarkdownUI/Views/Inlines/ImageView.swift | 17 +++- 3 files changed, 88 insertions(+), 35 deletions(-) diff --git a/Examples/Demo/Demo/ImagesView.swift b/Examples/Demo/Demo/ImagesView.swift index 71901acb..969ca300 100644 --- a/Examples/Demo/Demo/ImagesView.swift +++ b/Examples/Demo/Demo/ImagesView.swift @@ -5,14 +5,21 @@ struct ImagesView: View { private let content = """ You can display an image by adding `!` and wrapping the alt text in `[ ]`. Then wrap the link for the image in parentheses `()`. - + ``` - ![This is an image](https://picsum.photos/id/91/400/300){width=50px} + ![This is a 50 px image](https://picsum.photos/id/91/400/300){width=50px} ``` - - ![This is an image](https://picsum.photos/id/91/400/300){width=50px} - + + ![This is a 50px image](https://picsum.photos/id/91/400/300){width=50px} + ― Photo by Jennifer Trovato + + ``` + ![This is a 50% image](https://i.natgeofe.com/n/548467d8-c5f1-4551-9f58-6817a8d2c45e/NationalGeographic_2572187_3x2.jpg){width=50%} + ``` + + ![This is a 50% image](https://i.natgeofe.com/n/548467d8-c5f1-4551-9f58-6817a8d2c45e/NationalGeographic_2572187_3x2.jpg){width=50%} + """ private let inlineImageContent = """ diff --git a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift index ad32f31a..42372bc3 100644 --- a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift +++ b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift @@ -25,49 +25,80 @@ extension InlineNode { var size: MarkdownImageSize? { switch self { case .text(let input): - let pattern = "\\{(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?\\}" + // Trying first to found a fixed pattern match + let fixedPattern = "\\{(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?(?:width\\s*=\\s*(\\d+)px\\s*)?(?:height\\s*=\\s*(\\d+)px\\s*)?\\}" - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - return nil + if let (width, height) = extract(regexPattern: fixedPattern, from: input) { + return MarkdownImageSize(value: .fixed(width, height)) } - let range = NSRange(input.startIndex.. (width: CGFloat?, height: CGFloat?)? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + + let range = NSRange(input.startIndex.. some View { - content.frame(width: size?.width, height: size?.height) + if let size { + switch size.value { + case .fixed(let width, let height): + content.frame(width: width, height: height) + case .relative(let wRatio, _): + if #available(iOS 17.0, *) { + content + // .containerRelativeFrame(.vertical) { height, _ in height * hRatio } + .containerRelativeFrame(.horizontal) { width, _ in width * wRatio } + } else { + content + } + } + } else { + content + } } } From 47c147a1884f2fde8882f928d58a3902076f3ad0 Mon Sep 17 00:00:00 2001 From: Paul Bancarel Date: Sat, 11 Jan 2025 23:32:31 +0100 Subject: [PATCH 5/5] Handle relative size using GeometryReader --- .../Utility/InlineNode+RawImageData.swift | 9 ++--- .../MarkdownUI/Views/Inlines/ImageView.swift | 40 ++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift index 42372bc3..579859e1 100644 --- a/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift +++ b/Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift @@ -89,16 +89,15 @@ extension InlineNode { /// - `{width=50px height=100px}` /// - `{height=50px width=100px}` /// - `{width=50%}` -/// - `{height=50%}` -/// - `{width=50% height=100%}` -/// - `{height=50% width=100%}` +/// +/// - Note: Relative height is not supported struct MarkdownImageSize { let value: Value enum Value { - /// Represents a fixed value size:`.fixed(width, height)` + /// Represents a fixed value size: `.fixed(width, height)` case fixed(CGFloat?, CGFloat?) - /// Represents a relative value size: `.relative(proportionalWidth, proportionalHeight)` + /// Represents a relative value size: `.relative(relativeWidth, relativeHeight)` case relative(CGFloat, CGFloat) } } diff --git a/Sources/MarkdownUI/Views/Inlines/ImageView.swift b/Sources/MarkdownUI/Views/Inlines/ImageView.swift index 48046f38..b61718f9 100644 --- a/Sources/MarkdownUI/Views/Inlines/ImageView.swift +++ b/Sources/MarkdownUI/Views/Inlines/ImageView.swift @@ -105,18 +105,38 @@ extension View { private struct ImageViewFrameModifier: ViewModifier { let size: MarkdownImageSize? + @State private var currentSize: CGSize = .zero + func body(content: Content) -> some View { if let size { switch size.value { case .fixed(let width, let height): - content.frame(width: width, height: height) + content + .frame(width: width, height: height) case .relative(let wRatio, _): - if #available(iOS 17.0, *) { - content - // .containerRelativeFrame(.vertical) { height, _ in height * hRatio } - .containerRelativeFrame(.horizontal) { width, _ in width * wRatio } - } else { + ZStack(alignment: .leading) { + /// Track the full content width. + GeometryReader { metrics in + content + .preference(key: BoundsPreferenceKey.self, value: metrics.frame(in: .global).size) + } + .opacity(0.0) + + /// Draw the content applying relative width. Relative height is not handled. content + .frame( + width: currentSize.width * wRatio + ) + } + .onPreferenceChange(BoundsPreferenceKey.self) { newValue in + /// Avoid recursive loop that could happens + /// https://developer.apple.com/videos/play/wwdc2022/10056/?time=1107 + if Int(currentSize.width) == Int(newValue.width), + Int(currentSize.height) == Int(newValue.height) { + return + } + + self.currentSize = newValue } } } else { @@ -124,3 +144,11 @@ private struct ImageViewFrameModifier: ViewModifier { } } } + +private struct BoundsPreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() + } +}