From 4e9cd60540f8a139c53ebb7d8a0e34515f964074 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:58:30 +0000 Subject: [PATCH] Escape spaces before x and a separator in XML ids Found while testing crates/doc/src/xml_id.rs:32 (from_object_name), decoded by escaped() at :72 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/xml_id.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index ba454de01..08dd5edbe 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -32,9 +32,11 @@ pub fn to_object_name(id: &str) -> String { pub fn from_object_name(name: &str) -> String { let mut out = String::with_capacity(name.len().saturating_mul(2)); let last = name.chars().count().saturating_sub(1); - for (i, c) in name.chars().enumerate() { - if c == ' ' && i == last { - // A trailing bare `_` could read back as the end of a uniqueness suffix. + let chars: Vec = name.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == ' ' && (i == last || starts_fake_escape(chars.get(i + 1..).unwrap_or_default())) { + // A trailing bare `_` could read back as the end of a uniqueness suffix, and a bare + // `_` before `x_` would read back as an `_xHH_` escape. out.push_str("_x20_"); } else if c == ' ' { out.push('_'); @@ -68,6 +70,19 @@ pub fn is_valid(id: &str) -> bool { chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':')) } +/// Whether the encoded `rest` (after a space) starts with `x`, 1 to 6 hex digits and a character +/// whose encoding begins with `_`, so that a bare `_` before it would form an escape. +fn starts_fake_escape(rest: &[char]) -> bool { + let Some(('x', digits)) = rest.split_first() else { + return false; + }; + let n = digits.iter().take_while(|c| c.is_ascii_hexdigit()).count(); + if n == 0 || n > 6 { + return false; + } + digits.get(n).is_some_and(|&c| c == ' ' || c == '_' || !(c.is_alphanumeric() || matches!(c, '.' | '-' | ':'))) +} + /// `_xHHHH_` at the start of `s`: the character and the escape's length. fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; @@ -119,6 +134,15 @@ mod tests { assert_eq!(to_object_name("a_x-41_b"), "a x-41 b"); } + #[test] + fn space_before_x_hex_and_underscore_is_not_an_escape() { + for name in ["layer x1 copy", "a xa b", "a xe b", "a xb b", "a x1(b", "a x1_b", "a x123456 c"] { + let id = from_object_name(name); + assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}"); + } + assert_eq!(from_object_name("layer x1 copy"), "layer_x20_x1_copy"); + } + #[test] fn round_trip_names_that_cannot_start_an_xml_name() { for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] {