uqa_analysis/
char_filter.rs1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::AnalysisResult;
14use crate::FilteredText;
15use uqa_core::memory::MemoryBudget;
16
17mod compiled;
18#[cfg(feature = "kuromoji")]
19mod iteration;
20mod replacement;
21mod stream;
22mod width;
23pub(crate) use compiled::PreparedCharFilter;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27pub enum CharFilter {
28 #[serde(rename = "html_strip", alias = "h_t_m_l_strip")]
31 HTMLStrip,
32 #[serde(rename = "cjk_width")]
42 CJKWidth,
43 #[cfg(feature = "kuromoji")]
52 #[serde(rename = "kuromoji_iteration_mark")]
53 KuromojiIterationMark {
54 #[serde(default = "default_iteration_normalization")]
55 normalize_kanji: bool,
56 #[serde(default = "default_iteration_normalization")]
57 normalize_kana: bool,
58 },
59 Mapping {
60 mapping: BTreeMap<String, String>,
61 },
62 PatternReplace {
63 pattern: String,
64 #[serde(default)]
65 replacement: String,
66 },
67}
68
69#[cfg(feature = "kuromoji")]
70fn default_iteration_normalization() -> bool {
71 true
72}
73
74impl CharFilter {
75 pub fn validate(&self) -> AnalysisResult<()> {
77 match self {
78 CharFilter::PatternReplace { .. } => self.prepare().map(|_| ()),
79 _ => Ok(()),
80 }
81 }
82
83 pub fn filter(&self, text: &str) -> AnalysisResult<String> {
84 Ok(self.filter_with_offsets(text)?.into_string())
85 }
86
87 pub fn filter_with_offsets<'a>(&self, text: &'a str) -> AnalysisResult<FilteredText<'a>> {
89 self.filter_mapped(FilteredText::new(text))
90 }
91
92 pub fn filter_mapped<'a>(&self, text: FilteredText<'a>) -> AnalysisResult<FilteredText<'a>> {
94 self.prepare()?.filter_mapped(text)
95 }
96
97 pub fn filter_with_offsets_budgeted<'a>(
116 &self,
117 text: &'a str,
118 budget: &MemoryBudget,
119 poll: &mut dyn FnMut() -> AnalysisResult<()>,
120 ) -> AnalysisResult<FilteredText<'a>> {
121 self.filter_mapped_budgeted(FilteredText::new(text), budget, poll)
122 }
123
124 pub fn filter_mapped_budgeted<'a>(
126 &self,
127 text: FilteredText<'a>,
128 budget: &MemoryBudget,
129 poll: &mut dyn FnMut() -> AnalysisResult<()>,
130 ) -> AnalysisResult<FilteredText<'a>> {
131 poll()?;
132 self.prepare()?.filter_mapped_budgeted(text, budget, poll)
133 }
134}
135
136const HTML_ENTITIES: &[(&str, &str)] = &[
137 ("&", "&"),
138 ("<", "<"),
139 (">", ">"),
140 (""", "\""),
141 ("'", "'"),
142 ("'", "'"),
143 (" ", " "),
144];
145
146fn mapping_longest_first(m: &BTreeMap<String, String>) -> Vec<(String, String)> {
149 let mut entries: Vec<(String, String)> =
150 m.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
151 entries.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
152 entries
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[cfg(not(feature = "kuromoji"))]
160 #[test]
161 fn japanese_iteration_marks_require_the_kuromoji_feature() {
162 assert!(
163 serde_json::from_str::<CharFilter>(r#"{"type":"kuromoji_iteration_mark"}"#).is_err()
164 );
165 }
166
167 #[test]
168 fn html_strip_removes_tags_and_decodes_entities() {
169 let f = CharFilter::HTMLStrip;
170 assert_eq!(
171 f.filter("<p>hello & world</p>").unwrap(),
172 " hello & world ".to_string()
173 );
174 }
175
176 #[test]
177 fn mapping_replaces_longest_first() {
178 let mut m = BTreeMap::new();
183 m.insert("aa".to_string(), "X".to_string());
184 m.insert("a".to_string(), "Y".to_string());
185 let f = CharFilter::Mapping { mapping: m };
186 assert_eq!(f.filter("aab").unwrap(), "Xb");
187
188 assert_eq!(f.filter("aba").unwrap(), "YbY");
191 }
192
193 #[test]
194 fn pattern_replace_uses_regex() {
195 let f = CharFilter::PatternReplace {
196 pattern: r"\d+".to_string(),
197 replacement: "#".to_string(),
198 };
199 assert_eq!(f.filter("a1b22c").unwrap(), "a#b#c");
200 }
201}