use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use rustyfi_backend::FontKey;
use crate::ttf::{FontError, TtfFontStore};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FontSource {
Single(PathBuf),
Collection(PathBuf, u32),
}
#[derive(Debug, Clone)]
pub struct FontRegistry {
faces: BTreeMap<String, FontSource>,
default_faces: [String; 3],
script_fonts: [Option<(String, f64, f64)>; 4],
math_font: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct FontFlags {
pub regular: Option<PathBuf>,
pub bold: Option<PathBuf>,
pub oblique: Option<PathBuf>,
}
impl FontFlags {
fn is_empty(&self) -> bool {
self.regular.is_none() && self.bold.is_none() && self.oblique.is_none()
}
}
#[derive(Debug, thiserror::Error)]
pub enum FontConfigError {
#[error("failed to read font config {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse font config {path} as JSON: {source}")]
Json {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error(
"{path}: default-face {face:?} names abbrev {abbrev:?}, which is not \
defined in fonts.satysfi-hash"
)]
UnknownAbbrev {
path: PathBuf,
face: &'static str,
abbrev: String,
},
#[error(
"font abbrev {abbrev:?} names a font collection at TTC index {index}; \
only index 0 can be loaded in this port (TtfFontStore does not yet \
support selecting a non-zero face of a collection)"
)]
UnsupportedCollectionIndex { abbrev: String, index: u32 },
#[error("--font-bold/--font-oblique require --font (no regular face given)")]
RegularRequired,
#[error(transparent)]
Font(#[from] FontError),
}
fn yojson_to_json(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
let mut in_string = false;
let mut escaped = false;
let mut depth: usize = 0;
while let Some((i, c)) = chars.next() {
if in_string {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
continue;
}
match c {
'"' => {
in_string = true;
out.push(c);
}
'<' => {
let rest = &text[i + 1..];
match rest.find(':') {
Some(colon)
if rest[..colon]
.chars()
.all(|t| t.is_alphanumeric() || t == '"' || t == '_' || t == '-' || t.is_whitespace()) =>
{
for _ in 0..=colon {
chars.next();
}
depth += 1;
}
_ => out.push(c),
}
}
'>' if depth > 0 => depth -= 1,
_ => out.push(c),
}
}
out
}
#[derive(Debug, Deserialize)]
struct RawFontEntry {
#[serde(default)]
src: Option<PathBuf>,
#[serde(default, rename = "src-dist")]
src_dist: Option<PathBuf>,
#[serde(default)]
index: Option<u32>,
}
impl RawFontEntry {
fn resolve(&self, root: &std::path::Path) -> Option<PathBuf> {
if let Some(src) = &self.src {
return Some(root.join(src));
}
self.src_dist
.as_ref()
.map(|rel| root.join("dist").join("fonts").join(rel))
}
}
#[derive(Debug, Deserialize)]
struct RawDefaultFace {
regular: String,
#[serde(default)]
bold: Option<String>,
#[serde(default)]
oblique: Option<String>,
#[serde(default)]
scripts: Option<RawScripts>,
#[serde(default)]
math: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawScriptFont {
#[serde(rename = "font-name")]
font_name: String,
ratio: f64,
rising: f64,
}
#[derive(Debug, Deserialize, Default)]
struct RawScripts {
#[serde(rename = "han-ideographic")]
han_ideographic: Option<RawScriptFont>,
kana: Option<RawScriptFont>,
latin: Option<RawScriptFont>,
#[serde(rename = "other-script")]
other_script: Option<RawScriptFont>,
}
fn load_or_dedup(
files: &mut Vec<Vec<u8>>,
file_by_path: &mut BTreeMap<PathBuf, usize>,
path: &Path,
) -> Result<usize, FontConfigError> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Some(&idx) = file_by_path.get(&canon) {
return Ok(idx);
}
let bytes = TtfFontStore::read_and_validate(path)?;
let idx = files.len();
files.push(bytes);
file_by_path.insert(canon, idx);
Ok(idx)
}
const CLI_REGULAR: &str = "<--font>";
const CLI_BOLD: &str = "<--font-bold>";
const CLI_OBLIQUE: &str = "<--font-oblique>";
impl FontRegistry {
pub fn discover(
lib_root: Option<&Path>,
font_dir: Option<&Path>,
flags: &FontFlags,
) -> Result<Option<FontRegistry>, FontConfigError> {
if !flags.is_empty() {
return Self::from_flags(flags).map(Some);
}
let Some(root) = font_dir.or(lib_root) else {
return Ok(None);
};
let hash_dir = root.join("dist").join("hash");
let fonts_path = hash_dir.join("fonts.satysfi-hash");
let fonts_bytes = match std::fs::read(&fonts_path) {
Ok(bytes) => bytes,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(FontConfigError::Io {
path: fonts_path,
source,
})
}
};
let fonts_text = String::from_utf8_lossy(&fonts_bytes);
let raw: BTreeMap<String, RawFontEntry> =
serde_json::from_str(&yojson_to_json(&fonts_text)).map_err(|source| {
FontConfigError::Json {
path: fonts_path.clone(),
source,
}
})?;
let faces: BTreeMap<String, FontSource> = raw
.into_iter()
.filter_map(|(abbrev, entry)| {
let resolved = entry.resolve(root)?;
let source = match entry.index {
Some(index) => FontSource::Collection(resolved, index),
None => FontSource::Single(resolved),
};
Some((abbrev, source))
})
.collect();
let default_path = hash_dir.join("default-font.satysfi-hash");
let default_bytes = std::fs::read(&default_path).map_err(|source| FontConfigError::Io {
path: default_path.clone(),
source,
})?;
let raw_default: RawDefaultFace =
serde_json::from_slice(&default_bytes).map_err(|source| FontConfigError::Json {
path: default_path.clone(),
source,
})?;
let regular = raw_default.regular;
let bold = raw_default.bold.unwrap_or_else(|| regular.clone());
let oblique = raw_default.oblique.unwrap_or_else(|| regular.clone());
for (face, abbrev) in [
("regular", ®ular),
("bold", &bold),
("oblique", &oblique),
] {
if !faces.contains_key(abbrev) {
return Err(FontConfigError::UnknownAbbrev {
path: default_path,
face,
abbrev: abbrev.clone(),
});
}
}
let mut script_fonts: [Option<(String, f64, f64)>; 4] = [None, None, None, None];
if let Some(scripts) = raw_default.scripts {
for (idx, name, entry) in [
(0usize, "han-ideographic", scripts.han_ideographic),
(1, "kana", scripts.kana),
(2, "latin", scripts.latin),
(3, "other-script", scripts.other_script),
] {
let Some(entry) = entry else { continue };
if !faces.contains_key(&entry.font_name) {
return Err(FontConfigError::UnknownAbbrev {
path: default_path,
face: name,
abbrev: entry.font_name,
});
}
script_fonts[idx] = Some((entry.font_name, entry.ratio, entry.rising));
}
}
if let Some(abbrev) = &raw_default.math {
if !faces.contains_key(abbrev) {
return Err(FontConfigError::UnknownAbbrev {
path: default_path,
face: "math",
abbrev: abbrev.clone(),
});
}
}
Ok(Some(FontRegistry {
faces,
default_faces: [regular, bold, oblique],
script_fonts,
math_font: raw_default.math,
}))
}
fn from_flags(flags: &FontFlags) -> Result<FontRegistry, FontConfigError> {
let Some(regular) = &flags.regular else {
return Err(FontConfigError::RegularRequired);
};
let mut faces = BTreeMap::new();
faces.insert(CLI_REGULAR.to_string(), FontSource::Single(regular.clone()));
let bold_abbrev = match &flags.bold {
Some(path) => {
faces.insert(CLI_BOLD.to_string(), FontSource::Single(path.clone()));
CLI_BOLD.to_string()
}
None => CLI_REGULAR.to_string(),
};
let oblique_abbrev = match &flags.oblique {
Some(path) => {
faces.insert(CLI_OBLIQUE.to_string(), FontSource::Single(path.clone()));
CLI_OBLIQUE.to_string()
}
None => CLI_REGULAR.to_string(),
};
Ok(FontRegistry {
faces,
default_faces: [CLI_REGULAR.to_string(), bold_abbrev, oblique_abbrev],
script_fonts: [None, None, None, None],
math_font: None,
})
}
pub fn build_store(&self) -> Result<TtfFontStore, FontConfigError> {
let regular_path = self.resolve(&self.default_faces[0])?;
let bold_path = if self.default_faces[1] != self.default_faces[0] {
Some(self.resolve(&self.default_faces[1])?)
} else {
None
};
let oblique_path = if self.default_faces[2] != self.default_faces[0] {
Some(self.resolve(&self.default_faces[2])?)
} else {
None
};
let mut other_paths: Vec<(String, PathBuf)> = Vec::new();
for abbrev in self.faces.keys() {
if self.default_faces.contains(abbrev) {
continue; }
other_paths.push((abbrev.clone(), self.resolve(abbrev)?));
}
let mut files: Vec<Vec<u8>> = Vec::new();
let mut file_by_path: BTreeMap<PathBuf, usize> = BTreeMap::new();
let regular_idx = load_or_dedup(&mut files, &mut file_by_path, ®ular_path)?;
let mut slots = vec![regular_idx, regular_idx, regular_idx];
if let Some(path) = &bold_path {
slots[1] = load_or_dedup(&mut files, &mut file_by_path, path)?;
}
if let Some(path) = &oblique_path {
slots[2] = load_or_dedup(&mut files, &mut file_by_path, path)?;
}
let mut abbrevs: BTreeMap<String, FontKey> = BTreeMap::new();
for (abbrev, path) in &other_paths {
let idx = load_or_dedup(&mut files, &mut file_by_path, path)?;
let key = FontKey(slots.len() as u16);
slots.push(idx);
abbrevs.insert(abbrev.clone(), key);
}
for (i, abbrev) in self.default_faces.iter().enumerate() {
abbrevs.entry(abbrev.clone()).or_insert(FontKey(i as u16));
}
let mut script_defaults: [Option<(FontKey, f64, f64)>; 4] = [None, None, None, None];
for (i, entry) in self.script_fonts.iter().enumerate() {
if let Some((abbrev, ratio, rising)) = entry {
let key = *abbrevs.get(abbrev).unwrap_or_else(|| {
panic!("FontRegistry invariant violated: scripts abbrev {abbrev:?} unresolved")
});
script_defaults[i] = Some((key, *ratio, *rising));
}
}
let math_default = self.math_font.as_ref().map(|abbrev| {
*abbrevs.get(abbrev).unwrap_or_else(|| {
panic!("FontRegistry invariant violated: math abbrev {abbrev:?} unresolved")
})
});
Ok(TtfFontStore::from_parts(
files,
slots,
abbrevs,
script_defaults,
math_default,
))
}
fn resolve(&self, abbrev: &str) -> Result<PathBuf, FontConfigError> {
match self
.faces
.get(abbrev)
.unwrap_or_else(|| panic!("FontRegistry invariant violated: {abbrev:?} unresolved"))
{
FontSource::Single(path) => Ok(path.clone()),
FontSource::Collection(path, 0) => Ok(path.clone()),
FontSource::Collection(_, index) => Err(FontConfigError::UnsupportedCollectionIndex {
abbrev: abbrev.to_string(),
index: *index,
}),
}
}
#[cfg(test)]
pub(crate) fn faces(&self) -> &BTreeMap<String, FontSource> {
&self.faces
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustyfi_backend::FontMetrics as _;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
fn tmpdir(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"rustyfi-pdf-fonts-test-{tag}-{}-{}-{n}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_hash_dir(root: &Path, fonts_json: &str, default_json: Option<&str>) {
let hash_dir = root.join("dist/hash");
std::fs::create_dir_all(&hash_dir).unwrap();
std::fs::write(hash_dir.join("fonts.satysfi-hash"), fonts_json).unwrap();
if let Some(default_json) = default_json {
std::fs::write(hash_dir.join("default-font.satysfi-hash"), default_json).unwrap();
}
}
fn find_regular_font() -> Option<PathBuf> {
if let Ok(output) = Command::new("fc-match")
.args(["--format=%{file}", "DejaVuSans"])
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && Path::new(&path).is_file() {
return Some(PathBuf::from(path));
}
}
}
for candidate in [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/run/current-system/sw/share/fonts/truetype/DejaVuSans.ttf",
"/run/current-system/sw/share/X11/fonts/DejaVuSans.ttf",
] {
if Path::new(candidate).is_file() {
return Some(PathBuf::from(candidate));
}
}
None
}
macro_rules! need_font {
() => {
match find_regular_font() {
Some(path) => path,
None => {
eprintln!("skipping: no DejaVuSans-like TrueType font found on this system");
return;
}
}
};
}
#[test]
fn discover_returns_none_with_nothing_configured() {
let flags = FontFlags::default();
assert!(FontRegistry::discover(None, None, &flags)
.unwrap()
.is_none());
}
#[test]
fn discover_returns_none_when_root_has_no_hash_dir() {
let dir = tmpdir("no-hash-dir");
let flags = FontFlags::default();
assert!(
FontRegistry::discover(Some(&dir), None, &flags)
.unwrap()
.is_none(),
"an existing root with no dist/hash/fonts.satysfi-hash is 'nothing configured'"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn discover_parses_single_and_collection_sources() {
let dir = tmpdir("parse-sources");
write_hash_dir(
&dir,
r#"{ "lmroman": { "src": "dist/fonts/lmroman.otf" },
"somettc": { "src": "dist/fonts/foo.ttc", "index": 2 } }"#,
Some(r#"{ "regular": "lmroman" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.expect("config present");
assert_eq!(
registry.faces().get("lmroman"),
Some(&FontSource::Single(dir.join("dist/fonts/lmroman.otf")))
);
assert_eq!(
registry.faces().get("somettc"),
Some(&FontSource::Collection(dir.join("dist/fonts/foo.ttc"), 2))
);
assert_eq!(registry.default_faces, ["lmroman", "lmroman", "lmroman"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn discover_resolves_absolute_src_verbatim() {
let dir = tmpdir("abs-src");
let abs = dir.join("elsewhere/regular.ttf");
write_hash_dir(
&dir,
&format!(r#"{{ "abbr": {{ "src": {:?} }} }}"#, abs.to_str().unwrap()),
Some(r#"{ "regular": "abbr" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
assert_eq!(registry.faces().get("abbr"), Some(&FontSource::Single(abs)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn discover_font_dir_takes_precedence_over_lib_root() {
let lib_root = tmpdir("precedence-lib-root");
let font_root = tmpdir("precedence-font-root");
write_hash_dir(
&lib_root,
r#"{ "fromlib": { "src": "a.ttf" } }"#,
Some(r#"{ "regular": "fromlib" }"#),
);
write_hash_dir(
&font_root,
r#"{ "fromfontdir": { "src": "b.ttf" } }"#,
Some(r#"{ "regular": "fromfontdir" }"#),
);
let registry =
FontRegistry::discover(Some(&lib_root), Some(&font_root), &FontFlags::default())
.unwrap()
.unwrap();
assert!(registry.faces().contains_key("fromfontdir"));
assert!(!registry.faces().contains_key("fromlib"));
std::fs::remove_dir_all(&lib_root).ok();
std::fs::remove_dir_all(&font_root).ok();
}
#[test]
fn default_face_bold_and_oblique_can_diverge_from_regular() {
let dir = tmpdir("distinct-faces");
write_hash_dir(
&dir,
r#"{ "reg": { "src": "reg.ttf" },
"b": { "src": "b.ttf" },
"obl": { "src": "obl.ttf" } }"#,
Some(r#"{ "regular": "reg", "bold": "b", "oblique": "obl" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
assert_eq!(registry.default_faces, ["reg", "b", "obl"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn malformed_json_is_an_error_not_none() {
let dir = tmpdir("malformed");
write_hash_dir(&dir, "{ not json", None);
let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
assert!(matches!(err, FontConfigError::Json { .. }), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_default_font_file_is_an_error_once_fonts_hash_exists() {
let dir = tmpdir("missing-default");
write_hash_dir(&dir, r#"{ "reg": { "src": "reg.ttf" } }"#, None);
let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
assert!(matches!(err, FontConfigError::Io { .. }), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn unknown_default_abbrev_is_an_error() {
let dir = tmpdir("unknown-abbrev");
write_hash_dir(
&dir,
r#"{ "reg": { "src": "reg.ttf" } }"#,
Some(r#"{ "regular": "does-not-exist" }"#),
);
let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
assert!(
matches!(err, FontConfigError::UnknownAbbrev { .. }),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn from_flags_without_regular_is_an_error() {
let flags = FontFlags {
regular: None,
bold: Some(PathBuf::from("bold.ttf")),
oblique: None,
};
let err = FontRegistry::discover(None, None, &flags).unwrap_err();
assert!(matches!(err, FontConfigError::RegularRequired), "{err}");
}
#[test]
fn flags_take_precedence_over_any_directory() {
let dir = tmpdir("flags-precedence");
write_hash_dir(
&dir,
r#"{ "fromconfig": { "src": "a.ttf" } }"#,
Some(r#"{ "regular": "fromconfig" }"#),
);
let flags = FontFlags {
regular: Some(PathBuf::from("cli-regular.ttf")),
bold: None,
oblique: None,
};
let registry = FontRegistry::discover(Some(&dir), None, &flags)
.unwrap()
.unwrap();
assert!(!registry.faces().contains_key("fromconfig"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_store_rejects_nonzero_collection_index_without_touching_disk() {
let dir = tmpdir("bad-index");
write_hash_dir(
&dir,
r#"{ "reg": { "src": "reg.ttf" },
"obl": { "src": "obl.ttc", "index": 1 } }"#,
Some(r#"{ "regular": "reg", "oblique": "obl" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
let Err(err) = registry.build_store() else {
panic!("expected an UnsupportedCollectionIndex error");
};
assert!(
matches!(
err,
FontConfigError::UnsupportedCollectionIndex { index: 1, .. }
),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_store_loads_a_real_font_end_to_end() {
let font_path = need_font!();
let dir = tmpdir("real-font");
write_hash_dir(
&dir,
&format!(
r#"{{ "reg": {{ "src": {:?} }} }}"#,
font_path.to_str().unwrap()
),
Some(r#"{ "regular": "reg" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
let store = registry.build_store().expect("build_store should succeed");
assert_eq!(store.num_files(), 1);
let size = rustyfi_backend::Length::pt(12.0);
assert!(store
.advance(rustyfi_backend::FontKey(0), 'A', size)
.is_some());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn build_store_from_cli_flags_end_to_end() {
let font_path = need_font!();
let flags = FontFlags {
regular: Some(font_path),
bold: None,
oblique: None,
};
let registry = FontRegistry::discover(None, None, &flags).unwrap().unwrap();
let store = registry.build_store().expect("build_store should succeed");
assert_eq!(store.num_files(), 1);
let size = rustyfi_backend::Length::pt(12.0);
assert_eq!(
store.advance(rustyfi_backend::FontKey(0), 'A', size),
store.advance(rustyfi_backend::FontKey(1), 'A', size)
);
}
fn find_second_font() -> Option<PathBuf> {
if let Ok(output) = Command::new("fc-match")
.args(["--format=%{file}", "DejaVu Sans Mono"])
.output()
{
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() && Path::new(&path).is_file() {
return Some(PathBuf::from(path));
}
}
}
for candidate in [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/run/current-system/sw/share/fonts/truetype/DejaVuSansMono.ttf",
] {
if Path::new(candidate).is_file() {
return Some(PathBuf::from(candidate));
}
}
None
}
macro_rules! need_second_font {
() => {
match find_second_font() {
Some(path) => path,
None => {
eprintln!("skipping: no DejaVuSansMono-like TrueType font found");
return;
}
}
};
}
#[test]
fn build_store_allocates_extra_slots_and_dedups_shared_files() {
let regular = need_font!();
let mono = need_second_font!();
let dir = tmpdir("extra-abbrevs");
write_hash_dir(
&dir,
&format!(
r#"{{ "reg": {{ "src": {:?} }},
"mono": {{ "src": {:?} }},
"regalias": {{ "src": {:?} }} }}"#,
regular.to_str().unwrap(),
mono.to_str().unwrap(),
regular.to_str().unwrap(),
),
Some(r#"{ "regular": "reg" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
let store = registry.build_store().expect("build_store should succeed");
assert_eq!(store.num_files(), 2, "reg and regalias must dedup to one file");
assert_eq!(store.num_slots(), 5);
assert_eq!(store.abbrev_key("reg"), Some(rustyfi_backend::FontKey(0)));
let mono_key = store.abbrev_key("mono").expect("mono abbrev resolves");
let regalias_key = store.abbrev_key("regalias").expect("regalias abbrev resolves");
assert_ne!(mono_key, rustyfi_backend::FontKey(0));
assert_ne!(regalias_key, rustyfi_backend::FontKey(0));
assert_ne!(mono_key, regalias_key);
assert_eq!(store.abbrev_key("no-such-abbrev"), None);
assert_eq!(store.file_index(regalias_key), store.file_index(rustyfi_backend::FontKey(0)));
assert_ne!(store.file_index(mono_key), store.file_index(rustyfi_backend::FontKey(0)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scripts_block_parses_and_resolves_default_script_font() {
let regular = need_font!();
let cjk_stand_in = need_second_font!();
let dir = tmpdir("scripts-block");
write_hash_dir(
&dir,
&format!(
r#"{{ "reg": {{ "src": {:?} }}, "cjk": {{ "src": {:?} }} }}"#,
regular.to_str().unwrap(),
cjk_stand_in.to_str().unwrap(),
),
Some(
r#"{ "regular": "reg",
"scripts": {
"han-ideographic": { "font-name": "cjk", "ratio": 0.88, "rising": 0.0 },
"latin": { "font-name": "reg", "ratio": 1.0, "rising": 0.0 }
} }"#,
),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
let store = registry.build_store().expect("build_store should succeed");
let cjk_key = store.abbrev_key("cjk").expect("cjk abbrev resolves");
assert_eq!(store.script_default(0), Some((cjk_key, 0.88, 0.0)));
assert_eq!(
store.script_default(2),
Some((rustyfi_backend::FontKey(0), 1.0, 0.0))
);
assert_eq!(store.script_default(1), None); assert_eq!(store.script_default(3), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scripts_block_is_absent_by_default() {
let font_path = need_font!();
let dir = tmpdir("no-scripts-block");
write_hash_dir(
&dir,
&format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
Some(r#"{ "regular": "reg" }"#),
);
let registry = FontRegistry::discover(Some(&dir), None, &FontFlags::default())
.unwrap()
.unwrap();
let store = registry.build_store().expect("build_store should succeed");
for script in 0..4 {
assert_eq!(store.script_default(script), None);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scripts_block_unknown_abbrev_is_an_error() {
let font_path = need_font!();
let dir = tmpdir("scripts-unknown-abbrev");
write_hash_dir(
&dir,
&format!(r#"{{ "reg": {{ "src": {:?} }} }}"#, font_path.to_str().unwrap()),
Some(
r#"{ "regular": "reg",
"scripts": { "kana": { "font-name": "nope", "ratio": 1.0, "rising": 0.0 } } }"#,
),
);
let err = FontRegistry::discover(Some(&dir), None, &FontFlags::default()).unwrap_err();
assert!(matches!(err, FontConfigError::UnknownAbbrev { .. }), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(test)]
mod satysfi_compat_tests {
use super::*;
const UPSTREAM: &str = r#"{
"fonts-noto-emoji:NotoEmoji-Regular" : <Single: {"src": "dist/fonts/fonts-noto-emoji/NotoEmoji-Regular.ttf"}>,
"fonts-theano:TheanoDidot":<"Single":{"src-dist":"fonts-theano/TheanoDidot-Regular.otf"}>,
"somettc":<"Collection":{"src-dist":"x/foo.ttc","index":0}>
}"#;
#[test]
fn upstream_variants_become_plain_json() {
let json = yojson_to_json(UPSTREAM);
assert!(!json.contains('<') && !json.contains('>'), "{json}");
let raw: BTreeMap<String, RawFontEntry> =
serde_json::from_str(&json).expect("should parse once the variants are gone");
assert_eq!(raw.len(), 3);
assert_eq!(
raw["fonts-theano:TheanoDidot"].src_dist.as_deref(),
Some(std::path::Path::new("fonts-theano/TheanoDidot-Regular.otf"))
);
assert_eq!(raw["somettc"].index, Some(0));
}
#[test]
fn src_and_src_dist_resolve_from_different_bases() {
let root = std::path::Path::new("/root");
let by_src = RawFontEntry {
src: Some("dist/fonts/a.ttf".into()),
src_dist: None,
index: None,
};
let by_dist = RawFontEntry {
src: None,
src_dist: Some("fonts-theano/b.otf".into()),
index: None,
};
assert_eq!(by_src.resolve(root).unwrap(), root.join("dist/fonts/a.ttf"));
assert_eq!(
by_dist.resolve(root).unwrap(),
root.join("dist/fonts/fonts-theano/b.otf"),
"`src-dist` is relative to dist/fonts/ — where a package installs"
);
}
#[test]
fn an_angle_bracket_inside_a_string_survives() {
let json = yojson_to_json(r#"{"a":<Single: {"src":"we<ird>.ttf"}>}"#);
assert_eq!(json, r#"{"a": {"src":"we<ird>.ttf"}}"#);
}
}