diff --git a/ElementX/Sources/Other/Extensions/String.swift b/ElementX/Sources/Other/Extensions/String.swift index bd3e45a41..1de98e7c1 100644 --- a/ElementX/Sources/Other/Extensions/String.swift +++ b/ElementX/Sources/Other/Extensions/String.swift @@ -52,9 +52,10 @@ nonisolated extension String { } nonisolated extension String { + /// Drops stray new lines everywhere but paragraphs and lists when other paragraphs follow them func replacingHtmlBreaksOccurrences() -> String { var result = self - let pattern = #"

(\n+)

"# + let pattern = #"(\n+)(?=])"# guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return result @@ -63,11 +64,12 @@ nonisolated extension String { for match in matches.reversed() { guard let range = Range(match.range, in: self), - let innerMatchRange = Range(match.range(at: 1), in: self) else { + let tagRange = Range(match.range(at: 1), in: self), + let innerMatchRange = Range(match.range(at: 2), in: self) else { continue } let numberOfBreaks = (self[innerMatchRange].components(separatedBy: "\n").count - 1) - let replacement = "
" + String(repeating: "
", count: numberOfBreaks) + let replacement = "" + String(repeating: "
", count: numberOfBreaks) result.replaceSubrange(range, with: replacement) } diff --git a/UnitTests/Sources/AttributedStringBuilderTests.swift b/UnitTests/Sources/AttributedStringBuilderTests.swift index 67dd16c23..c2a71cd0a 100644 --- a/UnitTests/Sources/AttributedStringBuilderTests.swift +++ b/UnitTests/Sources/AttributedStringBuilderTests.swift @@ -684,6 +684,41 @@ struct AttributedStringBuilderTests { } } + /// A blank line the user typed after a list must survive, just like one between two paragraphs. + @Test + func newLineAfterList() throws { + let htmlString = "

Line 1

\n\n

Line 4

\n" + + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + + #expect(String(attributedString.characters) == "Line 1\n • Line 2\n\nLine 4") + } + + /// Without a blank line the trailing text is a lazy continuation of the item, so it stays on its line. + @Test + func noNewLineAfterList() throws { + let htmlString = "

Line 2

" + + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + + #expect(String(attributedString.characters) == " • Line 1\nLine 2") + } + + /// A blank line is preserved wherever it can be told apart from mere block separation. + @Test + func newLinesBetweenBlocks() throws { + let expectations = ["

a

\n

b

": "a\n\nb", + "

a

\n\n

b

": "a\n\n\nb", + "
  1. a
\n

b

": " 2. a\n\nb", + // A list can interrupt a paragraph, so this newline is block separation. + "

a

\n": "a\n • b"] + + for (htmlString, expectation) in expectations { + let attributedString = try #require(attributedStringBuilder.fromHTML(htmlString), "Could not build the attributed string") + #expect(String(attributedString.characters) == expectation, "Wrong rendering for \(htmlString)") + } + } + @Test func unorderedList() throws { let htmlString = "" diff --git a/UnitTests/Sources/StringTests.swift b/UnitTests/Sources/StringTests.swift index f2d4181a5..0388a74a5 100644 --- a/UnitTests/Sources/StringTests.swift +++ b/UnitTests/Sources/StringTests.swift @@ -73,13 +73,18 @@ struct StringTests { let input3 = "

\n\n\n\n

" let input4 = "

a

\n

b

" let input5 = "empty" + let input6 = "\n

b

" + let input7 = "

\n