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
\nnext
\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):