Skip to content

fix: soft markdown line breaks#585

Open
eszlamczyk wants to merge 6 commits into
mainfrom
fix/541/markdown-parser-fixes
Open

fix: soft markdown line breaks#585
eszlamczyk wants to merge 6 commits into
mainfrom
fix/541/markdown-parser-fixes

Conversation

@eszlamczyk

@eszlamczyk eszlamczyk commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What/Why?

Fixes #541

Soft Markdown line breaks (a single \n inside a paragraph) rendered as visual line breaks on native, unlike
standard CommonMark/GFM renderers which collapse them to a space.

The shared C++ parser mapped both MD_TEXT_SOFTBR and MD_TEXT_BR to NodeType::LineBreak. Instead of appending a literal space in the parser (which would destroy information that downstream logic depends on - display-math block promotion, editor round-trips), this PR introduces a dedicated NodeType::SoftBreak and lets each consumer decide:

  • Read-only renderers (iOS, Android, web): SoftBreak renders as a space; LineBreak (hard break: trailing or \) keeps rendering as a visual break.
  • Parser post-processing: isSeparatorNode accepts SoftBreak, so display math on its own line inside a paragraph is still promoted to a block.
  • Input: Android InputParser maps SoftBreak\n so the editor stays WYSIWYG and value round-trips are lossless (iOS input works on raw text and needed no change).
  • AST serializers (table copy-as-markdown): SoftBreak\n, LineBreak\ + newline (CommonMark hard-break syntax), so hard breaks no longer degrade to soft breaks on serialize→parse cycles.
  • LaTeX/alt-text extractors now emit a space for break nodes (previously $$a\nb$$ extracted as ab). SoftBreak is appended last in every enum - both Android JNI bridges map C++ NodeType to Kotlin by ordinal (guarded by the static_assert in ParserJni.cpp, now SoftBreak == 30).

Known behavior change

text\n![img](url) on Android now renders the image inline instead of block. iOS already rendered it inline (its block check tests for \n while LineBreak renders U+2028), so platforms are now consistent - but Android users relying on that layout will see a difference. Standalone images (![img] as its own paragraph) are
unaffected.

Testing

Unit: two new render tests in CommonMarkRendererTest (soft break → space, hard break → newline); full standalone suite.

