1use std::collections::HashSet;
27
28use read_fonts::types::Tag;
29use unicode_script::{Script, UnicodeScript};
30
31use crate::tags::tag4;
32
33pub const MIN_UNICODE_COVERAGE: usize = 4;
36
37#[derive(Debug, Clone)]
40pub struct ScriptRequirement {
41 input: String,
43 ot_tags: Vec<Tag>,
46 unicode_script: Option<Script>,
50}
51
52impl ScriptRequirement {
53 pub fn input(&self) -> &str {
55 &self.input
56 }
57
58 pub fn ot_tags(&self) -> &[Tag] {
60 &self.ot_tags
61 }
62
63 pub fn unicode_script(&self) -> Option<Script> {
65 self.unicode_script
66 }
67
68 pub fn ot_satisfied(&self, font_scripts: &HashSet<Tag>) -> bool {
70 self.ot_tags.iter().any(|tag| font_scripts.contains(tag))
71 }
72
73 pub fn unicode_satisfied<I>(&self, codepoints: I) -> bool
79 where
80 I: IntoIterator<Item = char>,
81 {
82 let Some(script) = self.unicode_script else {
83 return false;
84 };
85 let mut count = 0usize;
86 for ch in codepoints {
87 if ch.script() == script {
88 count += 1;
89 if count >= MIN_UNICODE_COVERAGE {
90 return true;
91 }
92 }
93 }
94 false
95 }
96}
97
98pub fn resolve_scripts(raw: &[String]) -> Vec<ScriptRequirement> {
103 raw.iter()
104 .filter_map(|s| {
105 let trimmed = s.trim();
106 if trimmed.is_empty() {
107 None
108 } else {
109 Some(resolve_one(trimmed))
110 }
111 })
112 .collect()
113}
114
115fn resolve_one(input: &str) -> ScriptRequirement {
117 let key = input.to_ascii_lowercase();
118
119 let (ot_strings, unicode_short): (Vec<&str>, Option<&str>) = match lookup(&key) {
120 Some((tags, uni)) => (tags.to_vec(), Some(uni)),
121 None => (vec![key.as_str()], None),
122 };
123
124 let ot_tags: Vec<Tag> = ot_strings.iter().filter_map(|t| tag4(t).ok()).collect();
125
126 let unicode_script = unicode_short
130 .and_then(Script::from_short_name)
131 .or_else(|| Script::from_short_name(&title_case(&key)))
132 .filter(|s| *s != Script::Unknown);
135
136 ScriptRequirement {
137 input: input.to_string(),
138 ot_tags,
139 unicode_script,
140 }
141}
142
143fn title_case(s: &str) -> String {
145 let mut chars = s.chars();
146 match chars.next() {
147 Some(first) => {
148 first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
149 }
150 None => String::new(),
151 }
152}
153
154fn lookup(key: &str) -> Option<(&'static [&'static str], &'static str)> {
165 const DEVA: &[&str] = &["deva", "dev2", "dev3"];
168 const BENG: &[&str] = &["beng", "bng2"];
169 const GUJR: &[&str] = &["gujr", "gjr2"];
170 const GURU: &[&str] = &["guru", "gur2"];
171 const KNDA: &[&str] = &["knda", "knd2"];
172 const MLYM: &[&str] = &["mlym", "mlm2"];
173 const ORYA: &[&str] = &["orya", "ory2"];
174 const TAML: &[&str] = &["taml", "tml2"];
175 const TELU: &[&str] = &["telu", "tel2"];
176
177 let entry: (&[&str], &str) = match key {
178 "deva" | "dev2" | "dev3" => (DEVA, "Deva"),
179 "beng" | "bng2" => (BENG, "Beng"),
180 "gujr" | "gjr2" => (GUJR, "Gujr"),
181 "guru" | "gur2" => (GURU, "Guru"),
182 "knda" | "knd2" => (KNDA, "Knda"),
183 "mlym" | "mlm2" => (MLYM, "Mlym"),
184 "orya" | "ory2" => (ORYA, "Orya"),
185 "taml" | "tml2" => (TAML, "Taml"),
186 "telu" | "tel2" => (TELU, "Telu"),
187 "latf" | "latg" => (&["latn"], "Latn"),
189 "aran" => (&["arab"], "Arab"),
190 "syre" | "syrj" | "syrn" => (&["syrc"], "Syrc"),
191 "hans" | "hant" => (&["hani"], "Hani"),
192 "lao" | "laoo" => (&["lao "], "Laoo"),
193 "yiii" => (&["yi "], "Yiii"),
194 "nkoo" => (&["nko "], "Nkoo"),
195 _ => return None,
196 };
197 Some(entry)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn tags(req: &ScriptRequirement) -> Vec<String> {
205 req.ot_tags()
206 .iter()
207 .map(|t| String::from_utf8_lossy(&t.to_be_bytes()).trim().to_string())
208 .collect()
209 }
210
211 #[test]
212 fn resolve_iso_devanagari_expands_to_ot_group() {
213 let reqs = resolve_scripts(&["deva".to_string()]);
214 assert_eq!(reqs.len(), 1);
215 assert_eq!(tags(&reqs[0]), vec!["deva", "dev2", "dev3"]);
216 assert_eq!(reqs[0].unicode_script(), Some(Script::Devanagari));
217 }
218
219 #[test]
220 fn resolve_v2_tag_maps_to_same_group_and_script() {
221 let reqs = resolve_scripts(&["dev2".to_string()]);
222 assert_eq!(tags(&reqs[0]), vec!["deva", "dev2", "dev3"]);
223 assert_eq!(reqs[0].unicode_script(), Some(Script::Devanagari));
224 }
225
226 #[test]
227 fn resolve_latin_fraktur_alias_maps_to_latn() {
228 let reqs = resolve_scripts(&["latf".to_string()]);
229 assert_eq!(tags(&reqs[0]), vec!["latn"]);
230 assert_eq!(reqs[0].unicode_script(), Some(Script::Latin));
231 }
232
233 #[test]
234 fn resolve_plain_opentype_tag_via_fallback() {
235 let reqs = resolve_scripts(&["latn".to_string()]);
236 assert_eq!(tags(&reqs[0]), vec!["latn"]);
237 assert_eq!(reqs[0].unicode_script(), Some(Script::Latin));
238 }
239
240 #[test]
241 fn resolve_is_case_insensitive() {
242 let reqs = resolve_scripts(&["LATN".to_string(), "Deva".to_string()]);
243 assert_eq!(tags(&reqs[0]), vec!["latn"]);
244 assert_eq!(reqs[0].unicode_script(), Some(Script::Latin));
245 assert_eq!(reqs[1].unicode_script(), Some(Script::Devanagari));
246 }
247
248 #[test]
249 fn resolve_unknown_tag_falls_back_to_literal_ot() {
250 let reqs = resolve_scripts(&["zzzz".to_string()]);
251 assert_eq!(tags(&reqs[0]), vec!["zzzz"]);
252 assert_eq!(reqs[0].unicode_script(), None);
253 }
254
255 #[test]
256 fn empty_and_blank_entries_are_skipped() {
257 let reqs = resolve_scripts(&["".to_string(), " ".to_string(), "latn".to_string()]);
258 assert_eq!(reqs.len(), 1);
259 }
260
261 #[test]
262 fn ot_satisfied_matches_any_group_member() {
263 let reqs = resolve_scripts(&["deva".to_string()]);
264 let mut font: HashSet<Tag> = HashSet::new();
265 font.insert(tag4("dev2").unwrap());
266 assert!(reqs[0].ot_satisfied(&font));
267
268 let empty: HashSet<Tag> = HashSet::new();
269 assert!(!reqs[0].ot_satisfied(&empty));
270 }
271
272 #[test]
273 fn unicode_satisfied_needs_min_coverage() {
274 let reqs = resolve_scripts(&["latn".to_string()]);
275 assert!(!reqs[0].unicode_satisfied(['a', 'b', 'c']));
277 assert!(reqs[0].unicode_satisfied(['a', 'b', 'c', 'd']));
279 assert!(!reqs[0].unicode_satisfied(['α', 'β', 'γ', 'δ']));
281 }
282}