use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use genpdfi::error::{Error, ErrorKind};
use genpdfi::fonts::{FontData, FontFamily};
use log::{debug, info, warn};
use printpdf::BuiltinFont;
#[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)
}
}
fn get_builtin_font(name: &str) -> Option<BuiltinFont> {
match name.to_lowercase().as_str() {
"helvetica" | "arial" | "sans-serif" => Some(BuiltinFont::Helvetica),
"times" | "times new roman" | "serif" => Some(BuiltinFont::TimesRoman),
"courier" | "courier new" | "monospace" => Some(BuiltinFont::Courier),
_ => None,
}
}
struct BuiltinVariants {
regular: BuiltinFont,
bold: BuiltinFont,
italic: BuiltinFont,
bold_italic: BuiltinFont,
}
fn get_builtin_variants(base: BuiltinFont) -> BuiltinVariants {
match base {
BuiltinFont::Helvetica => BuiltinVariants {
regular: BuiltinFont::Helvetica,
bold: BuiltinFont::HelveticaBold,
italic: BuiltinFont::HelveticaOblique,
bold_italic: BuiltinFont::HelveticaBoldOblique,
},
BuiltinFont::TimesRoman => BuiltinVariants {
regular: BuiltinFont::TimesRoman,
bold: BuiltinFont::TimesBold,
italic: BuiltinFont::TimesItalic,
bold_italic: BuiltinFont::TimesBoldItalic,
},
BuiltinFont::Courier => BuiltinVariants {
regular: BuiltinFont::Courier,
bold: BuiltinFont::CourierBold,
italic: BuiltinFont::CourierOblique,
bold_italic: BuiltinFont::CourierBoldOblique,
},
_ => BuiltinVariants {
regular: BuiltinFont::Helvetica,
bold: BuiltinFont::HelveticaBold,
italic: BuiltinFont::HelveticaOblique,
bold_italic: BuiltinFont::HelveticaBoldOblique,
},
}
}
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![]
}
}
fn find_system_font(name: &str) -> Option<PathBuf> {
let name_lower = name.to_lowercase();
let patterns = [
format!("{}.ttf", name),
format!("{}.otf", name),
format!("{}.ttf", name.replace(" MS", "")),
];
for dir in system_font_dirs() {
let dir_path = Path::new(dir);
if !dir_path.exists() {
continue;
}
if let Ok(entries) = fs::read_dir(dir_path) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
let file_lower = file_name_str.to_lowercase();
if file_lower.ends_with(".ttc") {
continue;
}
for pattern in &patterns {
if file_lower == pattern.to_lowercase() {
debug!("Found font '{}' at {:?}", name, entry.path());
return Some(entry.path());
}
}
if file_lower.starts_with(&name_lower)
&& (file_lower.ends_with(".ttf") || file_lower.ends_with(".otf"))
{
debug!("Found font '{}' at {:?}", name, entry.path());
return Some(entry.path());
}
}
}
}
None
}
pub fn load_font_family(source: FontSource) -> Result<FontFamily<FontData>, Error> {
load_font_family_impl(source, None)
}
pub fn load_font_family_with_subsetting(
source: FontSource,
text: &str,
) -> Result<FontFamily<FontData>, Error> {
load_font_family_impl(source, Some(text))
}
fn load_font_family_impl(
source: FontSource,
text: Option<&str>,
) -> Result<FontFamily<FontData>, Error> {
match source {
FontSource::Builtin(name) => load_builtin_family(name),
FontSource::System(name) => load_system_family(&name, text),
FontSource::File(path) => load_file_family(&path, text),
FontSource::Bytes(data) => load_bytes_family(data, text),
}
}
fn load_builtin_family(name: &str) -> Result<FontFamily<FontData>, Error> {
let builtin = get_builtin_font(name).ok_or_else(|| {
Error::new(
format!(
"'{}' is not a built-in font. Use Helvetica, Times, or Courier.",
name
),
ErrorKind::InvalidFont,
)
})?;
let variants = get_builtin_variants(builtin);
let metrics_data = load_builtin_metrics()?;
let shared = Arc::new(metrics_data);
let regular = FontData::new_shared(shared.clone(), Some(variants.regular))?;
let bold = FontData::new_shared(shared.clone(), Some(variants.bold))?;
let italic = FontData::new_shared(shared.clone(), Some(variants.italic))?;
let bold_italic = FontData::new_shared(shared, Some(variants.bold_italic))?;
info!("Loaded built-in font family: {}", name);
Ok(FontFamily {
regular,
bold,
italic,
bold_italic,
})
}
#[rustfmt::skip]
const FALLBACK_METRICS_FONT: &[u8] = &[
0x00, 0x01, 0x00, 0x00, 0x00, 0x08, 0x00, 0x80, 0x00, 0x03, 0x00, 0x00,
0x63, 0x6D, 0x61, 0x70, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xF6, 0x00, 0x00, 0x00, 0x24,
0x67, 0x6C, 0x79, 0x66, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xF4, 0x00, 0x00, 0x00, 0x02,
0x68, 0x65, 0x61, 0x64, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x8C, 0x00, 0x00, 0x00, 0x36,
0x68, 0x68, 0x65, 0x61, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xC2, 0x00, 0x00, 0x00, 0x24,
0x68, 0x6D, 0x74, 0x78, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xEC, 0x00, 0x00, 0x00, 0x04,
0x6C, 0x6F, 0x63, 0x61, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x04,
0x6D, 0x61, 0x78, 0x70, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xE6, 0x00, 0x00, 0x00, 0x06,
0x70, 0x6F, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x1A, 0x00, 0x00, 0x00, 0x20,
0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5F, 0x0F, 0x3C, 0xF5, 0x00, 0x0B, 0x03, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xE8, 0x03, 0xE8, 0x00, 0x00, 0x00, 0x08, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0x03, 0x02, 0xFF, 0x1A, 0x00, 0x00, 0x03, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x03, 0xE8, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x50, 0x00, 0x00, 0x01,
0x01, 0xF4, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x00, 0x18, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x01, 0x00, 0x00,
0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x9C, 0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ];
fn load_builtin_metrics() -> Result<Vec<u8>, Error> {
let candidates = [
"Helvetica",
"Arial",
"Liberation Sans",
"DejaVu Sans",
"FreeSans",
];
for name in &candidates {
if let Some(path) = find_system_font(name) {
if let Ok(data) = fs::read(&path) {
debug!("Using {} for built-in font metrics", name);
return Ok(data);
}
}
}
info!(
"No system font found for metrics; using embedded fallback \
(ascent=770, descent=-230, unitsPerEm=1000)"
);
Ok(FALLBACK_METRICS_FONT.to_vec())
}
fn load_system_family(name: &str, text: Option<&str>) -> Result<FontFamily<FontData>, Error> {
let path = find_system_font(name).ok_or_else(|| {
Error::new(
format!(
"Font '{}' not found in system directories: {:?}",
name,
system_font_dirs()
),
ErrorKind::InvalidFont,
)
})?;
load_file_family(&path, text)
}
fn load_file_family(path: &Path, text: Option<&str>) -> Result<FontFamily<FontData>, Error> {
let data = fs::read(path).map_err(|e| {
Error::new(
format!("Failed to read font file {:?}: {}", path, e),
ErrorKind::InvalidFont,
)
})?;
let original_size = data.len();
let shared = Arc::new(data);
let family = if let Some(text) = text {
if text.is_empty() {
create_font_family_from_data(shared)?
} else {
match subset_font_data(&shared, text) {
Ok((subset_data, glyph_map)) => {
let subset_size = subset_data.len();
info!(
"Font subset: {} -> {} ({:.1}% reduction)",
format_bytes(original_size),
format_bytes(subset_size),
(1.0 - subset_size as f64 / original_size as f64) * 100.0
);
create_subset_font_family(shared, Arc::new(subset_data), glyph_map)?
}
Err(e) => {
warn!("Subsetting failed: {}. Using full font.", e);
create_font_family_from_data(shared)?
}
}
}
} else {
create_font_family_from_data(shared)?
};
info!("Loaded font from {:?}", path);
Ok(family)
}
fn load_bytes_family(
data: &'static [u8],
text: Option<&str>,
) -> Result<FontFamily<FontData>, Error> {
let shared = Arc::new(data.to_vec());
let family = if let Some(text) = text {
if text.is_empty() {
create_font_family_from_data(shared)?
} else {
match subset_font_data(&shared, text) {
Ok((subset_data, glyph_map)) => {
create_subset_font_family(shared, Arc::new(subset_data), glyph_map)?
}
Err(e) => {
warn!("Subsetting failed: {}. Using full font.", e);
create_font_family_from_data(shared)?
}
}
}
} else {
create_font_family_from_data(shared)?
};
info!("Loaded font from embedded bytes");
Ok(family)
}
fn create_font_family_from_data(data: Arc<Vec<u8>>) -> Result<FontFamily<FontData>, Error> {
let regular = FontData::new_shared(data.clone(), None)?;
Ok(FontFamily {
regular: regular.clone(),
bold: regular.clone(),
italic: regular.clone(),
bold_italic: regular,
})
}
fn create_subset_font_family(
metrics_data: Arc<Vec<u8>>,
subset_data: Arc<Vec<u8>>,
glyph_map: genpdfi::fonts::GlyphIdMap,
) -> Result<FontFamily<FontData>, Error> {
let regular = FontData::clone_with_data(
&FontData::new_shared(metrics_data, None)?,
subset_data,
Some(glyph_map),
);
Ok(FontFamily {
regular: regular.clone(),
bold: regular.clone(),
italic: regular.clone(),
bold_italic: regular,
})
}
fn subset_font_data(
data: &[u8],
text: &str,
) -> Result<(Vec<u8>, genpdfi::fonts::GlyphIdMap), Error> {
let result = genpdfi::subsetting::subset_font_with_mapping(data, text)?;
Ok((result.data, result.glyph_id_map))
}
fn format_bytes(bytes: usize) -> String {
if bytes >= 1_000_000 {
format!("{:.1}MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1}KB", bytes as f64 / 1_000.0)
} else {
format!("{}B", bytes)
}
}
#[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 enable_subsetting: bool,
}
impl FontConfig {
pub fn new() -> Self {
Self {
default_font: None,
code_font: None,
default_font_source: None,
code_font_source: None,
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 resolve_font_source(name: &str) -> FontSource {
if get_builtin_font(name).is_some() {
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 load_font(
name: &str,
config: Option<&FontConfig>,
text: Option<&str>,
) -> Result<FontFamily<FontData>, Error> {
let source = resolve_font_source(name);
let enable_subsetting = config.map(|c| c.enable_subsetting).unwrap_or(true);
if enable_subsetting && text.is_some() {
load_font_family_with_subsetting(source, text.unwrap())
} else {
load_font_family(source)
}
}
pub fn load_builtin_font_family(name: &str) -> Result<FontFamily<FontData>, Error> {
load_font_family(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",
}))
}
pub fn load_font_with_config(
name: &str,
config: Option<&FontConfig>,
text: Option<&str>,
) -> Result<FontFamily<FontData>, Error> {
load_font(name, config, text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_builtin() {
assert!(matches!(
resolve_font_source("Helvetica"),
FontSource::Builtin(_)
));
assert!(matches!(
resolve_font_source("Times"),
FontSource::Builtin(_)
));
assert!(matches!(
resolve_font_source("Courier"),
FontSource::Builtin(_)
));
}
#[test]
fn test_resolve_file() {
assert!(matches!(
resolve_font_source("/path/to/font.ttf"),
FontSource::File(_)
));
assert!(matches!(
resolve_font_source("font.ttf"),
FontSource::File(_)
));
}
#[test]
fn test_resolve_system() {
assert!(matches!(
resolve_font_source("Georgia"),
FontSource::System(_)
));
assert!(matches!(
resolve_font_source("Arial Unicode MS"),
FontSource::System(_)
));
}
#[test]
fn test_system_font_dirs() {
let dirs = system_font_dirs();
assert!(!dirs.is_empty());
}
#[test]
fn test_resolve_bytes() {
let data: &'static [u8] = b"not a real font";
let source = FontSource::bytes(data);
assert!(matches!(source, FontSource::Bytes(_)));
assert!(load_font_family(source).is_err());
}
#[test]
fn test_fallback_metrics_font_is_valid() {
let data = FALLBACK_METRICS_FONT.to_vec();
let shared = std::sync::Arc::new(data);
let result = FontData::new_shared(shared, Some(BuiltinFont::Helvetica));
assert!(result.is_ok(), "Fallback font should be parseable by rusttype");
}
#[test]
fn test_load_builtin_metrics_always_succeeds() {
let result = load_builtin_metrics();
assert!(result.is_ok(), "load_builtin_metrics should always succeed");
assert!(!result.unwrap().is_empty());
}
#[test]
fn test_fallback_font_produces_valid_pdf() {
let metrics = FALLBACK_METRICS_FONT.to_vec();
let shared = Arc::new(metrics);
let regular = FontData::new_shared(shared.clone(), Some(BuiltinFont::Helvetica)).unwrap();
let bold = FontData::new_shared(shared.clone(), Some(BuiltinFont::HelveticaBold)).unwrap();
let italic =
FontData::new_shared(shared.clone(), Some(BuiltinFont::HelveticaOblique)).unwrap();
let bold_italic =
FontData::new_shared(shared, Some(BuiltinFont::HelveticaBoldOblique)).unwrap();
let family = FontFamily {
regular,
bold,
italic,
bold_italic,
};
let mut doc = genpdfi::Document::new(family);
let mut decorator = genpdfi::SimplePageDecorator::new();
decorator.set_margins(genpdfi::Margins::trbl(10, 10, 10, 10));
doc.set_page_decorator(decorator);
doc.set_font_size(12);
let mut para = genpdfi::elements::Paragraph::default();
para.push_styled("Normal, ", genpdfi::style::Style::new());
para.push_styled("bold, ", genpdfi::style::Style::new().bold());
para.push_styled("italic, ", genpdfi::style::Style::new().italic());
para.push_styled("bold-italic.", genpdfi::style::Style::new().bold().italic());
doc.push(para);
let mut buf = std::io::Cursor::new(Vec::new());
doc.render(&mut buf).expect("PDF rendering should succeed");
let pdf_bytes = buf.into_inner();
assert!(pdf_bytes.starts_with(b"%PDF-"), "Output must be a valid PDF");
assert!(pdf_bytes.len() > 100, "PDF should have meaningful content");
}
#[test]
fn test_fallback_all_builtin_variants() {
let metrics = FALLBACK_METRICS_FONT.to_vec();
let shared = Arc::new(metrics);
for (name, regular, bold, italic, bold_italic) in [
(
"Helvetica",
BuiltinFont::Helvetica,
BuiltinFont::HelveticaBold,
BuiltinFont::HelveticaOblique,
BuiltinFont::HelveticaBoldOblique,
),
(
"Times",
BuiltinFont::TimesRoman,
BuiltinFont::TimesBold,
BuiltinFont::TimesItalic,
BuiltinFont::TimesBoldItalic,
),
(
"Courier",
BuiltinFont::Courier,
BuiltinFont::CourierBold,
BuiltinFont::CourierOblique,
BuiltinFont::CourierBoldOblique,
),
] {
FontData::new_shared(shared.clone(), Some(regular))
.unwrap_or_else(|e| panic!("{} regular failed: {}", name, e));
FontData::new_shared(shared.clone(), Some(bold))
.unwrap_or_else(|e| panic!("{} bold failed: {}", name, e));
FontData::new_shared(shared.clone(), Some(italic))
.unwrap_or_else(|e| panic!("{} italic failed: {}", name, e));
FontData::new_shared(shared.clone(), Some(bold_italic))
.unwrap_or_else(|e| panic!("{} bold_italic failed: {}", name, e));
}
}
}