use std::io;
use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use directories::ProjectDirs;
use crate::text::{register_font_bytes, register_font_dir};
const GOOGLE_CSS_API: &str = "https://fonts.googleapis.com/css2";
const HTTP_TIMEOUT_SECS: u64 = 30;
#[derive(thiserror::Error, Debug)]
pub enum GoogleFontError {
#[error("could not resolve a platform cache directory")]
NoCacheDir,
#[error("google fonts network error: {0}")]
Network(#[from] Box<ureq::Error>),
#[error("google fonts io error: {0}")]
Io(#[from] io::Error),
#[error("no font URLs found in Google Fonts response for family {0:?}")]
NoFontUrls(String),
}
impl From<ureq::Error> for GoogleFontError {
fn from(e: ureq::Error) -> Self {
GoogleFontError::Network(Box::new(e))
}
}
pub fn fetch_google_font(family: &str) -> Result<usize, GoogleFontError> {
let family_dir = google_font_cache_dir()?.join(family_slug(family));
if family_dir.is_dir() {
let n = register_font_dir(&family_dir)?;
if n > 0 {
return Ok(n);
}
}
let css = http_get_string(&family_css_url(family))?;
let urls = parse_font_urls(&css);
if urls.is_empty() {
return Err(GoogleFontError::NoFontUrls(family.to_string()));
}
std::fs::create_dir_all(&family_dir)?;
let mut total = 0;
for (i, url) in urls.iter().enumerate() {
let bytes = http_get_bytes(url)?;
let file = family_dir.join(face_filename(i, url));
std::fs::write(&file, &bytes)?;
total += register_font_bytes(bytes);
}
Ok(total)
}
pub fn google_font_cache_dir() -> Result<PathBuf, GoogleFontError> {
let dirs =
ProjectDirs::from("org", "hephaestus", "hephaestus").ok_or(GoogleFontError::NoCacheDir)?;
Ok(dirs.cache_dir().join("fonts").join("google"))
}
fn family_slug(family: &str) -> String {
family
.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect()
}
fn family_css_url(family: &str) -> String {
let encoded: String = family
.chars()
.map(|c| if c == ' ' { '+' } else { c })
.collect();
format!("{}?family={}", GOOGLE_CSS_API, encoded)
}
fn parse_font_urls(css: &str) -> Vec<String> {
let mut out = Vec::new();
let mut s = css;
while let Some(start) = s.find("url(") {
let after = &s[start + 4..];
if let Some(end) = after.find(')') {
let url = after[..end].trim().trim_matches(|c| c == '"' || c == '\'');
out.push(url.to_string());
s = &after[end..];
} else {
break;
}
}
out
}
fn face_filename(i: usize, url: &str) -> String {
let tail = url.rsplit('/').next().unwrap_or("");
let no_query = tail.split('?').next().unwrap_or(tail);
let ext = no_query
.rfind('.')
.map(|p| &no_query[p + 1..])
.unwrap_or("");
let ext_clean: String = ext
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(4)
.collect();
let ext_final = if ext_clean.is_empty() {
"ttf".to_string()
} else {
ext_clean
};
format!("face-{:03}.{}", i, ext_final)
}
fn http_get_string(url: &str) -> Result<String, GoogleFontError> {
let resp = ureq::get(url)
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.call()?;
Ok(resp.into_string()?)
}
fn http_get_bytes(url: &str) -> Result<Vec<u8>, GoogleFontError> {
let resp = ureq::get(url)
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.call()?;
let mut bytes = Vec::new();
resp.into_reader().read_to_end(&mut bytes)?;
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn family_slug_normalises_spaces_and_case() {
assert_eq!(family_slug("Open Sans"), "open-sans");
assert_eq!(family_slug("Inter"), "inter");
assert_eq!(family_slug("Noto Sans CJK JP"), "noto-sans-cjk-jp");
}
#[test]
fn family_css_url_encodes_spaces_as_plus() {
assert_eq!(
family_css_url("Open Sans"),
"https://fonts.googleapis.com/css2?family=Open+Sans"
);
}
#[test]
fn parse_font_urls_extracts_each_src_entry() {
let css = r#"
@font-face {
font-family: 'Inter';
src: url(https://fonts.gstatic.com/s/inter/v1/foo.ttf) format('truetype');
}
@font-face {
font-family: 'Inter';
src: url("https://fonts.gstatic.com/s/inter/v1/bar.ttf") format('truetype');
}
"#;
let urls = parse_font_urls(css);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "https://fonts.gstatic.com/s/inter/v1/foo.ttf");
assert_eq!(urls[1], "https://fonts.gstatic.com/s/inter/v1/bar.ttf");
}
#[test]
#[ignore]
fn live_fetch_inter() {
let n = fetch_google_font("Inter").expect("fetch");
assert!(n > 0, "expected at least one face for Inter; got {n}");
}
#[test]
fn face_filename_preserves_extension() {
assert_eq!(face_filename(0, "https://x/y/a.ttf"), "face-000.ttf");
assert_eq!(face_filename(1, "https://x/y/b.otf?v=2"), "face-001.otf");
assert_eq!(face_filename(2, "https://x/y/nothing"), "face-002.ttf");
}
}