Skip to main content

stet_fonts/
system_fonts.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! System font discovery and caching.
6//!
7//! Scans platform-specific font directories for installed fonts, extracts
8//! PostScript names, and caches the mapping to a JSON file for fast lookups.
9
10use std::collections::HashMap;
11use std::fs;
12use std::io::BufRead;
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15use std::time::SystemTime;
16
17use crate::truetype::{find_table, read_u16, read_u32};
18
19/// Global singleton for the system font cache.
20static SYSTEM_FONT_CACHE: OnceLock<SystemFontCache> = OnceLock::new();
21
22/// Get or initialize the global system font cache.
23pub fn get_system_font_cache() -> &'static SystemFontCache {
24    SYSTEM_FONT_CACHE.get_or_init(SystemFontCache::load_or_build)
25}
26
27/// Maps PostScript font names to filesystem paths.
28pub struct SystemFontCache {
29    fonts: HashMap<String, PathBuf>,
30}
31
32/// JSON-serializable cache format.
33#[derive(serde::Serialize, serde::Deserialize)]
34struct CacheData {
35    version: u32,
36    dir_mtimes: HashMap<String, u64>,
37    fonts: HashMap<String, String>,
38}
39
40impl SystemFontCache {
41    /// Look up a font by PostScript name, returning its file path.
42    pub fn get_font_path(&self, ps_name: &str) -> Option<&Path> {
43        self.fonts.get(ps_name).map(|p| p.as_path())
44    }
45
46    /// Iterate over all cached fonts (PostScript name, file path).
47    pub fn iter(&self) -> impl Iterator<Item = (&str, &Path)> {
48        self.fonts.iter().map(|(k, v)| (k.as_str(), v.as_path()))
49    }
50
51    fn cache_path() -> Option<PathBuf> {
52        dirs_cache().map(|d| d.join("stet").join("system_fonts.json"))
53    }
54
55    fn load_or_build() -> SystemFontCache {
56        // Try loading from cache
57        if let Some(cache_path) = Self::cache_path()
58            && let Some(cache) = Self::try_load_cache(&cache_path)
59        {
60            return cache;
61        }
62
63        // Build from scratch
64        let cache = Self::build();
65
66        // Persist to disk
67        if let Some(cache_path) = Self::cache_path() {
68            let _ = cache.save(&cache_path);
69        }
70
71        cache
72    }
73
74    fn try_load_cache(path: &Path) -> Option<SystemFontCache> {
75        let data = fs::read_to_string(path).ok()?;
76        let cached: CacheData = serde_json::from_str(&data).ok()?;
77        if cached.version != 1 {
78            return None;
79        }
80
81        // Check staleness: compare directory mtimes
82        let current_mtimes = get_font_dir_mtimes();
83        for (dir, &cached_mtime) in &cached.dir_mtimes {
84            match current_mtimes.get(dir.as_str()) {
85                Some(&current) if current == cached_mtime => {}
86                _ => return None, // stale
87            }
88        }
89        // Also check if any new dirs appeared
90        for dir in current_mtimes.keys() {
91            if !cached.dir_mtimes.contains_key(*dir) {
92                return None;
93            }
94        }
95
96        let fonts = cached
97            .fonts
98            .into_iter()
99            .map(|(k, v)| (k, PathBuf::from(v)))
100            .collect();
101        Some(SystemFontCache { fonts })
102    }
103
104    fn build() -> SystemFontCache {
105        let mut fonts = HashMap::new();
106
107        for dir in font_directories() {
108            let dir_path = Path::new(dir);
109            if dir_path.is_dir() {
110                scan_directory(dir_path, &mut fonts);
111            }
112        }
113        for dir_path in home_font_directories() {
114            if dir_path.is_dir() {
115                scan_directory(&dir_path, &mut fonts);
116            }
117        }
118
119        SystemFontCache { fonts }
120    }
121
122    fn save(&self, path: &Path) -> std::io::Result<()> {
123        if let Some(parent) = path.parent() {
124            fs::create_dir_all(parent)?;
125        }
126
127        let dir_mtimes = get_font_dir_mtimes();
128        let cache_data = CacheData {
129            version: 1,
130            dir_mtimes: dir_mtimes
131                .into_iter()
132                .map(|(k, v)| (k.to_string(), v))
133                .collect(),
134            fonts: self
135                .fonts
136                .iter()
137                .map(|(k, v)| (k.clone(), v.to_string_lossy().to_string()))
138                .collect(),
139        };
140
141        let json = serde_json::to_string_pretty(&cache_data)?;
142        fs::write(path, json)
143    }
144}
145
146/// Platform-specific font directories.
147fn font_directories() -> Vec<&'static str> {
148    if cfg!(target_os = "macos") {
149        vec!["/System/Library/Fonts", "/Library/Fonts"]
150    } else if cfg!(target_os = "windows") {
151        vec!["C:\\Windows\\Fonts"]
152    } else {
153        // Linux
154        vec!["/usr/share/fonts", "/usr/local/share/fonts"]
155    }
156}
157
158/// Home-relative font directories (resolved at runtime).
159fn home_font_directories() -> Vec<PathBuf> {
160    let mut dirs = Vec::new();
161    if let Some(home) = std::env::var_os("HOME") {
162        let home = PathBuf::from(home);
163        if cfg!(target_os = "macos") {
164            dirs.push(home.join("Library/Fonts"));
165        } else if !cfg!(target_os = "windows") {
166            dirs.push(home.join(".local/share/fonts"));
167            dirs.push(home.join(".fonts"));
168        }
169    }
170    dirs
171}
172
173/// Get the cache directory (~/.cache on Linux/macOS).
174fn dirs_cache() -> Option<PathBuf> {
175    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))
176}
177
178/// Collect directory mtimes for staleness checking.
179fn get_font_dir_mtimes() -> HashMap<&'static str, u64> {
180    let mut mtimes = HashMap::new();
181    for dir in font_directories() {
182        if let Ok(meta) = fs::metadata(dir)
183            && let Ok(mtime) = meta.modified()
184            && let Ok(dur) = mtime.duration_since(SystemTime::UNIX_EPOCH)
185        {
186            mtimes.insert(dir, dur.as_secs());
187        }
188    }
189    mtimes
190}
191
192/// Recursively scan a directory for font files.
193fn scan_directory(dir: &Path, fonts: &mut HashMap<String, PathBuf>) {
194    let entries = match fs::read_dir(dir) {
195        Ok(e) => e,
196        Err(_) => return,
197    };
198
199    for entry in entries.flatten() {
200        let path = entry.path();
201        if path.is_dir() {
202            scan_directory(&path, fonts);
203            continue;
204        }
205
206        let ext = path
207            .extension()
208            .and_then(|e| e.to_str())
209            .map(|e| e.to_ascii_lowercase());
210
211        match ext.as_deref() {
212            Some("ttf" | "otf") => {
213                if let Some(name) = extract_ps_name_from_sfnt(&path) {
214                    fonts.entry(name).or_insert_with(|| path.clone());
215                }
216            }
217            Some("ttc") => {
218                // TrueType Collection: index each sub-font
219                if let Ok(data) = fs::read(&path)
220                    && data.len() > 12
221                    && &data[0..4] == b"ttcf"
222                {
223                    let num = read_u32(&data, 8) as usize;
224                    for i in 0..num {
225                        let off_pos = 12 + i * 4;
226                        if off_pos + 4 > data.len() {
227                            break;
228                        }
229                        let font_off = read_u32(&data, off_pos) as usize;
230                        if let Some(name) = extract_ps_name_at_ttc_offset(&data, font_off) {
231                            fonts.entry(name).or_insert_with(|| path.clone());
232                        }
233                    }
234                }
235            }
236            Some("pfa" | "t1") => {
237                if let Some(name) = extract_ps_name_from_pfa(&path) {
238                    fonts.entry(name).or_insert_with(|| path.clone());
239                }
240            }
241            Some("pfb") => {
242                if let Some(name) = extract_ps_name_from_pfb(&path) {
243                    fonts.entry(name).or_insert_with(|| path.clone());
244                }
245            }
246            _ => {}
247        }
248    }
249}
250
251/// Extract PostScript name from a .pfa or .t1 file (first 4KB).
252fn extract_ps_name_from_pfa(path: &Path) -> Option<String> {
253    let file = fs::File::open(path).ok()?;
254    let mut reader = std::io::BufReader::new(file);
255    let mut buf = String::new();
256    let mut bytes_read = 0usize;
257
258    while bytes_read < 4096 {
259        buf.clear();
260        let n = reader.read_line(&mut buf).ok()?;
261        if n == 0 {
262            break;
263        }
264        bytes_read += n;
265        if let Some(name) = parse_fontname_line(&buf) {
266            return Some(name);
267        }
268    }
269    None
270}
271
272/// Extract PostScript name from a .pfb file.
273/// PFB segments: 0x80 <type:u8> <length:u32_le> <data...>
274fn extract_ps_name_from_pfb(path: &Path) -> Option<String> {
275    let data = fs::read(path).ok()?;
276    let mut offset = 0;
277    let mut ascii_data = Vec::new();
278
279    // Collect ASCII segments (type 1) until we find the name
280    while offset + 6 <= data.len() && data[offset] == 0x80 {
281        let seg_type = data[offset + 1];
282        let seg_len = u32::from_le_bytes([
283            data[offset + 2],
284            data[offset + 3],
285            data[offset + 4],
286            data[offset + 5],
287        ]) as usize;
288        offset += 6;
289
290        if seg_type == 1 {
291            // ASCII segment
292            let end = (offset + seg_len).min(data.len());
293            ascii_data.extend_from_slice(&data[offset..end]);
294
295            // Check if we have enough data
296            if let Some(name) = find_fontname_in_bytes(&ascii_data) {
297                return Some(name);
298            }
299            if ascii_data.len() > 4096 {
300                break;
301            }
302        } else if seg_type == 3 {
303            break; // EOF marker
304        }
305
306        offset += seg_len;
307    }
308    None
309}
310
311/// Extract PostScript name from OTF/TTF using the `name` table (nameID 6).
312/// Extract PostScript name from a sub-font within a TTC at a given offset.
313fn extract_ps_name_at_ttc_offset(data: &[u8], font_offset: usize) -> Option<String> {
314    if font_offset + 12 > data.len() {
315        return None;
316    }
317    // Check for OTTO (CFF) or regular TrueType
318    let is_otto = &data[font_offset..font_offset + 4] == b"OTTO";
319
320    // Find the 'name' table in this sub-font's table directory
321    let num_tables = read_u16(data, font_offset + 4) as usize;
322    for i in 0..num_tables {
323        let entry = font_offset + 12 + i * 16;
324        if entry + 16 > data.len() {
325            break;
326        }
327        let tag = &data[entry..entry + 4];
328        if tag == b"name" {
329            let tbl_off = read_u32(data, entry + 8) as usize;
330            let tbl_len = read_u32(data, entry + 12) as usize;
331            if tbl_off + tbl_len <= data.len() {
332                return extract_ps_name_from_name_table_data(&data[tbl_off..tbl_off + tbl_len]);
333            }
334        }
335        // For CFF fonts, also try the CFF Name INDEX
336        if is_otto && tag == b"CFF " {
337            let cff_off = read_u32(data, entry + 8) as usize;
338            let cff_len = read_u32(data, entry + 12) as usize;
339            if cff_off + cff_len <= data.len()
340                && let Some(name) = extract_cff_name_from_data(&data[cff_off..cff_off + cff_len])
341            {
342                return Some(name);
343            }
344        }
345    }
346    None
347}
348
349/// Extract PostScript name from raw 'name' table data.
350fn extract_ps_name_from_name_table_data(name_data: &[u8]) -> Option<String> {
351    if name_data.len() < 6 {
352        return None;
353    }
354    let count = read_u16(name_data, 2) as usize;
355    let string_offset = read_u16(name_data, 4) as usize;
356    for i in 0..count {
357        let rec = 6 + i * 12;
358        if rec + 12 > name_data.len() {
359            break;
360        }
361        let platform_id = read_u16(name_data, rec);
362        let name_id = read_u16(name_data, rec + 6);
363        let length = read_u16(name_data, rec + 8) as usize;
364        let str_off = read_u16(name_data, rec + 10) as usize;
365        if name_id == 6 {
366            let start = string_offset + str_off;
367            if start + length <= name_data.len() {
368                let raw = &name_data[start..start + length];
369                if platform_id == 3 || platform_id == 0 {
370                    // UTF-16BE
371                    let s: String = raw
372                        .chunks(2)
373                        .filter_map(|c| {
374                            if c.len() == 2 {
375                                char::from_u32(u16::from_be_bytes([c[0], c[1]]) as u32)
376                            } else {
377                                None
378                            }
379                        })
380                        .collect();
381                    if !s.is_empty() {
382                        return Some(s);
383                    }
384                } else {
385                    let s = String::from_utf8_lossy(raw).to_string();
386                    if !s.is_empty() {
387                        return Some(s);
388                    }
389                }
390            }
391        }
392    }
393    None
394}
395
396/// Extract font name from raw CFF data's Name INDEX.
397fn extract_cff_name_from_data(cff: &[u8]) -> Option<String> {
398    if cff.len() < 4 {
399        return None;
400    }
401    let hdr_size = cff[2] as usize;
402    if hdr_size + 3 > cff.len() {
403        return None;
404    }
405    let count = u16::from_be_bytes([cff[hdr_size], cff[hdr_size + 1]]) as usize;
406    if count == 0 {
407        return None;
408    }
409    let off_size = cff[hdr_size + 2] as usize;
410    if off_size == 0 || off_size > 4 {
411        return None;
412    }
413    let offsets_start = hdr_size + 3;
414    let read_off = |idx: usize| -> usize {
415        let pos = offsets_start + idx * off_size;
416        let mut val = 0u32;
417        for j in 0..off_size {
418            if pos + j < cff.len() {
419                val = (val << 8) | cff[pos + j] as u32;
420            }
421        }
422        val as usize
423    };
424    let data_start = offsets_start + (count + 1) * off_size;
425    let off1 = read_off(0);
426    let off2 = read_off(1);
427    let start = data_start + off1 - 1;
428    let end = data_start + off2 - 1;
429    if start < cff.len() && end <= cff.len() && end > start {
430        return Some(String::from_utf8_lossy(&cff[start..end]).to_string());
431    }
432    None
433}
434
435fn extract_ps_name_from_sfnt(path: &Path) -> Option<String> {
436    let data = fs::read(path).ok()?;
437    if data.len() < 12 {
438        return None;
439    }
440
441    // Check if this is an OTF with CFF — try CFF Name INDEX first
442    if &data[0..4] == b"OTTO"
443        && let Some(name) = extract_ps_name_from_cff_table(&data)
444    {
445        return Some(name);
446    }
447
448    // Fall back to name table
449    extract_ps_name_from_name_table(&data)
450}
451
452/// Extract PostScript name from CFF Name INDEX (for OTF+CFF files).
453fn extract_ps_name_from_cff_table(font_data: &[u8]) -> Option<String> {
454    let (cff_offset, cff_length) = find_table(font_data, b"CFF ")?;
455    if cff_offset + cff_length > font_data.len() {
456        return None;
457    }
458    let cff = &font_data[cff_offset..cff_offset + cff_length];
459
460    // CFF header: major(1) minor(1) hdrSize(1) offSize(1)
461    if cff.len() < 4 {
462        return None;
463    }
464    let hdr_size = cff[2] as usize;
465
466    // Name INDEX starts right after header
467    if hdr_size >= cff.len() {
468        return None;
469    }
470    let name_idx_offset = hdr_size;
471
472    // Parse INDEX: count(2) offSize(1) offset[count+1](offSize each) data...
473    if name_idx_offset + 3 > cff.len() {
474        return None;
475    }
476    let count = u16::from_be_bytes([cff[name_idx_offset], cff[name_idx_offset + 1]]) as usize;
477    if count == 0 {
478        return None;
479    }
480    let off_size = cff[name_idx_offset + 2] as usize;
481    if off_size == 0 || off_size > 4 {
482        return None;
483    }
484
485    // Read first two offsets to get the first name
486    let offsets_start = name_idx_offset + 3;
487    let read_offset = |idx: usize| -> Option<usize> {
488        let pos = offsets_start + idx * off_size;
489        if pos + off_size > cff.len() {
490            return None;
491        }
492        let mut val = 0u32;
493        for b in 0..off_size {
494            val = (val << 8) | cff[pos + b] as u32;
495        }
496        Some(val as usize)
497    };
498
499    let off1 = read_offset(0)?;
500    let off2 = read_offset(1)?;
501    let data_start = offsets_start + (count + 1) * off_size;
502    let start = data_start + off1 - 1; // offsets are 1-based
503    let end = data_start + off2 - 1;
504
505    if end > cff.len() || start >= end {
506        return None;
507    }
508
509    String::from_utf8(cff[start..end].to_vec()).ok()
510}
511
512/// Extract PostScript name from the `name` table (nameID 6).
513pub fn extract_ps_name_from_name_table(font_data: &[u8]) -> Option<String> {
514    let (name_off, name_len) = find_table(font_data, b"name")?;
515    if name_off + name_len > font_data.len() || name_len < 6 {
516        return None;
517    }
518    let name_table = &font_data[name_off..name_off + name_len];
519
520    let count = read_u16(name_table, 2) as usize;
521    let string_offset = read_u16(name_table, 4) as usize;
522
523    // Prefer platform 3 (Windows), then platform 1 (Mac)
524    let mut win_result: Option<String> = None;
525    let mut mac_result: Option<String> = None;
526
527    for i in 0..count {
528        let rec_off = 6 + i * 12;
529        if rec_off + 12 > name_table.len() {
530            break;
531        }
532
533        let platform_id = read_u16(name_table, rec_off);
534        let encoding_id = read_u16(name_table, rec_off + 2);
535        let name_id = read_u16(name_table, rec_off + 6);
536        let length = read_u16(name_table, rec_off + 8) as usize;
537        let offset = read_u16(name_table, rec_off + 10) as usize;
538
539        if name_id != 6 {
540            continue;
541        }
542
543        let data_start = string_offset + offset;
544        if data_start + length > name_table.len() {
545            continue;
546        }
547        let data = &name_table[data_start..data_start + length];
548
549        if platform_id == 3 && encoding_id == 1 && win_result.is_none() {
550            // Windows Unicode BMP — UTF-16BE
551            win_result = decode_utf16be(data);
552        } else if platform_id == 1 && encoding_id == 0 && mac_result.is_none() {
553            // Mac Roman — treat as latin-1
554            mac_result = Some(data.iter().map(|&b| b as char).collect());
555        }
556    }
557
558    win_result.or(mac_result)
559}
560
561/// Decode UTF-16BE bytes to a String.
562fn decode_utf16be(data: &[u8]) -> Option<String> {
563    if !data.len().is_multiple_of(2) {
564        return None;
565    }
566    let units: Vec<u16> = data
567        .chunks_exact(2)
568        .map(|c| u16::from_be_bytes([c[0], c[1]]))
569        .collect();
570    String::from_utf16(&units).ok()
571}
572
573/// Parse a `/FontName /SomeName` line.
574fn parse_fontname_line(line: &str) -> Option<String> {
575    let trimmed = line.trim();
576    if let Some(rest) = trimmed.strip_prefix("/FontName") {
577        let rest = rest.trim_start();
578        if let Some(name) = rest.strip_prefix('/') {
579            // Name ends at whitespace or special chars
580            let end = name
581                .find(|c: char| c.is_whitespace() || c == '/' || c == '{' || c == '(')
582                .unwrap_or(name.len());
583            if end > 0 {
584                return Some(name[..end].to_string());
585            }
586        }
587    }
588    None
589}
590
591/// Find `/FontName /SomeName` in raw bytes.
592fn find_fontname_in_bytes(data: &[u8]) -> Option<String> {
593    let text = String::from_utf8_lossy(data);
594    for line in text.lines() {
595        if let Some(name) = parse_fontname_line(line) {
596            return Some(name);
597        }
598    }
599    None
600}
601
602/// Parse the `cmap` table to build a Unicode→GID mapping.
603///
604/// Returns a map from Unicode codepoint to glyph ID.
605/// Supports format 4 (BMP) and format 12 (full Unicode).
606pub fn parse_cmap_table(font_data: &[u8]) -> Option<HashMap<u32, u16>> {
607    let (cmap_off, cmap_len) = find_table(font_data, b"cmap")?;
608    if cmap_off + cmap_len > font_data.len() {
609        return None;
610    }
611    let cmap = &font_data[cmap_off..];
612
613    let num_subtables = read_u16(cmap, 2) as usize;
614
615    // Find best subtable: prefer format 12 (platform 3/encoding 10), then format 4
616    let mut format4_offset: Option<usize> = None;
617    let mut format12_offset: Option<usize> = None;
618
619    for i in 0..num_subtables {
620        let rec = 4 + i * 8;
621        if rec + 8 > cmap_len {
622            break;
623        }
624        let platform = read_u16(cmap, rec);
625        let encoding = read_u16(cmap, rec + 2);
626        let offset = read_u32(cmap, rec + 4) as usize;
627
628        if offset + 2 > cmap_len {
629            continue;
630        }
631        let format = read_u16(cmap, offset);
632
633        if platform == 3 && encoding == 10 && format == 12 {
634            format12_offset = Some(offset);
635        } else if platform == 3 && encoding == 1 && format == 4 && format4_offset.is_none() {
636            format4_offset = Some(offset);
637        }
638    }
639
640    // Try format 12 first
641    if let Some(off) = format12_offset
642        && let Some(map) = parse_cmap_format12(cmap, off)
643    {
644        return Some(map);
645    }
646
647    // Fall back to format 4
648    if let Some(off) = format4_offset {
649        return parse_cmap_format4(cmap, off);
650    }
651
652    None
653}
654
655/// Parse cmap format 4 (BMP segmented mapping).
656fn parse_cmap_format4(cmap: &[u8], offset: usize) -> Option<HashMap<u32, u16>> {
657    if offset + 14 > cmap.len() {
658        return None;
659    }
660    let seg_count_x2 = read_u16(cmap, offset + 6) as usize;
661    let seg_count = seg_count_x2 / 2;
662
663    let end_codes_off = offset + 14;
664    let start_codes_off = end_codes_off + seg_count_x2 + 2; // +2 for reservedPad
665    let id_delta_off = start_codes_off + seg_count_x2;
666    let id_range_off = id_delta_off + seg_count_x2;
667
668    if id_range_off + seg_count_x2 > cmap.len() {
669        return None;
670    }
671
672    let mut map = HashMap::new();
673
674    for i in 0..seg_count {
675        let end_code = read_u16(cmap, end_codes_off + i * 2) as u32;
676        let start_code = read_u16(cmap, start_codes_off + i * 2) as u32;
677        let id_delta = read_u16(cmap, id_delta_off + i * 2) as i16;
678        let id_range_offset = read_u16(cmap, id_range_off + i * 2) as usize;
679
680        if start_code == 0xFFFF {
681            break;
682        }
683
684        for code in start_code..=end_code {
685            let gid = if id_range_offset == 0 {
686                (code as i32 + id_delta as i32) as u16
687            } else {
688                let glyph_off =
689                    id_range_off + i * 2 + id_range_offset + (code - start_code) as usize * 2;
690                if glyph_off + 2 > cmap.len() {
691                    continue;
692                }
693                let gid = read_u16(cmap, glyph_off);
694                if gid == 0 {
695                    0
696                } else {
697                    (gid as i32 + id_delta as i32) as u16
698                }
699            };
700            if gid != 0 {
701                map.insert(code, gid);
702            }
703        }
704    }
705
706    Some(map)
707}
708
709/// Parse cmap format 12 (full Unicode segmented coverage).
710fn parse_cmap_format12(cmap: &[u8], offset: usize) -> Option<HashMap<u32, u16>> {
711    if offset + 16 > cmap.len() {
712        return None;
713    }
714    let num_groups = read_u32(cmap, offset + 12) as usize;
715    let groups_off = offset + 16;
716
717    if groups_off + num_groups * 12 > cmap.len() {
718        return None;
719    }
720
721    let mut map = HashMap::new();
722
723    for i in 0..num_groups {
724        let g = groups_off + i * 12;
725        let start_code = read_u32(cmap, g);
726        let end_code = read_u32(cmap, g + 4);
727        let start_gid = read_u32(cmap, g + 8);
728
729        for code in start_code..=end_code {
730            let gid = (start_gid + (code - start_code)) as u16;
731            if gid != 0 {
732                map.insert(code, gid);
733            }
734        }
735    }
736
737    Some(map)
738}
739
740/// Parse the `post` table to get GID→glyph name mapping.
741///
742/// Only handles format 2.0 (the common format with custom names).
743/// Returns None for other formats (caller should use AGL fallback).
744pub fn parse_post_table(font_data: &[u8]) -> Option<HashMap<u16, String>> {
745    let (post_off, post_len) = find_table(font_data, b"post")?;
746    if post_off + post_len > font_data.len() || post_len < 34 {
747        return None;
748    }
749    let post = &font_data[post_off..post_off + post_len];
750
751    // Format is a Fixed (16.16): check for 2.0
752    let format_major = read_u16(post, 0);
753    let format_minor = read_u16(post, 2);
754    if format_major != 2 || format_minor != 0 {
755        return None; // Only handle format 2.0
756    }
757
758    let num_glyphs = read_u16(post, 32) as usize;
759    if 34 + num_glyphs * 2 > post.len() {
760        return None;
761    }
762
763    // Read glyph name index array
764    let mut name_indices = Vec::with_capacity(num_glyphs);
765    for i in 0..num_glyphs {
766        name_indices.push(read_u16(post, 34 + i * 2));
767    }
768
769    // Read Pascal strings for indices >= 258
770    let mut extra_names = Vec::new();
771    let mut offset = 34 + num_glyphs * 2;
772    while offset < post.len() {
773        let str_len = post[offset] as usize;
774        offset += 1;
775        if offset + str_len > post.len() {
776            break;
777        }
778        let name = String::from_utf8_lossy(&post[offset..offset + str_len]).to_string();
779        extra_names.push(name);
780        offset += str_len;
781    }
782
783    let mut map = HashMap::new();
784    for (gid, &idx) in name_indices.iter().enumerate() {
785        let name = if (idx as usize) < MAC_GLYPH_NAMES.len() {
786            MAC_GLYPH_NAMES[idx as usize].to_string()
787        } else {
788            let extra_idx = idx as usize - 258;
789            if extra_idx < extra_names.len() {
790                extra_names[extra_idx].clone()
791            } else {
792                continue;
793            }
794        };
795        if name != ".notdef" {
796            map.insert(gid as u16, name);
797        }
798    }
799
800    Some(map)
801}
802
803/// Standard Macintosh glyph names (first 258 entries in post format 2.0).
804static MAC_GLYPH_NAMES: &[&str] = &[
805    ".notdef",
806    ".null",
807    "nonmarkingreturn",
808    "space",
809    "exclam",
810    "quotedbl",
811    "numbersign",
812    "dollar",
813    "percent",
814    "ampersand",
815    "quotesingle",
816    "parenleft",
817    "parenright",
818    "asterisk",
819    "plus",
820    "comma",
821    "hyphen",
822    "period",
823    "slash",
824    "zero",
825    "one",
826    "two",
827    "three",
828    "four",
829    "five",
830    "six",
831    "seven",
832    "eight",
833    "nine",
834    "colon",
835    "semicolon",
836    "less",
837    "equal",
838    "greater",
839    "question",
840    "at",
841    "A",
842    "B",
843    "C",
844    "D",
845    "E",
846    "F",
847    "G",
848    "H",
849    "I",
850    "J",
851    "K",
852    "L",
853    "M",
854    "N",
855    "O",
856    "P",
857    "Q",
858    "R",
859    "S",
860    "T",
861    "U",
862    "V",
863    "W",
864    "X",
865    "Y",
866    "Z",
867    "bracketleft",
868    "backslash",
869    "bracketright",
870    "asciicircum",
871    "underscore",
872    "grave",
873    "a",
874    "b",
875    "c",
876    "d",
877    "e",
878    "f",
879    "g",
880    "h",
881    "i",
882    "j",
883    "k",
884    "l",
885    "m",
886    "n",
887    "o",
888    "p",
889    "q",
890    "r",
891    "s",
892    "t",
893    "u",
894    "v",
895    "w",
896    "x",
897    "y",
898    "z",
899    "braceleft",
900    "bar",
901    "braceright",
902    "asciitilde",
903    "Adieresis",
904    "Aring",
905    "Ccedilla",
906    "Eacute",
907    "Ntilde",
908    "Odieresis",
909    "Udieresis",
910    "aacute",
911    "agrave",
912    "acircumflex",
913    "adieresis",
914    "atilde",
915    "aring",
916    "ccedilla",
917    "eacute",
918    "egrave",
919    "ecircumflex",
920    "edieresis",
921    "iacute",
922    "igrave",
923    "icircumflex",
924    "idieresis",
925    "ntilde",
926    "oacute",
927    "ograve",
928    "ocircumflex",
929    "odieresis",
930    "otilde",
931    "uacute",
932    "ugrave",
933    "ucircumflex",
934    "udieresis",
935    "dagger",
936    "degree",
937    "cent",
938    "sterling",
939    "section",
940    "bullet",
941    "paragraph",
942    "germandbls",
943    "registered",
944    "copyright",
945    "trademark",
946    "acute",
947    "dieresis",
948    "notequal",
949    "AE",
950    "Oslash",
951    "infinity",
952    "plusminus",
953    "lessequal",
954    "greaterequal",
955    "yen",
956    "mu",
957    "partialdiff",
958    "summation",
959    "product",
960    "pi",
961    "integral",
962    "ordfeminine",
963    "ordmasculine",
964    "Omega",
965    "ae",
966    "oslash",
967    "questiondown",
968    "exclamdown",
969    "logicalnot",
970    "radical",
971    "florin",
972    "approxequal",
973    "Delta",
974    "guillemotleft",
975    "guillemotright",
976    "ellipsis",
977    "nonbreakingspace",
978    "Agrave",
979    "Atilde",
980    "Otilde",
981    "OE",
982    "oe",
983    "endash",
984    "emdash",
985    "quotedblleft",
986    "quotedblright",
987    "quoteleft",
988    "quoteright",
989    "divide",
990    "lozenge",
991    "ydieresis",
992    "Ydieresis",
993    "fraction",
994    "currency",
995    "guilsinglleft",
996    "guilsinglright",
997    "fi",
998    "fl",
999    "daggerdbl",
1000    "periodcentered",
1001    "quotesinglbase",
1002    "quotedblbase",
1003    "perthousand",
1004    "Acircumflex",
1005    "Ecircumflex",
1006    "Aacute",
1007    "Edieresis",
1008    "Egrave",
1009    "Iacute",
1010    "Icircumflex",
1011    "Idieresis",
1012    "Igrave",
1013    "Oacute",
1014    "Ocircumflex",
1015    "apple",
1016    "Ograve",
1017    "Uacute",
1018    "Ucircumflex",
1019    "Ugrave",
1020    "dotlessi",
1021    "circumflex",
1022    "tilde",
1023    "macron",
1024    "breve",
1025    "dotaccent",
1026    "ring",
1027    "cedilla",
1028    "hungarumlaut",
1029    "ogonek",
1030    "caron",
1031    "Lslash",
1032    "lslash",
1033    "Scaron",
1034    "scaron",
1035    "Zcaron",
1036    "zcaron",
1037    "brokenbar",
1038    "Eth",
1039    "eth",
1040    "Yacute",
1041    "yacute",
1042    "Thorn",
1043    "thorn",
1044    "minus",
1045    "multiply",
1046    "onesuperior",
1047    "twosuperior",
1048    "threesuperior",
1049    "onehalf",
1050    "onequarter",
1051    "threequarters",
1052    "franc",
1053    "Gbreve",
1054    "gbreve",
1055    "Idotaccent",
1056    "Scedilla",
1057    "scedilla",
1058    "Cacute",
1059    "cacute",
1060    "Ccaron",
1061    "ccaron",
1062    "dcroat",
1063];
1064
1065/// Adobe Glyph List: Unicode codepoint → PostScript glyph name.
1066/// Covers the most commonly needed Latin characters (0x0000–0x00FF range).
1067pub fn unicode_to_glyph_name(codepoint: u32) -> Option<&'static str> {
1068    AGL_MAP
1069        .binary_search_by_key(&codepoint, |&(cp, _)| cp)
1070        .ok()
1071        .map(|idx| AGL_MAP[idx].1)
1072}
1073
1074/// Core AGL entries (Unicode → glyph name) for the 0x00–0xFF range.
1075/// Sorted by codepoint for binary search.
1076static AGL_MAP: &[(u32, &str)] = &[
1077    (0x0020, "space"),
1078    (0x0021, "exclam"),
1079    (0x0022, "quotedbl"),
1080    (0x0023, "numbersign"),
1081    (0x0024, "dollar"),
1082    (0x0025, "percent"),
1083    (0x0026, "ampersand"),
1084    (0x0027, "quotesingle"),
1085    (0x0028, "parenleft"),
1086    (0x0029, "parenright"),
1087    (0x002A, "asterisk"),
1088    (0x002B, "plus"),
1089    (0x002C, "comma"),
1090    (0x002D, "hyphen"),
1091    (0x002E, "period"),
1092    (0x002F, "slash"),
1093    (0x0030, "zero"),
1094    (0x0031, "one"),
1095    (0x0032, "two"),
1096    (0x0033, "three"),
1097    (0x0034, "four"),
1098    (0x0035, "five"),
1099    (0x0036, "six"),
1100    (0x0037, "seven"),
1101    (0x0038, "eight"),
1102    (0x0039, "nine"),
1103    (0x003A, "colon"),
1104    (0x003B, "semicolon"),
1105    (0x003C, "less"),
1106    (0x003D, "equal"),
1107    (0x003E, "greater"),
1108    (0x003F, "question"),
1109    (0x0040, "at"),
1110    (0x0041, "A"),
1111    (0x0042, "B"),
1112    (0x0043, "C"),
1113    (0x0044, "D"),
1114    (0x0045, "E"),
1115    (0x0046, "F"),
1116    (0x0047, "G"),
1117    (0x0048, "H"),
1118    (0x0049, "I"),
1119    (0x004A, "J"),
1120    (0x004B, "K"),
1121    (0x004C, "L"),
1122    (0x004D, "M"),
1123    (0x004E, "N"),
1124    (0x004F, "O"),
1125    (0x0050, "P"),
1126    (0x0051, "Q"),
1127    (0x0052, "R"),
1128    (0x0053, "S"),
1129    (0x0054, "T"),
1130    (0x0055, "U"),
1131    (0x0056, "V"),
1132    (0x0057, "W"),
1133    (0x0058, "X"),
1134    (0x0059, "Y"),
1135    (0x005A, "Z"),
1136    (0x005B, "bracketleft"),
1137    (0x005C, "backslash"),
1138    (0x005D, "bracketright"),
1139    (0x005E, "asciicircum"),
1140    (0x005F, "underscore"),
1141    (0x0060, "grave"),
1142    (0x0061, "a"),
1143    (0x0062, "b"),
1144    (0x0063, "c"),
1145    (0x0064, "d"),
1146    (0x0065, "e"),
1147    (0x0066, "f"),
1148    (0x0067, "g"),
1149    (0x0068, "h"),
1150    (0x0069, "i"),
1151    (0x006A, "j"),
1152    (0x006B, "k"),
1153    (0x006C, "l"),
1154    (0x006D, "m"),
1155    (0x006E, "n"),
1156    (0x006F, "o"),
1157    (0x0070, "p"),
1158    (0x0071, "q"),
1159    (0x0072, "r"),
1160    (0x0073, "s"),
1161    (0x0074, "t"),
1162    (0x0075, "u"),
1163    (0x0076, "v"),
1164    (0x0077, "w"),
1165    (0x0078, "x"),
1166    (0x0079, "y"),
1167    (0x007A, "z"),
1168    (0x007B, "braceleft"),
1169    (0x007C, "bar"),
1170    (0x007D, "braceright"),
1171    (0x007E, "asciitilde"),
1172    (0x00A0, "nonbreakingspace"),
1173    (0x00A1, "exclamdown"),
1174    (0x00A2, "cent"),
1175    (0x00A3, "sterling"),
1176    (0x00A4, "currency"),
1177    (0x00A5, "yen"),
1178    (0x00A6, "brokenbar"),
1179    (0x00A7, "section"),
1180    (0x00A8, "dieresis"),
1181    (0x00A9, "copyright"),
1182    (0x00AA, "ordfeminine"),
1183    (0x00AB, "guillemotleft"),
1184    (0x00AC, "logicalnot"),
1185    (0x00AD, "softhyphen"),
1186    (0x00AE, "registered"),
1187    (0x00AF, "macron"),
1188    (0x00B0, "degree"),
1189    (0x00B1, "plusminus"),
1190    (0x00B2, "twosuperior"),
1191    (0x00B3, "threesuperior"),
1192    (0x00B4, "acute"),
1193    (0x00B5, "mu"),
1194    (0x00B6, "paragraph"),
1195    (0x00B7, "periodcentered"),
1196    (0x00B8, "cedilla"),
1197    (0x00B9, "onesuperior"),
1198    (0x00BA, "ordmasculine"),
1199    (0x00BB, "guillemotright"),
1200    (0x00BC, "onequarter"),
1201    (0x00BD, "onehalf"),
1202    (0x00BE, "threequarters"),
1203    (0x00BF, "questiondown"),
1204    (0x00C0, "Agrave"),
1205    (0x00C1, "Aacute"),
1206    (0x00C2, "Acircumflex"),
1207    (0x00C3, "Atilde"),
1208    (0x00C4, "Adieresis"),
1209    (0x00C5, "Aring"),
1210    (0x00C6, "AE"),
1211    (0x00C7, "Ccedilla"),
1212    (0x00C8, "Egrave"),
1213    (0x00C9, "Eacute"),
1214    (0x00CA, "Ecircumflex"),
1215    (0x00CB, "Edieresis"),
1216    (0x00CC, "Igrave"),
1217    (0x00CD, "Iacute"),
1218    (0x00CE, "Icircumflex"),
1219    (0x00CF, "Idieresis"),
1220    (0x00D0, "Eth"),
1221    (0x00D1, "Ntilde"),
1222    (0x00D2, "Ograve"),
1223    (0x00D3, "Oacute"),
1224    (0x00D4, "Ocircumflex"),
1225    (0x00D5, "Otilde"),
1226    (0x00D6, "Odieresis"),
1227    (0x00D7, "multiply"),
1228    (0x00D8, "Oslash"),
1229    (0x00D9, "Ugrave"),
1230    (0x00DA, "Uacute"),
1231    (0x00DB, "Ucircumflex"),
1232    (0x00DC, "Udieresis"),
1233    (0x00DD, "Yacute"),
1234    (0x00DE, "Thorn"),
1235    (0x00DF, "germandbls"),
1236    (0x00E0, "agrave"),
1237    (0x00E1, "aacute"),
1238    (0x00E2, "acircumflex"),
1239    (0x00E3, "atilde"),
1240    (0x00E4, "adieresis"),
1241    (0x00E5, "aring"),
1242    (0x00E6, "ae"),
1243    (0x00E7, "ccedilla"),
1244    (0x00E8, "egrave"),
1245    (0x00E9, "eacute"),
1246    (0x00EA, "ecircumflex"),
1247    (0x00EB, "edieresis"),
1248    (0x00EC, "igrave"),
1249    (0x00ED, "iacute"),
1250    (0x00EE, "icircumflex"),
1251    (0x00EF, "idieresis"),
1252    (0x00F0, "eth"),
1253    (0x00F1, "ntilde"),
1254    (0x00F2, "ograve"),
1255    (0x00F3, "oacute"),
1256    (0x00F4, "ocircumflex"),
1257    (0x00F5, "otilde"),
1258    (0x00F6, "odieresis"),
1259    (0x00F7, "divide"),
1260    (0x00F8, "oslash"),
1261    (0x00F9, "ugrave"),
1262    (0x00FA, "uacute"),
1263    (0x00FB, "ucircumflex"),
1264    (0x00FC, "udieresis"),
1265    (0x00FD, "yacute"),
1266    (0x00FE, "thorn"),
1267    (0x00FF, "ydieresis"),
1268    // Common ligatures and extras
1269    (0x0131, "dotlessi"),
1270    (0x0141, "Lslash"),
1271    (0x0142, "lslash"),
1272    (0x0152, "OE"),
1273    (0x0153, "oe"),
1274    (0x0160, "Scaron"),
1275    (0x0161, "scaron"),
1276    (0x0178, "Ydieresis"),
1277    (0x017D, "Zcaron"),
1278    (0x017E, "zcaron"),
1279    (0x0192, "florin"),
1280    (0x02C6, "circumflex"),
1281    (0x02C7, "caron"),
1282    (0x02D8, "breve"),
1283    (0x02D9, "dotaccent"),
1284    (0x02DA, "ring"),
1285    (0x02DB, "ogonek"),
1286    (0x02DC, "tilde"),
1287    (0x02DD, "hungarumlaut"),
1288    (0x2013, "endash"),
1289    (0x2014, "emdash"),
1290    (0x2018, "quoteleft"),
1291    (0x2019, "quoteright"),
1292    (0x201A, "quotesinglbase"),
1293    (0x201C, "quotedblleft"),
1294    (0x201D, "quotedblright"),
1295    (0x201E, "quotedblbase"),
1296    (0x2020, "dagger"),
1297    (0x2021, "daggerdbl"),
1298    (0x2022, "bullet"),
1299    (0x2026, "ellipsis"),
1300    (0x2030, "perthousand"),
1301    (0x2039, "guilsinglleft"),
1302    (0x203A, "guilsinglright"),
1303    (0x2044, "fraction"),
1304    (0x20AC, "Euro"),
1305    (0x2122, "trademark"),
1306    (0x2202, "partialdiff"),
1307    (0x2206, "Delta"),
1308    (0x220F, "product"),
1309    (0x2211, "summation"),
1310    (0x221A, "radical"),
1311    (0x221E, "infinity"),
1312    (0x222B, "integral"),
1313    (0x2248, "approxequal"),
1314    (0x2260, "notequal"),
1315    (0x2264, "lessequal"),
1316    (0x2265, "greaterequal"),
1317    (0x25CA, "lozenge"),
1318    (0xF001, "fi"),
1319    (0xFB01, "fi"),
1320    (0xFB02, "fl"),
1321];
1322
1323#[cfg(test)]
1324mod tests {
1325    use super::*;
1326
1327    #[test]
1328    fn test_parse_fontname_line() {
1329        assert_eq!(
1330            parse_fontname_line("/FontName /Helvetica def"),
1331            Some("Helvetica".to_string())
1332        );
1333        assert_eq!(
1334            parse_fontname_line("/FontName /NimbusSans-Regular def"),
1335            Some("NimbusSans-Regular".to_string())
1336        );
1337        assert_eq!(parse_fontname_line("/FontType 1 def"), None);
1338    }
1339
1340    #[test]
1341    fn test_unicode_to_glyph_name() {
1342        assert_eq!(unicode_to_glyph_name(0x0041), Some("A"));
1343        assert_eq!(unicode_to_glyph_name(0x0020), Some("space"));
1344        assert_eq!(unicode_to_glyph_name(0x00C9), Some("Eacute"));
1345        assert_eq!(unicode_to_glyph_name(0x9999), None);
1346    }
1347
1348    #[test]
1349    fn test_extract_ps_name_from_pfa() {
1350        let font_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1351            .join("../../resources/Font/NimbusSans-Regular.t1");
1352        if !font_path.exists() {
1353            return;
1354        }
1355        let name = extract_ps_name_from_pfa(&font_path);
1356        assert_eq!(name, Some("NimbusSans-Regular".to_string()));
1357    }
1358
1359    #[test]
1360    fn test_system_font_cache_builds() {
1361        let cache = SystemFontCache::build();
1362        // On a typical Linux system, should find some fonts
1363        assert!(
1364            !cache.fonts.is_empty(),
1365            "Expected to find system fonts, found none"
1366        );
1367    }
1368
1369    #[test]
1370    fn test_cmap_format4_real_font() {
1371        // Test with a real system TTF if available
1372        let test_fonts = [
1373            "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
1374            "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
1375        ];
1376        for path in &test_fonts {
1377            if let Ok(data) = std::fs::read(path) {
1378                let map = parse_cmap_table(&data);
1379                assert!(map.is_some(), "Failed to parse cmap for {}", path);
1380                let map = map.unwrap();
1381                // Should at least map ASCII 'A' (0x41)
1382                assert!(map.contains_key(&0x41), "cmap missing 'A' for {}", path);
1383                return;
1384            }
1385        }
1386        eprintln!("Skipping cmap test — no test font found");
1387    }
1388}