From cd796a08e0c753f1adfebba43a1a5b2b5972d125 Mon Sep 17 00:00:00 2001 From: Assaf Inbal Date: Thu, 13 Aug 2026 21:23:56 +0300 Subject: [PATCH] fix(renderer): treat single newlines as soft breaks, not line breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer appended a hard newline after every source line, so a single newline inside a paragraph rendered as a visible line break. CommonMark treats it as a soft break — rendered as a space. Add MarkdownRenderer.joinSoftBreaks, a pre-pass that joins consecutive paragraph lines into one logical line before per-line rendering: - hard breaks still work: two+ trailing spaces, or a trailing backslash (odd runs only — an even run is escaped literal backslashes) - a list/task item starts its own line but absorbs the plain lines that follow it (CommonMark lazy continuation), so wrapped item text stays inside the item under its hanging indent instead of dropping to the left margin as a bogus paragraph - headers, rules, and blank lines keep their own line and never absorb or become a continuation - a continuation line's leading/trailing whitespace is collapsed into the single joining space Blockquote and alert bodies did the same per-line joining, so they now run their lines through the same pre-pass. Co-Authored-By: Claude Fable 5 --- QuickMD/QuickMD/MarkdownRenderer.swift | 73 +++++++++++++++++++- QuickMD/QuickMD/Views/AlertBlockView.swift | 4 +- QuickMD/QuickMD/Views/BlockquoteView.swift | 5 +- QuickMD/QuickMDTests/RendererTests.swift | 77 ++++++++++++++++++++++ 4 files changed, 155 insertions(+), 4 deletions(-) diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift index 089245a..78f3bdf 100644 --- a/QuickMD/QuickMD/MarkdownRenderer.swift +++ b/QuickMD/QuickMD/MarkdownRenderer.swift @@ -116,7 +116,7 @@ struct MarkdownRenderer: Sendable { func render(_ markdown: String) -> AttributedString { var result = AttributedString() - for line in markdown.components(separatedBy: "\n") { + for line in Self.joinSoftBreaks(markdown.components(separatedBy: "\n")) { result.append(renderLine(line)) result.append(AttributedString("\n")) } @@ -124,6 +124,77 @@ struct MarkdownRenderer: Sendable { return result } + // MARK: - Soft Breaks + + /// CommonMark renders a single newline inside a paragraph as a space (a + /// soft break), not a line break. The renderer is line-based, so this + /// pre-pass joins consecutive paragraph lines into one logical line before + /// they reach `renderLine`. A list item also absorbs the plain lines that + /// follow it (lazy continuation), so wrapped item text stays inside the + /// item and gets its hanging indent instead of dropping to the left + /// margin as a bogus paragraph. A hard break — two or more trailing + /// spaces, or a trailing backslash — still ends the visual line, and + /// headers, rules, and blanks always keep their own line. + static func joinSoftBreaks(_ lines: [String]) -> [String] { + var joined: [String] = [] + var openParagraph = false // last joined line can absorb a continuation + + for line in lines { + let kind = classify(line) + guard kind != .structural else { + joined.append(line) + openParagraph = false + continue + } + + let hardBreak = endsWithHardBreak(line) + var text = line + while text.last == " " || text.last == "\t" { text.removeLast() } + if hardBreak && text.hasSuffix("\\") { text.removeLast() } + + if kind == .paragraph && openParagraph { + joined[joined.count - 1] += " " + text.trimmingCharacters(in: .whitespaces) + } else { + joined.append(text) + } + openParagraph = !hardBreak + } + + return joined + } + + private enum LineKind { + /// Blank, header, or horizontal rule — never joins with anything. + case structural + /// Bullet/ordered/task item — always starts its own joined line, but + /// leaves its paragraph open so following plain lines merge into the + /// item (CommonMark lazy continuation). + case listItem + /// Regular paragraph text — continues an open paragraph, or opens one. + case paragraph + } + + /// Must mirror the dispatch in `renderLine`, or a structural line could + /// get glued into the paragraph before it. + private static func classify(_ line: String) -> LineKind { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .structural } + let nsRange = NSRange(line.startIndex..., in: line) + if headerRegex.firstMatch(in: line, range: nsRange) != nil { return .structural } + if trimmed.range(of: MarkdownTheme.horizontalRulePattern, options: .regularExpression) != nil { return .structural } + if taskListRegex.firstMatch(in: line, range: nsRange) != nil { return .listItem } + if ["- ", "* ", "+ "].contains(where: { trimmed.hasPrefix($0) }) { return .listItem } + if trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil { return .listItem } + return .paragraph + } + + /// Two-plus trailing spaces, or an odd run of trailing backslashes (an + /// even run is escaped literal backslashes, e.g. `foo\\`). + private static func endsWithHardBreak(_ line: String) -> Bool { + if line.hasSuffix(" ") { return true } + return line.reversed().prefix(while: { $0 == "\\" }).count % 2 == 1 + } + // MARK: - Line Rendering private func renderLine(_ line: String) -> AttributedString { diff --git a/QuickMD/QuickMD/Views/AlertBlockView.swift b/QuickMD/QuickMD/Views/AlertBlockView.swift index a8294d6..cbe450d 100644 --- a/QuickMD/QuickMD/Views/AlertBlockView.swift +++ b/QuickMD/QuickMD/Views/AlertBlockView.swift @@ -79,9 +79,11 @@ struct AlertBlockView: View { /// Inline-render each body line and join with newlines — one attributed /// string for the whole alert body (same approach as BlockquoteView). + /// Soft breaks are joined first so a single newline inside a paragraph + /// reads as a space (CommonMark). private func renderedContent() -> AttributedString { var result = AttributedString() - let lines = content.components(separatedBy: "\n") + let lines = MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n")) for (index, line) in lines.enumerated() { if line.trimmingCharacters(in: .whitespaces).isEmpty { result.append(AttributedString(" ")) diff --git a/QuickMD/QuickMD/Views/BlockquoteView.swift b/QuickMD/QuickMD/Views/BlockquoteView.swift index 314d16c..7819d65 100644 --- a/QuickMD/QuickMD/Views/BlockquoteView.swift +++ b/QuickMD/QuickMD/Views/BlockquoteView.swift @@ -65,10 +65,11 @@ struct BlockquoteView: View { } /// Inline-render each quote line and join with newlines — one attributed - /// string for the whole quote body. + /// string for the whole quote body. Soft breaks are joined first so a + /// single newline inside a quote paragraph reads as a space (CommonMark). private func renderedContent() -> AttributedString { var result = AttributedString() - let lines = content.components(separatedBy: "\n") + let lines = MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n")) for (index, line) in lines.enumerated() { if line.trimmingCharacters(in: .whitespaces).isEmpty { result.append(AttributedString(" ")) diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift index 5a51af2..877cb3d 100644 --- a/QuickMD/QuickMDTests/RendererTests.swift +++ b/QuickMD/QuickMDTests/RendererTests.swift @@ -158,6 +158,83 @@ final class RendererTests: XCTestCase { XCTAssertTrue(out.contains(" • nested")) } + // MARK: - Soft line breaks + + /// CommonMark: a single newline inside a paragraph is a soft break, + /// rendered as a space — not a visible line break. + func testSingleNewlineJoinsParagraphLines() { + let out = String(renderer.render("one\ntwo").characters) + XCTAssertTrue(out.contains("one two"), "soft break rendered as line break: \(out)") + } + + func testBlankLineStillSeparatesParagraphs() { + let out = String(renderer.render("one\n\ntwo").characters) + XCTAssertFalse(out.contains("one two")) + } + + func testTrailingDoubleSpaceIsHardBreak() { + let out = String(renderer.render("one \ntwo").characters) + XCTAssertTrue(out.contains("one\ntwo")) + } + + func testTrailingBackslashIsHardBreak() { + let out = String(renderer.render("one\\\ntwo").characters) + XCTAssertTrue(out.contains("one\ntwo")) + XCTAssertFalse(out.contains("\\"), "hard-break backslash must be consumed") + } + + func testEscapedTrailingBackslashIsNotHardBreak() { + // `one\\` is a literal backslash, so the newline stays a soft break. + let out = String(renderer.render("one\\\\\ntwo").characters) + XCTAssertTrue(out.contains("one\\ two")) + } + + func testStructuralLinesAreNotGluedIntoParagraph() { + XCTAssertFalse(String(renderer.render("para\n# Title").characters).contains("para #")) + XCTAssertFalse(String(renderer.render("para\n- item").characters).contains("para •")) + XCTAssertFalse(String(renderer.render("para\n1. item").characters).contains("para 1.")) + } + + func testListItemsKeepTheirOwnLines() { + let out = String(renderer.render("- one\n- two").characters) + XCTAssertTrue(out.contains("• one\n")) + XCTAssertTrue(out.contains("• two")) + } + + func testContinuationLineLeadingIndentIsDropped() { + let out = String(renderer.render("one\n two").characters) + XCTAssertTrue(out.contains("one two")) + } + + /// The bug: wrapped item text on its own (indented) source lines fell out + /// of the item and rendered as a flush-left paragraph. Lazy continuation + /// pulls it back into the item, where the hanging indent applies. + func testListItemAbsorbsWrappedContinuationLines() { + let out = String(renderer.render("- first part,\n wrapped middle,\n wrapped tail").characters) + XCTAssertTrue(out.contains("• first part, wrapped middle, wrapped tail")) + } + + func testOrderedItemAbsorbsWrappedContinuationLines() { + let out = String(renderer.render("1. first part\n wrapped tail").characters) + XCTAssertTrue(out.contains("1. first part wrapped tail")) + } + + func testTaskItemAbsorbsWrappedContinuationLines() { + let out = String(renderer.render("- [ ] first part\n wrapped tail").characters) + XCTAssertTrue(out.contains("☐ first part wrapped tail")) + } + + func testNextListItemIsNotAbsorbedAsContinuation() { + let out = String(renderer.render("- one\n- two").characters) + XCTAssertTrue(out.contains("• one\n")) + XCTAssertTrue(out.contains("• two")) + } + + func testBlankLineEndsListItemParagraph() { + let out = String(renderer.render("- item\n\npara").characters) + XCTAssertFalse(out.contains("item para")) + } + // MARK: - Footnote references func testFootnoteReferenceRendersSuperscript() {