use crate::error::{Result, XfaError};
use lopdf::ObjectId;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdfSourceFont {
pub object_id: ObjectId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedFontData {
pub name: String,
pub data: Vec<u8>,
pub pdf_widths: Option<(u16, Vec<u16>)>,
pub pdf_encoding: Option<PdfSimpleEncoding>,
pub pdf_source_font: Option<PdfSourceFont>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PdfBaseEncoding {
WinAnsi,
Standard,
MacRoman,
}
impl PdfBaseEncoding {
pub fn from_pdf_name(name: &[u8]) -> Option<Self> {
match name {
b"WinAnsiEncoding" => Some(Self::WinAnsi),
b"StandardEncoding" => Some(Self::Standard),
b"MacRomanEncoding" => Some(Self::MacRoman),
_ => None,
}
}
fn code_to_unicode_table(self) -> &'static [Option<u16>; 256] {
match self {
Self::WinAnsi => &base_encoding_tables().win_ansi,
Self::Standard => &base_encoding_tables().standard,
Self::MacRoman => &base_encoding_tables().mac_roman,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PdfSimpleEncoding {
pub base_encoding: PdfBaseEncoding,
pub differences: Vec<(u8, u16)>,
}
impl PdfSimpleEncoding {
pub fn code_to_unicode_table(&self) -> Vec<Option<u16>> {
let mut table = self.base_encoding.code_to_unicode_table().to_vec();
for (code, unicode) in &self.differences {
table[*code as usize] = Some(*unicode);
}
table
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PdfWidthData {
widths: (u16, Vec<u16>),
encoding: Option<PdfSimpleEncoding>,
source_font: Option<PdfSourceFont>,
}
#[derive(Debug)]
struct PdfBaseEncodingTables {
win_ansi: [Option<u16>; 256],
standard: [Option<u16>; 256],
mac_roman: [Option<u16>; 256],
}
#[derive(Debug, Clone)]
pub struct ResolvedFont {
pub name: String,
pub data: Vec<u8>,
pub face_index: u32,
pub units_per_em: u16,
pub ascender: i16,
pub descender: i16,
pub pdf_widths: Option<(u16, Vec<u16>)>,
pub pdf_encoding: Option<PdfSimpleEncoding>,
pub pdf_source_font: Option<PdfSourceFont>,
}
impl ResolvedFont {
pub fn measure_string(&self, text: &str, font_size: f64) -> f64 {
if let Some(widths) = self.pdf_unicode_widths() {
let mut total = 0.0;
for ch in text.chars() {
let code = ch as usize;
if code < widths.len() && widths[code] != 0 {
total += widths[code] as f64;
} else {
total += self.measure_char_fallback(ch);
}
}
return total * font_size / 1000.0;
}
if let Ok(face) = ttf_parser::Face::parse(&self.data, self.face_index) {
let upem = face.units_per_em() as f64;
let scale = font_size / upem;
let mut width = 0.0;
for ch in text.chars() {
if let Some(gid) = face.glyph_index(ch) {
width += face.glyph_hor_advance(gid).unwrap_or(0) as f64 * scale;
} else {
width += font_size * 0.5;
}
}
width
} else {
text.len() as f64 * font_size * 0.5
}
}
fn measure_char_fallback(&self, ch: char) -> f64 {
if let Ok(face) = ttf_parser::Face::parse(&self.data, self.face_index) {
if let Some(gid) = face.glyph_index(ch) {
let upem = face.units_per_em() as f64;
let scale = 1.0 / upem;
face.glyph_hor_advance(gid).unwrap_or(0) as f64 * scale * 1000.0
} else {
500.0
}
} else {
500.0
}
}
pub fn line_height(&self, font_size: f64) -> f64 {
let upem = self.units_per_em as f64;
if upem > 0.0 {
(self.ascender as f64 - self.descender as f64) / upem * font_size
} else {
font_size * 1.2
}
}
pub fn ascender_pt(&self, font_size: f64) -> f64 {
let upem = self.units_per_em as f64;
if upem > 0.0 {
self.ascender as f64 / upem * font_size
} else {
font_size * 0.8
}
}
pub fn descender_pt(&self, font_size: f64) -> f64 {
let upem = self.units_per_em as f64;
if upem > 0.0 {
self.descender as f64 / upem * font_size
} else {
font_size * -0.2
}
}
pub fn pdf_glyph_widths(&self) -> (u16, Vec<u16>) {
if let Some(widths) = self.pdf_unicode_widths() {
return (0, widths);
}
if let Ok(face) = ttf_parser::Face::parse(&self.data, self.face_index) {
let upem = face.units_per_em() as f64;
let scale = 1000.0 / upem;
let mut widths = Vec::with_capacity(256);
for code in 0u16..256 {
let w = if let Some(gid) = face.glyph_index(char::from(code as u8)) {
(face.glyph_hor_advance(gid).unwrap_or(0) as f64 * scale) as u16
} else {
0
};
widths.push(w);
}
(0, widths)
} else {
(0, vec![500; 256])
}
}
fn pdf_unicode_widths(&self) -> Option<Vec<u16>> {
let (first_char, widths) = self.pdf_widths.as_ref()?;
Some(unicode_widths_from_pdf_widths(
*first_char,
widths,
self.pdf_encoding.as_ref(),
))
}
pub fn cid_font_info(&self) -> Option<CidFontInfo> {
let face = ttf_parser::Face::parse(&self.data, self.face_index).ok()?;
let upem = face.units_per_em() as f64;
let scale = 1000.0 / upem;
let num_glyphs = face.number_of_glyphs();
let mut widths = Vec::with_capacity(num_glyphs as usize);
for gid_val in 0..num_glyphs {
let w = face
.glyph_hor_advance(ttf_parser::GlyphId(gid_val))
.map(|a| (a as f64 * scale) as u16)
.unwrap_or(0);
widths.push(w);
}
let mut gid_to_unicode = Vec::new();
for cp in 0x0020u32..=0xFFFDu32 {
if let Some(ch) = char::from_u32(cp) {
if let Some(gid) = face.glyph_index(ch) {
gid_to_unicode.push((gid.0, ch));
}
}
}
Some(CidFontInfo {
widths,
gid_to_unicode,
})
}
}
pub struct CidFontInfo {
pub widths: Vec<u16>,
pub gid_to_unicode: Vec<(u16, char)>,
}
pub fn font_variant_key(typeface: &str, weight: Option<&str>, posture: Option<&str>) -> String {
let w = match weight {
Some("bold") => "_Bold",
_ => "_Normal",
};
let p = match posture {
Some("italic") => "_Italic",
_ => "_Normal",
};
format!("{}{}{}", typeface, w, p)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenericFamily {
Serif,
SansSerif,
Monospaced,
Decorative,
Fantasy,
Cursive,
}
impl GenericFamily {
pub fn parse(s: &str) -> Option<Self> {
match s {
"serif" => Some(Self::Serif),
"sansSerif" => Some(Self::SansSerif),
"monospaced" => Some(Self::Monospaced),
"decorative" => Some(Self::Decorative),
"fantasy" => Some(Self::Fantasy),
"cursive" => Some(Self::Cursive),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct XfaFontSpec {
pub typeface: String,
pub weight: FontWeight,
pub posture: FontPosture,
pub size_pt: f64,
pub generic_family: Option<GenericFamily>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontWeight {
Normal,
Bold,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontPosture {
Normal,
Italic,
}
impl XfaFontSpec {
pub fn from_xfa_attrs(
typeface: &str,
weight: Option<&str>,
posture: Option<&str>,
size: Option<&str>,
generic_family: Option<&str>,
) -> Self {
Self {
typeface: typeface.to_string(),
weight: match weight {
Some("bold") => FontWeight::Bold,
_ => FontWeight::Normal,
},
posture: match posture {
Some("italic") => FontPosture::Italic,
_ => FontPosture::Normal,
},
size_pt: size
.and_then(|s| s.strip_suffix("pt").or(Some(s)))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(10.0),
generic_family: generic_family.and_then(GenericFamily::parse),
}
}
}
pub struct XfaFontResolver {
embedded: HashMap<String, ResolvedFont>,
embedded_pdf_widths: HashMap<String, PdfWidthData>,
system_fonts: HashMap<String, PathBuf>,
cache: HashMap<String, ResolvedFont>,
font_cache_fonts: HashMap<String, PathBuf>,
}
fn normalize_font_name(name: &str) -> String {
let stripped = if name.len() > 7 && name.as_bytes()[6] == b'+' {
let prefix = &name[..6];
if prefix.chars().all(|c| c.is_ascii_uppercase()) {
&name[7..]
} else {
name
}
} else {
name
};
let stripped = stripped
.strip_suffix("PSMT")
.or_else(|| stripped.strip_suffix("MT"))
.unwrap_or(stripped);
stripped.to_lowercase()
}
fn canonical_font_key(name: &str) -> Option<String> {
let normalized = normalize_font_name(name);
let canonical: String = normalized
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
if canonical.is_empty() {
None
} else {
Some(canonical)
}
}
fn font_family_aliases(name: &str) -> &'static [&'static str] {
match name {
"arial" | "arialmt" => &["liberationsans", "arimo", "freesans"],
"times new roman" | "timesnewroman" | "timesnewromanpsmt" => {
&["liberationserif", "tinos", "freeserif"]
}
"courier new" | "couriernew" | "couriernewpsmt" => {
&["liberationmono", "cousine", "freemono"]
}
"helvetica" => &["liberationsans", "arimo", "arial"],
"myriad pro" | "myriadpro" => &["liberationsans", "arimo", "dejavusans"],
"liberationsans" | "liberation sans" => &["arial", "arimo", "freesans", "helvetica"],
"liberationserif" | "liberation serif" => &["times new roman", "tinos", "freeserif"],
"liberationmono" | "liberation mono" => &["courier new", "cousine", "freemono"],
_ => &[],
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FontFamily {
Serif,
SansSerif,
Monospace,
Unknown,
}
fn classify_font_family(name: &str) -> FontFamily {
let lower = name.to_lowercase();
if lower.contains("mono")
|| lower.contains("courier")
|| lower.contains("consolas")
|| lower.contains("menlo")
|| lower.contains("fixed")
|| lower.contains("code")
{
return FontFamily::Monospace;
}
if lower.contains("serif") && !lower.contains("sans") {
return FontFamily::Serif;
}
if lower.contains("times")
|| lower.contains("garamond")
|| lower.contains("georgia")
|| lower.contains("palatino")
|| lower.contains("bodoni")
|| lower.contains("cambria")
|| lower.contains("tinos")
{
return FontFamily::Serif;
}
if lower.contains("sans")
|| lower.contains("arial")
|| lower.contains("helvetica")
|| lower.contains("verdana")
|| lower.contains("tahoma")
|| lower.contains("calibri")
|| lower.contains("arimo")
|| lower.contains("myriad")
|| lower.contains("segoe")
{
return FontFamily::SansSerif;
}
FontFamily::Unknown
}
fn family_fallback_chain(family: FontFamily) -> &'static [&'static str] {
match family {
FontFamily::SansSerif | FontFamily::Unknown => &[
"liberationsans",
"arimo",
"dejavusans",
"freesans",
"helvetica",
"arial",
],
FontFamily::Serif => &["liberationserif", "tinos", "dejavuserif", "freeserif"],
FontFamily::Monospace => &["liberationmono", "cousine", "dejavusansmono", "freemono"],
}
}
fn font_name_substring_match(requested: &str, available: &str) -> bool {
let requested_lower = requested.to_lowercase();
let available_lower = available.to_lowercase();
if requested_lower == available_lower {
return true;
}
if requested_lower.contains(&available_lower) || available_lower.contains(&requested_lower) {
return true;
}
let requested_stripped = normalize_font_name(requested);
let available_stripped = normalize_font_name(available);
if requested_stripped == available_stripped {
return true;
}
if requested_stripped.contains(&available_stripped)
|| available_stripped.contains(&requested_stripped)
{
return true;
}
false
}
fn generic_family_fallback_chain(gf: GenericFamily) -> &'static [&'static str] {
match gf {
GenericFamily::Serif | GenericFamily::Decorative => &[
"liberationserif",
"tinos",
"dejavuserif",
"freeserif",
"times new roman",
"times",
],
GenericFamily::SansSerif | GenericFamily::Fantasy => &[
"liberationsans",
"arimo",
"dejavusans",
"freesans",
"helvetica",
"arial",
],
GenericFamily::Monospaced => &[
"liberationmono",
"cousine",
"dejavusansmono",
"freemono",
"courier new",
"courier",
],
GenericFamily::Cursive => {
&[
"liberationserif",
"tinos",
"dejavuserif",
"freeserif",
"times new roman",
]
}
}
}
impl XfaFontResolver {
pub fn new(embedded_fonts: Vec<EmbeddedFontData>) -> Self {
let mut embedded = HashMap::new();
let mut embedded_pdf_widths = HashMap::new();
for font_data in embedded_fonts {
let EmbeddedFontData {
name,
data,
pdf_widths,
pdf_encoding,
pdf_source_font,
} = font_data;
if let Some(ref widths) = pdf_widths {
remember_pdf_widths(
&mut embedded_pdf_widths,
&name,
widths,
pdf_encoding.clone(),
pdf_source_font,
);
}
if let Some(font) = parse_font_data_with_widths(
&name,
&data,
pdf_widths.clone(),
pdf_encoding.clone(),
pdf_source_font,
)
.or_else(|| build_pdf_only_font(&name, pdf_widths, pdf_encoding, pdf_source_font))
{
let normalized = normalize_font_name(&name);
embedded.insert(name.to_lowercase(), font.clone());
if normalized != name.to_lowercase() {
embedded.insert(normalized, font);
}
}
}
let system_fonts = scan_system_fonts();
let font_cache_fonts = scan_font_cache_dir();
Self {
embedded,
embedded_pdf_widths,
system_fonts,
cache: HashMap::new(),
font_cache_fonts,
}
}
pub fn with_font_cache(mut self, dir: &std::path::Path) -> Self {
self.font_cache_fonts = scan_font_dir(dir);
self
}
pub fn resolve(&mut self, spec: &XfaFontSpec) -> Result<ResolvedFont> {
let cache_key = format!("{}_{:?}_{:?}", spec.typeface, spec.weight, spec.posture);
if let Some(cached) = self.cache.get(&cache_key) {
return Ok(cached.clone());
}
let normalized = normalize_font_name(&spec.typeface);
let variant_names = build_variant_names(&spec.typeface, spec.weight, spec.posture);
let font = variant_names
.iter()
.find_map(|vn| {
self.try_embedded(vn)
.or_else(|| self.try_font_cache(vn))
.or_else(|| self.try_system(vn))
.or_else(|| {
let norm = normalize_font_name(vn);
self.try_embedded(&norm)
.or_else(|| self.try_font_cache(&norm))
.or_else(|| self.try_system(&norm))
})
})
.or_else(|| self.try_embedded(&spec.typeface))
.or_else(|| self.try_embedded(&normalized))
.or_else(|| self.try_font_cache(&spec.typeface))
.or_else(|| self.try_font_cache(&normalized))
.or_else(|| self.try_system(&spec.typeface))
.or_else(|| self.try_system(&normalized))
.or_else(|| self.try_base_name(&spec.typeface))
.or_else(|| self.try_aliases(&spec.typeface))
.or_else(|| self.try_substring_match(&spec.typeface))
.or_else(|| self.try_generic_family_fallback(spec.generic_family))
.or_else(|| self.try_family_fallback(&spec.typeface))
.or_else(|| self.try_fallbacks())
.ok_or_else(|| {
XfaError::FontError(format!("cannot resolve font: {}", spec.typeface))
})?;
let mut font = self.attach_pdf_widths(font, spec, &variant_names);
if font.data.is_empty() && font.pdf_source_font.is_some() {
let sys = self
.try_system(&font.name)
.or_else(|| self.try_system(&spec.typeface))
.or_else(|| self.try_aliases(&spec.typeface));
if let Some(sys_font) = sys {
font.data = sys_font.data;
font.face_index = sys_font.face_index;
}
}
self.cache.insert(cache_key, font.clone());
Ok(font)
}
fn try_embedded(&self, name: &str) -> Option<ResolvedFont> {
self.embedded.get(&name.to_lowercase()).cloned()
}
fn try_font_cache(&self, name: &str) -> Option<ResolvedFont> {
let path = self.font_cache_fonts.get(&name.to_lowercase())?;
load_system_font(path, name)
}
fn try_system(&self, name: &str) -> Option<ResolvedFont> {
let path = self.system_fonts.get(&name.to_lowercase())?;
load_system_font(path, name)
}
fn try_base_name(&self, name: &str) -> Option<ResolvedFont> {
let base = name
.replace("-Bold", "")
.replace("-Italic", "")
.replace("-BoldItalic", "")
.replace(",Bold", "")
.replace(",Italic", "");
if base != name {
let normalized_base = normalize_font_name(&base);
self.try_embedded(&base)
.or_else(|| self.try_embedded(&normalized_base))
.or_else(|| self.try_system(&base))
.or_else(|| self.try_system(&normalized_base))
} else {
None
}
}
fn try_aliases(&self, name: &str) -> Option<ResolvedFont> {
let normalized = normalize_font_name(name);
for lookup in &[name.to_lowercase(), normalized] {
let no_spaces = lookup.replace(' ', "");
for candidate in [lookup.as_str(), no_spaces.as_str()] {
let aliases = font_family_aliases(candidate);
for alias in aliases {
if let Some(font) = self.try_system(alias) {
return Some(font);
}
}
}
}
None
}
fn try_substring_match(&self, name: &str) -> Option<ResolvedFont> {
let name_lower = name.to_lowercase();
let name_normalized = normalize_font_name(name);
for (available_key, font) in &self.embedded {
if font_name_substring_match(&name_lower, available_key)
|| font_name_substring_match(&name_normalized, available_key)
{
return Some(font.clone());
}
}
for (available_key, path) in &self.system_fonts {
if font_name_substring_match(&name_lower, available_key)
|| font_name_substring_match(&name_normalized, available_key)
{
if let Some(font) = load_system_font(path, available_key) {
return Some(font);
}
}
}
None
}
fn try_generic_family_fallback(&self, gf: Option<GenericFamily>) -> Option<ResolvedFont> {
let gf = gf?;
let chain = generic_family_fallback_chain(gf);
for candidate in chain {
if let Some(font) = self.try_system(candidate) {
return Some(font);
}
}
None
}
fn try_family_fallback(&self, name: &str) -> Option<ResolvedFont> {
let family = classify_font_family(name);
let chain = family_fallback_chain(family);
for candidate in chain {
if let Some(font) = self.try_system(candidate) {
return Some(font);
}
}
None
}
fn try_fallbacks(&self) -> Option<ResolvedFont> {
#[cfg(target_os = "macos")]
let fallback_chain = ["Arial", "Helvetica.ttc", "DejaVuSans", "LiberationSans"];
#[cfg(not(target_os = "macos"))]
let fallback_chain = [
"LiberationSans",
"DejaVuSans",
"Arial",
"Helvetica",
"FreeSans",
];
for name in &fallback_chain {
if let Some(font) = self.try_system(name) {
return Some(font);
}
}
None
}
fn attach_pdf_widths(
&self,
mut font: ResolvedFont,
spec: &XfaFontSpec,
variant_names: &[String],
) -> ResolvedFont {
if font.pdf_widths.is_some() && font.pdf_source_font.is_some() {
return font;
}
let mut matched = false;
for name in variant_names
.iter()
.map(String::as_str)
.chain([spec.typeface.as_str(), font.name.as_str()])
{
if let Some(width_data) = lookup_pdf_widths(&self.embedded_pdf_widths, name) {
if font.pdf_widths.is_none() {
font.pdf_widths = Some(width_data.widths);
}
if font.pdf_encoding.is_none() {
font.pdf_encoding = width_data.encoding;
}
if font.pdf_source_font.is_none() {
font.pdf_source_font = width_data.source_font;
}
matched = true;
break;
}
}
if !matched
&& font.pdf_widths.is_none()
&& !self.embedded_pdf_widths.is_empty()
&& std::env::var("XFA_FONT_BRIDGE_DEBUG").is_ok()
{
eprintln!(
"attach_pdf_widths: no /Widths match for typeface '{}' (resolved='{}', variants={:?}); available keys (first 20): {:?}",
spec.typeface,
font.name,
variant_names,
self.embedded_pdf_widths
.keys()
.take(20)
.collect::<Vec<_>>(),
);
}
font
}
}
fn remember_pdf_widths(
widths_map: &mut HashMap<String, PdfWidthData>,
name: &str,
widths: &(u16, Vec<u16>),
encoding: Option<PdfSimpleEncoding>,
source_font: Option<PdfSourceFont>,
) {
let record = PdfWidthData {
widths: widths.clone(),
encoding,
source_font,
};
let lower = name.to_lowercase();
widths_map.insert(lower.clone(), record.clone());
let normalized = normalize_font_name(name);
if normalized != lower {
widths_map.insert(normalized.clone(), record.clone());
}
let no_spaces = lower.replace(' ', "");
if no_spaces != lower {
widths_map.insert(no_spaces, record.clone());
}
if let Some(canonical) = canonical_font_key(name) {
widths_map.entry(canonical).or_insert(record);
}
}
fn lookup_pdf_widths(
widths_map: &HashMap<String, PdfWidthData>,
name: &str,
) -> Option<PdfWidthData> {
let lower = name.to_lowercase();
if let Some(hit) = widths_map.get(&lower) {
return Some(hit.clone());
}
let normalized = normalize_font_name(name);
if let Some(hit) = widths_map.get(&normalized) {
return Some(hit.clone());
}
let no_spaces = lower.replace(' ', "");
if let Some(hit) = widths_map.get(&no_spaces) {
return Some(hit.clone());
}
if let Some(canonical) = canonical_font_key(name) {
if let Some(hit) = widths_map.get(&canonical) {
return Some(hit.clone());
}
let mut best: Option<(&String, &PdfWidthData)> = None;
for (key, data) in widths_map {
if (key.contains(&canonical) || canonical.contains(key.as_str()))
&& best.is_none_or(|(b, _)| key.len() > b.len())
{
best = Some((key, data));
}
}
if let Some((_, data)) = best {
return Some(data.clone());
}
}
None
}
fn build_variant_names(typeface: &str, weight: FontWeight, posture: FontPosture) -> Vec<String> {
let suffix = match (weight, posture) {
(FontWeight::Bold, FontPosture::Italic) => "BoldItalic",
(FontWeight::Bold, FontPosture::Normal) => "Bold",
(FontWeight::Normal, FontPosture::Italic) => "Italic",
(FontWeight::Normal, FontPosture::Normal) => return Vec::new(),
};
let mut names = Vec::with_capacity(6);
names.push(format!("{}-{}", typeface, suffix));
names.push(format!("{}{}", typeface, suffix));
names.push(format!("{} {}", typeface, suffix));
names.push(format!("{},{}", typeface, suffix));
if weight == FontWeight::Bold && posture == FontPosture::Italic {
names.push(format!("{}-Bold Italic", typeface));
names.push(format!("{} Bold Italic", typeface));
}
names
}
fn _parse_font_data(name: &str, data: &[u8]) -> Option<ResolvedFont> {
let face = ttf_parser::Face::parse(data, 0).ok()?;
Some(ResolvedFont {
name: name.to_string(),
data: data.to_vec(),
face_index: 0,
units_per_em: face.units_per_em(),
ascender: face.ascender(),
descender: face.descender(),
pdf_widths: None,
pdf_encoding: None,
pdf_source_font: None,
})
}
fn parse_font_data_with_widths(
name: &str,
data: &[u8],
pdf_widths: Option<(u16, Vec<u16>)>,
pdf_encoding: Option<PdfSimpleEncoding>,
pdf_source_font: Option<PdfSourceFont>,
) -> Option<ResolvedFont> {
let face = ttf_parser::Face::parse(data, 0).ok()?;
Some(ResolvedFont {
name: name.to_string(),
data: data.to_vec(),
face_index: 0,
units_per_em: face.units_per_em(),
ascender: face.ascender(),
descender: face.descender(),
pdf_widths,
pdf_encoding,
pdf_source_font,
})
}
fn build_pdf_only_font(
name: &str,
pdf_widths: Option<(u16, Vec<u16>)>,
pdf_encoding: Option<PdfSimpleEncoding>,
pdf_source_font: Option<PdfSourceFont>,
) -> Option<ResolvedFont> {
let pdf_widths = pdf_widths?;
let pdf_source_font = pdf_source_font?;
Some(ResolvedFont {
name: name.to_string(),
data: Vec::new(),
face_index: 0,
units_per_em: 1000,
ascender: 800,
descender: -200,
pdf_widths: Some(pdf_widths),
pdf_encoding,
pdf_source_font: Some(pdf_source_font),
})
}
fn load_system_font(path: &PathBuf, name: &str) -> Option<ResolvedFont> {
let data = std::fs::read(path).ok()?;
let num_fonts = ttf_parser::fonts_in_collection(&data).unwrap_or(1);
for idx in 0..num_fonts {
if let Ok(face) = ttf_parser::Face::parse(&data, idx) {
let name_id_matches = |name_id: u16| {
name_id == ttf_parser::name_id::FULL_NAME
|| name_id == ttf_parser::name_id::POST_SCRIPT_NAME
|| name_id == ttf_parser::name_id::FAMILY
};
let matches = face.names().into_iter().any(|n| {
name_id_matches(n.name_id)
&& n.to_string().is_some_and(|s| s.eq_ignore_ascii_case(name))
});
if matches || idx == 0 {
return Some(ResolvedFont {
name: name.to_string(),
data: data.clone(),
face_index: idx,
units_per_em: face.units_per_em(),
ascender: face.ascender(),
descender: face.descender(),
pdf_widths: None,
pdf_encoding: None,
pdf_source_font: None,
});
}
}
}
None
}
fn unicode_widths_from_pdf_widths(
first_char: u16,
widths: &[u16],
encoding: Option<&PdfSimpleEncoding>,
) -> Vec<u16> {
let code_to_unicode = encoding
.map(PdfSimpleEncoding::code_to_unicode_table)
.unwrap_or_else(|| PdfBaseEncoding::WinAnsi.code_to_unicode_table().to_vec());
let mut max_codepoint = 255usize;
for (offset, _) in widths.iter().enumerate() {
let code = first_char as usize + offset;
if code >= 256 {
break;
}
if let Some(unicode) = code_to_unicode[code] {
max_codepoint = max_codepoint.max(unicode as usize);
}
}
let mut projected = vec![0u16; max_codepoint + 1];
for (offset, &width) in widths.iter().enumerate() {
let code = first_char as usize + offset;
if code >= 256 {
break;
}
if let Some(unicode) = code_to_unicode[code] {
projected[unicode as usize] = width;
}
}
projected
}
pub(crate) fn pdf_glyph_name_to_unicode(name: &str) -> Option<u16> {
if let Some(base) = name.split('.').next() {
if base != name {
return pdf_glyph_name_to_unicode(base);
}
}
if let Some(cp) = glyph_name_map().get(name) {
return Some(*cp);
}
if let Some(hex) = name.strip_prefix("uni") {
if hex.len() == 4 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return u16::from_str_radix(hex, 16).ok();
}
}
if let Some(hex) = name.strip_prefix('u') {
if (4..=6).contains(&hex.len()) && hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
let codepoint = u32::from_str_radix(hex, 16).ok()?;
return u16::try_from(codepoint).ok();
}
}
None
}
fn glyph_name_map() -> &'static HashMap<&'static str, u16> {
static MAP: OnceLock<HashMap<&'static str, u16>> = OnceLock::new();
MAP.get_or_init(|| {
let mut map = HashMap::new();
for line in include_str!("encodings/glyphnames.rs").lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("pub const ") else {
continue;
};
let Some((name, hex)) = rest.split_once(": u16 = 0x") else {
continue;
};
let Some(hex) = hex.strip_suffix(';') else {
continue;
};
if let Ok(codepoint) = u16::from_str_radix(hex, 16) {
map.insert(name, codepoint);
}
}
map
})
}
fn base_encoding_tables() -> &'static PdfBaseEncodingTables {
static TABLES: OnceLock<PdfBaseEncodingTables> = OnceLock::new();
TABLES.get_or_init(|| PdfBaseEncodingTables {
win_ansi: parse_base_encoding_table("WIN_ANSI_ENCODING"),
standard: parse_base_encoding_table("STANDARD_ENCODING"),
mac_roman: parse_base_encoding_table("MAC_ROMAN_ENCODING"),
})
}
fn parse_base_encoding_table(const_name: &str) -> [Option<u16>; 256] {
let src = include_str!("encodings/mappings.rs");
let marker = format!("pub const {const_name}: CodedCharacterSet = [");
let start = src
.find(&marker)
.unwrap_or_else(|| panic!("missing {const_name} in lopdf mappings"));
let body = &src[start + marker.len()..];
let end = body
.find("];")
.unwrap_or_else(|| panic!("unterminated {const_name} in lopdf mappings"));
let mut entries = Vec::with_capacity(256);
for line in body[..end].lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line == "None," {
entries.push(None);
continue;
}
if let Some(name) = line
.strip_prefix("Some(Glyph::")
.and_then(|rest| rest.strip_suffix("),"))
{
entries.push(pdf_glyph_name_to_unicode(name));
}
}
let entry_count = entries.len();
entries
.try_into()
.unwrap_or_else(|_| panic!("expected 256 entries in {const_name}, got {entry_count}"))
}
fn scan_system_fonts() -> HashMap<String, PathBuf> {
let mut fonts = HashMap::new();
let mut font_files = Vec::new();
for dir in system_font_dirs() {
collect_font_files(&dir, &mut font_files, 0);
}
for path in &font_files {
if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
fonts.insert(name.to_lowercase(), path.clone());
}
if let Ok(data) = std::fs::read(path) {
let num_faces = ttf_parser::fonts_in_collection(&data).unwrap_or(1);
for face_idx in 0..num_faces {
if let Ok(face) = ttf_parser::Face::parse(&data, face_idx) {
for name_record in face.names() {
let dominated = matches!(
name_record.name_id,
ttf_parser::name_id::FAMILY
| ttf_parser::name_id::FULL_NAME
| ttf_parser::name_id::POST_SCRIPT_NAME
);
if dominated {
if let Some(s) = name_record.to_string() {
let key = s.to_lowercase();
fonts.entry(key).or_insert_with(|| path.clone());
let no_spaces = s.replace(' ', "").to_lowercase();
if no_spaces != s.to_lowercase() {
fonts.entry(no_spaces).or_insert_with(|| path.clone());
}
}
}
}
}
}
}
}
fonts
}
fn collect_font_files(dir: &std::path::Path, out: &mut Vec<PathBuf>, depth: u32) {
if depth > 3 {
return;
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_font_files(&path, out, depth + 1);
} else {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
out.push(path);
}
}
}
}
}
fn system_font_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/System/Library/Fonts"));
dirs.push(PathBuf::from("/Library/Fonts"));
if let Ok(home) = std::env::var("HOME") {
dirs.push(PathBuf::from(format!("{home}/Library/Fonts")));
}
}
#[cfg(target_os = "linux")]
{
dirs.push(PathBuf::from("/usr/share/fonts"));
dirs.push(PathBuf::from("/usr/local/share/fonts"));
if let Ok(home) = std::env::var("HOME") {
dirs.push(PathBuf::from(format!("{home}/.local/share/fonts")));
dirs.push(PathBuf::from(format!("{home}/.fonts")));
}
}
#[cfg(target_os = "windows")]
{
if let Ok(windir) = std::env::var("WINDIR") {
dirs.push(PathBuf::from(format!("{windir}\\Fonts")));
}
if let Ok(local) = std::env::var("LOCALAPPDATA") {
dirs.push(PathBuf::from(format!("{local}\\Microsoft\\Windows\\Fonts")));
}
}
dirs
}
fn scan_font_dir(dir: &std::path::Path) -> HashMap<String, PathBuf> {
let mut fonts = HashMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return fonts,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
continue;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc" | "cff") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
fonts
.entry(stem.to_lowercase())
.or_insert_with(|| path.clone());
}
if let Ok(data) = std::fs::read(&path) {
let num_faces = ttf_parser::fonts_in_collection(&data).unwrap_or(1);
for face_idx in 0..num_faces {
if let Ok(face) = ttf_parser::Face::parse(&data, face_idx) {
for name_record in face.names() {
let dominated = matches!(
name_record.name_id,
ttf_parser::name_id::FAMILY
| ttf_parser::name_id::FULL_NAME
| ttf_parser::name_id::POST_SCRIPT_NAME
);
if dominated {
if let Some(s) = name_record.to_string() {
let key = s.to_lowercase();
fonts.entry(key).or_insert_with(|| path.clone());
let no_spaces = s.replace(' ', "").to_lowercase();
if no_spaces != s.to_lowercase() {
fonts.entry(no_spaces).or_insert_with(|| path.clone());
}
}
}
}
}
}
}
}
fonts
}
fn scan_font_cache_dir() -> HashMap<String, PathBuf> {
match std::env::var("XFA_FONT_CACHE") {
Ok(dir) => {
let path = PathBuf::from(&dir);
if path.is_dir() {
scan_font_dir(&path)
} else {
HashMap::new()
}
}
Err(_) => HashMap::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn font_spec_parsing() {
let spec = XfaFontSpec::from_xfa_attrs("Helvetica", Some("bold"), None, Some("12pt"), None);
assert_eq!(spec.typeface, "Helvetica");
assert_eq!(spec.weight, FontWeight::Bold);
assert_eq!(spec.posture, FontPosture::Normal);
assert!((spec.size_pt - 12.0).abs() < 0.001);
assert_eq!(spec.generic_family, None);
}
#[test]
fn font_spec_defaults() {
let spec = XfaFontSpec::from_xfa_attrs("Arial", None, None, None, None);
assert_eq!(spec.weight, FontWeight::Normal);
assert_eq!(spec.posture, FontPosture::Normal);
assert!((spec.size_pt - 10.0).abs() < 0.001);
}
#[test]
fn font_spec_generic_family() {
let spec = XfaFontSpec::from_xfa_attrs("FancyFont", None, None, None, Some("serif"));
assert_eq!(spec.generic_family, Some(GenericFamily::Serif));
let spec = XfaFontSpec::from_xfa_attrs("FancyFont", None, None, None, Some("sansSerif"));
assert_eq!(spec.generic_family, Some(GenericFamily::SansSerif));
let spec = XfaFontSpec::from_xfa_attrs("FancyFont", None, None, None, Some("monospaced"));
assert_eq!(spec.generic_family, Some(GenericFamily::Monospaced));
let spec = XfaFontSpec::from_xfa_attrs("FancyFont", None, None, None, Some("bogus"));
assert_eq!(spec.generic_family, None);
}
#[test]
fn resolver_empty() {
let mut resolver = XfaFontResolver::new(vec![]);
let spec = XfaFontSpec::from_xfa_attrs("NonExistentFont12345", None, None, None, None);
let _ = resolver.resolve(&spec);
}
#[test]
fn system_font_dirs_not_empty() {
let dirs = system_font_dirs();
assert!(!dirs.is_empty());
}
#[test]
fn cid_font_info_with_system_font() {
let mut resolver = XfaFontResolver::new(vec![]);
let spec = XfaFontSpec::from_xfa_attrs("Helvetica", None, None, None, None);
if let Ok(font) = resolver.resolve(&spec) {
let info = font.cid_font_info();
assert!(
info.is_some(),
"cid_font_info should succeed for a valid font"
);
let info = info.unwrap();
assert!(!info.widths.is_empty(), "widths should not be empty");
assert!(
!info.gid_to_unicode.is_empty(),
"gid_to_unicode should not be empty"
);
let has_a = info.gid_to_unicode.iter().any(|&(_, ch)| ch == 'A');
assert!(has_a, "font should have a mapping for 'A'");
}
}
#[test]
fn normalize_font_name_strips_subset_prefix() {
assert_eq!(normalize_font_name("ABCDEF+Arial"), "arial");
assert_eq!(
normalize_font_name("XYZABC+TimesNewRomanPSMT"),
"timesnewroman"
);
}
#[test]
fn normalize_font_name_strips_ps_suffixes() {
assert_eq!(normalize_font_name("ArialMT"), "arial");
assert_eq!(normalize_font_name("TimesNewRomanPSMT"), "timesnewroman");
assert_eq!(normalize_font_name("CourierNewPSMT"), "couriernew");
}
#[test]
fn normalize_font_name_preserves_normal_names() {
assert_eq!(normalize_font_name("Helvetica"), "helvetica");
assert_eq!(normalize_font_name("DejaVuSans"), "dejavusans");
}
#[test]
fn normalize_font_name_no_false_prefix_strip() {
assert_eq!(normalize_font_name("abcdef+Arial"), "abcdef+arial");
assert_eq!(normalize_font_name("AB+Arial"), "ab+arial");
}
#[test]
fn font_family_aliases_known_fonts() {
assert!(!font_family_aliases("arial").is_empty());
assert!(!font_family_aliases("helvetica").is_empty());
assert!(!font_family_aliases("courier new").is_empty());
assert!(!font_family_aliases("times new roman").is_empty());
assert!(!font_family_aliases("myriad pro").is_empty());
}
#[test]
fn font_family_aliases_unknown_font() {
assert!(font_family_aliases("some_unknown_font_xyz").is_empty());
}
#[test]
fn classify_font_family_sans() {
assert_eq!(classify_font_family("Arial"), FontFamily::SansSerif);
assert_eq!(classify_font_family("Helvetica"), FontFamily::SansSerif);
assert_eq!(classify_font_family("DejaVuSans"), FontFamily::SansSerif);
assert_eq!(
classify_font_family("LiberationSans"),
FontFamily::SansSerif
);
}
#[test]
fn classify_font_family_serif() {
assert_eq!(classify_font_family("Times New Roman"), FontFamily::Serif);
assert_eq!(classify_font_family("Georgia"), FontFamily::Serif);
assert_eq!(classify_font_family("LiberationSerif"), FontFamily::Serif);
}
#[test]
fn classify_font_family_mono() {
assert_eq!(classify_font_family("Courier New"), FontFamily::Monospace);
assert_eq!(
classify_font_family("LiberationMono"),
FontFamily::Monospace
);
assert_eq!(classify_font_family("Consolas"), FontFamily::Monospace);
}
#[test]
fn classify_font_family_unknown() {
assert_eq!(classify_font_family("FancyFont"), FontFamily::Unknown);
}
#[test]
fn scan_system_fonts_has_name_table_entries() {
let fonts = scan_system_fonts();
assert!(!fonts.is_empty(), "system fonts map should not be empty");
}
#[test]
fn build_variant_names_normal() {
let names = build_variant_names("Arial", FontWeight::Normal, FontPosture::Normal);
assert!(names.is_empty(), "normal/normal should produce no variants");
}
#[test]
fn build_variant_names_bold() {
let names = build_variant_names("Arial", FontWeight::Bold, FontPosture::Normal);
assert!(names.contains(&"Arial-Bold".to_string()));
assert!(names.contains(&"ArialBold".to_string()));
assert!(names.contains(&"Arial Bold".to_string()));
assert!(names.contains(&"Arial,Bold".to_string()));
}
#[test]
fn build_variant_names_italic() {
let names = build_variant_names("Helvetica", FontWeight::Normal, FontPosture::Italic);
assert!(names.contains(&"Helvetica-Italic".to_string()));
assert!(names.contains(&"HelveticaItalic".to_string()));
assert!(names.contains(&"Helvetica Italic".to_string()));
}
#[test]
fn build_variant_names_bold_italic() {
let names = build_variant_names("Arial", FontWeight::Bold, FontPosture::Italic);
assert!(names.contains(&"Arial-BoldItalic".to_string()));
assert!(names.contains(&"ArialBoldItalic".to_string()));
assert!(names.contains(&"Arial BoldItalic".to_string()));
assert!(names.contains(&"Arial-Bold Italic".to_string()));
assert!(names.contains(&"Arial Bold Italic".to_string()));
}
#[test]
fn font_variant_key_encoding() {
assert_eq!(font_variant_key("Arial", None, None), "Arial_Normal_Normal");
assert_eq!(
font_variant_key("Arial", Some("bold"), None),
"Arial_Bold_Normal"
);
assert_eq!(
font_variant_key("Arial", None, Some("italic")),
"Arial_Normal_Italic"
);
assert_eq!(
font_variant_key("Arial", Some("bold"), Some("italic")),
"Arial_Bold_Italic"
);
}
#[test]
fn resolve_uses_bold_variant_cache_key() {
let mut resolver = XfaFontResolver::new(vec![]);
let spec_normal = XfaFontSpec::from_xfa_attrs("Arial", None, None, None, None);
let spec_bold = XfaFontSpec::from_xfa_attrs("Arial", Some("bold"), None, None, None);
let _ = resolver.resolve(&spec_normal);
let _ = resolver.resolve(&spec_bold);
}
#[test]
fn resolver_preserves_pdf_widths_when_embedded_font_data_is_unparseable() {
let embedded = vec![EmbeddedFontData {
name: "Helvetica".to_string(),
data: vec![0_u8, 1, 2, 3],
pdf_widths: Some((32, vec![278, 333, 444])),
pdf_encoding: Some(PdfSimpleEncoding {
base_encoding: PdfBaseEncoding::WinAnsi,
differences: vec![(32, 0x0020)],
}),
pdf_source_font: None,
}];
let mut resolver = XfaFontResolver::new(embedded);
let spec = XfaFontSpec::from_xfa_attrs("Helvetica", None, None, None, None);
let resolved = resolver
.resolve(&spec)
.expect("resolver should fall back to a system font");
assert_eq!(resolved.pdf_widths, Some((32, vec![278, 333, 444])));
assert_eq!(
resolved.pdf_encoding,
Some(PdfSimpleEncoding {
base_encoding: PdfBaseEncoding::WinAnsi,
differences: vec![(32, 0x0020)],
})
);
}
#[test]
fn canonical_font_key_collapses_variant_separators() {
let keys: Vec<_> = ["Arial-Bold", "Arial,Bold", "Arial Bold", "ArialBold"]
.iter()
.map(|n| canonical_font_key(n).unwrap())
.collect();
assert!(keys.iter().all(|k| k == "arialbold"), "got {:?}", keys);
}
#[test]
fn canonical_font_key_strips_mt_and_subset_prefix() {
assert_eq!(
canonical_font_key("Arial-BoldMT").as_deref(),
Some("arialbold")
);
assert_eq!(
canonical_font_key("ABCDEF+Arial-Bold").as_deref(),
Some("arialbold")
);
assert_eq!(
canonical_font_key("TimesNewRomanPSMT").as_deref(),
Some("timesnewroman")
);
assert_eq!(canonical_font_key("").as_deref(), None);
assert_eq!(canonical_font_key("-,_").as_deref(), None);
}
#[test]
fn lookup_pdf_widths_matches_across_separator_variants() {
let mut map = HashMap::new();
let widths = (32_u16, vec![278_u16, 333, 611]);
remember_pdf_widths(&mut map, "Arial,Bold", &widths, None, None);
for probe in &[
"Arial,Bold",
"Arial-Bold",
"ArialBold",
"Arial Bold",
"Arial-BoldMT",
"ABCDEF+Arial-Bold",
] {
assert!(
lookup_pdf_widths(&map, probe).is_some(),
"lookup should resolve '{probe}' via the canonical key"
);
}
}
#[test]
fn lookup_pdf_widths_substring_fallback() {
let mut map = HashMap::new();
let widths = (32_u16, vec![500_u16; 3]);
remember_pdf_widths(&mut map, "Helvetica", &widths, None, None);
assert!(lookup_pdf_widths(&map, "HelveticaNeue").is_some());
}
#[test]
fn attach_pdf_widths_binds_arial_bold_mt_across_separator_variants() {
let widths = (32_u16, vec![278_u16, 333, 611]);
let embedded = vec![EmbeddedFontData {
name: "Arial,Bold".to_string(),
data: Vec::new(),
pdf_widths: Some(widths.clone()),
pdf_encoding: None,
pdf_source_font: Some(PdfSourceFont { object_id: (7, 0) }),
}];
let mut resolver = XfaFontResolver::new(embedded);
let spec = XfaFontSpec::from_xfa_attrs("Arial-BoldMT", Some("bold"), None, None, None);
let resolved = resolver
.resolve(&spec)
.expect("resolver should bind /Widths for the variant spelling");
assert_eq!(resolved.pdf_widths, Some(widths));
assert_eq!(
resolved.pdf_source_font,
Some(PdfSourceFont { object_id: (7, 0) })
);
}
#[test]
#[cfg_attr(target_os = "linux", ignore)]
fn resolver_prefers_reusable_pdf_font_over_system_fallback() {
let embedded = vec![EmbeddedFontData {
name: "Myriad Pro".to_string(),
data: Vec::new(),
pdf_widths: Some((32, vec![278, 333, 612])),
pdf_encoding: None,
pdf_source_font: Some(PdfSourceFont { object_id: (42, 0) }),
}];
let mut resolver = XfaFontResolver::new(embedded);
let spec = XfaFontSpec::from_xfa_attrs("Myriad Pro", None, None, None, None);
let resolved = resolver
.resolve(&spec)
.expect("resolver should reuse the original PDF font object");
assert_eq!(resolved.pdf_widths, Some((32, vec![278, 333, 612])));
assert_eq!(
resolved.pdf_source_font,
Some(PdfSourceFont { object_id: (42, 0) })
);
}
#[test]
fn scan_font_dir_empty_dir() {
let dir = std::env::temp_dir().join("xfa_font_cache_test_empty");
let _ = std::fs::create_dir_all(&dir);
let fonts = scan_font_dir(&dir);
assert!(fonts.len() < 10000);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scan_font_dir_nonexistent() {
let fonts = scan_font_dir(std::path::Path::new("/nonexistent/font/cache/dir"));
assert!(fonts.is_empty());
}
#[test]
fn scan_font_cache_dir_unset() {
std::env::remove_var("XFA_FONT_CACHE");
let fonts = scan_font_cache_dir();
assert!(fonts.is_empty());
}
#[test]
fn resolver_with_font_cache_dir() {
let dir = std::env::temp_dir().join("xfa_font_cache_test_resolver");
let _ = std::fs::create_dir_all(&dir);
let resolver = XfaFontResolver::new(vec![]).with_font_cache(&dir);
assert!(resolver.font_cache_fonts.len() < 10000);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn font_cache_resolves_before_system_fallback() {
let dir = std::env::temp_dir().join("xfa_font_cache_test_resolve_order");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let source = {
#[cfg(target_os = "macos")]
{
PathBuf::from("/System/Library/Fonts/Helvetica.ttc")
}
#[cfg(not(target_os = "macos"))]
{
PathBuf::from("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
}
};
if !source.exists() {
let _ = std::fs::remove_dir_all(&dir);
return;
}
let dest = dir.join("TestCacheFont.ttf");
std::fs::copy(&source, &dest).unwrap();
let resolver = XfaFontResolver::new(vec![]).with_font_cache(&dir);
assert!(
resolver.font_cache_fonts.contains_key("testcachefont"),
"font cache should contain the test font by stem name"
);
let _ = std::fs::remove_dir_all(&dir);
}
}