Skip to content

Commit

Permalink
Render HTML line breaks (#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
gonzalezreal authored Apr 15, 2023
1 parent 078d9de commit 3830c4c
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 21 deletions.
25 changes: 25 additions & 0 deletions Sources/MarkdownUI/Parser/HTMLTag.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

struct HTMLTag {
let name: String
}

extension HTMLTag {
private enum Constants {
static let tagExpression = try! NSRegularExpression(pattern: "<\\/?([a-zA-Z0-9]+)[^>]*>")
}

init?(_ description: String) {
guard
let match = Constants.tagExpression.firstMatch(
in: description,
range: NSRange(description.startIndex..., in: description)
),
let nameRange = Range(match.range(at: 1), in: description)
else {
return nil
}

self.name = String(description[nameRange])
}
}
38 changes: 29 additions & 9 deletions Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ private struct AttributedStringInlineRenderer {
private let baseURL: URL?
private let textStyles: InlineTextStyles
private var attributes: AttributeContainer
private var shouldSkipNextWhitespace = false

init(baseURL: URL?, textStyles: InlineTextStyles, attributes: AttributeContainer) {
self.baseURL = baseURL
Expand Down Expand Up @@ -55,26 +56,45 @@ private struct AttributedStringInlineRenderer {
}

private mutating func renderText(_ text: String) {
var text = text

if self.shouldSkipNextWhitespace {
self.shouldSkipNextWhitespace = false
text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}

self.result += .init(text, attributes: self.attributes)
}

private mutating func renderSoftBreak() {
self.result += .init(" ", attributes: self.attributes)
if self.shouldSkipNextWhitespace {
self.shouldSkipNextWhitespace = false
} else {
self.result += .init(" ", attributes: self.attributes)
}
}

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

mutating func renderCode(_ code: String) {
private mutating func renderCode(_ code: String) {
self.result += .init(code, attributes: self.textStyles.code.mergingAttributes(self.attributes))
}

mutating func renderHTML(_ html: String) {
self.result += .init(html, attributes: self.attributes)
private mutating func renderHTML(_ html: String) {
let tag = HTMLTag(html)

switch tag?.name.lowercased() {
case "br":
self.renderLineBreak()
self.shouldSkipNextWhitespace = true
default:
self.renderText(html)
}
}

mutating func renderEmphasis(children: [InlineNode]) {
private mutating func renderEmphasis(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.emphasis.mergingAttributes(self.attributes)

Expand All @@ -85,7 +105,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}

mutating func renderStrong(children: [InlineNode]) {
private mutating func renderStrong(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.strong.mergingAttributes(self.attributes)

Expand All @@ -96,7 +116,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}

mutating func renderStrikethrough(children: [InlineNode]) {
private mutating func renderStrikethrough(children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.strikethrough.mergingAttributes(self.attributes)

Expand All @@ -107,7 +127,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}

mutating func renderLink(destination: String, children: [InlineNode]) {
private mutating func renderLink(destination: String, children: [InlineNode]) {
let savedAttributes = self.attributes
self.attributes = self.textStyles.link.mergingAttributes(self.attributes)
self.attributes.link = URL(string: destination, relativeTo: self.baseURL)
Expand All @@ -119,7 +139,7 @@ private struct AttributedStringInlineRenderer {
self.attributes = savedAttributes
}

mutating func renderImage(source: String, children: [InlineNode]) {
private mutating func renderImage(source: String, children: [InlineNode]) {
// AttributedString does not support images
}
}
Expand Down
70 changes: 58 additions & 12 deletions Sources/MarkdownUI/Renderer/TextInlineRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ private struct TextInlineRenderer {
private let textStyles: InlineTextStyles
private let images: [String: Image]
private let attributes: AttributeContainer
private var shouldSkipNextWhitespace = false

init(
baseURL: URL?,
Expand All @@ -46,20 +47,65 @@ private struct TextInlineRenderer {

private mutating func render(_ inline: InlineNode) {
switch inline {
case .text(let content):
self.renderText(content)
case .softBreak:
self.renderSoftBreak()
case .html(let content):
self.renderHTML(content)
case .image(let source, _):
if let image = self.images[source] {
self.result = self.result + Text(image)
}
self.renderImage(source)
default:
self.result =
self.result
+ Text(
inline.renderAttributedString(
baseURL: self.baseURL,
textStyles: self.textStyles,
attributes: self.attributes
)
)
self.defaultRender(inline)
}
}

private mutating func renderText(_ text: String) {
var text = text

if self.shouldSkipNextWhitespace {
self.shouldSkipNextWhitespace = false
text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}

self.defaultRender(.text(text))
}

private mutating func renderSoftBreak() {
if self.shouldSkipNextWhitespace {
self.shouldSkipNextWhitespace = false
} else {
self.defaultRender(.softBreak)
}
}

private mutating func renderHTML(_ html: String) {
let tag = HTMLTag(html)

switch tag?.name.lowercased() {
case "br":
self.defaultRender(.lineBreak)
self.shouldSkipNextWhitespace = true
default:
self.defaultRender(.html(html))
}
}

private mutating func renderImage(_ source: String) {
if let image = self.images[source] {
self.result = self.result + Text(image)
}
}

private mutating func defaultRender(_ inline: InlineNode) {
self.result =
self.result
+ Text(
inline.renderAttributedString(
baseURL: self.baseURL,
textStyles: self.textStyles,
attributes: self.attributes
)
)
}
}
40 changes: 40 additions & 0 deletions Tests/MarkdownUITests/HTMLTagTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Foundation
import XCTest

@testable import MarkdownUI

final class HTMLTagTests: XCTestCase {
func testInvalidTag() {
XCTAssertNil(HTMLTag(""))
XCTAssertNil(HTMLTag("foo"))
XCTAssertNil(HTMLTag("<"))
XCTAssertNil(HTMLTag("<>"))
}

func testOpeningTag() {
// given
let tag = HTMLTag("<sub>")

// then
XCTAssertEqual("sub", tag?.name)
}

func testOpeningTagWithAttributes() {
// given
let tag = HTMLTag(
"<img src=\"img_girl.jpg\" alt=\"Girl in a jacket\" width=\"500\" height=\"600\">"
)

// then
XCTAssertEqual("img", tag?.name)
}

func testClosingTag() {
let tag = HTMLTag("</sub>")
XCTAssertEqual(tag?.name, "sub")
}

func testSelfClosingTag() {
XCTAssertEqual("br", HTMLTag("<br />")?.name)
}
}
4 changes: 4 additions & 0 deletions Tests/MarkdownUITests/MarkdownTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@
Visit https://github.com.
Use `git status` to list all new or modified files that haven't yet been committed.
You can insert a line break<br>
using the HTML `<br>`
<br> tag.
"""#
}
.border(Color.accentColor)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 3830c4c

Please sign in to comment.