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() {