1use std::collections::BTreeMap;
31use std::sync::OnceLock;
32
33use crate::model::{
34 GlyphId, PdfEditError, PdfFontCatalogEntry, PdfFontResolver, PdfFontSource, ResolvedPdfFont,
35};
36
37#[derive(Clone, Debug)]
44struct BundledFontEntry {
45 id: String,
46 family: String,
47 postscript_name: Option<String>,
48 style: String,
49 bytes: Vec<u8>,
50 face_index: u32,
51}
52
53pub struct FontCatalog {
57 db: fontdb::Database,
58 bundled: Vec<BundledFontEntry>,
59}
60
61impl FontCatalog {
62 pub fn discover() -> FontCatalog {
65 let mut db = fontdb::Database::new();
66 db.load_system_fonts();
67 let bundled = discover_bundled_fonts();
68 FontCatalog { db, bundled }
69 }
70
71 pub fn shared() -> &'static FontCatalog {
75 static CATALOG: OnceLock<FontCatalog> = OnceLock::new();
76 CATALOG.get_or_init(FontCatalog::discover)
77 }
78
79 #[cfg(test)]
83 fn from_parts(db: fontdb::Database, bundled: Vec<BundledFontEntry>) -> FontCatalog {
84 FontCatalog { db, bundled }
85 }
86
87 pub fn list_entries(&self) -> Vec<PdfFontCatalogEntry> {
91 let mut entries: Vec<PdfFontCatalogEntry> = Vec::new();
92
93 for bundled in &self.bundled {
94 let (embeddable, subsettable) = self
95 .parse_permissions(&bundled.bytes, bundled.face_index)
96 .unwrap_or((false, false));
97 entries.push(PdfFontCatalogEntry {
98 id: bundled.id.clone(),
99 family: bundled.family.clone(),
100 postscript_name: bundled.postscript_name.clone(),
101 styles: vec![bundled.style.clone()],
102 embeddable,
103 subsettable,
104 source: PdfFontSource::Bundled,
105 });
106 }
107
108 let mut by_family: BTreeMap<String, (String, Option<String>, Vec<String>)> =
111 BTreeMap::new();
112 let mut first_id_by_family: BTreeMap<String, fontdb::ID> = BTreeMap::new();
113
114 for face in self.db.faces() {
115 let Some((family, _)) = face
116 .families
117 .iter()
118 .find(|(_, lang)| *lang == fontdb::Language::English_UnitedStates)
119 .or_else(|| face.families.first())
120 .cloned()
121 else {
122 continue;
123 };
124 let style = format_style(face.style, face.weight, face.stretch);
125 let entry = by_family.entry(family.clone()).or_insert_with(|| {
126 (
127 family.clone(),
128 Some(face.post_script_name.clone()),
129 Vec::new(),
130 )
131 });
132 if !entry.2.contains(&style) {
133 entry.2.push(style);
134 }
135 first_id_by_family.entry(family).or_insert(face.id);
136 }
137
138 for (family, (_, postscript_name, styles)) in by_family {
139 let Some(&face_id) = first_id_by_family.get(&family) else {
140 continue;
141 };
142 let (embeddable, subsettable) = self
143 .parse_permissions_by_id(face_id)
144 .unwrap_or((false, false));
145 entries.push(PdfFontCatalogEntry {
146 id: system_font_id(&family),
147 family,
148 postscript_name,
149 styles,
150 embeddable,
151 subsettable,
152 source: PdfFontSource::System,
153 });
154 }
155
156 entries
157 }
158
159 fn parse_permissions(&self, bytes: &[u8], face_index: u32) -> Option<(bool, bool)> {
160 let face = ttf_parser::Face::parse(bytes, face_index).ok()?;
161 Some(face_embedding_permissions(&face))
162 }
163
164 fn parse_permissions_by_id(&self, id: fontdb::ID) -> Option<(bool, bool)> {
165 self.db
166 .with_face_data(id, |bytes, index| {
167 ttf_parser::Face::parse(bytes, index)
168 .ok()
169 .map(|face| face_embedding_permissions(&face))
170 })
171 .flatten()
172 }
173}
174
175impl PdfFontResolver for FontCatalog {
176 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
177 if let Some(bundled) = self.bundled.iter().find(|entry| entry.id == id) {
178 let (embeddable, subsettable) = self
179 .parse_permissions(&bundled.bytes, bundled.face_index)
180 .unwrap_or((false, false));
181 return Ok(ResolvedPdfFont {
182 id: bundled.id.clone(),
183 family: bundled.family.clone(),
184 bytes: bundled.bytes.clone(),
185 face_index: bundled.face_index,
186 embeddable,
187 subsettable,
188 });
189 }
190
191 for face in self.db.faces() {
194 let Some((family, _)) = face.families.first().cloned() else {
195 continue;
196 };
197 if system_font_id(&family) != id {
198 continue;
199 }
200 let resolved = self.db.with_face_data(face.id, |bytes, face_index| {
201 let (embeddable, subsettable) = ttf_parser::Face::parse(bytes, face_index)
202 .ok()
203 .map(|f| face_embedding_permissions(&f))
204 .unwrap_or((false, false));
205 ResolvedPdfFont {
206 id: id.to_string(),
207 family: family.clone(),
208 bytes: bytes.to_vec(),
209 face_index,
210 embeddable,
211 subsettable,
212 }
213 });
214 if let Some(resolved) = resolved {
215 return Ok(resolved);
216 }
217 }
218
219 Err(PdfEditError::UnsupportedEncoding)
220 }
221
222 fn glyphs_for_text(
223 &self,
224 font: &ResolvedPdfFont,
225 text: &str,
226 ) -> Result<Vec<GlyphId>, PdfEditError> {
227 glyph_ids_for_text(&font.bytes, font.face_index, text)
228 }
229}
230
231pub(crate) fn glyph_ids_for_text(
237 bytes: &[u8],
238 face_index: u32,
239 text: &str,
240) -> Result<Vec<GlyphId>, PdfEditError> {
241 let face = ttf_parser::Face::parse(bytes, face_index)
242 .map_err(|_| PdfEditError::UnsupportedEncoding)?;
243 text.chars()
244 .map(|c| {
245 face.glyph_index(c)
246 .map(|g| GlyphId(g.0))
247 .ok_or(PdfEditError::MissingGlyph)
248 })
249 .collect()
250}
251
252fn face_embedding_permissions(face: &ttf_parser::Face) -> (bool, bool) {
256 let restricted = matches!(
257 face.permissions(),
258 Some(ttf_parser::Permissions::Restricted)
259 );
260 let embeddable = !restricted && face.is_outline_embedding_allowed();
261 let subsettable = !restricted && face.is_subsetting_allowed();
262 (embeddable, subsettable)
263}
264
265fn format_style(style: fontdb::Style, weight: fontdb::Weight, stretch: fontdb::Stretch) -> String {
266 let mut parts = Vec::new();
267 if stretch != fontdb::Stretch::Normal {
268 parts.push(format!("{stretch:?}"));
269 }
270 match weight.0 {
271 400 => {}
272 700 => parts.push("Bold".to_string()),
273 other => parts.push(format!("{other}")),
274 }
275 match style {
276 fontdb::Style::Normal => {}
277 fontdb::Style::Italic => parts.push("Italic".to_string()),
278 fontdb::Style::Oblique => parts.push("Oblique".to_string()),
279 }
280 if parts.is_empty() {
281 "Regular".to_string()
282 } else {
283 parts.join(" ")
284 }
285}
286
287fn system_font_id(family: &str) -> String {
291 let slug: String = family
292 .chars()
293 .map(|c| {
294 if c.is_ascii_alphanumeric() {
295 c.to_ascii_lowercase()
296 } else {
297 '-'
298 }
299 })
300 .collect();
301 format!("system-{slug}")
302}
303
304const SOURCE_SERIF_4_WOFF2: &[u8] = include_bytes!("../assets/SourceSerif4-Variable.woff2");
308
309fn bundled_font_id(family: &str, postscript_name: Option<&str>) -> String {
310 if family.eq_ignore_ascii_case("Source Serif 4") {
313 return "source-serif-4".to_string();
314 }
315 postscript_name
316 .unwrap_or(family)
317 .to_ascii_lowercase()
318 .replace(' ', "-")
319}
320
321fn discover_bundled_fonts() -> Vec<BundledFontEntry> {
322 let Some(bytes) = wuff::decompress_woff2(SOURCE_SERIF_4_WOFF2).ok() else {
323 return Vec::new();
324 };
325 let Ok(face) = ttf_parser::Face::parse(&bytes, 0) else {
326 return Vec::new();
327 };
328 let family = face
329 .names()
330 .into_iter()
331 .find(|name| name.name_id == ttf_parser::name_id::FAMILY && name.is_unicode())
332 .and_then(|name| name.to_string())
333 .unwrap_or_else(|| "Source Serif 4".to_string());
334 let postscript_name = face
335 .names()
336 .into_iter()
337 .find(|name| name.name_id == ttf_parser::name_id::POST_SCRIPT_NAME && name.is_unicode())
338 .and_then(|name| name.to_string());
339 let style = if face.is_bold() && face.is_italic() {
340 "Bold Italic"
341 } else if face.is_bold() {
342 "Bold"
343 } else if face.is_italic() {
344 "Italic"
345 } else {
346 "Regular"
347 }
348 .to_string();
349
350 vec![BundledFontEntry {
351 id: bundled_font_id(&family, postscript_name.as_deref()),
352 family,
353 postscript_name,
354 style,
355 bytes,
356 face_index: 0,
357 }]
358}
359
360#[derive(Clone, Copy, Debug, PartialEq)]
367pub struct PdfCidWidth {
368 pub cid: u32,
369 pub width_1000: f64,
370}
371
372#[derive(Clone, Debug)]
380pub struct EmbeddedPdfFontSubset {
381 pub font_program: Vec<u8>,
383 pub widths: Vec<PdfCidWidth>,
386 pub to_unicode: Vec<(u32, String)>,
388 #[allow(dead_code)]
392 pub glyph_count: u16,
393}
394
395pub fn subset_for_pdf(
400 bytes: &[u8],
401 face_index: u32,
402 text: &str,
403) -> Result<EmbeddedPdfFontSubset, PdfEditError> {
404 let face = ttf_parser::Face::parse(bytes, face_index)
405 .map_err(|_| PdfEditError::UnsupportedEncoding)?;
406
407 let (embeddable, subsettable) = face_embedding_permissions(&face);
408 if !embeddable || !subsettable {
409 return Err(PdfEditError::FontEmbeddingDenied);
410 }
411
412 let units_per_em = face.units_per_em() as f64;
413 if units_per_em <= 0.0 {
414 return Err(PdfEditError::UnsupportedEncoding);
415 }
416
417 let mut seen_glyphs: Vec<u16> = Vec::new();
422 let mut char_glyphs: Vec<(char, u16)> = Vec::new();
423 for c in text.chars() {
424 let gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
425 if !seen_glyphs.contains(&gid) {
426 seen_glyphs.push(gid);
427 }
428 char_glyphs.push((c, gid));
429 }
430
431 let mut remapper = subsetter::GlyphRemapper::new();
432 let mut old_to_new: BTreeMap<u16, u16> = BTreeMap::new();
437 for &old_gid in &seen_glyphs {
438 let new_gid = remapper.remap(old_gid);
439 old_to_new.insert(old_gid, new_gid);
440 }
441
442 let font_program = subsetter::subset(bytes, face_index, &remapper)
443 .map_err(|_| PdfEditError::UnsupportedEncoding)?;
444
445 let mut widths = Vec::with_capacity(seen_glyphs.len());
446 for &old_gid in &seen_glyphs {
447 let new_gid = old_to_new[&old_gid];
448 let advance = face
449 .glyph_hor_advance(ttf_parser::GlyphId(old_gid))
450 .unwrap_or(0);
451 widths.push(PdfCidWidth {
452 cid: new_gid as u32,
453 width_1000: advance as f64 * 1000.0 / units_per_em,
454 });
455 }
456
457 let mut to_unicode: Vec<(u32, String)> = Vec::new();
460 let mut cid_has_unicode: BTreeMap<u32, ()> = BTreeMap::new();
461 for (c, old_gid) in &char_glyphs {
462 let new_gid = old_to_new[old_gid] as u32;
463 if cid_has_unicode.insert(new_gid, ()).is_none() {
464 to_unicode.push((new_gid, c.to_string()));
465 }
466 }
467
468 Ok(EmbeddedPdfFontSubset {
469 font_program,
470 widths,
471 to_unicode,
472 glyph_count: seen_glyphs.len() as u16 + 1, })
474}
475
476pub fn build_to_unicode_cmap(entries: &[(u32, String)]) -> String {
482 let mut sorted = entries.to_vec();
483 sorted.sort_by_key(|(cid, _)| *cid);
484
485 let mut out = String::new();
486 out.push_str("/CIDInit /ProcSet findresource begin\n");
487 out.push_str("12 dict begin\nbegincmap\n");
488 out.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
489 out.push_str("/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n");
490 out.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
491 out.push_str(&format!("{} beginbfchar\n", sorted.len()));
492 for (cid, unicode) in &sorted {
493 out.push_str(&format!(
494 "<{:04X}> <{}>\n",
495 cid,
496 unicode_to_utf16be_hex(unicode)
497 ));
498 }
499 out.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
500 out
501}
502
503fn unicode_to_utf16be_hex(text: &str) -> String {
504 let mut hex = String::new();
505 for unit in text.encode_utf16() {
506 hex.push_str(&format!("{unit:04X}"));
507 }
508 hex
509}
510
511#[cfg(test)]
516mod tests {
517 use super::*;
518 use crate::test_fixtures::build_test_ttf;
519
520 const FS_TYPE_INSTALLABLE: u16 = 0x0000;
525 const FS_TYPE_RESTRICTED: u16 = 0x0002; const FS_TYPE_NO_SUBSETTING: u16 = 0x0100; fn installable_font() -> Vec<u8> {
529 build_test_ttf(FS_TYPE_INSTALLABLE, &[('A', 1), ('B', 2), ('C', 3)], 600)
530 }
531
532 #[test]
533 fn installable_font_parses_and_reports_embeddable_subsettable() {
534 let bytes = installable_font();
535 let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
536 let (embeddable, subsettable) = face_embedding_permissions(&face);
537 assert!(embeddable);
538 assert!(subsettable);
539 }
540
541 #[test]
542 fn restricted_font_is_not_embeddable() {
543 let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
544 let face = ttf_parser::Face::parse(&bytes, 0).expect("valid synthetic font");
545 let (embeddable, subsettable) = face_embedding_permissions(&face);
546 assert!(!embeddable);
547 assert!(!subsettable);
548 }
549
550 #[test]
551 fn subset_for_pdf_rejects_embedding_restricted_font() {
552 let bytes = build_test_ttf(FS_TYPE_RESTRICTED, &[('A', 1)], 600);
553 let err = subset_for_pdf(&bytes, 0, "A").expect_err("restricted font must reject");
554 assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
555 }
556
557 #[test]
558 fn subset_for_pdf_rejects_subset_disallowed_font() {
559 let bytes = build_test_ttf(FS_TYPE_NO_SUBSETTING, &[('A', 1)], 600);
560 let err = subset_for_pdf(&bytes, 0, "A").expect_err("no-subsetting font must reject");
561 assert!(matches!(err, PdfEditError::FontEmbeddingDenied));
562 }
563
564 #[test]
565 fn subset_for_pdf_reports_missing_glyph() {
566 let bytes = installable_font(); let err = subset_for_pdf(&bytes, 0, "AZ").expect_err("Z has no glyph");
568 assert!(matches!(err, PdfEditError::MissingGlyph));
569 }
570
571 #[test]
572 fn subset_for_pdf_succeeds_for_covered_text() {
573 let bytes = installable_font();
574 let subset = subset_for_pdf(&bytes, 0, "ABA").expect("A and B are covered");
575 assert_eq!(subset.widths.len(), 2);
577 assert!(subset
578 .widths
579 .iter()
580 .all(|w| (w.width_1000 - 600.0).abs() < 1e-6));
581 assert_eq!(subset.to_unicode.len(), 2);
583 let mut chars: Vec<&str> = subset
584 .to_unicode
585 .iter()
586 .map(|(_, unicode)| unicode.as_str())
587 .collect();
588 chars.sort();
589 assert_eq!(chars, vec!["A", "B"]);
590 ttf_parser::Face::parse(&subset.font_program, 0).expect("subset output is valid TTF");
592 }
593
594 #[test]
595 fn subset_for_pdf_glyphs_for_text_agrees_with_resolver_trait() {
596 let bytes = installable_font();
597 let resolved = ResolvedPdfFont {
598 id: "test-font".to_string(),
599 family: "Test Font".to_string(),
600 bytes: bytes.clone(),
601 face_index: 0,
602 embeddable: true,
603 subsettable: true,
604 };
605 let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
606 let glyphs = catalog
607 .glyphs_for_text(&resolved, "AB")
608 .expect("A and B are covered");
609 assert_eq!(glyphs, vec![GlyphId(1), GlyphId(2)]);
610
611 let err = catalog
612 .glyphs_for_text(&resolved, "Z")
613 .expect_err("Z is not covered");
614 assert!(matches!(err, PdfEditError::MissingGlyph));
615 }
616
617 #[test]
618 fn build_to_unicode_cmap_round_trips_generated_mapping() {
619 let subset = subset_for_pdf(&installable_font(), 0, "AB").expect("covered text");
620 let cmap = build_to_unicode_cmap(&subset.to_unicode);
621
622 assert!(cmap.contains("beginbfchar"));
623 assert!(cmap.contains("endbfchar"));
624 assert!(cmap.contains("/CMapType 2 def"));
625
626 for (cid, unicode) in &subset.to_unicode {
628 let expected = format!("<{cid:04X}> <{}>", unicode_to_utf16be_hex(unicode));
629 assert!(
630 cmap.contains(&expected),
631 "missing {expected} in generated CMap:\n{cmap}"
632 );
633 }
634 }
635
636 #[test]
637 fn build_to_unicode_cmap_is_sorted_by_cid_and_deterministic() {
638 let entries = vec![
639 (5u32, "b".to_string()),
640 (1u32, "a".to_string()),
641 (3u32, "c".to_string()),
642 ];
643 let cmap_a = build_to_unicode_cmap(&entries);
644 let cmap_b = build_to_unicode_cmap(&entries);
645 assert_eq!(cmap_a, cmap_b);
646 let pos_1 = cmap_a.find("<0001>").expect("cid 1 present");
647 let pos_3 = cmap_a.find("<0003>").expect("cid 3 present");
648 let pos_5 = cmap_a.find("<0005>").expect("cid 5 present");
649 assert!(pos_1 < pos_3 && pos_3 < pos_5);
650 }
651
652 #[test]
653 fn unicode_to_utf16be_hex_encodes_bmp_text() {
654 assert_eq!(unicode_to_utf16be_hex("A"), "0041");
655 assert_eq!(unicode_to_utf16be_hex("AB"), "00410042");
656 }
657
658 #[test]
659 fn font_catalog_from_parts_lists_bundled_entry_with_permissions() {
660 let bundled = BundledFontEntry {
661 id: "test-serif".to_string(),
662 family: "Test Serif".to_string(),
663 postscript_name: Some("TestSerif-Regular".to_string()),
664 style: "Regular".to_string(),
665 bytes: installable_font(),
666 face_index: 0,
667 };
668 let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
669 let entries = catalog.list_entries();
670 assert_eq!(entries.len(), 1);
671 let entry = &entries[0];
672 assert_eq!(entry.id, "test-serif");
673 assert_eq!(entry.source, PdfFontSource::Bundled);
674 assert!(entry.embeddable);
675 assert!(entry.subsettable);
676 }
677
678 #[test]
679 fn font_catalog_resolve_returns_bundled_font_bytes() {
680 let bytes = installable_font();
681 let bundled = BundledFontEntry {
682 id: "test-serif".to_string(),
683 family: "Test Serif".to_string(),
684 postscript_name: None,
685 style: "Regular".to_string(),
686 bytes: bytes.clone(),
687 face_index: 0,
688 };
689 let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled]);
690 let resolved = catalog
691 .resolve("test-serif")
692 .expect("bundled font resolves");
693 assert_eq!(resolved.bytes, bytes);
694 assert!(resolved.embeddable);
695 }
696
697 #[test]
698 fn font_catalog_resolve_unknown_id_is_unsupported_encoding() {
699 let catalog = FontCatalog::from_parts(fontdb::Database::new(), Vec::new());
700 let err = catalog
701 .resolve("does-not-exist")
702 .expect_err("unknown id must fail");
703 assert!(matches!(err, PdfEditError::UnsupportedEncoding));
704 }
705
706 #[test]
707 fn bundled_fonts_are_available_without_runtime_files() {
708 assert_eq!(discover_bundled_fonts().len(), 1);
709 }
710
711 #[test]
712 fn source_serif_4_woff2_is_discovered_resolved_parseable_and_subsettable() {
713 let bundled = discover_bundled_fonts()
714 .into_iter()
715 .find(|font| font.id == "source-serif-4")
716 .expect("embedded Source Serif 4 WOFF2 is available");
717 let face = ttf_parser::Face::parse(&bundled.bytes, bundled.face_index)
718 .expect("decoded Source Serif 4 is parseable SFNT");
719 assert!(face.glyph_index('ç').is_some());
720 assert!(face.glyph_index('ã').is_some());
721
722 let catalog = FontCatalog::from_parts(fontdb::Database::new(), vec![bundled.clone()]);
723 let entry = catalog
724 .list_entries()
725 .into_iter()
726 .find(|entry| entry.id == "source-serif-4")
727 .expect("Source Serif 4 catalog entry");
728 assert_eq!(entry.source, PdfFontSource::Bundled);
729 assert!(entry.embeddable);
730 assert!(entry.subsettable);
731
732 let resolved = catalog
733 .resolve("source-serif-4")
734 .expect("stable frontend font id resolves");
735 assert_eq!(resolved.bytes, bundled.bytes);
736
737 let correction = "A correção mantém ação, informação e português.";
738 catalog
739 .glyphs_for_text(&resolved, correction)
740 .expect("Portuguese correction text has complete glyph coverage");
741 let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, correction)
742 .expect("Source Serif 4 subset is embeddable");
743 ttf_parser::Face::parse(&subset.font_program, 0)
744 .expect("Source Serif 4 subset output is parseable SFNT");
745 assert!(subset.widths.len() > 10);
746 assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ç"));
747 assert!(subset.to_unicode.iter().any(|(_, unicode)| unicode == "ã"));
748 }
749
750 #[test]
751 fn bundled_font_id_stabilizes_source_serif_metadata() {
752 assert_eq!(
753 bundled_font_id("Source Serif 4", Some("SourceSerif4-Roman")),
754 "source-serif-4"
755 );
756 assert_eq!(
757 bundled_font_id("Test Serif", Some("TestSerif-Regular")),
758 "testserif-regular"
759 );
760 }
761
762 #[test]
763 fn system_font_id_is_stable_and_slugified() {
764 assert_eq!(system_font_id("Arial"), "system-arial");
765 assert_eq!(system_font_id("Source Serif 4"), "system-source-serif-4");
766 assert_eq!(system_font_id("Arial"), system_font_id("Arial"));
767 }
768}