1use std::collections::BTreeMap;
73use std::path::{Path, PathBuf};
74
75use serde::Deserialize;
76
77use rustyfi_backend::FontKey;
78
79use crate::ttf::{FontError, TtfFontStore};
80
81#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum FontSource {
89 Single(PathBuf),
91 Collection(PathBuf, u32),
93}
94
95#[derive(Debug, Clone)]
103pub struct FontRegistry {
104 faces: BTreeMap<String, FontSource>,
105 default_faces: [String; 3],
110 script_fonts: [Option<(String, f64, f64)>; 4],
116 math_font: Option<String>,
121}
122
123#[derive(Debug, Clone, Default)]
127pub struct FontFlags {
128 pub regular: Option<PathBuf>,
129 pub bold: Option<PathBuf>,
130 pub oblique: Option<PathBuf>,
131}
132
133impl FontFlags {
134 fn is_empty(&self) -> bool {
135 self.regular.is_none() && self.bold.is_none() && self.oblique.is_none()
136 }
137}
138
139#[derive(Debug, thiserror::Error)]
146pub enum FontConfigError {
147 #[error("failed to read font config {path}: {source}")]
148 Io {
149 path: PathBuf,
150 #[source]
151 source: std::io::Error,
152 },
153 #[error("failed to parse font config {path} as JSON: {source}")]
154 Json {
155 path: PathBuf,
156 #[source]
157 source: serde_json::Error,
158 },
159 #[error(
160 "{path}: default-face {face:?} names abbrev {abbrev:?}, which is not \
161 defined in fonts.satysfi-hash"
162 )]
163 UnknownAbbrev {
164 path: PathBuf,
165 face: &'static str,
166 abbrev: String,
167 },
168 #[error(
169 "font abbrev {abbrev:?} names a font collection at TTC index {index}; \
170 only index 0 can be loaded in this port (TtfFontStore does not yet \
171 support selecting a non-zero face of a collection)"
172 )]
173 UnsupportedCollectionIndex { abbrev: String, index: u32 },
174 #[error("--font-bold/--font-oblique require --font (no regular face given)")]
175 RegularRequired,
176 #[error(transparent)]
177 Font(#[from] FontError),
178}
179
180fn yojson_to_json(text: &str) -> String {
192 let mut out = String::with_capacity(text.len());
193 let mut chars = text.char_indices().peekable();
194 let mut in_string = false;
195 let mut escaped = false;
196 let mut depth: usize = 0;
197
198 while let Some((i, c)) = chars.next() {
199 if in_string {
200 out.push(c);
201 if escaped {
202 escaped = false;
203 } else if c == '\\' {
204 escaped = true;
205 } else if c == '"' {
206 in_string = false;
207 }
208 continue;
209 }
210 match c {
211 '"' => {
212 in_string = true;
213 out.push(c);
214 }
215 '<' => {
216 let rest = &text[i + 1..];
219 match rest.find(':') {
220 Some(colon)
221 if rest[..colon]
222 .chars()
223 .all(|t| t.is_alphanumeric() || t == '"' || t == '_' || t == '-' || t.is_whitespace()) =>
224 {
225 for _ in 0..=colon {
226 chars.next();
227 }
228 depth += 1;
229 }
230 _ => out.push(c),
232 }
233 }
234 '>' if depth > 0 => depth -= 1,
235 _ => out.push(c),
236 }
237 }
238 out
239}
240
241#[derive(Debug, Deserialize)]
244struct RawFontEntry {
245 #[serde(default)]
248 src: Option<PathBuf>,
249 #[serde(default, rename = "src-dist")]
254 src_dist: Option<PathBuf>,
255 #[serde(default)]
256 index: Option<u32>,
257}
258
259impl RawFontEntry {
260 fn resolve(&self, root: &std::path::Path) -> Option<PathBuf> {
263 if let Some(src) = &self.src {
264 return Some(root.join(src));
265 }
266 self.src_dist
267 .as_ref()
268 .map(|rel| root.join("dist").join("fonts").join(rel))
269 }
270}
271
272#[derive(Debug, Deserialize)]
279struct RawDefaultFace {
280 regular: String,
281 #[serde(default)]
282 bold: Option<String>,
283 #[serde(default)]
284 oblique: Option<String>,
285 #[serde(default)]
286 scripts: Option<RawScripts>,
287 #[serde(default)]
292 math: Option<String>,
293}
294
295#[derive(Debug, Deserialize)]
298struct RawScriptFont {
299 #[serde(rename = "font-name")]
300 font_name: String,
301 ratio: f64,
302 rising: f64,
303}
304
305#[derive(Debug, Deserialize, Default)]
310struct RawScripts {
311 #[serde(rename = "han-ideographic")]
312 han_ideographic: Option<RawScriptFont>,
313 kana: Option<RawScriptFont>,
314 latin: Option<RawScriptFont>,
315 #[serde(rename = "other-script")]
316 other_script: Option<RawScriptFont>,
317}
318
319fn load_or_dedup(
323 files: &mut Vec<Vec<u8>>,
324 file_by_path: &mut BTreeMap<PathBuf, usize>,
325 path: &Path,
326) -> Result<usize, FontConfigError> {
327 let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
328 if let Some(&idx) = file_by_path.get(&canon) {
329 return Ok(idx);
330 }
331 let bytes = TtfFontStore::read_and_validate(path)?;
332 let idx = files.len();
333 files.push(bytes);
334 file_by_path.insert(canon, idx);
335 Ok(idx)
336}
337
338const CLI_REGULAR: &str = "<--font>";
342const CLI_BOLD: &str = "<--font-bold>";
343const CLI_OBLIQUE: &str = "<--font-oblique>";
344
345impl FontRegistry {
346 pub fn discover(
350 lib_root: Option<&Path>,
351 font_dir: Option<&Path>,
352 flags: &FontFlags,
353 ) -> Result<Option<FontRegistry>, FontConfigError> {
354 if !flags.is_empty() {
355 return Self::from_flags(flags).map(Some);
356 }
357
358 let Some(root) = font_dir.or(lib_root) else {
359 return Ok(None);
360 };
361
362 let hash_dir = root.join("dist").join("hash");
363 let fonts_path = hash_dir.join("fonts.satysfi-hash");
364 let fonts_bytes = match std::fs::read(&fonts_path) {
365 Ok(bytes) => bytes,
366 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
367 Err(source) => {
368 return Err(FontConfigError::Io {
369 path: fonts_path,
370 source,
371 })
372 }
373 };
374 let fonts_text = String::from_utf8_lossy(&fonts_bytes);
376 let raw: BTreeMap<String, RawFontEntry> =
377 serde_json::from_str(&yojson_to_json(&fonts_text)).map_err(|source| {
378 FontConfigError::Json {
379 path: fonts_path.clone(),
380 source,
381 }
382 })?;
383 let faces: BTreeMap<String, FontSource> = raw
384 .into_iter()
385 .filter_map(|(abbrev, entry)| {
386 let resolved = entry.resolve(root)?;
389 let source = match entry.index {
390 Some(index) => FontSource::Collection(resolved, index),
391 None => FontSource::Single(resolved),
392 };
393 Some((abbrev, source))
394 })
395 .collect();
396
397 let default_path = hash_dir.join("default-font.satysfi-hash");
398 let default_bytes = std::fs::read(&default_path).map_err(|source| FontConfigError::Io {
399 path: default_path.clone(),
400 source,
401 })?;
402 let raw_default: RawDefaultFace =
403 serde_json::from_slice(&default_bytes).map_err(|source| FontConfigError::Json {
404 path: default_path.clone(),
405 source,
406 })?;
407
408 let regular = raw_default.regular;
409 let bold = raw_default.bold.unwrap_or_else(|| regular.clone());
410 let oblique = raw_default.oblique.unwrap_or_else(|| regular.clone());
411
412 for (face, abbrev) in [
413 ("regular", ®ular),
414 ("bold", &bold),
415 ("oblique", &oblique),
416 ] {
417 if !faces.contains_key(abbrev) {
418 return Err(FontConfigError::UnknownAbbrev {
419 path: default_path,
420 face,
421 abbrev: abbrev.clone(),
422 });
423 }
424 }
425
426 let mut script_fonts: [Option<(String, f64, f64)>; 4] = [None, None, None, None];
431 if let Some(scripts) = raw_default.scripts {
432 for (idx, name, entry) in [
433 (0usize, "han-ideographic", scripts.han_ideographic),
434 (1, "kana", scripts.kana),
435 (2, "latin", scripts.latin),
436 (3, "other-script", scripts.other_script),
437 ] {
438 let Some(entry) = entry else { continue };
439 if !faces.contains_key(&entry.font_name) {
440 return Err(FontConfigError::UnknownAbbrev {
441 path: default_path,
442 face: name,
443 abbrev: entry.font_name,
444 });
445 }
446 script_fonts[idx] = Some((entry.font_name, entry.ratio, entry.rising));
447 }
448 }
449
450 if let Some(abbrev) = &raw_default.math {
454 if !faces.contains_key(abbrev) {
455 return Err(FontConfigError::UnknownAbbrev {
456 path: default_path,
457 face: "math",
458 abbrev: abbrev.clone(),
459 });
460 }
461 }
462
463 Ok(Some(FontRegistry {
464 faces,
465 default_faces: [regular, bold, oblique],
466 script_fonts,
467 math_font: raw_default.math,
468 }))
469 }
470
471 fn from_flags(flags: &FontFlags) -> Result<FontRegistry, FontConfigError> {
474 let Some(regular) = &flags.regular else {
475 return Err(FontConfigError::RegularRequired);
476 };
477 let mut faces = BTreeMap::new();
478 faces.insert(CLI_REGULAR.to_string(), FontSource::Single(regular.clone()));
479 let bold_abbrev = match &flags.bold {
480 Some(path) => {
481 faces.insert(CLI_BOLD.to_string(), FontSource::Single(path.clone()));
482 CLI_BOLD.to_string()
483 }
484 None => CLI_REGULAR.to_string(),
485 };
486 let oblique_abbrev = match &flags.oblique {
487 Some(path) => {
488 faces.insert(CLI_OBLIQUE.to_string(), FontSource::Single(path.clone()));
489 CLI_OBLIQUE.to_string()
490 }
491 None => CLI_REGULAR.to_string(),
492 };
493 Ok(FontRegistry {
494 faces,
495 default_faces: [CLI_REGULAR.to_string(), bold_abbrev, oblique_abbrev],
496 script_fonts: [None, None, None, None],
497 math_font: None,
498 })
499 }
500
501 pub fn build_store(&self) -> Result<TtfFontStore, FontConfigError> {
515 let regular_path = self.resolve(&self.default_faces[0])?;
521 let bold_path = if self.default_faces[1] != self.default_faces[0] {
522 Some(self.resolve(&self.default_faces[1])?)
523 } else {
524 None
525 };
526 let oblique_path = if self.default_faces[2] != self.default_faces[0] {
527 Some(self.resolve(&self.default_faces[2])?)
528 } else {
529 None
530 };
531 let mut other_paths: Vec<(String, PathBuf)> = Vec::new();
532 for abbrev in self.faces.keys() {
533 if self.default_faces.contains(abbrev) {
534 continue; }
536 other_paths.push((abbrev.clone(), self.resolve(abbrev)?));
537 }
538
539 let mut files: Vec<Vec<u8>> = Vec::new();
540 let mut file_by_path: BTreeMap<PathBuf, usize> = BTreeMap::new();
541
542 let regular_idx = load_or_dedup(&mut files, &mut file_by_path, ®ular_path)?;
547 let mut slots = vec![regular_idx, regular_idx, regular_idx];
548 if let Some(path) = &bold_path {
549 slots[1] = load_or_dedup(&mut files, &mut file_by_path, path)?;
550 }
551 if let Some(path) = &oblique_path {
552 slots[2] = load_or_dedup(&mut files, &mut file_by_path, path)?;
553 }
554
555 let mut abbrevs: BTreeMap<String, FontKey> = BTreeMap::new();
560 for (abbrev, path) in &other_paths {
561 let idx = load_or_dedup(&mut files, &mut file_by_path, path)?;
562 let key = FontKey(slots.len() as u16);
563 slots.push(idx);
564 abbrevs.insert(abbrev.clone(), key);
565 }
566
567 for (i, abbrev) in self.default_faces.iter().enumerate() {
577 abbrevs.entry(abbrev.clone()).or_insert(FontKey(i as u16));
578 }
579
580 let mut script_defaults: [Option<(FontKey, f64, f64)>; 4] = [None, None, None, None];
584 for (i, entry) in self.script_fonts.iter().enumerate() {
585 if let Some((abbrev, ratio, rising)) = entry {
586 let key = *abbrevs.get(abbrev).unwrap_or_else(|| {
587 panic!("FontRegistry invariant violated: scripts abbrev {abbrev:?} unresolved")
588 });
589 script_defaults[i] = Some((key, *ratio, *rising));
590 }
591 }
592
593 let math_default = self.math_font.as_ref().map(|abbrev| {
596 *abbrevs.get(abbrev).unwrap_or_else(|| {
597 panic!("FontRegistry invariant violated: math abbrev {abbrev:?} unresolved")
598 })
599 });
600
601 Ok(TtfFontStore::from_parts(
602 files,
603 slots,
604 abbrevs,
605 script_defaults,
606 math_default,
607 ))
608 }
609
610 fn resolve(&self, abbrev: &str) -> Result<PathBuf, FontConfigError> {
618 match self
619 .faces
620 .get(abbrev)
621 .unwrap_or_else(|| panic!("FontRegistry invariant violated: {abbrev:?} unresolved"))
622 {
623 FontSource::Single(path) => Ok(path.clone()),
624 FontSource::Collection(path, 0) => Ok(path.clone()),
625 FontSource::Collection(_, index) => Err(FontConfigError::UnsupportedCollectionIndex {
626 abbrev: abbrev.to_string(),
627 index: *index,
628 }),
629 }
630 }
631
632 #[cfg(test)]
636 pub(crate) fn faces(&self) -> &BTreeMap<String, FontSource> {
637 &self.faces
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use rustyfi_backend::FontMetrics as _;
645 use std::process::Command;
646 use std::sync::atomic::{AtomicU64, Ordering};
647
648 fn tmpdir(tag: &str) -> PathBuf {
649 static COUNTER: AtomicU64 = AtomicU64::new(0);
650 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
651 let dir = std::env::temp_dir().join(format!(
652 "rustyfi-pdf-fonts-test-{tag}-{}-{}-{n}",
653 std::process::id(),
654 std::time::SystemTime::now()
655 .duration_since(std::time::UNIX_EPOCH)
656 .unwrap()
657 .as_nanos(),
658 ));
659 std::fs::create_dir_all(&dir).unwrap();
660 dir
661 }
662
663 fn write_hash_dir(root: &Path, fonts_json: &str, default_json: Option<&str>) {
664 let hash_dir = root.join("dist/hash");
665 std::fs::create_dir_all(&hash_dir).unwrap();
666 std::fs::write(hash_dir.join("fonts.satysfi-hash"), fonts_json).unwrap();
667 if let Some(default_json) = default_json {
668 std::fs::write(hash_dir.join("default-font.satysfi-hash"), default_json).unwrap();
669 }
670 }
671
672 fn find_regular_font() -> Option<PathBuf> {
675 if let Ok(output) = Command::new("fc-match")
676 .args(["--format=%{file}", "DejaVuSans"])
677 .output()
678 {
679 if output.status.success() {
680 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
681 if !path.is_empty() && Path::new(&path).is_file() {
682 return Some(PathBuf::from(path));
683 }
684 }
685 }
686 for candidate in [
687 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
688 "/usr/share/fonts/dejavu/DejaVuSans.ttf",
689 "/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
690 "/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf",
691 ] {
692 if Path::new(candidate).is_file() {
693 return Some(PathBuf::from(candidate));
694 }
695 }
696 None
697 }
698
699 macro_rules! need_font {
700 () => {
701 match find_regular_font() {
702 Some(path) => path,
703 None => {
704 eprintln!("skipping: no DejaVuSans-like TrueType font found on this system");
705 return;
706 }
707 }
708 };
709 }
710
711 #[test]
712 fn discover_returns_none_with_nothing_configured() {
713 let flags = FontFlags::default();
714 assert!(FontRegistry::discover(None, None, &flags)
715 .unwrap()
716 .is_none());
717 }
718
719 #[test]
720 fn discover_returns_none_when_root_has_no_hash_dir() {
721 let dir = tmpdir("no-hash-dir");
722 let flags = FontFlags::default();
723 assert!(
724 FontRegistry::discover(Some(&dir), None, &flags)
725 .unwrap()
726 .is_none(),
727 "an existing root with no dist/hash/fonts.satysfi-hash is 'nothing configured'"
728 );
729 std::fs::remove_dir_all(&dir).ok();
730 }
731
732 #[test]
733 fn discover_parses_single_and_collection_sources() {
734 let dir = tmpdir("parse-sources");
735 write_hash_dir(
736 &dir,
737 r#"{ "lmroman": { "src": "dist/fonts/lmroman.otf" },
738 "somettc": { "src": "dist/fonts/foo.ttc", "index": 2 } }"#,
739 Some(r#"{ "regular": "lmroman" }"#),
740 );
741 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
742 .unwrap()
743 .expect("config present");
744 assert_eq!(
745 registry.faces().get("lmroman"),
746 Some(&FontSource::Single(dir.join("dist/fonts/lmroman.otf")))
747 );
748 assert_eq!(
749 registry.faces().get("somettc"),
750 Some(&FontSource::Collection(dir.join("dist/fonts/foo.ttc"), 2))
751 );
752 assert_eq!(registry.default_faces, ["lmroman", "lmroman", "lmroman"]);
753 std::fs::remove_dir_all(&dir).ok();
754 }
755
756 #[test]
757 fn discover_resolves_absolute_src_verbatim() {
758 let dir = tmpdir("abs-src");
759 let abs = dir.join("elsewhere/regular.ttf");
761 write_hash_dir(
762 &dir,
763 &format!(r#"{{ "abbr": {{ "src": {:?} }} }}"#, abs.to_str().unwrap()),
764 Some(r#"{ "regular": "abbr" }"#),
765 );
766 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
767 .unwrap()
768 .unwrap();
769 assert_eq!(registry.faces().get("abbr"), Some(&FontSource::Single(abs)));
770 std::fs::remove_dir_all(&dir).ok();
771 }
772
773 #[test]
774 fn discover_font_dir_takes_precedence_over_lib_root() {
775 let lib_root = tmpdir("precedence-lib-root");
776 let font_root = tmpdir("precedence-font-root");
777 write_hash_dir(
778 &lib_root,
779 r#"{ "fromlib": { "src": "a.ttf" } }"#,
780 Some(r#"{ "regular": "fromlib" }"#),
781 );
782 write_hash_dir(
783 &font_root,
784 r#"{ "fromfontdir": { "src": "b.ttf" } }"#,
785 Some(r#"{ "regular": "fromfontdir" }"#),
786 );
787 let registry =
788 FontRegistry::discover(Some(&lib_root), Some(&font_root), &FontFlags::default())
789 .unwrap()
790 .unwrap();
791 assert!(registry.faces().contains_key("fromfontdir"));
792 assert!(!registry.faces().contains_key("fromlib"));
793 std::fs::remove_dir_all(&lib_root).ok();
794 std::fs::remove_dir_all(&font_root).ok();
795 }
796
797 #[test]
798 fn default_face_bold_and_oblique_can_diverge_from_regular() {
799 let dir = tmpdir("distinct-faces");
800 write_hash_dir(
801 &dir,
802 r#"{ "reg": { "src": "reg.ttf" },
803 "b": { "src": "b.ttf" },
804 "obl": { "src": "obl.ttf" } }"#,
805 Some(r#"{ "regular": "reg", "bold": "b", "oblique": "obl" }"#),
806 );
807 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
808 .unwrap()
809 .unwrap();
810 assert_eq!(registry.default_faces, ["reg", "b", "obl"]);
811 std::fs::remove_dir_all(&dir).ok();
812 }
813
814 #[test]
815 fn malformed_json_is_an_error_not_none() {
816 let dir = tmpdir("malformed");
817 write_hash_dir(&dir, "{ not json", None);
818 let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
819 assert!(matches!(err, FontConfigError::Json { .. }), "{err}");
820 std::fs::remove_dir_all(&dir).ok();
821 }
822
823 #[test]
824 fn missing_default_font_file_is_an_error_once_fonts_hash_exists() {
825 let dir = tmpdir("missing-default");
826 write_hash_dir(&dir, r#"{ "reg": { "src": "reg.ttf" } }"#, None);
827 let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
828 assert!(matches!(err, FontConfigError::Io { .. }), "{err}");
829 std::fs::remove_dir_all(&dir).ok();
830 }
831
832 #[test]
833 fn unknown_default_abbrev_is_an_error() {
834 let dir = tmpdir("unknown-abbrev");
835 write_hash_dir(
836 &dir,
837 r#"{ "reg": { "src": "reg.ttf" } }"#,
838 Some(r#"{ "regular": "does-not-exist" }"#),
839 );
840 let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
841 assert!(
842 matches!(err, FontConfigError::UnknownAbbrev { .. }),
843 "{err}"
844 );
845 std::fs::remove_dir_all(&dir).ok();
846 }
847
848 #[test]
849 fn from_flags_without_regular_is_an_error() {
850 let flags = FontFlags {
851 regular: None,
852 bold: Some(PathBuf::from("bold.ttf")),
853 oblique: None,
854 };
855 let err = FontRegistry::discover(None, None, &flags).unwrap_err();
856 assert!(matches!(err, FontConfigError::RegularRequired), "{err}");
857 }
858
859 #[test]
860 fn flags_take_precedence_over_any_directory() {
861 let dir = tmpdir("flags-precedence");
862 write_hash_dir(
863 &dir,
864 r#"{ "fromconfig": { "src": "a.ttf" } }"#,
865 Some(r#"{ "regular": "fromconfig" }"#),
866 );
867 let flags = FontFlags {
868 regular: Some(PathBuf::from("cli-regular.ttf")),
869 bold: None,
870 oblique: None,
871 };
872 let registry = FontRegistry::discover(Some(&dir), None, &flags)
873 .unwrap()
874 .unwrap();
875 assert!(!registry.faces().contains_key("fromconfig"));
876 std::fs::remove_dir_all(&dir).ok();
877 }
878
879 #[test]
880 fn build_store_rejects_nonzero_collection_index_without_touching_disk() {
881 let dir = tmpdir("bad-index");
885 write_hash_dir(
886 &dir,
887 r#"{ "reg": { "src": "reg.ttf" },
888 "obl": { "src": "obl.ttc", "index": 1 } }"#,
889 Some(r#"{ "regular": "reg", "oblique": "obl" }"#),
890 );
891 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
892 .unwrap()
893 .unwrap();
894 let Err(err) = registry.build_store() else {
897 panic!("expected an UnsupportedCollectionIndex error");
898 };
899 assert!(
900 matches!(
901 err,
902 FontConfigError::UnsupportedCollectionIndex { index: 1, .. }
903 ),
904 "{err}"
905 );
906 std::fs::remove_dir_all(&dir).ok();
907 }
908
909 #[test]
910 fn build_store_loads_a_real_font_end_to_end() {
911 let font_path = need_font!();
912 let dir = tmpdir("real-font");
913 write_hash_dir(
914 &dir,
915 &format!(
916 r#"{{ "reg": {{ "src": {:?} }} }}"#,
917 font_path.to_str().unwrap()
918 ),
919 Some(r#"{ "regular": "reg" }"#),
920 );
921 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
922 .unwrap()
923 .unwrap();
924 let store = registry.build_store().expect("build_store should succeed");
925 assert_eq!(store.num_files(), 1);
926 let size = rustyfi_backend::Length::pt(12.0);
927 assert!(store
928 .advance(rustyfi_backend::FontKey(0), 'A', size)
929 .is_some());
930 std::fs::remove_dir_all(&dir).ok();
931 }
932
933 #[test]
934 fn build_store_from_cli_flags_end_to_end() {
935 let font_path = need_font!();
936 let flags = FontFlags {
937 regular: Some(font_path),
938 bold: None,
939 oblique: None,
940 };
941 let registry = FontRegistry::discover(None, None, &flags).unwrap().unwrap();
942 let store = registry.build_store().expect("build_store should succeed");
943 assert_eq!(store.num_files(), 1);
944 let size = rustyfi_backend::Length::pt(12.0);
947 assert_eq!(
948 store.advance(rustyfi_backend::FontKey(0), 'A', size),
949 store.advance(rustyfi_backend::FontKey(1), 'A', size)
950 );
951 }
952
953 fn find_second_font() -> Option<PathBuf> {
959 if let Ok(output) = Command::new("fc-match")
960 .args(["--format=%{file}", "DejaVu Sans Mono"])
961 .output()
962 {
963 if output.status.success() {
964 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
965 if !path.is_empty() && Path::new(&path).is_file() {
966 return Some(PathBuf::from(path));
967 }
968 }
969 }
970 for candidate in [
971 "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
972 "/run/current-system/sw/share/fonts/truetype/DejaVuSansMono.ttf",
973 ] {
974 if Path::new(candidate).is_file() {
975 return Some(PathBuf::from(candidate));
976 }
977 }
978 None
979 }
980
981 macro_rules! need_second_font {
982 () => {
983 match find_second_font() {
984 Some(path) => path,
985 None => {
986 eprintln!("skipping: no DejaVuSansMono-like TrueType font found");
987 return;
988 }
989 }
990 };
991 }
992
993 #[test]
994 fn build_store_allocates_extra_slots_and_dedups_shared_files() {
995 let regular = need_font!();
996 let mono = need_second_font!();
997 let dir = tmpdir("extra-abbrevs");
998 write_hash_dir(
999 &dir,
1000 &format!(
1001 r#"{{ "reg": {{ "src": {:?} }},
1002 "mono": {{ "src": {:?} }},
1003 "regalias": {{ "src": {:?} }} }}"#,
1004 regular.to_str().unwrap(),
1005 mono.to_str().unwrap(),
1006 regular.to_str().unwrap(),
1007 ),
1008 Some(r#"{ "regular": "reg" }"#),
1009 );
1010 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1011 .unwrap()
1012 .unwrap();
1013 let store = registry.build_store().expect("build_store should succeed");
1014
1015 assert_eq!(store.num_files(), 2, "reg and regalias must dedup to one file");
1019 assert_eq!(store.num_slots(), 5);
1020
1021 assert_eq!(store.abbrev_key("reg"), Some(rustyfi_backend::FontKey(0)));
1025 let mono_key = store.abbrev_key("mono").expect("mono abbrev resolves");
1026 let regalias_key = store.abbrev_key("regalias").expect("regalias abbrev resolves");
1027 assert_ne!(mono_key, rustyfi_backend::FontKey(0));
1028 assert_ne!(regalias_key, rustyfi_backend::FontKey(0));
1029 assert_ne!(mono_key, regalias_key);
1030 assert_eq!(store.abbrev_key("no-such-abbrev"), None);
1031
1032 assert_eq!(store.file_index(regalias_key), store.file_index(rustyfi_backend::FontKey(0)));
1035 assert_ne!(store.file_index(mono_key), store.file_index(rustyfi_backend::FontKey(0)));
1036
1037 std::fs::remove_dir_all(&dir).ok();
1038 }
1039
1040 #[test]
1041 fn scripts_block_parses_and_resolves_default_script_font() {
1042 let regular = need_font!();
1043 let cjk_stand_in = need_second_font!();
1044 let dir = tmpdir("scripts-block");
1045 write_hash_dir(
1046 &dir,
1047 &format!(
1048 r#"{{ "reg": {{ "src": {:?} }}, "cjk": {{ "src": {:?} }} }}"#,
1049 regular.to_str().unwrap(),
1050 cjk_stand_in.to_str().unwrap(),
1051 ),
1052 Some(
1053 r#"{ "regular": "reg",
1054 "scripts": {
1055 "han-ideographic": { "font-name": "cjk", "ratio": 0.88, "rising": 0.0 },
1056 "latin": { "font-name": "reg", "ratio": 1.0, "rising": 0.0 }
1057 } }"#,
1058 ),
1059 );
1060 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1061 .unwrap()
1062 .unwrap();
1063 let store = registry.build_store().expect("build_store should succeed");
1064
1065 let cjk_key = store.abbrev_key("cjk").expect("cjk abbrev resolves");
1066 assert_eq!(store.script_default(0), Some((cjk_key, 0.88, 0.0)));
1069 assert_eq!(
1070 store.script_default(2),
1071 Some((rustyfi_backend::FontKey(0), 1.0, 0.0))
1072 );
1073 assert_eq!(store.script_default(1), None); assert_eq!(store.script_default(3), None); std::fs::remove_dir_all(&dir).ok();
1079 }
1080
1081 #[test]
1082 fn scripts_block_is_absent_by_default() {
1083 let font_path = need_font!();
1084 let dir = tmpdir("no-scripts-block");
1085 write_hash_dir(
1086 &dir,
1087 &format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
1088 Some(r#"{ "regular": "reg" }"#),
1089 );
1090 let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
1091 .unwrap()
1092 .unwrap();
1093 let store = registry.build_store().expect("build_store should succeed");
1094 for script in 0..4 {
1095 assert_eq!(store.script_default(script), None);
1096 }
1097 std::fs::remove_dir_all(&dir).ok();
1098 }
1099
1100 #[test]
1101 fn scripts_block_unknown_abbrev_is_an_error() {
1102 let font_path = need_font!();
1103 let dir = tmpdir("scripts-unknown-abbrev");
1104 write_hash_dir(
1105 &dir,
1106 &format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
1107 Some(
1108 r#"{ "regular": "reg",
1109 "scripts": { "kana": { "font-name": "nope", "ratio": 1.0, "rising": 0.0 } } }"#,
1110 ),
1111 );
1112 let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
1113 assert!(matches!(err, FontConfigError::UnknownAbbrev { .. }), "{err}");
1114 std::fs::remove_dir_all(&dir).ok();
1115 }
1116}
1117
1118#[cfg(test)]
1119mod satysfi_compat_tests {
1120 use super::*;
1121
1122 const UPSTREAM: &str = r#"{
1126 "fonts-noto-emoji:NotoEmoji-Regular" : <Single: {"src": "dist/fonts/fonts-noto-emoji/NotoEmoji-Regular.ttf"}>,
1127 "fonts-theano:TheanoDidot":<"Single":{"src-dist":"fonts-theano/TheanoDidot-Regular.otf"}>,
1128 "somettc":<"Collection":{"src-dist":"x/foo.ttc","index":0}>
1129}"#;
1130
1131 #[test]
1132 fn upstream_variants_become_plain_json() {
1133 let json = yojson_to_json(UPSTREAM);
1134 assert!(!json.contains('<') && !json.contains('>'), "{json}");
1135 let raw: BTreeMap<String, RawFontEntry> =
1136 serde_json::from_str(&json).expect("should parse once the variants are gone");
1137 assert_eq!(raw.len(), 3);
1138 assert_eq!(
1139 raw["fonts-theano:TheanoDidot"].src_dist.as_deref(),
1140 Some(std::path::Path::new("fonts-theano/TheanoDidot-Regular.otf"))
1141 );
1142 assert_eq!(raw["somettc"].index, Some(0));
1143 }
1144
1145 #[test]
1146 fn src_and_src_dist_resolve_from_different_bases() {
1147 let root = std::path::Path::new("/root");
1148 let by_src = RawFontEntry {
1149 src: Some("dist/fonts/a.ttf".into()),
1150 src_dist: None,
1151 index: None,
1152 };
1153 let by_dist = RawFontEntry {
1154 src: None,
1155 src_dist: Some("fonts-theano/b.otf".into()),
1156 index: None,
1157 };
1158 assert_eq!(by_src.resolve(root).unwrap(), root.join("dist/fonts/a.ttf"));
1159 assert_eq!(
1160 by_dist.resolve(root).unwrap(),
1161 root.join("dist/fonts/fonts-theano/b.otf"),
1162 "`src-dist` is relative to dist/fonts/ — where a package installs"
1163 );
1164 }
1165
1166 #[test]
1167 fn an_angle_bracket_inside_a_string_survives() {
1168 let json = yojson_to_json(r#"{"a":<Single: {"src":"we<ird>.ttf"}>}"#);
1170 assert_eq!(json, r#"{"a": {"src":"we<ird>.ttf"}}"#);
1171 }
1172}