Skip to main content

text/
fontconf.rs

1// fontconf -- generic families, substitutes and choices per language: a light fontconfig
2//
3//   FontConf::new()                  no rules
4//   FontConf::system()               the rules of this system: on Linux the <alias> and
5//                                    language rules of fontconfig's files (/etc/fonts/fonts.conf
6//                                    and what it includes) -- or instead the file of
7//                                    jfgui_FONTCONF (the same format, every OS; tests) --, then
8//                                    the built-in rules of this OS
9//   conf.parse(text, dir) / conf.load(path) -> bool   add the rules of a fontconfig file
10//   conf.add_alias(family, prefer, accept, default)    add an alias
11//   conf.add_lang(lang, family, mode, families)        add a language rule (LR_*)
12//   conf.candidates(family, lang) -> Vec<String>       the families to try, best first
13//   conf.lang_families(lang) -> Vec<String>            what the language puts into
14//                                    sans-serif beyond what every language gets (fallback.rs
15//                                    tries them first: Han characters in Japanese forms for ja)
16//   conf.files() -> usize            fontconfig files read
17//   is_generic(name)                 GENERICS: sans-serif, serif, monospace, cursive, fantasy,
18//                                    system-ui, emoji, math (also fontconfig's sans and mono)
19//
20// FontDb::resolve(&conf, family, lang, weight, italic) (fontdb.rs) finds the first installed
21// candidate with Latin letters (without any, the first installed one); for a name nothing
22// installed matches, the candidates of sans-serif.
23//
24// Rules, fontconfig's semantics in short: an alias for F puts its <prefer> families right
25// before F (so a later alias's prefer list follows an earlier one), its <accept> families
26// right after F (a later list before an earlier one), its <default> families at the end.  A
27// <match target="pattern"> applies when its tests are only lang (eq / contains) and family
28// (eq) and they hold; its family edits prepend (before F), prepend_first, append (after F),
29// append_last or assign (in place of F).  Other matches (charset, spacing, prgname, target
30// font: hinting) are skipped.  The families in the list that have rules of their own are
31// expanded in place (4 levels), only their <default> and append_last families go to the end
32// of the whole list.  Layers: the files' rules first, the built-in ones after them;
33// names compare ASCII-case-insensitively, each once.  Languages: "ja" matches ja and ja-JP;
34// zh-Hans / zh-CN / zh-SG / plain zh also match zh-cn, zh-Hant / zh-TW zh-tw, zh-HK / zh-MO
35// zh-hk and zh-tw.  <include> follows files and folders (*.conf in name order) relative to
36// the including file's folder, prefix="xdg" ($XDG_CONFIG_HOME, else ~/.config) and "~/"; at
37// most FILES_MAX files of at most 1 MiB.
38
39use alloc::string::String;
40use alloc::vec::Vec;
41
42/// add_lang modes (fontconfig's edit modes)
43pub const LR_PREPEND: u8 = 0;
44pub const LR_PREPEND_FIRST: u8 = 1;
45pub const LR_APPEND: u8 = 2;
46pub const LR_APPEND_LAST: u8 = 3;
47pub const LR_ASSIGN: u8 = 4;
48
49/// the generic families
50pub const GENERICS: [&str; 10] = ["sans-serif", "serif", "monospace", "cursive", "fantasy", "system-ui", "emoji", "math", "sans", "mono"];
51
52const FILES_MAX: usize = 256;
53const FILE_MAX: usize = 1 << 20;
54const DEPTH_MAX: u32 = 4;
55
56/// a generic family name (GENERICS)
57pub fn is_generic(name: &str) -> bool {
58    GENERICS.iter().any(|g| g.eq_ignore_ascii_case(name.trim()))
59}
60
61fn same(a: &str, b: &str) -> bool {
62    a.trim().eq_ignore_ascii_case(b.trim())
63}
64
65#[derive(Clone, Debug)]
66enum Rule {
67    Alias { family: String, prefer: Vec<String>, accept: Vec<String>, default: Vec<String> },
68    Lang { lang: Option<String>, family: Option<String>, mode: u8, families: Vec<String> },
69}
70
71impl Rule {
72    fn family(&self) -> Option<&str> {
73        match self {
74            Rule::Alias { family, .. } => Some(family),
75            Rule::Lang { family, .. } => family.as_deref(),
76        }
77    }
78}
79
80/// generic families, substitutes and language choices (see the module header)
81#[derive(Clone, Debug, Default)]
82pub struct FontConf {
83    files: Vec<Rule>,
84    builtin: Vec<Rule>,
85    nfiles: usize,
86}
87
88impl FontConf {
89    pub fn new() -> FontConf {
90        FontConf::default()
91    }
92
93    /// the rules of this system (see the module header)
94    pub fn system() -> FontConf {
95        let mut c = FontConf::new();
96        match plat::env("jfgui_FONTCONF") {
97            Some(p) => {
98                c.load(&p);
99            }
100            None => {
101                #[cfg(target_os = "linux")]
102                c.load("/etc/fonts/fonts.conf");
103            }
104        }
105        c.builtin = builtin();
106        c
107    }
108
109    /// fontconfig files read
110    pub fn files(&self) -> usize {
111        self.nfiles
112    }
113
114    /// add the rules of a fontconfig file (and of what it includes) -> it could be read
115    pub fn load(&mut self, path: &str) -> bool {
116        let mut seen = Vec::new();
117        self.load_file(path, &mut seen)
118    }
119
120    fn load_file(&mut self, path: &str, seen: &mut Vec<String>) -> bool {
121        let norm = path_norm(path);
122        if seen.len() >= FILES_MAX || seen.iter().any(|s| *s == norm) {
123            return false;
124        }
125        seen.push(norm);
126        let Some(data) = plat::read_file(path) else { return false };
127        if data.len() > FILE_MAX {
128            return false;
129        }
130        let text = String::from_utf8_lossy(&data);
131        let dir = path.rsplit_once(['/', '\\']).map_or("", |x| x.0);
132        let root = parse_xml(&text);
133        self.nfiles += 1;
134        for e in &root.kids {
135            self.element(e, dir, seen);
136        }
137        true
138    }
139
140    /// add the rules of fontconfig text (includes relative to dir) -> rules found
141    pub fn parse(&mut self, text: &str, dir: &str) -> usize {
142        let n = self.files.len();
143        let root = parse_xml(text);
144        let mut seen = Vec::new();
145        for e in &root.kids {
146            self.element(e, dir, &mut seen);
147        }
148        self.files.len() - n
149    }
150
151    /// add an alias: prefer before the family, accept after it, default at the end
152    pub fn add_alias(&mut self, family: &str, prefer: &[&str], accept: &[&str], default: &[&str]) {
153        let v = |l: &[&str]| l.iter().map(|s| String::from(*s)).collect();
154        self.files.push(Rule::Alias { family: String::from(family), prefer: v(prefer), accept: v(accept), default: v(default) });
155    }
156
157    /// add a language rule (lang None: every language; family None: every family)
158    pub fn add_lang(&mut self, lang: Option<&str>, family: Option<&str>, mode: u8, families: &[&str]) {
159        self.files.push(Rule::Lang {
160            lang: lang.map(lang_norm),
161            family: family.map(String::from),
162            mode,
163            families: families.iter().map(|s| String::from(*s)).collect(),
164        });
165    }
166
167    fn element(&mut self, e: &Elem, dir: &str, seen: &mut Vec<String>) {
168        match e.name.as_str() {
169            "fontconfig" => {
170                for k in &e.kids {
171                    self.element(k, dir, seen);
172                }
173            }
174            "include" => self.include(e, dir, seen),
175            "alias" => {
176                let Some(family) = e.kid("family").map(|f| f.text.trim()).filter(|t| !t.is_empty()) else { return };
177                let list = |name: &str| -> Vec<String> {
178                    e.kids.iter().filter(|k| k.name == name).flat_map(|k| k.kids.iter()).filter(|f| f.name == "family")
179                        .map(|f| String::from(f.text.trim())).filter(|t| !t.is_empty()).collect()
180                };
181                self.files.push(Rule::Alias { family: String::from(family), prefer: list("prefer"), accept: list("accept"), default: list("default") });
182            }
183            "match" => {
184                if e.attr("target").is_some_and(|t| t != "pattern") {
185                    return;
186                }
187                let (mut lang, mut family) = (None, None);
188                for t in e.kids.iter().filter(|k| k.name == "test") {
189                    let cmp = t.attr("compare").unwrap_or("eq");
190                    let val = t.kid("string").map(|s| String::from(s.text.trim()));
191                    match (t.attr("name"), cmp, val) {
192                        (Some("lang"), "eq" | "contains", Some(v)) if lang.is_none() => lang = Some(lang_norm(&v)),
193                        (Some("family"), "eq", Some(v)) if family.is_none() => family = Some(v),
194                        _ => return, // a test this light reader cannot decide
195                    }
196                }
197                for ed in e.kids.iter().filter(|k| k.name == "edit" && k.attr("name") == Some("family")) {
198                    let mode = match ed.attr("mode").unwrap_or("assign") {
199                        "prepend" => LR_PREPEND,
200                        "prepend_first" => LR_PREPEND_FIRST,
201                        "append" => LR_APPEND,
202                        "append_last" => LR_APPEND_LAST,
203                        "assign" | "assign_replace" => LR_ASSIGN,
204                        _ => continue,
205                    };
206                    let families: Vec<String> =
207                        ed.kids.iter().filter(|s| s.name == "string").map(|s| String::from(s.text.trim())).filter(|t| !t.is_empty()).collect();
208                    // an assignment without a family test would replace every family: only with one
209                    if !families.is_empty() && (mode != LR_ASSIGN || family.is_some()) {
210                        self.files.push(Rule::Lang { lang: lang.clone(), family: family.clone(), mode, families });
211                    }
212                }
213            }
214            _ => {}
215        }
216    }
217
218    fn include(&mut self, e: &Elem, dir: &str, seen: &mut Vec<String>) {
219        let p = e.text.trim();
220        if p.is_empty() {
221            return;
222        }
223        let path = if e.attr("prefix") == Some("xdg") {
224            match plat::env("XDG_CONFIG_HOME") {
225                Some(x) if x.starts_with('/') => alloc::format!("{}/{}", x, p),
226                _ => match plat::env("HOME") {
227                    Some(h) => alloc::format!("{}/.config/{}", h, p),
228                    None => return,
229                },
230            }
231        } else if let Some(rest) = p.strip_prefix("~/") {
232            match plat::env("HOME") {
233                Some(h) => alloc::format!("{}/{}", h, rest),
234                None => return,
235            }
236        } else if p.starts_with('/') || dir.is_empty() {
237            String::from(p)
238        } else {
239            alloc::format!("{}/{}", dir, p)
240        };
241        if plat::file_exists(&path) == 2 {
242            let mut names = Vec::new();
243            plat::dir_list(&path, &mut |name, is_dir, _| {
244                if !is_dir && name.ends_with(".conf") {
245                    names.push(String::from(name));
246                }
247            });
248            names.sort();
249            for n in names {
250                self.load_file(&alloc::format!("{}/{}", path, n), seen);
251            }
252        } else {
253            self.load_file(&path, seen);
254        }
255    }
256
257    /// the families to try for a name in a language, best first (each once; generic names
258    /// stay in the list where they stand)
259    pub fn candidates(&self, family: &str, lang: &str) -> Vec<String> {
260        let langs = lang_keys(lang);
261        let mut out: Vec<String> = Vec::new();
262        for (k, layer) in [&self.files, &self.builtin].into_iter().enumerate() {
263            // the first layer decides whether the name itself stays (assign removes it)
264            let mut memo = Memo { done: Vec::new(), steps: 0 };
265            for f in expand_list(layer, family, &langs, &mut memo) {
266                if !out.iter().any(|o| same(o, &f)) && (k == 0 || !same(&f, family)) {
267                    out.push(f);
268                }
269            }
270        }
271        out
272    }
273
274    /// the families a language adds to sans-serif (fallback.rs tries them first)
275    pub fn lang_families(&self, lang: &str) -> Vec<String> {
276        let all = self.candidates("sans-serif", "");
277        let mut out: Vec<String> = self.candidates("sans-serif", lang).into_iter().filter(|f| !all.iter().any(|a| same(a, f))).collect();
278        out.retain(|f| !is_generic(f));
279        out
280    }
281}
282
283// the list for one family in one layer (see the module header)
284// expansions already done (name, depth) and the steps spent: a crafted config cannot blow up the recursion
285struct Memo {
286    done: Vec<(String, u32, (Vec<String>, Vec<String>))>,
287    steps: u32,
288}
289
290const STEPS_MAX: u32 = 2000;
291
292fn expand_list(rules: &[Rule], family: &str, langs: &[String], memo: &mut Memo) -> Vec<String> {
293    let (mut out, mut last) = expand(rules, family, langs, 0, memo);
294    let mut i = 0;
295    while i < last.len() {
296        let f = last[i].clone();
297        i += 1;
298        if out.iter().any(|o| same(o, &f)) {
299            continue;
300        }
301        if !same(&f, family) && memo.steps < STEPS_MAX && rules.iter().any(|r| r.family().is_some_and(|x| same(x, &f))) {
302            memo.steps += 1;
303            let (parts, more) = expand(rules, &f, langs, 1, memo);
304            for x in parts {
305                if !out.iter().any(|o| same(o, &x)) {
306                    out.push(x);
307                }
308            }
309            last.extend(more);
310        } else {
311            out.push(f);
312        }
313    }
314    out
315}
316
317// -> (the list, what goes to the end of the whole list)
318fn expand(rules: &[Rule], family: &str, langs: &[String], depth: u32, memo: &mut Memo) -> (Vec<String>, Vec<String>) {
319    let mut list: Vec<String> = alloc::vec![String::from(family)];
320    let mut last: Vec<String> = Vec::new();
321    let mut fpos = Some(0usize);
322    for r in rules {
323        let Some(p) = fpos else { break };
324        if r.family().is_some_and(|f| !same(f, family)) {
325            continue;
326        }
327        match r {
328            Rule::Alias { prefer, accept, default, .. } => {
329                insert(&mut list, p, prefer);
330                let p = p + prefer.len();
331                insert(&mut list, p + 1, accept);
332                last.extend(default.iter().cloned());
333                fpos = Some(p);
334            }
335            Rule::Lang { lang, mode, families, .. } => {
336                if lang.as_ref().is_some_and(|l| !langs.iter().any(|k| k == l)) {
337                    continue;
338                }
339                match *mode {
340                    LR_PREPEND => {
341                        insert(&mut list, p, families);
342                        fpos = Some(p + families.len());
343                    }
344                    LR_PREPEND_FIRST => {
345                        insert(&mut list, 0, families);
346                        fpos = Some(p + families.len());
347                    }
348                    LR_APPEND => insert(&mut list, p + 1, families),
349                    LR_APPEND_LAST => last.extend(families.iter().cloned()),
350                    _ => {
351                        list.remove(p);
352                        insert(&mut list, p, families);
353                        fpos = None; // the family is gone: later rules for it do not apply
354                    }
355                }
356            }
357        }
358    }
359    // the families of the list that have rules of their own, expanded in place
360    let mut out: Vec<String> = Vec::new();
361    for f in list {
362        let own = depth < DEPTH_MAX && !same(&f, family) && rules.iter().any(|r| r.family().is_some_and(|x| same(x, &f)));
363        let (parts, more) = if own && memo.steps < STEPS_MAX {
364            let key = f.trim().to_ascii_lowercase();
365            if let Some(d) = memo.done.iter().find(|d| d.1 == depth + 1 && d.0 == key) {
366                d.2.clone()
367            } else {
368                memo.steps += 1;
369                let r = expand(rules, &f, langs, depth + 1, memo);
370                memo.done.push((key, depth + 1, r.clone()));
371                r
372            }
373        } else {
374            (alloc::vec![f], Vec::new())
375        };
376        for x in parts {
377            if !out.iter().any(|o| same(o, &x)) {
378                out.push(x);
379            }
380        }
381        last.extend(more);
382    }
383    (out, last)
384}
385
386fn insert(list: &mut Vec<String>, at: usize, items: &[String]) {
387    let at = at.min(list.len());
388    for (k, it) in items.iter().enumerate() {
389        list.insert(at + k, it.clone());
390    }
391}
392
393// a path with '\' as '/' and its "." / ".." segments resolved (include cycle detection)
394fn path_norm(p: &str) -> String {
395    let mut parts: Vec<&str> = Vec::new();
396    for s in p.split(['/', '\\']) {
397        match s {
398            "." => {}
399            ".." if parts.last().is_some_and(|l| !l.is_empty() && *l != "..") => {
400                parts.pop();
401            }
402            _ => parts.push(s),
403        }
404    }
405    parts.join("/")
406}
407
408// fontconfig's language form: lower case, '-' (ja_JP -> ja-jp)
409fn lang_norm(l: &str) -> String {
410    l.trim().to_ascii_lowercase().replace('_', "-")
411}
412
413// the rule languages a request matches: itself, its prefixes, the Chinese forms
414fn lang_keys(lang: &str) -> Vec<String> {
415    let l = lang_norm(lang);
416    let mut out: Vec<String> = Vec::new();
417    if l.is_empty() {
418        return out;
419    }
420    let mut cur = l.as_str();
421    loop {
422        out.push(String::from(cur));
423        match cur.rfind('-') {
424            Some(i) => cur = &cur[..i],
425            None => break,
426        }
427    }
428    if l == "zh" || l.starts_with("zh-hans") || l.starts_with("zh-cn") || l.starts_with("zh-sg") {
429        out.push(String::from("zh-cn"));
430    }
431    if l.starts_with("zh-hk") || l.starts_with("zh-mo") || l.starts_with("zh-hant-hk") || l.starts_with("zh-hant-mo") {
432        out.push(String::from("zh-hk"));
433        out.push(String::from("zh-tw"));
434    }
435    if l.starts_with("zh-hant") || l.starts_with("zh-tw") {
436        out.push(String::from("zh-tw"));
437    }
438    out
439}
440
441// ---- the built-in rules: every generic family's defaults, the languages' families
442
443#[cfg(windows)]
444const GEN: &[(&str, &[&str])] = &[
445    ("sans-serif", &["Segoe UI", "Arial", "Tahoma", "Verdana", "Microsoft Sans Serif"]),
446    ("serif", &["Times New Roman", "Cambria", "Georgia"]),
447    ("monospace", &["Cascadia Mono", "Consolas", "Courier New", "Lucida Console"]),
448    ("cursive", &["Segoe Script", "Comic Sans MS", "Segoe Print"]),
449    ("fantasy", &["Impact", "Gabriola"]),
450    ("system-ui", &["Segoe UI Variable Text", "Segoe UI"]),
451    ("emoji", &["Segoe UI Emoji"]),
452    ("math", &["Cambria Math"]),
453    ("sans", &["sans-serif"]),
454    ("mono", &["monospace"]),
455    ("Helvetica", &["Arial"]),
456    ("Times", &["Times New Roman"]),
457    ("Courier", &["Courier New"]),
458    ("Liberation Sans", &["Arial"]),
459    ("Liberation Serif", &["Times New Roman"]),
460    ("Liberation Mono", &["Courier New"]),
461    ("DejaVu Sans", &["Segoe UI", "Verdana"]),
462    ("DejaVu Serif", &["Cambria", "Georgia"]),
463    ("DejaVu Sans Mono", &["Consolas", "Courier New"]),
464];
465
466#[cfg(windows)]
467const LANGS: &[(&str, &str, &[&str])] = &[
468    ("ja", "sans-serif", &["Yu Gothic UI", "Meiryo UI", "Yu Gothic", "Meiryo", "MS UI Gothic"]),
469    ("ja", "serif", &["Yu Mincho", "MS Mincho"]),
470    ("ja", "monospace", &["MS Gothic"]),
471    ("zh-cn", "sans-serif", &["Microsoft YaHei UI", "Microsoft YaHei", "SimHei"]),
472    ("zh-cn", "serif", &["SimSun"]),
473    ("zh-cn", "monospace", &["NSimSun"]),
474    ("zh-tw", "sans-serif", &["Microsoft JhengHei UI", "Microsoft JhengHei"]),
475    ("zh-tw", "serif", &["PMingLiU", "MingLiU"]),
476    ("zh-tw", "monospace", &["MingLiU"]),
477    ("zh-hk", "serif", &["PMingLiU_HKSCS", "MingLiU_HKSCS"]),
478    ("ko", "sans-serif", &["Malgun Gothic", "Gulim"]),
479    ("ko", "serif", &["Batang"]),
480    ("ko", "monospace", &["GulimChe"]),
481    ("th", "sans-serif", &["Leelawadee UI", "Tahoma"]),
482    ("lo", "sans-serif", &["Leelawadee UI"]),
483    ("km", "sans-serif", &["Leelawadee UI", "Khmer UI"]),
484    ("hi", "sans-serif", &["Nirmala UI"]),
485    ("mr", "sans-serif", &["Nirmala UI"]),
486    ("ne", "sans-serif", &["Nirmala UI"]),
487    ("bn", "sans-serif", &["Nirmala UI"]),
488    ("ta", "sans-serif", &["Nirmala UI"]),
489    ("te", "sans-serif", &["Nirmala UI"]),
490    ("gu", "sans-serif", &["Nirmala UI"]),
491    ("pa", "sans-serif", &["Nirmala UI"]),
492    ("am", "sans-serif", &["Ebrima"]),
493    ("he", "sans-serif", &["Segoe UI", "Arial"]),
494    ("ar", "sans-serif", &["Segoe UI", "Tahoma"]),
495    ("fa", "sans-serif", &["Segoe UI", "Tahoma"]),
496];
497
498#[cfg(not(windows))]
499const GEN: &[(&str, &[&str])] = &[
500    ("sans-serif", &["DejaVu Sans", "Noto Sans", "Liberation Sans", "Ubuntu", "Cantarell", "FreeSans"]),
501    ("serif", &["DejaVu Serif", "Noto Serif", "Liberation Serif", "FreeSerif"]),
502    ("monospace", &["DejaVu Sans Mono", "Noto Sans Mono", "Liberation Mono", "Ubuntu Mono", "FreeMono"]),
503    ("cursive", &["URW Chancery L", "Z003"]),
504    ("fantasy", &["Impact"]),
505    ("system-ui", &["Cantarell", "Ubuntu", "Noto Sans", "DejaVu Sans"]),
506    ("emoji", &["Noto Color Emoji", "Noto Emoji"]),
507    ("math", &["Noto Sans Math", "DejaVu Math TeX Gyre", "STIX Two Math"]),
508    ("sans", &["sans-serif"]),
509    ("mono", &["monospace"]),
510    ("Arial", &["Liberation Sans"]),
511    ("Helvetica", &["Liberation Sans"]),
512    ("Times New Roman", &["Liberation Serif"]),
513    ("Times", &["Liberation Serif"]),
514    ("Courier New", &["Liberation Mono"]),
515    ("Courier", &["Liberation Mono"]),
516    ("Segoe UI", &["Noto Sans", "DejaVu Sans"]),
517    ("Consolas", &["DejaVu Sans Mono", "Liberation Mono"]),
518];
519
520#[cfg(not(windows))]
521const LANGS: &[(&str, &str, &[&str])] = &[
522    ("ja", "sans-serif", &["Noto Sans CJK JP", "IPAGothic", "TakaoGothic", "VL Gothic"]),
523    ("ja", "serif", &["Noto Serif CJK JP", "IPAMincho", "TakaoMincho"]),
524    ("ja", "monospace", &["Noto Sans Mono CJK JP", "IPAGothic"]),
525    ("zh-cn", "sans-serif", &["Noto Sans CJK SC", "WenQuanYi Micro Hei", "Droid Sans Fallback"]),
526    ("zh-cn", "serif", &["Noto Serif CJK SC", "AR PL UMing CN"]),
527    ("zh-cn", "monospace", &["Noto Sans Mono CJK SC", "WenQuanYi Micro Hei Mono"]),
528    ("zh-tw", "sans-serif", &["Noto Sans CJK TC", "WenQuanYi Micro Hei"]),
529    ("zh-tw", "serif", &["Noto Serif CJK TC", "AR PL UMing TW"]),
530    ("zh-tw", "monospace", &["Noto Sans Mono CJK TC"]),
531    ("zh-hk", "sans-serif", &["Noto Sans CJK HK", "Noto Sans CJK TC"]),
532    ("ko", "sans-serif", &["Noto Sans CJK KR", "NanumGothic", "UnDotum"]),
533    ("ko", "serif", &["Noto Serif CJK KR", "NanumMyeongjo", "UnBatang"]),
534    ("ko", "monospace", &["Noto Sans Mono CJK KR", "NanumGothicCoding"]),
535    ("th", "sans-serif", &["Noto Sans Thai", "Loma", "Garuda"]),
536    ("hi", "sans-serif", &["Noto Sans Devanagari", "Lohit Devanagari"]),
537    ("mr", "sans-serif", &["Noto Sans Devanagari", "Lohit Marathi"]),
538    ("bn", "sans-serif", &["Noto Sans Bengali", "Lohit Bengali"]),
539    ("ta", "sans-serif", &["Noto Sans Tamil", "Lohit Tamil"]),
540    ("he", "sans-serif", &["Noto Sans Hebrew", "DejaVu Sans"]),
541    ("ar", "sans-serif", &["Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans"]),
542    ("fa", "sans-serif", &["Noto Sans Arabic", "Vazirmatn", "DejaVu Sans"]),
543    ("am", "sans-serif", &["Noto Sans Ethiopic", "Abyssinica SIL"]),
544];
545
546fn builtin() -> Vec<Rule> {
547    let s = |l: &[&str]| -> Vec<String> { l.iter().map(|x| String::from(*x)).collect() };
548    let mut out = Vec::new();
549    // language families before the generic's defaults (prepend), so a CJK font comes first
550    for (lang, fam, list) in LANGS {
551        out.push(Rule::Lang { lang: Some(String::from(*lang)), family: Some(String::from(*fam)), mode: LR_PREPEND, families: s(list) });
552    }
553    for (fam, list) in GEN {
554        out.push(Rule::Alias { family: String::from(*fam), prefer: Vec::new(), accept: Vec::new(), default: s(list) });
555    }
556    out
557}
558
559// ---- a tolerant reader of fontconfig's XML: elements, attributes, text; comments, the
560// declaration, DOCTYPE and processing instructions are skipped
561
562#[derive(Default, Debug)]
563struct Elem {
564    name: String,
565    attrs: Vec<(String, String)>,
566    text: String,
567    kids: Vec<Elem>,
568}
569
570impl Elem {
571    fn attr(&self, n: &str) -> Option<&str> {
572        self.attrs.iter().find(|a| a.0 == n).map(|a| a.1.as_str())
573    }
574    fn kid(&self, n: &str) -> Option<&Elem> {
575        self.kids.iter().find(|k| k.name == n)
576    }
577}
578
579fn unescape(s: &str) -> String {
580    if !s.contains('&') {
581        return String::from(s);
582    }
583    let mut out = String::new();
584    let mut rest = s;
585    while let Some(i) = rest.find('&') {
586        out.push_str(&rest[..i]);
587        let tail = &rest[i..];
588        let Some(j) = tail.find(';').filter(|&j| j <= 10) else {
589            out.push('&');
590            rest = &tail[1..];
591            continue;
592        };
593        let ent = &tail[1..j];
594        let c = match ent {
595            "amp" => Some('&'),
596            "lt" => Some('<'),
597            "gt" => Some('>'),
598            "quot" => Some('"'),
599            "apos" => Some('\''),
600            _ if ent.starts_with("#x") => u32::from_str_radix(&ent[2..], 16).ok().and_then(char::from_u32),
601            _ if ent.starts_with('#') => ent[1..].parse::<u32>().ok().and_then(char::from_u32),
602            _ => None,
603        };
604        match c {
605            Some(c) => out.push(c),
606            None => out.push_str(&tail[..=j]),
607        }
608        rest = &tail[j + 1..];
609    }
610    out.push_str(rest);
611    out
612}
613
614// the document as a tree under a nameless root (unclosed elements end with the text)
615fn parse_xml(s: &str) -> Elem {
616    let mut stack: Vec<Elem> = alloc::vec![Elem::default()];
617    let mut rest = s;
618    while !rest.is_empty() {
619        let Some(lt) = rest.find('<') else {
620            if let Some(top) = stack.last_mut() {
621                top.text.push_str(&unescape(rest));
622            }
623            break;
624        };
625        if lt > 0 {
626            if let Some(top) = stack.last_mut() {
627                top.text.push_str(&unescape(&rest[..lt]));
628            }
629        }
630        rest = &rest[lt..];
631        // comments, declarations, processing instructions
632        let skip_to = |r: &str, end: &str| r.find(end).map(|i| i + end.len()).unwrap_or(r.len());
633        if rest.starts_with("<!--") {
634            rest = &rest[skip_to(rest, "-->")..];
635            continue;
636        }
637        if rest.starts_with("<?") {
638            rest = &rest[skip_to(rest, "?>")..];
639            continue;
640        }
641        if rest.starts_with("<![CDATA[") {
642            let end = rest.find("]]>").unwrap_or(rest.len());
643            if let Some(top) = stack.last_mut() {
644                top.text.push_str(&rest[9.min(end)..end]);
645            }
646            rest = &rest[(end + 3).min(rest.len())..];
647            continue;
648        }
649        if rest.starts_with("<!") {
650            rest = &rest[skip_to(rest, ">")..];
651            continue;
652        }
653        let Some(gt) = rest.find('>') else { break };
654        let tag = &rest[1..gt];
655        rest = &rest[gt + 1..];
656        if let Some(name) = tag.strip_prefix('/') {
657            let name = name.trim();
658            // close up to the matching element (tolerating a missing end tag)
659            if let Some(k) = stack.iter().rposition(|e| e.name == name).filter(|&k| k > 0) {
660                while stack.len() > k {
661                    let e = stack.pop().unwrap_or_default();
662                    if let Some(parent) = stack.last_mut() {
663                        parent.kids.push(e);
664                    }
665                }
666            }
667            continue;
668        }
669        let empty = tag.ends_with('/');
670        let tag = tag.trim_end_matches('/');
671        let name_end = tag.find(|c: char| c.is_ascii_whitespace()).unwrap_or(tag.len());
672        let mut e = Elem { name: String::from(&tag[..name_end]), ..Elem::default() };
673        let mut a = &tag[name_end..];
674        loop {
675            a = a.trim_start();
676            let Some(eq) = a.find('=') else { break };
677            let key = a[..eq].trim();
678            let v = a[eq + 1..].trim_start();
679            let Some(q) = v.chars().next().filter(|c| *c == '"' || *c == '\'') else { break };
680            let Some(close) = v[1..].find(q) else { break };
681            e.attrs.push((String::from(key), unescape(&v[1..1 + close])));
682            a = &v[close + 2..];
683        }
684        if empty {
685            if let Some(top) = stack.last_mut() {
686                top.kids.push(e);
687            }
688        } else if stack.len() < 64 {
689            stack.push(e);
690        }
691    }
692    while stack.len() > 1 {
693        let e = stack.pop().unwrap_or_default();
694        if let Some(parent) = stack.last_mut() {
695            parent.kids.push(e);
696        }
697    }
698    stack.pop().unwrap_or_default()
699}