use super::{AxisInfo, FaceInfo};
use anyhow::Result;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
#[cfg(not(target_os = "macos"))]
pub(super) fn is_font_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref(),
Some("ttf") | Some("otf") | Some("ttc") | Some("otc") | Some("dfont")
)
}
pub(super) fn read_font_file(path: &Path, user_installed: bool) -> Result<Vec<FaceInfo>> {
let bytes = std::fs::read(path)?;
let modified_at = file_ctime(path);
let face_count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1);
let mut out = Vec::new();
for index in 0..face_count {
let Ok(face) = ttf_parser::Face::parse(&bytes, index) else { continue };
let base = make_info(&face, modified_at, user_installed);
if base.family.is_empty() || base.family.starts_with('.') {
continue;
}
let instances = parse_fvar_instances(&face, base.variation_axes.len());
if instances.is_empty() {
out.push(base);
} else {
for inst in instances {
out.push(apply_instance(&base, inst));
}
}
}
Ok(out)
}
fn make_info(face: &ttf_parser::Face, modified_at: u64, user_installed: bool) -> FaceInfo {
let family = pick_name(face, ttf_parser::name_id::TYPOGRAPHIC_FAMILY)
.or_else(|| pick_name(face, ttf_parser::name_id::FAMILY))
.unwrap_or_default();
let style = pick_name(face, ttf_parser::name_id::TYPOGRAPHIC_SUBFAMILY)
.or_else(|| pick_name(face, ttf_parser::name_id::SUBFAMILY))
.map(|s| canonicalize_style(face, &s))
.unwrap_or_else(|| "Regular".to_string());
let postscript = pick_name(face, ttf_parser::name_id::POST_SCRIPT_NAME).unwrap_or_default();
let weight = face.weight().to_number();
let stretch = read_os2_width(face);
let italic = face.is_italic() || face.is_oblique();
let variation_axes = extract_axes(face);
FaceInfo {
family,
style,
postscript,
weight,
stretch,
italic,
variation_axes,
modified_at,
user_installed,
}
}
fn pick_name(face: &ttf_parser::Face, name_id: u16) -> Option<String> {
let names = face.names();
let mut fallback = None;
for n in (0..names.len()).filter_map(|i| names.get(i)) {
if n.name_id != name_id {
continue;
}
if n.platform_id == ttf_parser::PlatformId::Windows && n.language_id == 0x0409 {
if let Some(s) = decode_name(&n) {
return Some(s);
}
}
if fallback.is_none() {
fallback = Some(n);
}
}
fallback.and_then(|n| decode_name(&n))
}
fn decode_name(n: &ttf_parser::name::Name) -> Option<String> {
if let Some(s) = n.to_string() {
return Some(s);
}
if n.name.iter().all(|&b| b.is_ascii() && b != 0) {
std::str::from_utf8(n.name).ok().map(|s| s.to_string())
} else {
None
}
}
fn extract_axes(face: &ttf_parser::Face) -> Vec<AxisInfo> {
face.variation_axes()
.into_iter()
.map(|a| {
let tag = a.tag.to_string();
AxisInfo {
name: pick_axis_name(face, &tag, a.name_id),
tag,
value: a.def_value,
min: a.min_value,
max: a.max_value,
default: a.def_value,
hidden: a.hidden,
}
})
.collect()
}
fn pick_axis_name(face: &ttf_parser::Face, tag: &str, name_id: u16) -> String {
let names = face.names();
let mut has_mac = false;
let mut mac_utf16: Option<String> = None;
let mut mac_ascii: Option<String> = None;
let mut win_utf16: Option<String> = None;
for j in 0..names.len() {
let Some(n) = names.get(j) else { continue };
if n.name_id != name_id {
continue;
}
if n.platform_id == ttf_parser::PlatformId::Macintosh {
if !has_mac {
has_mac = true;
mac_utf16 = n.to_string();
}
if n.language_id == 0 && mac_ascii.is_none() {
mac_ascii = decode_ascii(&n);
}
} else if n.platform_id == ttf_parser::PlatformId::Windows
&& n.language_id == 0x0409
&& win_utf16.is_none()
{
win_utf16 = n.to_string();
}
}
if is_registered_axis(tag) {
return win_utf16
.or(mac_ascii)
.map(|s| pascal_case(&s))
.unwrap_or_else(|| tag.to_string());
}
if has_mac {
mac_utf16.filter(|s| !s.is_empty()).unwrap_or_else(|| tag.to_string())
} else {
win_utf16.unwrap_or_else(|| tag.to_string())
}
}
fn decode_ascii(n: &ttf_parser::name::Name) -> Option<String> {
if n.name.iter().all(|&b| b.is_ascii() && b != 0) {
std::str::from_utf8(n.name).ok().map(|s| s.to_string())
} else {
None
}
}
fn is_registered_axis(tag: &str) -> bool {
matches!(tag, "wght" | "wdth" | "slnt" | "ital" | "opsz")
}
fn pascal_case(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for word in input.split(' ') {
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
out
}
fn canonicalize_style(face: &ttf_parser::Face, style: &str) -> String {
let italic = face.is_italic() || face.is_oblique();
if !italic || !style.eq_ignore_ascii_case("Oblique") {
return style.to_string();
}
let names = face.names();
let has_italic_sibling = (0..names.len()).filter_map(|i| names.get(i)).any(|n| {
n.name_id == ttf_parser::name_id::SUBFAMILY
&& n.platform_id == ttf_parser::PlatformId::Windows
&& n.language_id & 0xff == 0x09
&& n.to_string().is_some_and(|s| s.eq_ignore_ascii_case("Italic"))
});
if has_italic_sibling { "Italic".to_string() } else { style.to_string() }
}
fn read_os2_width(face: &ttf_parser::Face) -> u8 {
let Some(data) = face.raw_face().table(ttf_parser::Tag::from_bytes(b"OS/2")) else {
return 5;
};
if data.len() < 8 {
return 5;
}
let raw = u16::from_be_bytes([data[6], data[7]]);
raw.clamp(1, 9) as u8
}
fn quantize_width(percent: f64) -> u8 {
const BUCKETS: &[(f64, u8)] = &[
(50.0, 1),
(62.5, 2),
(75.0, 3),
(87.5, 4),
(100.0, 5),
(112.5, 6),
(125.0, 7),
(150.0, 8),
(200.0, 9),
];
let mut best = 5u8;
let mut best_diff = f64::INFINITY;
for (target, bucket) in BUCKETS {
let d = (percent - target).abs();
if d < best_diff {
best_diff = d;
best = *bucket;
}
}
best
}
pub(super) fn file_ctime(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.ctime() as u64).unwrap_or(0)
}
struct NamedInstance {
style: String,
coords: Vec<f32>,
}
fn parse_fvar_instances(face: &ttf_parser::Face, axis_count: usize) -> Vec<NamedInstance> {
let Some(data) = face.raw_face().table(ttf_parser::Tag::from_bytes(b"fvar")) else {
return Vec::new();
};
if data.len() < 16 || axis_count == 0 {
return Vec::new();
}
let u16_at = |o: usize| u16::from_be_bytes([data[o], data[o + 1]]);
let i32_at = |o: usize| {
i32::from_be_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]])
};
let axes_offset = u16_at(4) as usize;
let axis_size = u16_at(10) as usize;
let instance_count = u16_at(12) as usize;
let instance_size = u16_at(14) as usize;
let coords_bytes = axis_count.saturating_mul(4);
if instance_size < coords_bytes + 4 {
return Vec::new();
}
let instances_offset = axes_offset.saturating_add(axis_count.saturating_mul(axis_size));
let mut out = Vec::with_capacity(instance_count);
for i in 0..instance_count {
let base = match instances_offset.checked_add(i.saturating_mul(instance_size)) {
Some(b) if b.checked_add(instance_size).is_some_and(|e| e <= data.len()) => b,
_ => break,
};
let subfamily_id = u16_at(base);
let coords: Vec<f32> = (0..axis_count)
.map(|a| i32_at(base + 4 + a * 4) as f32 / 65536.0)
.collect();
let style = pick_name(face, subfamily_id).unwrap_or_else(|| "Regular".to_string());
out.push(NamedInstance { style, coords });
}
out
}
fn apply_instance(base: &FaceInfo, inst: NamedInstance) -> FaceInfo {
let mut info = base.clone();
info.postscript = format!(
"{}-{}",
base.family.replace(' ', ""),
inst.style.replace(' ', "")
);
info.style = inst.style;
for (axis, coord) in info.variation_axes.iter_mut().zip(inst.coords.iter()) {
axis.value = *coord;
match axis.tag.as_str() {
"wght" => info.weight = *coord as u16,
"wdth" => info.stretch = quantize_width(*coord as f64),
"slnt" | "ital" => {
if *coord != 0.0 {
info.italic = true;
}
}
_ => {}
}
}
info
}