118 lines
4.2 KiB
Rust
118 lines
4.2 KiB
Rust
//! Object names ↔ XML IDs (Preferences › Units › Identify Objects By).
|
||
//!
|
||
//! Illustrator's public documentation: with XML ID selected, the Layers panel shows and edits
|
||
//! names that conform to XML naming (letter, underscore or colon first; no spaces). Underscores
|
||
//! stand for spaces and `_xHH_` / `_xHHHH_` escape other characters, as in `.ai` `AI10_ArtUID`
|
||
//! values ([edit dynamic objects](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/edit-dynamic-objects.html)).
|
||
|
||
/// An object name from its XML id: `_` is a space and `_xHH_` / `_xHHHH_` a character; ids made
|
||
/// unique end with `_` and 20 or more digits (stripped).
|
||
pub fn to_object_name(id: &str) -> String {
|
||
let id = strip_unique(id);
|
||
let mut out = String::with_capacity(id.len());
|
||
let mut rest = id;
|
||
while let Some(c) = rest.chars().next() {
|
||
if c == '_' {
|
||
if let Some((ch, n)) = escaped(rest) {
|
||
out.push(ch);
|
||
rest = rest.get(n..).unwrap_or_default();
|
||
continue;
|
||
}
|
||
out.push(' ');
|
||
} else {
|
||
out.push(c);
|
||
}
|
||
rest = rest.get(c.len_utf8()..).unwrap_or_default();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The XML id for an object name (the inverse of [`to_object_name`]).
|
||
pub fn from_object_name(name: &str) -> String {
|
||
let mut out = String::with_capacity(name.len().saturating_mul(2));
|
||
for (i, c) in name.chars().enumerate() {
|
||
if c == ' ' {
|
||
out.push('_');
|
||
} else if c == '_' {
|
||
out.push_str("_x5F_");
|
||
} else if i == 0 && c.is_ascii_digit() {
|
||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
|
||
out.push(c);
|
||
} else {
|
||
out.push_str(&format!("_x{:X}_", u32::from(c)));
|
||
}
|
||
}
|
||
if out.is_empty() || !starts_name(&out) {
|
||
out.insert(0, '_');
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Whether `id` is a usable XML ID for Identify Objects By › XML ID: non-empty, no whitespace,
|
||
/// starts with a letter, `_` or `:`, and otherwise letters, digits, `.`, `-`, `_` or `:`.
|
||
pub fn is_valid(id: &str) -> bool {
|
||
let id = id.trim();
|
||
if id.is_empty() || id.chars().any(char::is_whitespace) {
|
||
return false;
|
||
}
|
||
let mut chars = id.chars();
|
||
let Some(first) = chars.next() else {
|
||
return false;
|
||
};
|
||
if !(first.is_alphabetic() || first == '_' || first == ':') {
|
||
return false;
|
||
}
|
||
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
|
||
}
|
||
|
||
fn starts_name(id: &str) -> bool {
|
||
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || 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")?;
|
||
let end = body.find('_')?;
|
||
let hex = body.get(..end)?;
|
||
if hex.is_empty() || hex.len() > 6 {
|
||
return None;
|
||
}
|
||
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
|
||
Some((c, end + 3))
|
||
}
|
||
|
||
/// `id` without the `_000…_` suffix that makes it unique.
|
||
fn strip_unique(id: &str) -> &str {
|
||
let Some(body) = id.strip_suffix('_') else {
|
||
return id;
|
||
};
|
||
let Some(at) = body.rfind('_') else {
|
||
return id;
|
||
};
|
||
let digits = body.get(at + 1..).unwrap_or_default();
|
||
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn round_trip_ai_style_ids() {
|
||
for (name, id) in [
|
||
("Layer 1", "Layer_1"),
|
||
("My Rect #2", "My_Rect__x23_2"),
|
||
("1abc (paren)", "_x31_abc__x28_paren_x29_"),
|
||
("Ünï 日本", "Ünï_日本"),
|
||
("a_b", "a_x5F_b"),
|
||
] {
|
||
assert_eq!(from_object_name(name), id, "encode {name}");
|
||
assert_eq!(to_object_name(id), name, "decode {id}");
|
||
}
|
||
assert_eq!(to_object_name("S_00000092429200007574234970000005974767392385616558_"), "S");
|
||
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
|
||
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
|
||
}
|
||
}
|