Skip to content

Commit 3830c4c

Browse files
authored
Render HTML line breaks (#221)
1 parent 078d9de commit 3830c4c

File tree

6 files changed

+156
-21
lines changed

6 files changed

+156
-21
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
struct HTMLTag {
4+
let name: String
5+
}
6+
7+
extension HTMLTag {
8+
private enum Constants {
9+
static let tagExpression = try! NSRegularExpression(pattern: "<\\/?([a-zA-Z0-9]+)[^>]*>")
10+
}
11+
12+
init?(_ description: String) {
13+
guard
14+
let match = Constants.tagExpression.firstMatch(
15+
in: description,
16+
range: NSRange(description.startIndex..., in: description)
17+
),
18+
let nameRange = Range(match.range(at: 1), in: description)
19+
else {
20+
return nil
21+
}
22+
23+
self.name = String(description[nameRange])
24+
}
25+
}

Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ private struct AttributedStringInlineRenderer {
2222
private let baseURL: URL?
2323
private let textStyles: InlineTextStyles
2424
private var attributes: AttributeContainer
25+
private var shouldSkipNextWhitespace = false
2526

2627
init(baseURL: URL?, textStyles: InlineTextStyles, attributes: AttributeContainer) {
2728
self.baseURL = baseURL
@@ -55,26 +56,45 @@ private struct AttributedStringInlineRenderer {
5556
}
5657

5758
private mutating func renderText(_ text: String) {
59+
var text = text
60+
61+
if self.shouldSkipNextWhitespace {
62+
self.shouldSkipNextWhitespace = false
63+
text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
64+
}
65+
5866
self.result += .init(text, attributes: self.attributes)
5967
}
6068

6169
private mutating func renderSoftBreak() {
62-
self.result += .init(" ", attributes: self.attributes)
70+
if self.shouldSkipNextWhitespace {
71+
self.shouldSkipNextWhitespace = false
72+
} else {
73+
self.result += .init(" ", attributes: self.attributes)
74+
}
6375
}
6476

6577
private mutating func renderLineBreak() {
6678
self.result += .init("\n", attributes: self.attributes)
6779
}
6880

69-
mutating func renderCode(_ code: String) {
81+
private mutating func renderCode(_ code: String) {
7082
self.result += .init(code, attributes: self.textStyles.code.mergingAttributes(self.attributes))
7183
}
7284

73-
mutating func renderHTML(_ html: String) {
74-
self.result += .init(html, attributes: self.attributes)
85+
private mutating func renderHTML(_ html: String) {
86+
let tag = HTMLTag(html)
87+
88+
switch tag?.name.lowercased() {
89+
case "br":
90+
self.renderLineBreak()
91+
self.shouldSkipNextWhitespace = true
92+
default:
93+
self.renderText(html)
94+
}
7595
}
7696

77-
mutating func renderEmphasis(children: [InlineNode]) {
97+
private mutating func renderEmphasis(children: [InlineNode]) {
7898
let savedAttributes = self.attributes
7999
self.attributes = self.textStyles.emphasis.mergingAttributes(self.attributes)
80100

@@ -85,7 +105,7 @@ private struct AttributedStringInlineRenderer {
85105
self.attributes = savedAttributes
86106
}
87107

88-
mutating func renderStrong(children: [InlineNode]) {
108+
private mutating func renderStrong(children: [InlineNode]) {
89109
let savedAttributes = self.attributes
90110
self.attributes = self.textStyles.strong.mergingAttributes(self.attributes)
91111

@@ -96,7 +116,7 @@ private struct AttributedStringInlineRenderer {
96116
self.attributes = savedAttributes
97117
}
98118

99-
mutating func renderStrikethrough(children: [InlineNode]) {
119+
private mutating func renderStrikethrough(children: [InlineNode]) {
100120
let savedAttributes = self.attributes
101121
self.attributes = self.textStyles.strikethrough.mergingAttributes(self.attributes)
102122

@@ -107,7 +127,7 @@ private struct AttributedStringInlineRenderer {
107127
self.attributes = savedAttributes
108128
}
109129

110-
mutating func renderLink(destination: String, children: [InlineNode]) {
130+
private mutating func renderLink(destination: String, children: [InlineNode]) {
111131
let savedAttributes = self.attributes
112132
self.attributes = self.textStyles.link.mergingAttributes(self.attributes)
113133
self.attributes.link = URL(string: destination, relativeTo: self.baseURL)
@@ -119,7 +139,7 @@ private struct AttributedStringInlineRenderer {
119139
self.attributes = savedAttributes
120140
}
121141

122-
mutating func renderImage(source: String, children: [InlineNode]) {
142+
private mutating func renderImage(source: String, children: [InlineNode]) {
123143
// AttributedString does not support images
124144
}
125145
}

Sources/MarkdownUI/Renderer/TextInlineRenderer.swift

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ private struct TextInlineRenderer {
2525
private let textStyles: InlineTextStyles
2626
private let images: [String: Image]
2727
private let attributes: AttributeContainer
28+
private var shouldSkipNextWhitespace = false
2829

2930
init(
3031
baseURL: URL?,
@@ -46,20 +47,65 @@ private struct TextInlineRenderer {
4647

4748
private mutating func render(_ inline: InlineNode) {
4849
switch inline {
50+
case .text(let content):
51+
self.renderText(content)
52+
case .softBreak:
53+
self.renderSoftBreak()
54+
case .html(let content):
55+
self.renderHTML(content)
4956
case .image(let source, _):
50-
if let image = self.images[source] {
51-
self.result = self.result + Text(image)
52-
}
57+
self.renderImage(source)
5358
default:
54-
self.result =
55-
self.result
56-
+ Text(
57-
inline.renderAttributedString(
58-
baseURL: self.baseURL,
59-
textStyles: self.textStyles,
60-
attributes: self.attributes
61-
)
62-
)
59+
self.defaultRender(inline)
60+
}
61+
}
62+
63+
private mutating func renderText(_ text: String) {
64+
var text = text
65+
66+
if self.shouldSkipNextWhitespace {
67+
self.shouldSkipNextWhitespace = false
68+
text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
69+
}
70+
71+
self.defaultRender(.text(text))
72+
}
73+
74+
private mutating func renderSoftBreak() {
75+
if self.shouldSkipNextWhitespace {
76+
self.shouldSkipNextWhitespace = false
77+
} else {
78+
self.defaultRender(.softBreak)
79+
}
80+
}
81+
82+
private mutating func renderHTML(_ html: String) {
83+
let tag = HTMLTag(html)
84+
85+
switch tag?.name.lowercased() {
86+
case "br":
87+
self.defaultRender(.lineBreak)
88+
self.shouldSkipNextWhitespace = true
89+
default:
90+
self.defaultRender(.html(html))
91+
}
92+
}
93+
94+
private mutating func renderImage(_ source: String) {
95+
if let image = self.images[source] {
96+
self.result = self.result + Text(image)
6397
}
6498
}
99+
100+
private mutating func defaultRender(_ inline: InlineNode) {
101+
self.result =
102+
self.result
103+
+ Text(
104+
inline.renderAttributedString(
105+
baseURL: self.baseURL,
106+
textStyles: self.textStyles,
107+
attributes: self.attributes
108+
)
109+
)
110+
}
65111
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
import XCTest
3+
4+
@testable import MarkdownUI
5+
6+
final class HTMLTagTests: XCTestCase {
7+
func testInvalidTag() {
8+
XCTAssertNil(HTMLTag(""))
9+
XCTAssertNil(HTMLTag("foo"))
10+
XCTAssertNil(HTMLTag("<"))
11+
XCTAssertNil(HTMLTag("<>"))
12+
}
13+
14+
func testOpeningTag() {
15+
// given
16+
let tag = HTMLTag("<sub>")
17+
18+
// then
19+
XCTAssertEqual("sub", tag?.name)
20+
}
21+
22+
func testOpeningTagWithAttributes() {
23+
// given
24+
let tag = HTMLTag(
25+
"<img src=\"img_girl.jpg\" alt=\"Girl in a jacket\" width=\"500\" height=\"600\">"
26+
)
27+
28+
// then
29+
XCTAssertEqual("img", tag?.name)
30+
}
31+
32+
func testClosingTag() {
33+
let tag = HTMLTag("</sub>")
34+
XCTAssertEqual(tag?.name, "sub")
35+
}
36+
37+
func testSelfClosingTag() {
38+
XCTAssertEqual("br", HTMLTag("<br />")?.name)
39+
}
40+
}

Tests/MarkdownUITests/MarkdownTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@
241241
Visit https://github.com.
242242
243243
Use `git status` to list all new or modified files that haven't yet been committed.
244+
245+
You can insert a line break<br>
246+
using the HTML `<br>`
247+
<br> tag.
244248
"""#
245249
}
246250
.border(Color.accentColor)
11 KB
Loading

0 commit comments

Comments
 (0)