diff --git a/Sources/MarkdownUI/Parser/HTMLTag.swift b/Sources/MarkdownUI/Parser/HTMLTag.swift new file mode 100644 index 00000000..08bbfce1 --- /dev/null +++ b/Sources/MarkdownUI/Parser/HTMLTag.swift @@ -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]) + } +} diff --git a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift index aa5d7cdc..a6d2dced 100644 --- a/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift +++ b/Sources/MarkdownUI/Renderer/AttributedStringInlineRenderer.swift @@ -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 @@ -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) @@ -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) @@ -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) @@ -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) @@ -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 } } diff --git a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift index 79c6375d..50ae3657 100644 --- a/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift +++ b/Sources/MarkdownUI/Renderer/TextInlineRenderer.swift @@ -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?, @@ -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 + ) + ) + } } diff --git a/Tests/MarkdownUITests/HTMLTagTests.swift b/Tests/MarkdownUITests/HTMLTagTests.swift new file mode 100644 index 00000000..b4080921 --- /dev/null +++ b/Tests/MarkdownUITests/HTMLTagTests.swift @@ -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("") + + // then + XCTAssertEqual("sub", tag?.name) + } + + func testOpeningTagWithAttributes() { + // given + let tag = HTMLTag( + "\"Girl" + ) + + // then + XCTAssertEqual("img", tag?.name) + } + + func testClosingTag() { + let tag = HTMLTag("") + XCTAssertEqual(tag?.name, "sub") + } + + func testSelfClosingTag() { + XCTAssertEqual("br", HTMLTag("
")?.name) + } +} diff --git a/Tests/MarkdownUITests/MarkdownTests.swift b/Tests/MarkdownUITests/MarkdownTests.swift index e2e7fd9c..8a190f8d 100644 --- a/Tests/MarkdownUITests/MarkdownTests.swift +++ b/Tests/MarkdownUITests/MarkdownTests.swift @@ -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
+ using the HTML `
` +
tag. """# } .border(Color.accentColor) diff --git a/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png b/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png index 94332df9..a25c1074 100644 Binary files a/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png and b/Tests/MarkdownUITests/__Snapshots__/MarkdownTests/testInlines.1.png differ