diff --git a/README.md b/README.md index 676c448..53af41a 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ xhtmlmd is largely implemented using AI, except for the tests. The tests are lar - HTML-in-Markdown: block containers opened with `markdown="1"`; the control attribute is stripped, indented code blocks are disabled inside the container, and fenced code is the code-block syntax there. - Math: four modes: `brackets` for `\(...\)`, `\[...\]`, and `$$...$$`, `dollars` for those plus `$...$` using Pandoc's non-space/digit dollar rules, `on` to preserve `\(...\)` and `\[...\]` delimiters for client-side renderers such as KaTeX, and `off`. Brackets mode is the default. - Attributes and inline spans: Pandoc/kramdown-style `{#id .class key="value"}`, block IALs `{: ...}`, span IALs, ALDs such as `{:note: #id .class}` with references, superscript `^x^`, subscript `~x~`, and highlight `==x==`. +- Pandoc-style escapes: `\ ` is a non-breaking space, and a trailing `\` is a hard break even at the end of a block, so a paragraph of just `\` renders as a `
` spacer. - Definition lists: PHP Markdown Extra/Pandoc-style `Term` followed by `: definition` or `~ definition`. - Footnotes: `[^id]` references to defined `[^id]:` definitions with indented continuation blocks. - Abbreviations: `*[HTML]: Hyper Text Markup Language` definitions render matching text as ``. diff --git a/docs/DIALECT.md b/docs/DIALECT.md index d5501aa..e786ab7 100644 --- a/docs/DIALECT.md +++ b/docs/DIALECT.md @@ -8,6 +8,7 @@ When Markdown extensions disagree, this crate chooses the behavior closest to Pa - Definition lists follow PHP Markdown Extra/Pandoc: one-line terms with one or more `:` or `~` definitions. - Footnotes follow Pandoc/kramdown label rules and render as XHTML endnotes with backlinks. The endnotes `
` has no leading `
` (unlike cmark-gfm): separators are a styling concern, so add one with CSS if wanted. - Inline `~~x~~` renders as strikethrough. Inline `~x~` renders as subscript, using the same no-whitespace rule as superscript `^x^`. +- Backslash escapes follow Pandoc's `escaped_line_breaks`/escaped-space rules: `\ ` (backslash before a space) is a non-breaking space, and a backslash at the end of a line is a hard break even at the end of a block, so a paragraph of just `\` renders as `


