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 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
18 changes: 13 additions & 5 deletions Examples/Demo/Demo/ImagesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
![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)

![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 = """
Expand Down Expand Up @@ -45,6 +52,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
81 changes: 81 additions & 0 deletions Sources/MarkdownUI/Utility/InlineNode+RawImageData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,84 @@ extension InlineNode {
}
}
}

extension InlineNode {
var size: MarkdownImageSize? {
switch self {
case .text(let input):
// 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*)?\\}"

if let (width, height) = extract(regexPattern: fixedPattern, from: input) {
return MarkdownImageSize(value: .fixed(width, height))
}

// Trying then to found a relative pattern match
let relativePattern = "\\{(?:width\\s*=\\s*(\\d+)%\\s*)?(?:height\\s*=\\s*(\\d+)%\\s*)?(?:width\\s*=\\s*(\\d+)%\\s*)?(?:height\\s*=\\s*(\\d+)%\\s*)?\\}"

if let (wRatio, hRatio) = extract(regexPattern: relativePattern, from: input) {
return MarkdownImageSize(value: .relative((wRatio ?? 100)/100, (hRatio ?? 100)/100))
}

return nil
default:
return nil
}
}

private func extract(
regexPattern pattern: String,
from input: String
) -> (width: CGFloat?, height: CGFloat?)? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return nil
}

let range = NSRange(input.startIndex..<input.endIndex, in: input)
guard let match = regex.firstMatch(in: input, options: [], range: range) else {
return nil
}

var width: CGFloat?
var height: CGFloat?

if let widthRange = Range(match.range(at: 1), in: input), let widthValue = Int(input[widthRange]) {
width = CGFloat(widthValue)
} else if let widthRange = Range(match.range(at: 3), in: input), let widthValue = Int(input[widthRange]) {
width = CGFloat(widthValue)
}

if let heightRange = Range(match.range(at: 2), in: input), let heightValue = Int(input[heightRange]) {
height = CGFloat(heightValue)
} else if let heightRange = Range(match.range(at: 4), in: input), let heightValue = Int(input[heightRange]) {
height = CGFloat(heightValue)
}

return (width, height)
}
}

/// A value type representating an image size suffix.
///
/// Example:
/// - `![This is an image](https://foo/bar.png){width=50px}`
/// - `![This is an image](https://foo/bar.png){width=50%}`
///
/// Suffix can either be:
/// - `{width=50px}`
/// - `{height=50px}`
/// - `{width=50px height=100px}`
/// - `{height=50px width=100px}`
/// - `{width=50%}`
///
/// - Note: Relative height is not supported
struct MarkdownImageSize {
let value: Value

enum Value {
/// Represents a fixed value size: `.fixed(width, height)`
case fixed(CGFloat?, CGFloat?)
/// Represents a relative value size: `.relative(relativeWidth, relativeHeight)`
case relative(CGFloat, CGFloat)
}
}
76 changes: 70 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, 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,60 @@ 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?

@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)
case .relative(let wRatio, _):
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 {
content
}
}
}

private struct BoundsPreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero

static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}