c2pa_text_binding/
normalize.rs1use unicode_normalization::UnicodeNormalization;
16
17pub fn is_zero_width_format(c: char) -> bool {
22 matches!(
23 c,
24 '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}'
25 ) || matches!(c, '\u{FE00}'..='\u{FE0F}')
26}
27
28pub fn canonical(text: &str) -> String {
34 let mut out = String::with_capacity(text.len());
35 let mut pending_separator = false;
36 for c in text.nfc() {
37 if is_zero_width_format(c) {
38 continue;
39 }
40 for lc in c.to_lowercase() {
41 if lc.is_alphanumeric() {
42 if pending_separator && !out.is_empty() {
43 out.push(' ');
44 }
45 pending_separator = false;
46 out.push(lc);
47 } else {
48 pending_separator = true;
49 }
50 }
51 }
52 out
53}
54
55pub fn structural(text: &str) -> String {
60 let mut out = String::with_capacity(text.len());
61 for c in text.nfc() {
62 if is_zero_width_format(c) {
63 continue;
64 }
65 out.push(c);
66 }
67 out
68}
69
70pub fn words(text: &str) -> Vec<String> {
73 canonical(text)
74 .split(' ')
75 .filter(|w| !w.is_empty())
76 .map(str::to_string)
77 .collect()
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn strips_zero_width_and_format() {
86 let dirty = "he\u{200B}llo\u{FEFF} wor\u{2060}ld";
87 assert_eq!(canonical(dirty), "hello world");
88 }
89
90 #[test]
91 fn lowercases_and_collapses_whitespace() {
92 assert_eq!(canonical("Hello WORLD\t\nFoo"), "hello world foo");
93 }
94
95 #[test]
96 fn punctuation_separates_tokens() {
97 assert_eq!(canonical("foo, bar; baz."), "foo bar baz");
98 assert_eq!(canonical("foo,bar"), "foo bar");
99 }
100
101 #[test]
102 fn no_leading_or_trailing_space() {
103 assert_eq!(canonical(" ...hi! "), "hi");
104 }
105
106 #[test]
107 fn nfc_equivalence() {
108 let precomposed = "caf\u{00E9}";
110 let decomposed = "cafe\u{0301}";
111 assert_eq!(canonical(precomposed), canonical(decomposed));
112 }
113
114 #[test]
115 fn structural_keeps_punctuation_and_case() {
116 let s = "Hello, World! A test.";
117 assert_eq!(structural(s), s);
118 }
119
120 #[test]
121 fn structural_strips_zero_width() {
122 assert_eq!(structural("a\u{200D}b."), "ab.");
123 }
124
125 #[test]
126 fn words_tokenizes() {
127 assert_eq!(
128 words("The quick, brown fox."),
129 vec!["the", "quick", "brown", "fox"]
130 );
131 }
132
133 #[test]
135 fn vector_canonical() {
136 let input = "The Quick\u{200B} Brown Fox — jumps!";
137 assert_eq!(canonical(input), "the quick brown fox jumps");
138 }
139}