use std::collections::BTreeMap;
use std::sync::OnceLock;
use crate::model::{
GlyphId, PdfEditError, PdfFontCatalogEntry, PdfFontResolver, PdfFontSource, ResolvedPdfFont,
};
#[derive(Clone, Debug)]
struct BundledFontEntry {
id: String,
family: String,
postscript_name: Option<String>,
style: String,
bytes: Vec<u8>,
face_index: u32,
}
pub struct FontCatalog {
db: fontdb::Database,
bundled: Vec<BundledFontEntry>,
}
impl FontCatalog {
pub fn discover() -> FontCatalog {
let mut db = fontdb::Database::new();
db.load_system_fonts();
let bundled = discover_bundled_fonts();
FontCatalog { db, bundled }
}
pub fn shared() -> &'static FontCatalog {
static CATALOG: OnceLock<FontCatalog> = OnceLock::new();
CATALOG.get_or_init(FontCatalog::discover)
}
#[cfg(test)]
fn from_parts(db: fontdb::Database, bundled: Vec<BundledFontEntry>) -> FontCatalog {
FontCatalog { db, bundled }
}
pub fn list_entries(&self) -> Vec<PdfFontCatalogEntry> {
let mut entries: Vec<PdfFontCatalogEntry> = Vec::new();
for bundled in &self.bundled {
let (embeddable, subsettable) = self
.parse_permissions(&bundled.bytes, bundled.face_index)
.unwrap_or((false, false));
entries.push(PdfFontCatalogEntry {
id: bundled.id.clone(),
family: bundled.family.clone(),
postscript_name: bundled.postscript_name.clone(),
styles: vec![bundled.style.clone()],
embeddable,
subsettable,
source: PdfFontSource::Bundled,
});
}
let mut by_family: BTreeMap<String, (String, Option<String>, Vec<String>)> =
BTreeMap::new();
let mut first_id_by_family: BTreeMap<String, fontdb::ID> = BTreeMap::new();
for face in self.db.faces() {
let Some((family, _)) = face
.families
.iter()
.find(|(_, lang)| *lang == fontdb::Language::English_UnitedStates)
.or_else(|| face.families.first())
.cloned()
else {
continue;
};
let style = format_style(face.style, face.weight, face.stretch);
let entry = by_family.entry(family.clone()).or_insert_with(|| {
(
family.clone(),
Some(face.post_script_name.clone()),
Vec::new(),
)
});
if !entry.2.contains(&style) {
entry.2.push(style);
}
first_id_by_family.entry(family).or_insert(face.id);
}
for (family, (_, postscript_name, styles)) in by_family {
let Some(&face_id) = first_id_by_family.get(&family) else {
continue;
};
let (embeddable, subsettable) = self
.parse_permissions_by_id(face_id)
.unwrap_or((false, false));
entries.push(PdfFontCatalogEntry {
id: system_font_id(&family),
family,
postscript_name,
styles,
embeddable,
subsettable,
source: PdfFontSource::System,
});
}
entries
}
fn parse_permissions(&self, bytes: &[u8], face_index: u32) -> Option<(bool, bool)> {
let face = ttf_parser::Face::parse(bytes, face_index).ok()?;
Some(face_embedding_permissions(&face))
}
fn parse_permissions_by_id(&self, id: fontdb::ID) -> Option<(bool, bool)> {
self.db
.with_face_data(id, |bytes, index| {
ttf_parser::Face::parse(bytes, index)
.ok()
.map(|face| face_embedding_permissions(&face))
})
.flatten()
}
}
impl PdfFontResolver for FontCatalog {
fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
if let Some(bundled) = self.bundled.iter().find(|entry| entry.id == id) {
let (embeddable, subsettable) = self
.parse_permissions(&bundled.bytes, bundled.face_index)
.unwrap_or((false, false));
return Ok(ResolvedPdfFont {
id: bundled.id.clone(),
family: bundled.family.clone(),
bytes: bundled.bytes.clone(),
face_index: bundled.face_index,
embeddable,
subsettable,
});
}
for face in self.db.faces() {
let Some((family, _)) = face.families.first().cloned() else {
continue;
};
if system_font_id(&family) != id {
continue;
}
let resolved = self.db.with_face_data(face.id, |bytes, face_index| {
let (embeddable, subsettable) = ttf_parser::Face::parse(bytes, face_index)
.ok()
.map(|f| face_embedding_permissions(&f))
.unwrap_or((false, false));
ResolvedPdfFont {
id: id.to_string(),
family: family.clone(),
bytes: bytes.to_vec(),
face_index,
embeddable,
subsettable,
}
});
if let Some(resolved) = resolved {
return Ok(resolved);
}
}
Err(PdfEditError::UnsupportedEncoding)
}
fn glyphs_for_text(
&self,
font: &ResolvedPdfFont,
text: &str,
) -> Result<Vec<GlyphId>, PdfEditError> {
glyph_ids_for_text(&font.bytes, font.face_index, text)
}
}
pub(crate) fn glyph_ids_for_text(
bytes: &[u8],
face_index: u32,
text: &str,
) -> Result<Vec<GlyphId>, PdfEditError> {
let face = ttf_parser::Face::parse(bytes, face_index)
.map_err(|_| PdfEditError::UnsupportedEncoding)?;
text.chars()
.map(|c| {
face.glyph_index(c)
.map(|g| GlyphId(g.0))
.ok_or(PdfEditError::MissingGlyph)
})
.collect()
}
fn face_embedding_permissions(face: &ttf_parser::Face) -> (bool, bool) {
let restricted = matches!(
face.permissions(),
Some(ttf_parser::Permissions::Restricted)
);
let embeddable = !restricted && face.is_outline_embedding_allowed();
let subsettable = !restricted && face.is_subsetting_allowed();
(embeddable, subsettable)
}
fn format_style(style: fontdb::Style, weight: fontdb::Weight, stretch: fontdb::Stretch) -> String {
let mut parts = Vec::new();
if stretch != fontdb::Stretch::Normal {
parts.push(format!("{stretch:?}"));
}
match weight.0 {
400 => {}
700 => parts.push("Bold".to_string()),
other => parts.push(format!("{other}")),
}
match style {
fontdb::Style::Normal => {}
fontdb::Style::Italic => parts.push("Italic".to_string()),
fontdb::Style::Oblique => parts.push("Oblique".to_string()),
}
if parts.is_empty() {
"Regular".to_string()
} else {
parts.join(" ")
}
}
fn system_font_id(family: &str) -> String {
let slug: String = family
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
format!("system-{slug}")
}
const SOURCE_SERIF_4_WOFF2: &[u8] = include_bytes!("../assets/SourceSerif4-Variable.woff2");
fn bundled_font_id(family: &str, postscript_name: Option<&str>) -> String {
if family.eq_ignore_ascii_case("Source Serif 4") {
return "source-serif-4".to_string();
}
postscript_name
.unwrap_or(family)
.to_ascii_lowercase()
.replace(' ', "-")
}
fn discover_bundled_fonts() -> Vec<BundledFontEntry> {
let Some(bytes) = wuff::decompress_woff2(SOURCE_SERIF_4_WOFF2).ok() else {
return Vec::new();
};
let Ok(face) = ttf_parser::Face::parse(&bytes, 0) else {
return Vec::new();
};
let family = face
.names()
.into_iter()
.find(|name| name.name_id == ttf_parser::name_id::FAMILY && name.is_unicode())
.and_then(|name| name.to_string())
.unwrap_or_else(|| "Source Serif 4".to_string());
let postscript_name = face
.names()
.into_iter()
.find(|name| name.name_id == ttf_parser::name_id::POST_SCRIPT_NAME && name.is_unicode())
.and_then(|name| name.to_string());
let style = if face.is_bold() && face.is_italic() {
"Bold Italic"
} else if face.is_bold() {
"Bold"
} else if face.is_italic() {
"Italic"
} else {
"Regular"
}
.to_string();
vec![BundledFontEntry {
id: bundled_font_id(&family, postscript_name.as_deref()),
family,
postscript_name,
style,
bytes,
face_index: 0,
}]
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PdfCidWidth {
pub cid: u32,
pub width_1000: f64,
}
#[derive(Clone, Debug)]
pub struct EmbeddedPdfFontSubset {
pub font_program: Vec<u8>,
pub widths: Vec<PdfCidWidth>,
pub to_unicode: Vec<(u32, String)>,
#[allow(dead_code)]
pub glyph_count: u16,
}
pub fn subset_for_pdf(
bytes: &[u8],
face_index: u32,
text: &str,
) -> Result<EmbeddedPdfFontSubset, PdfEditError> {
let face = ttf_parser::Face::parse(bytes, face_index)
.map_err(|_| PdfEditError::UnsupportedEncoding)?;
let (embeddable, subsettable) = face_embedding_permissions(&face);
if !embeddable || !subsettable {
return Err(PdfEditError::FontEmbeddingDenied);
}
let units_per_em = face.units_per_em() as f64;
if units_per_em <= 0.0 {
return Err(PdfEditError::UnsupportedEncoding);
}
let mut seen_glyphs: Vec<u16> = Vec::new();
let mut char_glyphs: Vec<(char, u16)> = Vec::new();
for c in text.chars() {
let gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
if !seen_glyphs.contains(&gid) {
seen_glyphs.push(gid);
}
char_glyphs.push((c, gid));
}
let mut remapper = subsetter::GlyphRemapper::new();
let mut old_to_new: BTreeMap<u16, u16> = BTreeMap::new();
for &old_gid in &seen_glyphs {
let new_gid = remapper.remap(old_gid);
old_to_new.insert(old_gid, new_gid);
}
let font_program = subsetter::subset(bytes, face_index, &remapper)
.map_err(|_| PdfEditError::UnsupportedEncoding)?;
let mut widths = Vec::with_capacity(seen_glyphs.len());
for &old_gid in &seen_glyphs {
let new_gid = old_to_new[&old_gid];
let advance = face
.glyph_hor_advance(ttf_parser::GlyphId(old_gid))
.unwrap_or(0);
widths.push(PdfCidWidth {
cid: new_gid as u32,
width_1000: advance as f64 * 1000.0 / units_per_em,
});
}
let mut to_unicode: Vec<(u32, String)> = Vec::new();
let mut cid_has_unicode: BTreeMap<u32, ()> = BTreeMap::new();
for (c, old_gid) in &char_glyphs {
let new_gid = old_to_new[old_gid] as u32;
if cid_has_unicode.insert(new_gid, ()).is_none() {
to_unicode.push((new_gid, c.to_string()));
}
}
Ok(EmbeddedPdfFontSubset {
font_program,
widths,
to_unicode,
glyph_count: seen_glyphs.len() as u16 + 1, })
}
pub fn build_to_unicode_cmap(entries: &[(u32, String)]) -> String {
let mut sorted = entries.to_vec();
sorted.sort_by_key(|(cid, _)| *cid);
let mut out = String::new();
out.push_str("/CIDInit /ProcSet findresource begin\n");
out.push_str("12 dict begin\nbegincmap\n");
out.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
out.push_str("/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n");
out.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
out.push_str(&format!("{} beginbfchar\n", sorted.len()));
for (cid, unicode) in &sorted {
out.push_str(&format!(
"<{:04X}> <{}>\n",
cid,
unicode_to_utf16be_hex(unicode)
));
}
out.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
out
}
fn unicode_to_utf16be_hex(text: &str) -> String {
let mut hex = String::new();
for unit in text.encode_utf16() {
hex.push_str(&format!("{unit:04X}"));
}
hex
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::build_test_ttf;
const FS_TYPE_INSTALLABLE: u16 = 0x0000;
const FS_TYPE_RESTRICTED: u16 = 0x0002; const FS_TYPE_NO_SUBSETTING: u16 = 0x0100;
fn installable_font() -> Vec<u8> {
build_test_ttf(FS_TYPE_INSTALLABLE, &[('A', 1), ('B', 2), ('C', 3)], 600)
}
#[test]
fn installable_font_parses_and_reports_embeddable_subsettable() {
let bytes = installable_font();
let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
let (embeddable, subsettable) = face_embedding_permissions(&face);
assert!(embeddable);
assert!(subsettable);
}
#[test]
fn restricted_font_is_not_embeddable() {
let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
let (embeddable, subsettable) = face_embedding_permissions(&face);
assert!(!embeddable);
assert!(!subsettable);
}
#[test]
fn subset_for_pdf_rejects_embedding_restricted_font() {
let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
let err = subset_for_pdf(&bytes, 0, "A").expect_err("restricted font must reject");
assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
}
#[test]
fn subset_for_pdf_rejects_subset_disallowed_font() {
let bytes = build_test_ttf(FS_TYPE_NO_SUBSETTING, &[('A', 1)], 600);
let err = subset_for_pdf(&bytes, 0, "A").expect_err("no-subsetting font must reject");
assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
}
#[test]
fn subset_for_pdf_reports_missing_glyph() {
let bytes = installable_font(); let err = subset_for_pdf(&bytes, 0, "AZ").expect_err("Z has no glyph");
assert!(matches!(err, PdfEditError::MissingGlyph));
}
#[test]
fn subset_for_pdf_succeeds_for_covered_text() {
let bytes = installable_font();
let subset = subset_for_pdf(&bytes, 0, "ABA").expect("A and B are covered");
assert_eq!(subset.widths.len(), 2);
assert!(subset
.widths
.iter()
.all(|w| (w.width_1000 - 600.0).abs() < 1e-6));
assert_eq!(subset.to_unicode.len(), 2);
let mut chars: Vec<&str> = subset
.to_unicode
.iter()
.map(|(_, unicode)| unicode.as_str())
.collect();
chars.sort();
assert_eq!(chars, vec!["A", "B"]);
ttf_parser::Face::parse(&subset.font_program, 0).expect("subset output is valid TTF");
}
#[test]
fn subset_for_pdf_glyphs_for_text_agrees_with_resolver_trait() {
let bytes = installable_font();
let resolved = ResolvedPdfFont {
id: "test-font".to_string(),
family: "Test Font".to_string(),
bytes: bytes.clone(),
face_index: 0,
embeddable: true,
subsettable: true,
};
let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
let glyphs = catalog
.glyphs_for_text(&resolved, "AB")
.expect("A and B are covered");
assert_eq!(glyphs, vec![GlyphId(1), GlyphId(2)]);
let err = catalog
.glyphs_for_text(&resolved, "Z")
.expect_err("Z is not covered");
assert!(matches!(err, PdfEditError::MissingGlyph));
}
#[test]
fn build_to_unicode_cmap_round_trips_generated_mapping() {
let subset = subset_for_pdf(&installable_font(), 0, "AB").expect("covered text");
let cmap = build_to_unicode_cmap(&subset.to_unicode);
assert!(cmap.contains("beginbfchar"));
assert!(cmap.contains("endbfchar"));
assert!(cmap.contains("/CMapType 2 def"));
for (cid, unicode) in &subset.to_unicode {
let expected = format!("<{cid:04X}> <{}>", unicode_to_utf16be_hex(unicode));
assert!(
cmap.contains(&expected),
"missing {expected} in generated CMap:\n{cmap}"
);
}
}
#[test]
fn build_to_unicode_cmap_is_sorted_by_cid_and_deterministic() {
let entries = vec![
(5u32, "b".to_string()),
(1u32, "a".to_string()),
(3u32, "c".to_string()),
];
let cmap_a = build_to_unicode_cmap(&entries);
let cmap_b = build_to_unicode_cmap(&entries);
assert_eq!(cmap_a, cmap_b);
let pos_1 = cmap_a.find("<0001>").expect("cid 1 present");
let pos_3 = cmap_a.find("<0003>").expect("cid 3 present");
let pos_5 = cmap_a.find("<0005>").expect("cid 5 present");
assert!(pos_1 < pos_3 && pos_3 < pos_5);
}
#[test]
fn unicode_to_utf16be_hex_encodes_bmp_text() {
assert_eq!(unicode_to_utf16be_hex("A"), "0041");
assert_eq!(unicode_to_utf16be_hex("AB"), "00410042");
}
#[test]
fn font_catalog_from_parts_lists_bundled_entry_with_permissions() {
let bundled = BundledFontEntry {
id: "test-serif".to_string(),
family: "Test Serif".to_string(),
postscript_name: Some("TestSerif-Regular".to_string()),
style: "Regular".to_string(),
bytes: installable_font(),
face_index: 0,
};
let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
let entries = catalog.list_entries();
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.id, "test-serif");
assert_eq!(entry.source, PdfFontSource::Bundled);
assert!(entry.embeddable);
assert!(entry.subsettable);
}
#[test]
fn font_catalog_resolve_returns_bundled_font_bytes() {
let bytes = installable_font();
let bundled = BundledFontEntry {
id: "test-serif".to_string(),
family: "Test Serif".to_string(),
postscript_name: None,
style: "Regular".to_string(),
bytes: bytes.clone(),
face_index: 0,
};
let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
let resolved = catalog
.resolve("test-serif")
.expect("bundled font resolves");
assert_eq!(resolved.bytes, bytes);
assert!(resolved.embeddable);
}
#[test]
fn font_catalog_resolve_unknown_id_is_unsupported_encoding() {
let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
let err = catalog
.resolve("does-not-exist")
.expect_err("unknown id must fail");
assert!(matches!(err, PdfEditError::UnsupportedEncoding));
}
#[test]
fn bundled_fonts_are_available_without_runtime_files() {
assert_eq!(discover_bundled_fonts().len(), 1);
}
#[test]
fn source_serif_4_woff2_is_discovered_resolved_parseable_and_subsettable() {
let bundled = discover_bundled_fonts()
.into_iter()
.find(|font| font.id == "source-serif-4")
.expect("embedded Source Serif 4 WOFF2 is available");
let face = ttf_parser::Face::parse(&bundled.bytes, bundled.face_index)
.expect("decoded Source Serif 4 is parseable SFNT");
assert!(face.glyph_index('ç').is_some());
assert!(face.glyph_index('ã').is_some());
let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled.clone()]);
let entry = catalog
.list_entries()
.into_iter()
.find(|entry| entry.id == "source-serif-4")
.expect("Source Serif 4 catalog entry");
assert_eq!(entry.source, PdfFontSource::Bundled);
assert!(entry.embeddable);
assert!(entry.subsettable);
let resolved = catalog
.resolve("source-serif-4")
.expect("stable frontend font id resolves");
assert_eq!(resolved.bytes, bundled.bytes);
let correction = "A correção mantém ação, informação e português.";
catalog
.glyphs_for_text(&resolved, correction)
.expect("Portuguese correction text has complete glyph coverage");
let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, correction)
.expect("Source Serif 4 subset is embeddable");
ttf_parser::Face::parse(&subset.font_program, 0)
.expect("Source Serif 4 subset output is parseable SFNT");
assert!(subset.widths.len() > 10);
assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ç"));
assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ã"));
}
#[test]
fn bundled_font_id_stabilizes_source_serif_metadata() {
assert_eq!(
bundled_font_id("Source Serif 4", Some("SourceSerif4-Roman")),
"source-serif-4"
);
assert_eq!(
bundled_font_id("Test Serif", Some("TestSerif-Regular")),
"testserif-regular"
);
}
#[test]
fn system_font_id_is_stable_and_slugified() {
assert_eq!(system_font_id("Arial"), "system-arial");
assert_eq!(system_font_id("Source Serif 4"), "system-source-serif-4");
assert_eq!(system_font_id("Arial"), system_font_id("Arial"));
}
}