Skip to main content

blit_fonts/
lib.rs

1use std::collections::BTreeSet;
2
3pub fn font_dirs() -> Vec<String> {
4    let mut dirs = Vec::new();
5    if let Ok(extra) = std::env::var("BLIT_FONT_DIRS") {
6        let sep = if cfg!(windows) { ';' } else { ':' };
7        for d in extra.split(sep) {
8            let d = d.trim();
9            if !d.is_empty() {
10                dirs.push(d.to_owned());
11            }
12        }
13    }
14    #[cfg(unix)]
15    {
16        if let Some(home) = std::env::var_os("HOME") {
17            let home = home.to_string_lossy();
18            dirs.push(format!("{home}/Library/Fonts"));
19            dirs.push(format!("{home}/.local/share/fonts"));
20            dirs.push(format!("{home}/.fonts"));
21        }
22        dirs.push("/Library/Fonts".into());
23        dirs.push("/System/Library/Fonts".into());
24        dirs.push("/usr/share/fonts".into());
25        dirs.push("/usr/local/share/fonts".into());
26    }
27    #[cfg(windows)]
28    {
29        if let Ok(windir) = std::env::var("SYSTEMROOT") {
30            dirs.push(format!("{windir}\\Fonts"));
31        } else {
32            dirs.push(r"C:\Windows\Fonts".into());
33        }
34        if let Ok(local) = std::env::var("LOCALAPPDATA") {
35            dirs.push(format!(r"{local}\Microsoft\Windows\Fonts"));
36        }
37    }
38    dirs
39}
40
41#[derive(Debug, Clone)]
42pub struct FontInfo {
43    pub family: String,
44    pub subfamily: String,
45    pub is_monospace: bool,
46}
47
48#[derive(Debug, Clone)]
49pub struct FontVariant {
50    pub path: String,
51    pub weight: String,
52    pub style: String,
53}
54
55fn sfnt_offset(data: &[u8]) -> Option<usize> {
56    if data.len() < 12 {
57        return None;
58    }
59    if &data[0..4] == b"ttcf" {
60        if data.len() < 16 {
61            return None;
62        }
63        Some(u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize)
64    } else {
65        Some(0)
66    }
67}
68
69fn table_slice<'a>(data: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> {
70    let offset = sfnt_offset(data)?;
71    if offset + 12 > data.len() {
72        return None;
73    }
74    let num_tables = u16::from_be_bytes([data[offset + 4], data[offset + 5]]) as usize;
75    if offset + 12 + num_tables * 16 > data.len() {
76        return None;
77    }
78    for i in 0..num_tables {
79        let rec = offset + 12 + i * 16;
80        if &data[rec..rec + 4] == tag {
81            let table_offset =
82                u32::from_be_bytes([data[rec + 8], data[rec + 9], data[rec + 10], data[rec + 11]])
83                    as usize;
84            let table_length = u32::from_be_bytes([
85                data[rec + 12],
86                data[rec + 13],
87                data[rec + 14],
88                data[rec + 15],
89            ]) as usize;
90            let table_end = table_offset.checked_add(table_length)?;
91            if table_end > data.len() {
92                return None;
93            }
94            return Some(&data[table_offset..table_end]);
95        }
96    }
97    None
98}
99
100fn read_is_monospace(data: &[u8]) -> bool {
101    if let Some(post) = table_slice(data, b"post")
102        && post.len() >= 16
103    {
104        let is_fixed_pitch = u32::from_be_bytes([post[12], post[13], post[14], post[15]]);
105        if is_fixed_pitch != 0 {
106            return true;
107        }
108    }
109
110    let Some(hhea) = table_slice(data, b"hhea") else {
111        return false;
112    };
113    let Some(hmtx) = table_slice(data, b"hmtx") else {
114        return false;
115    };
116    if hhea.len() < 36 {
117        return false;
118    }
119    let num_long_metrics = u16::from_be_bytes([hhea[34], hhea[35]]) as usize;
120    if num_long_metrics == 0 {
121        return false;
122    }
123    let Some(metrics_len) = num_long_metrics.checked_mul(4) else {
124        return false;
125    };
126    if hmtx.len() < metrics_len {
127        return false;
128    }
129
130    let mut reference_width: Option<u16> = None;
131    for i in 0..num_long_metrics {
132        let idx = i * 4;
133        let advance = u16::from_be_bytes([hmtx[idx], hmtx[idx + 1]]);
134        if advance == 0 {
135            continue;
136        }
137        match reference_width {
138            Some(width) if width != advance => return false,
139            Some(_) => {}
140            None => reference_width = Some(advance),
141        }
142    }
143
144    reference_width.is_some()
145}
146
147/// Read the monospace advance width as a fraction of the em square.
148/// Returns `advance_width / units_per_em` for the first non-zero advance in hmtx,
149/// matching how native terminals (Ghostty, kitty) compute cell width.
150fn read_advance_ratio(data: &[u8]) -> Option<f64> {
151    let head = table_slice(data, b"head")?;
152    if head.len() < 20 {
153        return None;
154    }
155    let units_per_em = u16::from_be_bytes([head[18], head[19]]) as f64;
156    if units_per_em == 0.0 {
157        return None;
158    }
159
160    let hhea = table_slice(data, b"hhea")?;
161    let hmtx = table_slice(data, b"hmtx")?;
162    if hhea.len() < 36 {
163        return None;
164    }
165    let num_long_metrics = u16::from_be_bytes([hhea[34], hhea[35]]) as usize;
166    if num_long_metrics == 0 || hmtx.len() < num_long_metrics * 4 {
167        return None;
168    }
169
170    for i in 0..num_long_metrics {
171        let idx = i * 4;
172        let advance = u16::from_be_bytes([hmtx[idx], hmtx[idx + 1]]);
173        if advance > 0 {
174            return Some(advance as f64 / units_per_em);
175        }
176    }
177    None
178}
179
180/// Read font family and subfamily from a TTF/OTF/TTC file's `name` table.
181fn read_font_info(data: &[u8]) -> Option<FontInfo> {
182    let tbl = table_slice(data, b"name")?;
183    if tbl.len() < 6 {
184        return None;
185    }
186    let count = u16::from_be_bytes([tbl[2], tbl[3]]) as usize;
187    let string_offset = u16::from_be_bytes([tbl[4], tbl[5]]) as usize;
188    if tbl.len() < 6 + count * 12 {
189        return None;
190    }
191
192    // Collect candidates for name IDs 1 (family), 2 (subfamily), 16 (typo family), 17 (typo subfamily).
193    // Prefer platform 3 (Windows UTF-16) over 1 (Mac).
194    // Prefer typo (16/17) over legacy (1/2).
195    let mut family: Option<String> = None;
196    let mut family_pri = 0u8;
197    let mut subfamily: Option<String> = None;
198    let mut subfamily_pri = 0u8;
199
200    for i in 0..count {
201        let rec = 6 + i * 12;
202        let platform = u16::from_be_bytes([tbl[rec], tbl[rec + 1]]);
203        let name_id = u16::from_be_bytes([tbl[rec + 6], tbl[rec + 7]]);
204        let length = u16::from_be_bytes([tbl[rec + 8], tbl[rec + 9]]) as usize;
205        let str_off = u16::from_be_bytes([tbl[rec + 10], tbl[rec + 11]]) as usize;
206
207        let is_family = name_id == 1 || name_id == 16;
208        let is_subfamily = name_id == 2 || name_id == 17;
209        if !is_family && !is_subfamily {
210            continue;
211        }
212
213        let plat_bonus: u8 = if platform == 3 {
214            2
215        } else if platform == 1 {
216            1
217        } else {
218            0
219        };
220        if plat_bonus == 0 {
221            continue;
222        }
223        let typo_bonus: u8 = if name_id >= 16 { 4 } else { 0 };
224        let priority = plat_bonus + typo_bonus;
225
226        let start = string_offset + str_off;
227        if start + length > tbl.len() {
228            continue;
229        }
230        let raw = &tbl[start..start + length];
231
232        let decoded = if platform == 3 {
233            let chars: Vec<u16> = raw
234                .chunks_exact(2)
235                .map(|c| u16::from_be_bytes([c[0], c[1]]))
236                .collect();
237            String::from_utf16_lossy(&chars)
238        } else {
239            String::from_utf8_lossy(raw).into_owned()
240        };
241        let decoded = decoded.trim().to_owned();
242        if decoded.is_empty() {
243            continue;
244        }
245
246        if is_family && priority > family_pri {
247            family = Some(decoded);
248            family_pri = priority;
249        } else if is_subfamily && priority > subfamily_pri {
250            subfamily = Some(decoded);
251            subfamily_pri = priority;
252        }
253    }
254
255    Some(FontInfo {
256        family: family?,
257        subfamily: subfamily.unwrap_or_else(|| "Regular".to_owned()),
258        is_monospace: read_is_monospace(data),
259    })
260}
261
262fn subfamily_to_weight_style(subfamily: &str) -> (&'static str, &'static str) {
263    let s = subfamily.to_lowercase();
264    let bold = s.contains("bold") || s.contains("heavy") || s.contains("black");
265    let italic = s.contains("italic") || s.contains("oblique");
266    match (bold, italic) {
267        (true, true) => ("bold", "italic"),
268        (true, false) => ("bold", "normal"),
269        (false, true) => ("normal", "italic"),
270        (false, false) => ("normal", "normal"),
271    }
272}
273
274pub fn find_font_files(family: &str) -> Vec<FontVariant> {
275    #[cfg(unix)]
276    if let Some(results) = find_via_fc_match(family)
277        && !results.is_empty()
278    {
279        return results;
280    }
281    let dirs = font_dirs();
282    let family_lower = family.to_lowercase();
283    let family_nospace = family_lower.replace(' ', "");
284    let mut results = Vec::new();
285    for dir in &dirs {
286        find_in_dir_recursive(dir, &family_lower, &family_nospace, &mut results);
287    }
288    results
289}
290
291#[cfg(unix)]
292fn find_via_fc_match(family: &str) -> Option<Vec<FontVariant>> {
293    let output = std::process::Command::new("fc-match")
294        .args(["--format", "%{file}\n%{style}\n", "-a", family])
295        .output()
296        .ok()?;
297    if !output.status.success() {
298        return None;
299    }
300    let text = String::from_utf8_lossy(&output.stdout);
301    let lines: Vec<&str> = text.lines().collect();
302    let mut results = Vec::with_capacity(lines.len() / 2);
303    let mut seen = BTreeSet::new();
304    for pair in lines.chunks(2) {
305        if pair.len() < 2 {
306            break;
307        }
308        let path = pair[0].trim();
309        let style_str = pair[1].trim();
310        if path.is_empty() || !seen.insert(path.to_owned()) {
311            continue;
312        }
313        if let Ok(data) = std::fs::read(path)
314            && let Some(info) = read_font_info(&data)
315        {
316            if !info.family.eq_ignore_ascii_case(family) {
317                continue;
318            }
319            let (weight, style) = subfamily_to_weight_style(style_str);
320            results.push(FontVariant {
321                path: path.to_owned(),
322                weight: weight.to_owned(),
323                style: style.to_owned(),
324            });
325        }
326    }
327    if results.is_empty() {
328        None
329    } else {
330        Some(results)
331    }
332}
333
334fn find_in_dir_recursive(
335    dir: &str,
336    family_lower: &str,
337    family_nospace: &str,
338    results: &mut Vec<FontVariant>,
339) {
340    let Ok(entries) = std::fs::read_dir(dir) else {
341        return;
342    };
343    for entry in entries.flatten() {
344        let path = entry.path();
345        if path.is_dir() {
346            find_in_dir_recursive(
347                &path.to_string_lossy(),
348                family_lower,
349                family_nospace,
350                results,
351            );
352            continue;
353        }
354        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
355        if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
356            continue;
357        }
358
359        if let Ok(data) = std::fs::read(&path)
360            && let Some(info) = read_font_info(&data)
361        {
362            let parsed_lower = info.family.to_lowercase();
363            if parsed_lower != family_lower && parsed_lower.replace(' ', "") != family_nospace {
364                continue;
365            }
366            let (weight, style) = subfamily_to_weight_style(&info.subfamily);
367            results.push(FontVariant {
368                path: path.to_string_lossy().into_owned(),
369                weight: weight.to_owned(),
370                style: style.to_owned(),
371            });
372        }
373    }
374}
375
376pub fn list_font_families() -> Vec<String> {
377    #[cfg(unix)]
378    if let Some(families) = list_via_fc_list() {
379        return families;
380    }
381    list_via_name_tables()
382}
383
384pub fn list_monospace_font_families() -> Vec<String> {
385    #[cfg(unix)]
386    if let Some(families) = list_monospace_via_fc_list() {
387        return families;
388    }
389    list_monospace_via_name_tables()
390}
391
392#[cfg(unix)]
393fn list_via_fc_list() -> Option<Vec<String>> {
394    let output = std::process::Command::new("fc-list")
395        .args(["--format", "%{family}\n"])
396        .output()
397        .ok()?;
398    if !output.status.success() {
399        return None;
400    }
401    let text = String::from_utf8_lossy(&output.stdout);
402    let mut families = BTreeSet::new();
403    for line in text.lines() {
404        for name in line.split(',') {
405            let name = name.trim();
406            if !name.is_empty() {
407                families.insert(name.to_owned());
408            }
409        }
410    }
411    if families.is_empty() {
412        return None;
413    }
414    Some(families.into_iter().collect())
415}
416
417fn list_via_name_tables() -> Vec<String> {
418    let dirs = font_dirs();
419    let mut families = BTreeSet::new();
420    for dir in &dirs {
421        scan_dir_recursive(dir, &mut families);
422    }
423    families.into_iter().collect()
424}
425
426#[cfg(unix)]
427fn list_monospace_via_fc_list() -> Option<Vec<String>> {
428    let output = std::process::Command::new("fc-list")
429        .args(["--format", "%{file}\n"])
430        .output()
431        .ok()?;
432    if !output.status.success() {
433        return None;
434    }
435    let text = String::from_utf8_lossy(&output.stdout);
436    let mut families = BTreeSet::new();
437    let mut seen_paths = BTreeSet::new();
438    for line in text.lines() {
439        let path = line.trim();
440        if path.is_empty() || !seen_paths.insert(path.to_owned()) {
441            continue;
442        }
443        let Ok(data) = std::fs::read(path) else {
444            continue;
445        };
446        let Some(info) = read_font_info(&data) else {
447            continue;
448        };
449        if !info.is_monospace {
450            continue;
451        }
452        // Use the name table family so the name matches what find_font_files expects.
453        families.insert(info.family);
454    }
455    if families.is_empty() {
456        return None;
457    }
458    Some(families.into_iter().collect())
459}
460
461fn list_monospace_via_name_tables() -> Vec<String> {
462    let dirs = font_dirs();
463    let mut families = BTreeSet::new();
464    for dir in &dirs {
465        scan_monospace_dir_recursive(dir, &mut families);
466    }
467    families.into_iter().collect()
468}
469
470fn scan_dir_recursive(dir: &str, families: &mut BTreeSet<String>) {
471    let Ok(entries) = std::fs::read_dir(dir) else {
472        return;
473    };
474    for entry in entries.flatten() {
475        let path = entry.path();
476        if path.is_dir() {
477            scan_dir_recursive(&path.to_string_lossy(), families);
478            continue;
479        }
480        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
481        if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
482            continue;
483        }
484        if let Ok(data) = std::fs::read(&path)
485            && let Some(info) = read_font_info(&data)
486        {
487            families.insert(info.family);
488        }
489    }
490}
491
492fn scan_monospace_dir_recursive(dir: &str, families: &mut BTreeSet<String>) {
493    let Ok(entries) = std::fs::read_dir(dir) else {
494        return;
495    };
496    for entry in entries.flatten() {
497        let path = entry.path();
498        if path.is_dir() {
499            scan_monospace_dir_recursive(&path.to_string_lossy(), families);
500            continue;
501        }
502        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
503        if !matches!(ext, "ttf" | "otf" | "woff" | "woff2" | "ttc") {
504            continue;
505        }
506        if let Ok(data) = std::fs::read(&path)
507            && let Some(info) = read_font_info(&data)
508            && info.is_monospace
509        {
510            families.insert(info.family);
511        }
512    }
513}
514
515pub fn base64_encode(data: &[u8]) -> String {
516    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
517    let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
518    for chunk in data.chunks(3) {
519        let b0 = chunk[0] as u32;
520        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
521        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
522        let n = (b0 << 16) | (b1 << 8) | b2;
523        out.push(CHARS[(n >> 18 & 63) as usize] as char);
524        out.push(CHARS[(n >> 12 & 63) as usize] as char);
525        if chunk.len() > 1 {
526            out.push(CHARS[(n >> 6 & 63) as usize] as char);
527        } else {
528            out.push('=');
529        }
530        if chunk.len() > 2 {
531            out.push(CHARS[(n & 63) as usize] as char);
532        } else {
533            out.push('=');
534        }
535    }
536    out
537}
538
539pub fn font_face_css(family: &str) -> Option<String> {
540    let files = find_font_files_with_data(family);
541    if files.is_empty() {
542        return None;
543    }
544    let mut css = String::new();
545    for (variant, data) in &files {
546        let ext = variant.path.rsplit('.').next().unwrap_or("ttf");
547        let mime = match ext {
548            "otf" => "font/otf",
549            "woff" => "font/woff",
550            "woff2" => "font/woff2",
551            _ => "font/ttf",
552        };
553        let b64 = base64_encode(data);
554        // Escape single quotes in the family name to prevent CSS injection.
555        let safe_family = family.replace('\\', "\\\\").replace('\'', "\\'");
556        css.push_str(&format!(
557            "@font-face {{ font-family: '{}'; font-weight: {}; font-style: {}; src: url('data:{};base64,{}'); }}\n",
558            safe_family, variant.weight, variant.style, mime, b64,
559        ));
560    }
561    if css.is_empty() { None } else { Some(css) }
562}
563
564/// Return the advance-width / units-per-em ratio for a font family's regular variant.
565/// This is how native terminals compute cell width: `ratio * font_size_px`.
566pub fn font_advance_ratio(family: &str) -> Option<f64> {
567    let files = find_font_files_with_data(family);
568    // Prefer the "normal" weight regular variant
569    for (variant, data) in &files {
570        if variant.style == "normal"
571            && (variant.weight == "400" || variant.weight == "normal")
572            && let Some(ratio) = read_advance_ratio(data)
573        {
574            return Some(ratio);
575        }
576    }
577    // Fall back to any variant
578    for (_variant, data) in &files {
579        if let Some(ratio) = read_advance_ratio(data) {
580            return Some(ratio);
581        }
582    }
583    None
584}
585
586/// Like `find_font_files` but returns the file data alongside each variant,
587/// avoiding a second read in `font_face_css`.
588fn find_font_files_with_data(family: &str) -> Vec<(FontVariant, Vec<u8>)> {
589    let variants = find_font_files(family);
590    variants
591        .into_iter()
592        .filter_map(|v| {
593            let data = std::fs::read(&v.path).ok()?;
594            Some((v, data))
595        })
596        .collect()
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    fn build_test_font(tables: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
604        let header_len = 12 + tables.len() * 16;
605        let mut data = vec![0u8; header_len];
606        data[0..4].copy_from_slice(&[0, 1, 0, 0]);
607        data[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
608
609        let mut offset = header_len;
610        for (i, (tag, table)) in tables.iter().enumerate() {
611            let rec = 12 + i * 16;
612            data[rec..rec + 4].copy_from_slice(*tag);
613            data[rec + 8..rec + 12].copy_from_slice(&(offset as u32).to_be_bytes());
614            data[rec + 12..rec + 16].copy_from_slice(&(table.len() as u32).to_be_bytes());
615            data.extend_from_slice(table);
616            offset += table.len();
617        }
618
619        data
620    }
621
622    #[test]
623    fn parse_font_info_from_system_fonts() {
624        let families = list_font_families();
625        assert!(!families.is_empty(), "no fonts found on system");
626        for f in &families {
627            assert!(!f.is_empty());
628            assert!(!f.contains('\0'));
629        }
630    }
631
632    #[test]
633    fn subfamily_parsing() {
634        assert_eq!(subfamily_to_weight_style("Regular"), ("normal", "normal"));
635        assert_eq!(subfamily_to_weight_style("Bold"), ("bold", "normal"));
636        assert_eq!(subfamily_to_weight_style("Italic"), ("normal", "italic"));
637        assert_eq!(subfamily_to_weight_style("Bold Italic"), ("bold", "italic"));
638        assert_eq!(
639            subfamily_to_weight_style("Bold Oblique"),
640            ("bold", "italic")
641        );
642    }
643
644    #[test]
645    fn detects_monospace_from_post_table() {
646        let mut post = vec![0u8; 32];
647        post[12..16].copy_from_slice(&1u32.to_be_bytes());
648        let font = build_test_font(&[(b"post", post)]);
649        assert!(read_is_monospace(&font));
650    }
651
652    #[test]
653    fn detects_monospace_from_uniform_hmtx_widths() {
654        let mut hhea = vec![0u8; 36];
655        hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
656
657        let mut hmtx = vec![0u8; 8];
658        hmtx[0..2].copy_from_slice(&600u16.to_be_bytes());
659        hmtx[4..6].copy_from_slice(&600u16.to_be_bytes());
660
661        let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
662        assert!(read_is_monospace(&font));
663    }
664
665    #[test]
666    fn rejects_variable_width_fonts() {
667        let mut hhea = vec![0u8; 36];
668        hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
669
670        let mut hmtx = vec![0u8; 8];
671        hmtx[0..2].copy_from_slice(&500u16.to_be_bytes());
672        hmtx[4..6].copy_from_slice(&700u16.to_be_bytes());
673
674        let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
675        assert!(!read_is_monospace(&font));
676    }
677
678    // ── base64_encode ──
679
680    #[test]
681    fn base64_empty() {
682        assert_eq!(base64_encode(b""), "");
683    }
684
685    #[test]
686    fn base64_one_byte() {
687        assert_eq!(base64_encode(b"M"), "TQ==");
688    }
689
690    #[test]
691    fn base64_two_bytes() {
692        assert_eq!(base64_encode(b"Ma"), "TWE=");
693    }
694
695    #[test]
696    fn base64_three_bytes() {
697        assert_eq!(base64_encode(b"Man"), "TWFu");
698    }
699
700    #[test]
701    fn base64_rfc4648_vectors() {
702        assert_eq!(base64_encode(b"f"), "Zg==");
703        assert_eq!(base64_encode(b"fo"), "Zm8=");
704        assert_eq!(base64_encode(b"foo"), "Zm9v");
705        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
706        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
707        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
708    }
709
710    // ── sfnt_offset ──
711
712    #[test]
713    fn sfnt_offset_too_short() {
714        assert_eq!(sfnt_offset(b"abc"), None);
715    }
716
717    #[test]
718    fn sfnt_offset_non_ttc() {
719        let font = build_test_font(&[]);
720        assert_eq!(sfnt_offset(&font), Some(0));
721    }
722
723    #[test]
724    fn sfnt_offset_ttc_header() {
725        let mut data = vec![0u8; 20];
726        data[0..4].copy_from_slice(b"ttcf");
727        data[12..16].copy_from_slice(&100u32.to_be_bytes());
728        assert_eq!(sfnt_offset(&data), Some(100));
729    }
730
731    #[test]
732    fn sfnt_offset_ttc_too_short() {
733        let mut data = vec![0u8; 14];
734        data[0..4].copy_from_slice(b"ttcf");
735        assert_eq!(sfnt_offset(&data), None);
736    }
737
738    // ── table_slice ──
739
740    #[test]
741    fn table_slice_found() {
742        let table_data = vec![1, 2, 3, 4];
743        let font = build_test_font(&[(b"test", table_data.clone())]);
744        let slice = table_slice(&font, b"test");
745        assert_eq!(slice, Some(table_data.as_slice()));
746    }
747
748    #[test]
749    fn table_slice_not_found() {
750        let font = build_test_font(&[(b"aaaa", vec![0])]);
751        assert_eq!(table_slice(&font, b"zzzz"), None);
752    }
753
754    #[test]
755    fn table_slice_empty_font() {
756        let font = build_test_font(&[]);
757        assert_eq!(table_slice(&font, b"test"), None);
758    }
759
760    // ── read_advance_ratio ──
761
762    #[test]
763    fn advance_ratio_basic() {
764        let mut head = vec![0u8; 20];
765        head[18..20].copy_from_slice(&1000u16.to_be_bytes());
766
767        let mut hhea = vec![0u8; 36];
768        hhea[34..36].copy_from_slice(&1u16.to_be_bytes());
769
770        let mut hmtx = vec![0u8; 4];
771        hmtx[0..2].copy_from_slice(&600u16.to_be_bytes());
772
773        let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
774        let ratio = read_advance_ratio(&font).unwrap();
775        assert!((ratio - 0.6).abs() < 1e-10);
776    }
777
778    #[test]
779    fn advance_ratio_skips_zero_advances() {
780        let mut head = vec![0u8; 20];
781        head[18..20].copy_from_slice(&1000u16.to_be_bytes());
782
783        let mut hhea = vec![0u8; 36];
784        hhea[34..36].copy_from_slice(&2u16.to_be_bytes());
785
786        let mut hmtx = vec![0u8; 8];
787        hmtx[0..2].copy_from_slice(&0u16.to_be_bytes());
788        hmtx[4..6].copy_from_slice(&500u16.to_be_bytes());
789
790        let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
791        let ratio = read_advance_ratio(&font).unwrap();
792        assert!((ratio - 0.5).abs() < 1e-10);
793    }
794
795    #[test]
796    fn advance_ratio_no_head_table() {
797        let hhea = vec![0u8; 36];
798        let hmtx = vec![0u8; 4];
799        let font = build_test_font(&[(b"hhea", hhea), (b"hmtx", hmtx)]);
800        assert!(read_advance_ratio(&font).is_none());
801    }
802
803    #[test]
804    fn advance_ratio_zero_units_per_em() {
805        let head = vec![0u8; 20];
806        let hhea = vec![0u8; 36];
807        let hmtx = vec![0u8; 4];
808        let font = build_test_font(&[(b"head", head), (b"hhea", hhea), (b"hmtx", hmtx)]);
809        assert!(read_advance_ratio(&font).is_none());
810    }
811
812    // ── subfamily_to_weight_style (extra cases) ──
813
814    #[test]
815    fn subfamily_heavy() {
816        assert_eq!(subfamily_to_weight_style("Heavy"), ("bold", "normal"));
817    }
818
819    #[test]
820    fn subfamily_black() {
821        assert_eq!(subfamily_to_weight_style("Black"), ("bold", "normal"));
822    }
823
824    #[test]
825    fn subfamily_oblique() {
826        assert_eq!(subfamily_to_weight_style("Oblique"), ("normal", "italic"));
827    }
828
829    #[test]
830    fn subfamily_case_insensitive() {
831        assert_eq!(subfamily_to_weight_style("BOLD ITALIC"), ("bold", "italic"));
832        assert_eq!(subfamily_to_weight_style("bold italic"), ("bold", "italic"));
833    }
834
835    #[test]
836    fn subfamily_unrecognized() {
837        assert_eq!(subfamily_to_weight_style("Light"), ("normal", "normal"));
838        assert_eq!(subfamily_to_weight_style("Thin"), ("normal", "normal"));
839    }
840}