Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - Support for image resizing inside Markdown text #376

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions Examples/Demo/Demo/ImagesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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))
Expand Down
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.6
// swift-tools-version:5.8

import PackageDescription

Expand Down Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be awesome if it also supported proportional width and height
e.g. {width=50%}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I thought about it too. Can be quickly achieve for iOS 17+ with the new containerRelativeFrame modifier for horizontal configuration.

But I'm not sure of what 50% height would mean in the context of a vertical scrolling view. Does it mean 50% of the scrollview content, 50% of the nearest block where the content is, ... Depending on the answer could be harder to implement.

I've draft a commit to demonstrate horizontal relative width handling.

This is a cat with {width=50%}
relative-size

Copy link

@cernym46 cernym46 Jan 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't support relative height in the vertically scrollable ScrollView and relative width in the horizontally scrollable ScrollView. GeometryReader also "doesn't work" in these cases and returns a size equal to 10, but I think it's fine because these use cases don't make much sense and users of this framework would be also confused about what the 100 % actually is.

The containerRelativeFrame is cool but unfortunately supported just by iOS 17+, as you mentioned.

GeometryReader could be utilized, so it also supports older iOS.
Here is an example of the relative sizes:
image.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've submitted a new commit using GeometryReader to handle relative width size.

struct MarkdownImageSize {
let width: CGFloat?
let height: CGFloat?
}
45 changes: 39 additions & 6 deletions Sources/MarkdownUI/Views/Inlines/ImageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,6 +20,7 @@ struct ImageView: View {
content: .init(block: self.content)
)
)
.frame(size: size)
}

private var label: some View {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The width and height parameters of the frame ViewModifier are optional, so it should be possible to do just:

content.frame(width: size?.width, height: size?.height)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right. I'm making the change.

}
}