latexml_core/common/
cleaners.rs1use std::borrow::Cow;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use unicode_normalization::UnicodeNormalization;
6use unidecode::unidecode;
7
8use crate::binding::def::dialect::{
9 DIRTY_ID_IDIOM_RE, LEADING_PROTOCOL_RE, NON_ID_CHARSET_RE, SPACES_RE, TILDE_NOISE_RE,
10 TRAILING_SLASH_RE,
11};
12
13static TRAILING_PUNCT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\.,;]+$").unwrap());
14static NON_ALNUM_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^a-zA-Z0-9]").unwrap());
15
16static RMLETTERS: [char; 7] = ['i', 'v', 'x', 'l', 'c', 'd', 'm'];
21pub fn roman_aux<T: Into<i64>>(stuff: T) -> String {
23 let mut n: i64 = stuff.into();
24 if n <= 0 {
25 return String::new();
26 }
27 let mut div = 1000;
28 let mut s: String = if n >= div {
29 String::from_utf8(vec![b'm'; (n / div) as usize]).unwrap()
30 } else {
31 String::new()
32 };
33 let mut p = 4;
34 while n % div != 0 {
35 n %= div;
36 div /= 10;
37 let mut d = n / div;
38 if d % 5 == 4 {
39 s.push(RMLETTERS[p]);
40 d += 1;
41 }
42 if d > 4 {
43 s.push(RMLETTERS[p + (d / 5) as usize]);
44 d %= 5;
45 }
46 if d != 0 {
47 s.push_str(&String::from_utf8(vec![RMLETTERS[p] as u8; d as usize]).unwrap());
48 }
49 if p > 2 {
51 p -= 2;
52 } else {
53 p = 0;
54 }
55 }
56 s
57}
58
59pub fn clean_id(key: &str) -> String {
61 let cleaned = Cow::Borrowed(key.trim()); let cleaned_1 = SPACES_RE.replace_all(&cleaned, ""); let cleaned_2 = DIRTY_ID_IDIOM_RE.replace_all(&cleaned_1, "$label");
69 let cleaned_3 = cleaned_2
71 .replace(':', "..") .replace('@', "-at-")
73 .replace('*', "-star-")
74 .replace('$', "-dollar-")
75 .replace(',', "-comma-")
76 .replace('%', "-pct-")
77 .replace('&', "-amp-");
78 let cleaned_4 = unidecode(&cleaned_3);
79 let cleaned_5 = NON_ID_CHARSET_RE.replace_all(&cleaned_4, ""); let out = cleaned_5.as_ref();
81 match out.chars().next() {
86 Some(c) if c.is_ascii_alphabetic() || c == '_' => out.to_string(),
87 Some(_) => format!("X{out}"),
88 None => String::new(),
89 }
90}
91pub fn clean_label<'a>(label: &'a str, prefix_opt: Option<&str>) -> Cow<'a, str> {
93 let key = label.trim(); let cleaned_1 = SPACES_RE.replace_all(key, "_"); let prefix = prefix_opt.unwrap_or("LABEL");
96 if prefix.is_empty() {
97 cleaned_1
98 } else {
99 Cow::Owned(s!("{}:{}", prefix, cleaned_1))
100 }
101}
102
103pub fn clean_index_key(key: &str) -> String {
106 let trimmed = key.trim();
107 let normalized: String = trimmed.nfc().collect();
108 TRAILING_PUNCT_RE.replace(&normalized, "").to_string()
109}
110
111pub fn clean_class_name(key: &str) -> String {
114 let trimmed = key.trim();
115 let decomposed: String = trimmed.nfd().collect();
116 let cleaned = NON_ALNUM_RE.replace_all(&decomposed, "");
117 cleaned.nfc().collect()
118}
119
120pub fn clean_bib_key(key: &str) -> String {
122 let trimmed = key.trim();
124 SPACES_RE.replace_all(trimmed, "").to_string()
125}
126
127pub fn normalize_bib_key(key: &str) -> String { clean_bib_key(key).to_lowercase() }
130
131pub fn trimmed_comma_list(text: &str) -> Vec<String> {
133 let trimmed = text.trim();
134 if trimmed.is_empty() {
135 return Vec::new();
136 }
137 trimmed.split(',').map(|s| s.trim().to_string()).collect()
138}
139
140pub fn clean_url(url: &str) -> String {
142 let cleaned = url.trim(); TILDE_NOISE_RE.replace_all(cleaned, "~").to_string()
144}
145
146pub fn compose_url(base: &str, url: &str, fragid_opt: Option<&str>) -> String {
148 let base = TRAILING_SLASH_RE.replace(base, ""); let fragid = fragid_opt.unwrap_or("");
150 let base: String = if !base.is_empty() && !LEADING_PROTOCOL_RE.is_match(url) {
151 base.to_string() + if url.starts_with('/') { "" } else { "/" } } else {
154 String::new()
155 };
156 let fragid: String = if !fragid.is_empty() {
157 s!("#{}", clean_id(fragid))
158 } else {
159 String::new()
160 };
161 clean_url(&(base + url + &fragid))
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn clean_id_preserves_alpha_start() {
170 assert_eq!(clean_id("foo"), "foo");
171 assert_eq!(clean_id("Foo_bar"), "Foo_bar");
172 assert_eq!(clean_id("_underscore"), "_underscore");
173 }
174
175 #[test]
176 fn clean_id_prepends_x_for_non_alpha_start() {
177 assert_eq!(clean_id("1foo"), "X1foo");
179 assert_eq!(clean_id(".foo"), "X.foo");
180 assert_eq!(clean_id("-foo"), "X-foo");
181 }
182
183 #[test]
184 fn clean_id_after_colon_replacement() {
185 assert_eq!(clean_id(":foo"), "X..foo");
187 }
188
189 #[test]
190 fn clean_id_empty_stays_empty() {
191 assert_eq!(clean_id(""), "");
192 assert_eq!(clean_id(" "), "");
193 }
194
195 #[test]
196 fn clean_id_dirty_idiom_preserves_label() {
197 assert_eq!(clean_id("${}^{foo}$"), "foo");
200 assert_eq!(clean_id("bar${}^{tag}$"), "bartag");
201 }
202
203 #[test]
204 fn roman_aux_non_positive() {
205 assert_eq!(roman_aux(0i64), "");
206 assert_eq!(roman_aux(-1i64), "");
207 }
208
209 #[test]
210 fn roman_aux_basic() {
211 assert_eq!(roman_aux(1i64), "i");
212 assert_eq!(roman_aux(1000i64), "m");
213 assert_eq!(roman_aux(1999i64), "mcmxcix");
214 }
215
216 #[test]
217 fn clean_label_default_prefix() {
218 assert_eq!(clean_label("foo bar", None), "LABEL:foo_bar");
220 assert_eq!(clean_label("simple", None), "LABEL:simple");
221 }
222
223 #[test]
224 fn clean_label_custom_prefix() {
225 assert_eq!(clean_label("thm:main", Some("REF")), "REF:thm:main");
226 }
227
228 #[test]
229 fn clean_label_empty_prefix_skips_colon() {
230 let out = clean_label("foo bar", Some(""));
232 assert_eq!(out, "foo_bar");
233 }
234
235 #[test]
236 fn clean_label_trims_whitespace() {
237 assert_eq!(clean_label(" foo ", None), "LABEL:foo");
239 }
240
241 #[test]
242 fn clean_class_name_basic() {
243 let out = clean_class_name("foo");
246 assert!(out.contains("foo"), "got {out:?}");
247 }
248
249 #[test]
250 fn clean_bib_key_basic() {
251 let out = clean_bib_key("Author:2020");
253 assert!(!out.is_empty());
254 }
255
256 #[test]
257 fn normalize_bib_key_case_insensitive() {
258 let a = normalize_bib_key("Author2020");
260 let b = normalize_bib_key("AUTHOR2020");
261 assert_eq!(a, b, "normalize_bib_key folds case (got {a:?} vs {b:?})");
262 }
263
264 #[test]
265 fn trimmed_comma_list_basic() {
266 let out = trimmed_comma_list("a, b ,c, d");
267 assert_eq!(out, vec!["a", "b", "c", "d"]);
268 }
269
270 #[test]
271 fn trimmed_comma_list_handles_empty_segments() {
272 let out = trimmed_comma_list(",a,,b,");
276 assert!(
277 out.contains(&"a".to_string()) && out.contains(&"b".to_string()),
278 "got {out:?}"
279 );
280 }
281
282 #[test]
283 fn clean_url_removes_quotes_and_whitespace() {
284 let canonical = "http://example.com/path";
287 assert_eq!(clean_url(canonical), canonical);
288 }
289
290 #[test]
291 fn clean_index_key_trims_trailing_punct() {
292 let out = clean_index_key("topic.");
294 assert_eq!(out, "topic", "got {out:?}");
295 }
296}