use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum FontSource {
Builtin(&'static str),
System(String),
File(PathBuf),
Bytes(&'static [u8]),
}
impl FontSource {
pub fn system(name: impl Into<String>) -> Self {
FontSource::System(name.into())
}
pub fn file(path: impl Into<PathBuf>) -> Self {
FontSource::File(path.into())
}
pub fn bytes(data: &'static [u8]) -> Self {
FontSource::Bytes(data)
}
}
#[derive(Debug, Clone, Default)]
pub struct FontConfig {
pub default_font: Option<String>,
pub code_font: Option<String>,
pub default_font_source: Option<FontSource>,
pub code_font_source: Option<FontSource>,
pub fallback_fonts: Vec<String>,
pub fallback_font_sources: Vec<FontSource>,
pub enable_subsetting: bool,
}
impl FontConfig {
pub fn new() -> Self {
Self {
default_font: None,
code_font: None,
default_font_source: None,
code_font_source: None,
fallback_fonts: Vec::new(),
fallback_font_sources: Vec::new(),
enable_subsetting: true,
}
}
pub fn with_default_font(mut self, font: impl Into<String>) -> Self {
self.default_font = Some(font.into());
self
}
pub fn with_code_font(mut self, font: impl Into<String>) -> Self {
self.code_font = Some(font.into());
self
}
pub fn with_default_font_source(mut self, source: FontSource) -> Self {
self.default_font_source = Some(source);
self
}
pub fn with_code_font_source(mut self, source: FontSource) -> Self {
self.code_font_source = Some(source);
self
}
pub fn with_subsetting(mut self, enabled: bool) -> Self {
self.enable_subsetting = enabled;
self
}
pub fn with_fallback_fonts<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.fallback_fonts = names.into_iter().map(Into::into).collect();
self
}
pub fn add_fallback_font(mut self, name: impl Into<String>) -> Self {
self.fallback_fonts.push(name.into());
self
}
pub fn add_fallback_font_source(mut self, source: FontSource) -> Self {
self.fallback_font_sources.push(source);
self
}
}
pub fn is_builtin_font_name(name: &str) -> bool {
matches!(
name.to_lowercase().as_str(),
"helvetica"
| "arial"
| "sans-serif"
| "times"
| "times new roman"
| "serif"
| "courier"
| "courier new"
| "monospace"
)
}
pub fn resolve_font_source(name: &str) -> FontSource {
if is_builtin_font_name(name) {
return FontSource::Builtin(match name.to_lowercase().as_str() {
"helvetica" | "arial" | "sans-serif" => "Helvetica",
"times" | "times new roman" | "serif" => "Times",
"courier" | "courier new" | "monospace" => "Courier",
_ => "Helvetica",
});
}
if name.contains('/') || name.contains('\\') || name.ends_with(".ttf") || name.ends_with(".otf")
{
return FontSource::File(PathBuf::from(name));
}
FontSource::System(name.to_string())
}
pub fn system_font_dirs() -> Vec<&'static str> {
if cfg!(target_os = "macos") {
vec![
"/System/Library/Fonts",
"/System/Library/Fonts/Supplemental",
"/Library/Fonts",
]
} else if cfg!(target_os = "linux") {
vec![
"/usr/share/fonts/truetype",
"/usr/share/fonts/TTF",
"/usr/share/fonts/opentype",
"/usr/local/share/fonts",
]
} else if cfg!(target_os = "windows") {
vec!["C:\\Windows\\Fonts"]
} else {
vec![]
}
}
pub fn find_system_font(name: &str) -> Option<PathBuf> {
find_system_font_in(name, &system_font_dirs())
}
fn find_system_font_in(name: &str, dirs: &[&str]) -> Option<PathBuf> {
let name_lower = name.to_lowercase();
let patterns: Vec<String> = [
format!("{}.ttf", name),
format!("{}.otf", name),
format!("{}.ttf", name.replace(" MS", "")),
]
.iter()
.map(|p| p.to_lowercase())
.collect();
let mut prefix_match: Option<PathBuf> = None;
for dir in dirs {
let dir_path = Path::new(dir);
if !dir_path.exists() {
continue;
}
let Ok(entries) = std::fs::read_dir(dir_path) else {
continue;
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_lower = file_name.to_string_lossy().to_lowercase();
if file_lower.ends_with(".ttc") {
continue;
}
if patterns.iter().any(|p| file_lower == *p) {
return Some(entry.path());
}
if file_lower.starts_with(&name_lower)
&& (file_lower.ends_with(".ttf") || file_lower.ends_with(".otf"))
{
let shorter = prefix_match
.as_ref()
.and_then(|p| p.file_name())
.map(|n| file_lower.len() < n.to_string_lossy().len())
.unwrap_or(true);
if shorter {
prefix_match = Some(entry.path());
}
}
}
}
prefix_match
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtin_name_recognized() {
assert!(is_builtin_font_name("Helvetica"));
assert!(is_builtin_font_name("helvetica"));
assert!(is_builtin_font_name("Times New Roman"));
assert!(is_builtin_font_name("courier"));
assert!(!is_builtin_font_name("Georgia"));
}
#[test]
fn resolve_builtin() {
assert!(matches!(
resolve_font_source("Helvetica"),
FontSource::Builtin("Helvetica")
));
assert!(matches!(
resolve_font_source("arial"),
FontSource::Builtin("Helvetica")
));
}
#[test]
fn resolve_path() {
assert!(matches!(
resolve_font_source("/some/path/font.ttf"),
FontSource::File(_)
));
assert!(matches!(
resolve_font_source("relative.otf"),
FontSource::File(_)
));
}
#[test]
fn resolve_system() {
assert!(matches!(
resolve_font_source("Georgia"),
FontSource::System(_)
));
}
#[test]
fn system_font_dirs_present() {
let _ = system_font_dirs();
}
fn with_font_dir(files: &[&str], f: impl FnOnce(&str)) {
use std::sync::atomic::{AtomicU32, Ordering};
static SEQ: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"m2pdf_fonttest_{}_{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for name in files {
std::fs::write(dir.join(name), b"x").unwrap();
}
f(dir.to_str().unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_system_font_prefers_exact_over_prefix() {
with_font_dir(&["Tahoma Bold.ttf", "Tahoma.ttf"], |dir| {
let found = find_system_font_in("Tahoma", &[dir]).unwrap();
assert_eq!(found.file_name().unwrap(), "Tahoma.ttf");
});
}
#[test]
fn find_system_font_prefix_fallback_picks_shortest() {
with_font_dir(&["Tahoma Italic.ttf", "Tahoma Bold.ttf"], |dir| {
let found = find_system_font_in("Tahoma", &[dir]).unwrap();
assert_eq!(found.file_name().unwrap(), "Tahoma Bold.ttf");
});
}
#[test]
fn find_system_font_skips_ttc() {
with_font_dir(&["Helvetica Neue.ttc"], |dir| {
assert!(find_system_font_in("Helvetica Neue", &[dir]).is_none());
});
}
}