#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
use super::charset::charset_for_code_page_bit;
use super::charset::{Charset, PitchFamily};
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
use super::probe::{FaceProbe, ProbeError};
use super::style::{style_bits, tt_normalize};
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
use read_fonts::TableProvider;
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
use skrifa::MetadataProvider;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct FaceHandle(usize);
impl FaceHandle {
pub(crate) const fn from_index(index: usize) -> Self {
Self(index)
}
pub(crate) const fn index(self) -> usize {
self.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FaceInfo {
pub name: String,
pub styles: u32,
pub charsets: Vec<Charset>,
}
impl FaceInfo {
#[must_use]
pub fn similarity_score(
&self,
weight: i32,
italic: bool,
pitch: PitchFamily,
exact_match_bonus: bool,
) -> i32 {
let mut score = 0;
if (self.styles & style_bits::FORCE_BOLD != 0) == (weight > 400) {
score += 16;
}
if (self.styles & style_bits::ITALIC != 0) == italic {
score += 16;
}
if (self.styles & STYLE_SERIF != 0) == pitch.has(PitchFamily::ROMAN) {
score += 16;
}
if (self.styles & STYLE_SCRIPT != 0) == pitch.has(PitchFamily::SCRIPT) {
score += 8;
}
if (self.styles & STYLE_FIXED_PITCH != 0) == pitch.has(PitchFamily::FIXED) {
score += 8;
}
if exact_match_bonus {
score += 4;
}
score
}
#[must_use]
pub fn is_exact_match(
&self,
weight: i32,
italic: bool,
pitch: PitchFamily,
exact_match_bonus: bool,
) -> bool {
self.similarity_score(weight, italic, pitch, exact_match_bonus) == SIMILARITY_SCORE_MAX
}
#[must_use]
pub fn is_eligible(&self, charset: Charset) -> bool {
charset == Charset::Default || self.charsets.contains(&charset)
}
}
const SIMILARITY_SCORE_MAX: i32 = 68;
const STYLE_SERIF: u32 = 1 << 1;
const STYLE_SCRIPT: u32 = 1 << 3;
const STYLE_FIXED_PITCH: u32 = 1 << 0;
#[must_use]
pub fn find_family_name_match(family: &str, installed: &str) -> bool {
let Some(at) = installed.find(family) else {
return false;
};
let next = at + family.len();
!installed
.as_bytes()
.get(next)
.is_some_and(u8::is_ascii_lowercase)
}
pub trait FontDb {
fn faces(&self) -> &[FaceInfo];
fn face_bytes(&self, h: FaceHandle) -> Option<(Arc<[u8]>, u32)>;
fn find_font(
&self,
weight: i32,
italic: bool,
charset: Charset,
pitch: PitchFamily,
family: &str,
must_match_name: bool,
) -> Option<FaceHandle> {
let faces = self.faces();
let mut best: Option<usize> = None;
let mut best_score = 0;
if must_match_name
&& let Some((i, face)) = faces
.iter()
.enumerate()
.find(|(_, f)| f.name == family && f.is_eligible(charset))
{
best_score = face.similarity_score(weight, italic, pitch, true);
best = Some(i);
if face.is_exact_match(weight, italic, pitch, true) {
return Some(FaceHandle::from_index(i));
}
}
for (i, face) in faces.iter().enumerate() {
if !face.is_eligible(charset) {
continue;
}
let bonus = must_match_name && family.len() == face.name.len();
let score = face.similarity_score(weight, italic, pitch, bonus);
if score <= best_score {
continue;
}
if must_match_name && !find_family_name_match(family, &face.name) {
continue;
}
best_score = score;
best = Some(i);
}
if let Some(i) = best {
return Some(FaceHandle::from_index(i));
}
if charset == Charset::Ansi && pitch.has(PitchFamily::FIXED) {
return self.font_by_name("Courier New");
}
None
}
fn font_by_name(&self, name: &str) -> Option<FaceHandle> {
self.faces()
.iter()
.position(|f| f.name == name)
.map(FaceHandle::from_index)
}
fn match_installed(&self, normalized: &str) -> Option<String> {
self.faces()
.iter()
.rev()
.find(|f| tt_normalize(&f.name) == normalized)
.map(|f| f.name.clone())
}
}
#[derive(Debug, Clone, Copy)]
pub struct CroscoreDb<'a, D> {
inner: &'a D,
}
impl<'a, D: FontDb> CroscoreDb<'a, D> {
#[must_use]
pub fn new(inner: &'a D) -> Self {
Self { inner }
}
}
impl<D: FontDb> FontDb for CroscoreDb<'_, D> {
fn faces(&self) -> &[FaceInfo] {
self.inner.faces()
}
fn face_bytes(&self, h: FaceHandle) -> Option<(Arc<[u8]>, u32)> {
self.inner.face_bytes(h)
}
fn find_font(
&self,
weight: i32,
italic: bool,
charset: Charset,
pitch: PitchFamily,
family: &str,
must_match_name: bool,
) -> Option<FaceHandle> {
self.inner.find_font(
weight,
italic,
charset,
pitch,
&super::croscore_name(family),
must_match_name,
)
}
fn font_by_name(&self, name: &str) -> Option<FaceHandle> {
self.inner.font_by_name(&super::croscore_name(name))
}
}
#[derive(Debug, Clone, Default)]
pub struct TestFontDb {
faces: Vec<FaceInfo>,
bytes: Vec<Option<(Arc<[u8]>, u32)>>,
}
impl TestFontDb {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub fn push(&mut self, name: &str, styles: u32, charsets: Vec<Charset>) {
self.faces.push(FaceInfo {
name: name.to_owned(),
styles,
charsets,
});
self.bytes.push(None);
}
#[cfg(test)]
pub fn push_with_bytes(
&mut self,
name: &str,
styles: u32,
charsets: Vec<Charset>,
bytes: Arc<[u8]>,
) {
self.faces.push(FaceInfo {
name: name.to_owned(),
styles,
charsets,
});
self.bytes.push(Some((bytes, 0)));
}
}
impl FontDb for TestFontDb {
fn faces(&self) -> &[FaceInfo] {
&self.faces
}
fn face_bytes(&self, h: FaceHandle) -> Option<(Arc<[u8]>, u32)> {
self.bytes.get(h.0).cloned().flatten()
}
}
#[derive(Debug)]
pub struct SystemFontDb {
faces: Vec<FaceInfo>,
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
sources: Vec<FaceSource>,
}
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
#[derive(Debug, Clone)]
enum FaceSource {
File(PathBuf, u32),
Bytes(Arc<[u8]>, u32),
}
impl SystemFontDb {
#[cfg(not(all(feature = "system-fonts", not(target_arch = "wasm32"))))]
#[must_use]
pub fn scan(extra_dirs: &[PathBuf]) -> Self {
let _ = extra_dirs;
Self { faces: Vec::new() }
}
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
#[must_use]
pub fn scan(extra_dirs: &[PathBuf]) -> Self {
let mut db = fontdb::Database::new();
if extra_dirs.is_empty() {
db.load_system_fonts();
} else {
for dir in extra_dirs {
db.load_fonts_dir(dir);
}
}
let mut faces = Vec::new();
let mut sources = Vec::new();
for face in db.faces() {
let (bytes, source): (std::borrow::Cow<'_, [u8]>, FaceSource) = match &face.source {
fontdb::Source::Binary(data) => {
let bytes: Arc<[u8]> = Arc::from(data.as_ref().as_ref());
(
std::borrow::Cow::Owned(bytes.to_vec()),
FaceSource::Bytes(bytes, face.index),
)
}
fontdb::Source::SharedFile(path, data) => (
std::borrow::Cow::Borrowed(data.as_ref().as_ref()),
FaceSource::File(path.clone(), face.index),
),
fontdb::Source::File(path) => {
match FaceProbe::read(path, face.index) {
Ok(probe) => {
let Some(info) = describe(0, probe.as_font_bytes()) else {
continue;
};
faces.push(info);
sources.push(FaceSource::File(path.clone(), face.index));
continue;
}
Err(ProbeError::NotSfnt) => {
let Ok(read) = std::fs::read(path) else {
continue;
};
(
std::borrow::Cow::Owned(read),
FaceSource::File(path.clone(), face.index),
)
}
Err(_) => continue,
}
}
};
let Some(info) = describe(face.index, &bytes) else {
continue;
};
faces.push(info);
sources.push(source);
}
let mut order: Vec<usize> = (0..faces.len()).collect();
order.sort_by(|&l, &r| match (faces.get(l), faces.get(r)) {
(Some(l), Some(r)) => l.name.cmp(&r.name),
_ => std::cmp::Ordering::Equal,
});
let sorted_faces = order
.iter()
.filter_map(|&i| faces.get(i).cloned())
.collect();
let sorted_sources = order
.iter()
.filter_map(|&i| sources.get(i).cloned())
.collect();
Self {
faces: sorted_faces,
sources: sorted_sources,
}
}
}
impl FontDb for SystemFontDb {
fn faces(&self) -> &[FaceInfo] {
&self.faces
}
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
fn face_bytes(&self, h: FaceHandle) -> Option<(Arc<[u8]>, u32)> {
match self.sources.get(h.0)? {
FaceSource::File(path, index) => std::fs::read(path)
.ok()
.map(|bytes| (Arc::from(bytes), *index)),
FaceSource::Bytes(bytes, index) => Some((bytes.clone(), *index)),
}
}
#[cfg(not(all(feature = "system-fonts", not(target_arch = "wasm32"))))]
fn face_bytes(&self, h: FaceHandle) -> Option<(Arc<[u8]>, u32)> {
let _ = h;
None
}
}
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
fn face_styles(name: &str, style: &str) -> u32 {
let mut styles = style_bits::NORMAL;
if style.contains("Bold") {
styles |= style_bits::FORCE_BOLD;
}
if style.contains("Italic") || style.contains("Oblique") {
styles |= style_bits::ITALIC;
}
if name.contains("Serif") {
styles |= STYLE_SERIF;
}
styles
}
#[cfg(all(feature = "system-fonts", not(target_arch = "wasm32")))]
fn describe(index: u32, bytes: &[u8]) -> Option<FaceInfo> {
let font = skrifa::FontRef::from_index(bytes, index).ok()?;
let family: String = font
.localized_strings(skrifa::string::StringId::FAMILY_NAME)
.english_or_first()
.map(|s| s.chars().collect())?;
let style: String = font
.localized_strings(skrifa::string::StringId::SUBFAMILY_NAME)
.english_or_first()
.map(|s| s.chars().collect())
.unwrap_or_default();
let name = if style.is_empty() || style == "Regular" {
family
} else {
format!("{family} {style}")
};
let styles = face_styles(&name, &style);
let mut charsets = vec![Charset::Ansi];
if let Ok(os2) = font.os2() {
let ranges = u64::from(os2.ul_code_page_range_1().unwrap_or(0))
| (u64::from(os2.ul_code_page_range_2().unwrap_or(0)) << 32);
for bit in 0..64u32 {
if ranges & (1u64 << bit) == 0 {
continue;
}
if let Some(c) = charset_for_code_page_bit(bit)
&& !charsets.contains(&c)
{
charsets.push(c);
}
}
}
Some(FaceInfo {
name,
styles,
charsets,
})
}
#[cfg(all(test, feature = "system-fonts", not(target_arch = "wasm32")))]
mod tests {
#![allow(clippy::indexing_slicing)]
use super::*;
fn font_files() -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fontdata"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../benches/fixtures"),
];
let checkout = std::env::var_os("PDFRUM_ORACLE_CHECKOUT").map_or_else(
|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../pdfium-c++"),
PathBuf::from,
);
let oracle = checkout.join("third_party/test_fonts");
if oracle.is_dir() {
roots.push(oracle);
}
let mut out = Vec::new();
while let Some(root) = roots.pop() {
let Ok(entries) = std::fs::read_dir(&root) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
roots.push(path);
} else {
out.push(path);
}
}
}
out.sort();
out
}
#[test]
fn a_probe_describes_a_face_exactly_as_the_whole_file_does() {
let files = font_files();
assert!(!files.is_empty(), "no font files found to compare");
let mut sfnts = 0;
let mut fallbacks = 0;
for path in &files {
let Ok(whole) = std::fs::read(path) else {
continue;
};
match FaceProbe::read(path, 0) {
Ok(probe) => {
sfnts += 1;
assert_eq!(
describe(0, probe.as_font_bytes()),
describe(0, &whole),
"probe and whole-file describe disagree for {}",
path.display()
);
}
Err(ProbeError::NotSfnt) => {
fallbacks += 1;
}
Err(_) => {}
}
}
assert!(fallbacks > 0, "the non-sfnt fallback was never taken");
if sfnts == 0 {
eprintln!(
"no sfnt among {} candidates: set PDFRUM_ORACLE_CHECKOUT to \
compare against the oracle's test_fonts",
files.len()
);
}
}
#[test]
fn a_probe_is_far_smaller_than_the_file_it_came_from() {
for path in font_files() {
let Ok(probe) = FaceProbe::read(&path, 0) else {
continue;
};
let Ok(len) = std::fs::metadata(&path).map(|m| m.len()) else {
continue;
};
if len < 1024 * 1024 {
continue;
}
assert!(
(probe.as_font_bytes().len() as u64) < len / 100,
"probe of {} is {} bytes against a {} byte file",
path.display(),
probe.as_font_bytes().len(),
len
);
}
}
fn db() -> TestFontDb {
let mut db = TestFontDb::new();
db.push("Arial", 0, vec![Charset::Ansi]);
db.push("Arial Bold", style_bits::FORCE_BOLD, vec![Charset::Ansi]);
db.push("Arial Italic", style_bits::ITALIC, vec![Charset::Ansi]);
db.push("Times New Roman", STYLE_SERIF, vec![Charset::Ansi]);
db.push("Courier New", STYLE_FIXED_PITCH, vec![Charset::Ansi]);
db.push("MS Gothic", 0, vec![Charset::ShiftJis]);
db
}
#[test]
fn a_family_name_must_match_at_a_word_boundary() {
assert!(find_family_name_match("Univers", "Univers Bold"));
assert!(!find_family_name_match("Univers", "Universal"));
assert!(!find_family_name_match("Book", "Bookshelf Symbol 7"));
assert!(find_family_name_match("Bookshelf", "Bookshelf Symbol 7"));
assert!(find_family_name_match("Tofu", "Tofu"));
assert!(find_family_name_match("Lato", "Lato"));
assert!(find_family_name_match("Oxygen", "Oxygen"));
assert!(find_family_name_match("Oxygen", "Oxygen-Sans"));
assert!(!find_family_name_match("Helvetica", "Arial"));
}
#[test]
fn an_uppercase_next_character_still_matches() {
assert!(find_family_name_match("Foo", "FooBar"));
assert!(!find_family_name_match("Foo", "Foobar"));
}
#[test]
fn the_similarity_score_rewards_agreement_in_both_directions() {
let plain = FaceInfo {
name: "Plain".to_owned(),
styles: 0,
charsets: vec![Charset::Ansi],
};
let bold = FaceInfo {
name: "Bold".to_owned(),
styles: style_bits::FORCE_BOLD,
charsets: vec![Charset::Ansi],
};
let p = PitchFamily::default();
assert!(
plain.similarity_score(400, false, p, false)
> bold.similarity_score(400, false, p, false)
);
assert!(
bold.similarity_score(700, false, p, false)
> plain.similarity_score(700, false, p, false)
);
}
#[test]
fn a_perfect_score_is_sixty_eight() {
let face = FaceInfo {
name: "F".to_owned(),
styles: style_bits::FORCE_BOLD | style_bits::ITALIC,
charsets: vec![Charset::Ansi],
};
assert_eq!(
face.similarity_score(700, true, PitchFamily::default(), true),
SIMILARITY_SCORE_MAX
);
}
#[test]
fn the_exact_match_bonus_is_worth_four() {
let face = FaceInfo {
name: "F".to_owned(),
styles: 0,
charsets: vec![Charset::Ansi],
};
let p = PitchFamily::default();
assert_eq!(
face.similarity_score(400, false, p, true)
- face.similarity_score(400, false, p, false),
4
);
}
#[test]
fn eligibility_gates_on_charset_unless_the_request_is_default() {
let face = FaceInfo {
name: "MS Gothic".to_owned(),
styles: 0,
charsets: vec![Charset::ShiftJis],
};
assert!(face.is_eligible(Charset::ShiftJis));
assert!(!face.is_eligible(Charset::Ansi));
assert!(face.is_eligible(Charset::Default));
}
#[test]
fn find_font_prefers_the_matching_style() {
let db = db();
let bold = db
.find_font(
700,
false,
Charset::Ansi,
PitchFamily::default(),
"Arial",
true,
)
.expect("a face is found");
assert_eq!(db.faces()[bold.0].name, "Arial Bold");
let italic = db
.find_font(
400,
true,
Charset::Ansi,
PitchFamily::default(),
"Arial",
true,
)
.expect("a face is found");
assert_eq!(db.faces()[italic.0].name, "Arial Italic");
}
#[test]
fn find_font_respects_the_charset_gate() {
let db = db();
let jp = db
.find_font(
400,
false,
Charset::ShiftJis,
PitchFamily::default(),
"MS Gothic",
true,
)
.expect("the Japanese face is found");
assert_eq!(db.faces()[jp.0].name, "MS Gothic");
assert!(
db.find_font(
400,
false,
Charset::Hebrew,
PitchFamily::default(),
"Arial",
true
)
.is_none()
);
}
#[test]
fn find_font_falls_back_to_courier_new_for_a_fixed_ansi_request() {
let mut db = TestFontDb::new();
db.push("Courier New", STYLE_FIXED_PITCH, vec![Charset::Ansi]);
let h = db.find_font(
400,
false,
Charset::Ansi,
PitchFamily(PitchFamily::FIXED),
"NoSuchFamily",
true,
);
assert_eq!(
h.map(|h| db.faces()[h.0].name.as_str()),
Some("Courier New")
);
}
#[test]
fn the_name_gate_is_lifted_when_must_match_name_is_false() {
let db = db();
assert!(
db.find_font(
400,
false,
Charset::Ansi,
PitchFamily::default(),
"Zzz",
true
)
.is_none()
);
assert!(
db.find_font(
400,
false,
Charset::Ansi,
PitchFamily::default(),
"Zzz",
false
)
.is_some()
);
}
#[test]
fn match_installed_scans_in_reverse_so_a_later_face_shadows_an_earlier() {
let mut db = TestFontDb::new();
db.push("Foo Bar", 0, vec![Charset::Ansi]);
db.push("FooBar", 0, vec![Charset::Ansi]);
assert_eq!(db.match_installed("foobar").as_deref(), Some("FooBar"));
assert_eq!(db.match_installed("nothing"), None);
}
#[test]
fn an_empty_database_answers_nothing() {
let db = TestFontDb::new();
assert!(db.faces().is_empty());
assert!(
db.find_font(
400,
false,
Charset::Ansi,
PitchFamily::default(),
"Arial",
true
)
.is_none()
);
assert!(db.font_by_name("Arial").is_none());
assert!(db.face_bytes(FaceHandle(0)).is_none());
}
}
#[cfg(all(test, feature = "system-fonts", not(target_arch = "wasm32")))]
mod face_style_bits {
use super::*;
#[test]
fn bold_reads_the_style_not_the_face_name() {
assert_eq!(
face_styles("Arimo Bold", "Bold") & style_bits::FORCE_BOLD,
style_bits::FORCE_BOLD
);
assert_eq!(
face_styles("Arimo Bold Italic", "Bold Italic") & style_bits::FORCE_BOLD,
style_bits::FORCE_BOLD
);
assert_eq!(face_styles("Arimo", "Regular") & style_bits::FORCE_BOLD, 0);
assert_eq!(
face_styles("Bold Sans", "Regular") & style_bits::FORCE_BOLD,
0
);
}
#[test]
fn italic_takes_oblique_as_well() {
assert_eq!(
face_styles("X Italic", "Italic") & style_bits::ITALIC,
style_bits::ITALIC
);
assert_eq!(
face_styles("X Oblique", "Oblique") & style_bits::ITALIC,
style_bits::ITALIC
);
assert_eq!(face_styles("X", "Regular") & style_bits::ITALIC, 0);
}
#[test]
fn serif_is_the_face_name_containing_serif() {
assert_eq!(
face_styles("PT Serif", "Regular") & STYLE_SERIF,
STYLE_SERIF
);
assert_eq!(
face_styles("Noto Serif Bold", "Bold") & STYLE_SERIF,
STYLE_SERIF
);
assert_eq!(face_styles("PT SERIF", "Regular") & STYLE_SERIF, 0);
assert_eq!(face_styles("PT serif", "Regular") & STYLE_SERIF, 0);
}
#[test]
fn no_face_in_the_hermetic_font_set_is_serif() {
for (name, style) in [
("Ahem", "Regular"),
("Arimo", "Regular"),
("Arimo Bold", "Bold"),
("Arimo Bold Italic", "Bold Italic"),
("Arimo Italic", "Italic"),
("Cousine", "Regular"),
("Cousine Bold", "Bold"),
("Cousine Bold Italic", "Bold Italic"),
("Cousine Italic", "Italic"),
("DejaVu Sans Bold", "Bold"),
("DejaVu Sans Book", "Book"),
("GardinerMod", "Regular"),
("Garuda", "Regular"),
("Gelasio", "Regular"),
("Gelasio Bold", "Bold"),
("Gelasio Bold Italic", "Bold Italic"),
("Gelasio Italic", "Italic"),
("Lohit Devanagari", "Regular"),
("Lohit Gurmukhi", "Regular"),
("Lohit Tamil", "Regular"),
("Mukti Narrow", "Regular"),
("Noto Color Emoji", "Regular"),
("Noto Sans CJK JP Regular", "Regular"),
("Noto Sans Khmer", "Regular"),
("Noto Sans Symbols2", "Regular"),
("Noto Sans Tibetan", "Regular"),
("Tinos", "Regular"),
("Tinos Bold", "Bold"),
("Tinos Bold Italic", "Bold Italic"),
("Tinos Italic", "Italic"),
] {
let bits = face_styles(name, style);
assert_eq!(bits & STYLE_SERIF, 0, "{name} took a serif bit");
assert_eq!(bits & STYLE_SCRIPT, 0, "{name} took a script bit");
assert_eq!(bits & STYLE_FIXED_PITCH, 0, "{name} took a fixed-pitch bit");
}
}
#[test]
fn a_monospaced_face_takes_no_fixed_pitch_bit() {
let cousine = face_styles("Cousine", "Regular");
let arimo = face_styles("Arimo", "Regular");
assert_eq!(cousine, arimo);
let fixed = PitchFamily(PitchFamily::FIXED);
let info = |styles| FaceInfo {
name: String::new(),
styles,
charsets: vec![Charset::Ansi],
};
assert_eq!(
info(cousine).similarity_score(400, false, fixed, false),
info(arimo).similarity_score(400, false, fixed, false),
"a fixed-pitch request must not separate Cousine from Arimo"
);
}
}