`. CommonMark instead keeps both as literal text. An escaped backslash (`\\`) never starts a break. - `` parses block Markdown inside the balanced tag. `markdown="span"` parses inline content into a single paragraph child. - Raw HTML passes through unbalanced, per CommonMark. The opt-in `balance` option closes unclosed elements at the fragment end, drops stray closes, and self-closes void tags, without HTML5 implied-end-tag rules. - Math defaults to `MathMode::Brackets`, which recognizes `\(...\)`, `\[...\]`, and `$$...$$`. `MathMode::Dollars` also recognizes `$...$` with Pandoc's guard against currency-like spans. `MathMode::On` preserves backslashes before `[]()` so client-side renderers such as KaTeX can see TeX delimiters. `MathMode::Off` treats TeX delimiters as ordinary Markdown text. diff --git a/src/inline.rs b/src/inline.rs index 3c822b7..39b9389 100644 --- a/src/inline.rs +++ b/src/inline.rs @@ -42,6 +42,7 @@ fn parse_inner(src: &str, ctx: &InlineContext<'_>, depth: usize) -> Vec }; let mut failed = FailedScans::default(); let mut i = 0; + let mut last_escape_end = usize::MAX; while i < src.len() { if starts(src, i, "\\[") && matches!(ctx.options.math, MathMode::Brackets | MathMode::Dollars) @@ -210,24 +211,33 @@ fn parse_inner(src: &str, ctx: &InlineContext<'_>, depth: usize) -> Vec if starts(src, i, "\\") { if i + 1 < src.len() { let next = next_char(src, i + 1); + if next == ' ' { + scanner.text.push('\u{a0}'); + i += 2; + last_escape_end = i; + continue; + } if is_escapable(next) { if ctx.options.math == MathMode::On && matches!(next, '[' | ']' | '(' | ')') { scanner.text.push('\\'); scanner.text.push(next); i += 1 + next.len_utf8(); + last_escape_end = i; continue; } scanner .text .push(if next == '&' { ESCAPED_AMP } else { next }); i += 1 + next.len_utf8(); + last_escape_end = i; continue; } } } if ch == '\n' { - if scanner.text.ends_with(" ") || scanner.text.ends_with('\\') { - if scanner.text.ends_with('\\') { + let backslash_break = scanner.text.ends_with('\\') && last_escape_end != i; + if backslash_break || scanner.text.ends_with(" ") { + if backslash_break { scanner.text.pop(); } else { while scanner.text.ends_with(' ') { @@ -246,6 +256,11 @@ fn parse_inner(src: &str, ctx: &InlineContext<'_>, depth: usize) -> Vec i += ch.len_utf8(); } } + if scanner.text.ends_with('\\') && last_escape_end != src.len() { + scanner.text.pop(); + scanner.flush_text(); + scanner.push_inline(Inline::HardBreak); + } scanner.flush_text(); process_delimiters(&mut nodes, &mut delimiters); nodes_to_inlines(&nodes) diff --git a/tests/source/cmark-gfm/spec.txt b/tests/source/cmark-gfm/spec.txt index 19adf90..25514fa 100644 --- a/tests/source/cmark-gfm/spec.txt +++ b/tests/source/cmark-gfm/spec.txt @@ -1155,7 +1155,7 @@ Foo Nor does a backslash at the end: -```````````````````````````````` example +```````````````````````````````` example disabled Foo\ ---- . @@ -5786,7 +5786,7 @@ Any ASCII punctuation character may be backslash-escaped: Backslashes before other characters are treated as literal backslashes: -```````````````````````````````` example +```````````````````````````````` example disabled \→\A\a\ \3\φ\« .

\→\A\a\ \3\φ\«

@@ -9770,7 +9770,7 @@ Hard line breaks are for separating inline content within a block. Neither syntax for hard line breaks works at the end of a paragraph or other block element: -```````````````````````````````` example +```````````````````````````````` example disabled foo\ .

foo\

@@ -9784,7 +9784,7 @@ foo ```````````````````````````````` -```````````````````````````````` example +```````````````````````````````` example disabled ### foo\ .

foo\

diff --git a/tests/test_focused.py b/tests/test_focused.py index c0bca57..6dd473b 100644 --- a/tests/test_focused.py +++ b/tests/test_focused.py @@ -90,6 +90,21 @@ def test_balance_ignores_rawtext_and_voids(): assert "" not in html assert "
" in html +def test_escaped_space_is_nbsp(): + assert to_xhtml("a\\ b\n") == "

a\u00a0b

\n" + assert to_xhtml("# foo\\ bar\n") == "

foo\u00a0bar

\n" + +def test_trailing_backslash_is_hard_break_at_block_end(): + assert to_xhtml("text\\\n\nnext\n") == "

text
\n

\n

next

\n" + assert to_xhtml("\\\n") == "


\n

\n" + assert to_xhtml("\\ \n") == "


\n

\n" + assert to_xhtml("# foo\\\n") == "

foo
\n

\n" + assert to_xhtml("Foo\\\n----\n") == "

Foo
\n

\n" + +def test_escaped_backslash_is_never_a_break(): + assert to_xhtml("a\\\\\nb\n") == "

a\\\nb

\n" + assert to_xhtml("a\\\\\n") == "

a\\

\n" + def test_long_nonascii_words_near_autolink_cap_do_not_error(): for boundary in ("(", "a: ", "x '"): for count in (126, 127, 128, 129, 130, 200):