From 949079b0b206a05342d8c8cf00a2303e96be5af6 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Fri, 21 Aug 2026 21:57:43 +0200 Subject: [PATCH 1/3] fix: pass existing backslash escapes through escape_content escape_content had no case for backslash, so it escaped the backslash of an escape sequence that was already in the source: `\]` became `\\]`, a literal backslash followed by a bare `]` that closes the caller's content block early. Copy escape sequences through whole instead. --- src/render.rs | 21 ++++++++++++++++++++- tests/integration.rs | 14 ++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/render.rs b/src/render.rs index 37a775a..a89d431 100644 --- a/src/render.rs +++ b/src/render.rs @@ -239,6 +239,8 @@ fn write_enum_prefix(number: Option, out: &mut String) { /// /// - Tracks bracket depth so that balanced `[...]` pairs are left untouched. /// - Unbalanced `]` is escaped as `\]`. +/// - Backslash escapes already in the source are passed through as-is, so an +/// escaped character is not escaped a second time. /// - If the content ends with an odd number of backslashes, a trailing space is /// appended to prevent the closing `]` from being interpreted as `\]`. /// - When `escape_refs` is true, `@` is escaped as `\@` and `<` is escaped @@ -249,8 +251,17 @@ fn write_enum_prefix(number: Option, out: &mut String) { fn escape_content(s: &str, escape_refs: bool) -> String { let mut result = String::with_capacity(s.len()); let mut depth: i32 = 0; - for (i, ch) in s.char_indices() { + let mut chars = s.char_indices(); + while let Some((i, ch)) = chars.next() { match ch { + // An escape sequence is already literal; copy it through whole so + // the escaped character is not treated as markup below. + '\\' => { + result.push('\\'); + if let Some((_, escaped)) = chars.next() { + result.push(escaped); + } + } '[' => { depth += 1; result.push(ch); @@ -317,6 +328,14 @@ mod tests { assert_eq!(escape_content("a ] b", false), "a \\] b"); } + #[test] + fn test_escape_content_preserves_existing_escapes() { + // `\]` is already literal; escaping the backslash would hand the `]` + // back to Typst and close the caller's content block early. + assert_eq!(escape_content("a \\] b", false), "a \\] b"); + assert_eq!(escape_content("\\[x", false), "\\[x"); + } + #[test] fn test_escape_content_trailing_backslash() { assert_eq!(escape_content("text\\", false), "text\\ "); diff --git a/tests/integration.rs b/tests/integration.rs index dacad43..18098b6 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -199,6 +199,20 @@ after the field log was reconciled. assert!(!output.contains("#ref(\\")); } +#[test] +fn test_existing_backslash_escape_is_not_double_escaped() { + // `\*` and `\[` are already literal. Escaping the backslash instead would + // hand the `*`/`[` back to Typst as markup and reopen the delimiter. + let old = "Foo bar.\n"; + let new = "Foo \\*baz* and \\[qux bar.\n"; + let output = run_diff(old, new); + + assert!( + !output.contains("\\\\*") && !output.contains("\\\\["), + "an existing escape must not be escaped a second time: {output}" + ); +} + #[test] fn test_indented_line_comment_does_not_split_paragraph() { let old = "First sentence.\n // indented comment\nSecond sentence.\n"; From 53cf0b66662e7defb72731c95e2e71e5826e5d90 Mon Sep 17 00:00:00 2001 From: Philipp Wagner Date: Sat, 22 Aug 2026 00:10:38 +0200 Subject: [PATCH 2/3] fix: keep escape sequences whole when diffing Tokenizing `\]` as two tokens lets the diff cut between them. The backslash is then left at the end of the unchanged text, where it escapes the `#` of the call written next to it, so "\[x\] [grp]" against "\[x\]" renders as \[x\#diff-deleted[\] \[grp]] and Typst prints the call itself to the page instead of striking the deleted words through. The character the backslash guarded is loose markup on the other side of the cut. Treat an escape sequence as one atomic token, like `#func[...]` and `@ref`. --- src/diff.rs | 14 ++++++++++++-- tests/integration.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/diff.rs b/src/diff.rs index 208733b..a1c2e08 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -137,8 +137,8 @@ fn process_replace(old_range: &[Block], new_range: &[Block], results: &mut Vec`) are treated as atomic tokens so the diff never -/// fragments valid Typst syntax. +/// labels (`