Manual: these exact strings hit the combinations most likely to regress (parser post-processing and renderer heuristics key off break nodes, so they're the flaky spots):

  1. Soft break (the issue repro):
    keep taking your medications as prescribed, and we'll begin tracking your\nblood sugar and blood pressure → single wrapped paragraph, no forced break after "your".
  2. Hard breaks still work (both syntaxes):
    line one \nline two (two trailing spaces) and line one\\nline two (trailing backslash) → visible line break within one paragraph.
    2.1. Pagragraph vs Hard break:
    line1\n\nline2 → two paragraphs with paragraph margin between them; line1\\\nline2 → one paragraph, so the lines are separated by line height only (no paragraph margin).
  3. Display math promotion (most fragile - exercises isSeparatorNode):
    • text before\n$$x^2$$\ntext after → math must still render as its own centered block, text split around it
    • $$x^2$$ alone → block (unchanged)
    • a $$x^2$$ b (same line) → stays inline (unchanged)
  4. Multi-line math span: $$a +\nb$$ → typesets a + b; previously the break was dropped (ab).
  5. Image after soft break (intentional change): some text\n![img](https://...) → image renders inline on both
    platforms
    now (was block on Android). ![img](https://...) alone in a paragraph → still block with block styling
    (maxHeight/aspectRatio/resizeMode).
  6. Lazy continuations: - item\n continued and > quote\ncontinued → continuation joins with a space inside the item/quote.
  7. Code unaffected: fenced block with real newlines renders unchanged; inline `code\nspan` renders with a space (md4c handles both before the AST — should be identical to before).
  8. Input round-trip (Android especially): set value={"line1\nline2"} on EnrichedMarkdownTextInput → editor shows two lines; type/edit and confirm onChangeMarkdown output re-renders identically (breaks must not collapse into spaces after an edit cycle).
  9. Table copy-as-markdown: copy a table via the selection menu → output re-parses to the same table.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a distinct SoftBreak AST node to differentiate CommonMark soft line breaks from hard line breaks, and updates renderers/serializers across Web, iOS, Android, and core C++ to render/serialize them appropriately.

Changes:

  • Add SoftBreak to the AST/node-type enums/unions and propagate it through platform bridges/serializers.
  • Render soft breaks as spaces while keeping hard breaks as explicit line breaks (<br />, \n, or markdown hard-break syntax in serializers).
  • Add Android UI tests covering soft-break vs hard-break rendering behavior.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/react-native-enriched-markdown/src/web/utils.ts Treat SoftBreak/LineBreak as whitespace when extracting plain text.
packages/react-native-enriched-markdown/src/web/types.ts Add SoftBreak to NodeType and update AST node docs.
packages/react-native-enriched-markdown/src/web/renderers/InlineRenderers.tsx Add SoftBreak renderer and register it in inlineRenderers.
packages/react-native-enriched-markdown/ios/utils/MarkdownASTSerializer.m Differentiate serialization of hard breaks vs soft breaks.
packages/react-native-enriched-markdown/ios/renderer/RendererFactory.m Render SoftBreak as a space in attributed output.
packages/react-native-enriched-markdown/ios/renderer/ENRMMathInlineRenderer.m Treat breaks as spaces when extracting inline LaTeX.
packages/react-native-enriched-markdown/ios/renderer/ENRMImageRenderer.m Treat breaks as spaces when building image text/alt-like content.
packages/react-native-enriched-markdown/ios/parser/MarkdownParserBridge.mm Bridge C++ SoftBreak node type to ObjC enum.
packages/react-native-enriched-markdown/ios/parser/MarkdownASTNode.h Add MarkdownNodeTypeSoftBreak to the ObjC node-type enum.
packages/react-native-enriched-markdown/android/src/math/java/com/swmansion/enriched/markdown/renderer/MathInlineRenderer.kt Treat soft/hard breaks as spaces when extracting LaTeX.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/utils/common/serialization/MarkdownASTSerializer.kt Differentiate serialization of hard breaks vs soft breaks.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt New renderer to output a space for SoftBreak.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt Register SoftBreak renderer in the Android renderer factory.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt Add SoftBreak to the Android node-type enum.
packages/react-native-enriched-markdown/android/src/main/java/com/swmansion/enriched/markdown/input/formatting/InputParser.kt Treat SoftBreak similarly to LineBreak during plain-text extraction for input parsing.
packages/react-native-enriched-markdown/android/src/main/cpp/jni-adapter.cpp Map C++ SoftBreak to the correct Kotlin enum ordinal.
packages/core/cpp/wasm/ASTSerializer.cpp Add stringification support for SoftBreak in WASM serializer.
packages/core/cpp/parser/MD4CParser.cpp Emit SoftBreak vs LineBreak nodes from md4c and treat soft breaks as separators.
packages/core/cpp/parser/MarkdownASTNode.hpp Add SoftBreak to core C++ NodeType.
packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/test/TestAstFactory.kt Add factory helpers for softBreak() and lineBreak().
packages/android-enriched-markdown/ui/src/test/java/com/swmansion/enriched/markdown/CommonMarkRendererTest.kt Add tests for soft-break-as-space and hard-break-as-newline.
packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/SoftBreakRenderer.kt New UI renderer for SoftBreak.
packages/android-enriched-markdown/ui/src/main/java/com/swmansion/enriched/markdown/renderer/NodeRenderer.kt Register SoftBreak renderer in UI renderer factory.
packages/android-enriched-markdown/parser/src/main/java/com/swmansion/enriched/markdown/parser/MarkdownASTNode.kt Add SoftBreak to parser module node-type enum.
packages/android-enriched-markdown/parser/src/main/cpp/jni/ParserJni.cpp Update JNI enum-sync assertion for new node type.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@eszlamczyk
eszlamczyk requested a review from hryhoriiK97 July 24, 2026 10:35
@eszlamczyk
eszlamczyk marked this pull request as ready for review July 24, 2026 10:35

@hryhoriiK97 hryhoriiK97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall! One minor nit below.

@@ -42,6 +42,11 @@ class MathInlineRenderer(

private fun extractLatex(node: MarkdownASTNode): String {
if (!node.content.isNullOrEmpty()) return node.content!!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Since MarkdownASTNode.content is String (non-nullable, defaults to ""), both isNullOrEmpty() and !! are unnecessary here. While you're already touching this function, could simplify to:

private fun extractLatex(node: MarkdownASTNode): String {
    if (node.content.isNotEmpty()) return node.content
    return node.children.joinToString("") { child ->
      when (child.type) {
        MarkdownASTNode.NodeType.SoftBreak, MarkdownASTNode.NodeType.LineBreak -> " "
        else -> child.content
      }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Soft Markdown line breaks render as hard line breaks on native

3 participants