Merge pull request #4027 from Silvertree2010/fix/h1953-xml-id-name-round-trip-turns-a-s

Escape spaces before x<hex> and a separator in XML ids
This commit is contained in:
BFlat
2026-10-24 21:18:44 +07:00
committed by GitHub
+27 -3
View File
@@ -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<char> = 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<hex>_` 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", "#"] {