use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_rustyfi"))
}
fn lib_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../lib-rustyfi")
}
fn realfont_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/realfont.saty")
}
fn minimal_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/minimal.saty")
}
fn font_free_lib_root(work: &Path) -> PathBuf {
let root = work.join("no-font-lib-root");
let dist = root.join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::os::unix::fs::symlink(lib_root().join("dist/packages"), dist.join("packages"))
.expect("symlink dist/packages into the font-free lib root");
root
}
fn tmpdir(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!(
"rustyfi-fonts-{tag}-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
n
));
std::fs::create_dir_all(&p).unwrap();
p
}
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 \
(tried `fc-match DejaVuSans` and common nix/distro paths)"
);
return;
}
}
};
}
macro_rules! need_pdftotext {
() => {
match Command::new("which").arg("pdftotext").output() {
Ok(out) if out.status.success() => {}
_ => {
eprintln!("skipping: pdftotext not on PATH");
return;
}
}
};
}
fn font_supports_fixture_glyphs(font_path: &Path) -> bool {
use rustyfi_backend::{FontKey, FontMetrics, Length};
let Ok(store) = rustyfi_pdf::TtfFontStore::load(font_path, None, None) else {
return false;
};
let size = Length::pt(12.0);
store.advance(FontKey(0), 'é', size).is_some() && store.advance(FontKey(0), '→', size).is_some()
}
fn write_font_config(work: &Path, font_path: &Path) -> PathBuf {
let root = work.join("fontroot");
let hash_dir = root.join("dist/hash");
std::fs::create_dir_all(&hash_dir).unwrap();
let fonts_hash = serde_json::json!({
"testface": { "src": font_path.to_str().expect("font path is valid UTF-8") }
});
std::fs::write(
hash_dir.join("fonts.satysfi-hash"),
serde_json::to_vec_pretty(&fonts_hash).unwrap(),
)
.unwrap();
let default_font_hash = serde_json::json!({ "regular": "testface" });
std::fs::write(
hash_dir.join("default-font.satysfi-hash"),
serde_json::to_vec_pretty(&default_font_hash).unwrap(),
)
.unwrap();
root
}
fn as_v006(cst: rustyfi_loader::LoadedCst) -> rustyfi_syntax::cst::File {
match cst {
rustyfi_loader::LoadedCst::V0_0(f) => f,
rustyfi_loader::LoadedCst::V0_1(_) => {
unreachable!("this test's ground-truth path is V0_0-only")
}
}
}
fn assert_ok(out: &Output, ctx: &str) {
assert!(
out.status.success(),
"{ctx}: compile failed (code {:?})\nstdout:\n{}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn real_font_fixture_renders_through_ttf_path_and_roundtrips() {
let font_path = need_font!();
if !font_supports_fixture_glyphs(&font_path) {
eprintln!(
"skipping: discovered font {} lacks café/→ glyphs used by realfont.saty",
font_path.display()
);
return;
}
need_pdftotext!();
let work = tmpdir("realfont");
let font_root = write_font_config(&work, &font_path);
let out = work.join("out.pdf");
let output = Command::new(bin())
.arg(realfont_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), lib_root().as_os_str()])
.args(["--font-dir".as_ref(), font_root.as_os_str()])
.args(["--no-cache"])
.output()
.expect("spawn rustyfi");
assert_ok(&output, "real-font compile");
let pdf_bytes = std::fs::read(&out).expect("read output pdf");
assert!(
pdf_bytes.starts_with(b"%PDF-"),
"not a PDF header: {:?}",
&pdf_bytes[..pdf_bytes.len().min(16)]
);
let font_bytes = std::fs::read(&font_path).expect("read font file");
assert!(
pdf_bytes.len() < font_bytes.len(),
"expected the subsetted PDF ({} bytes) to be smaller than the source font file ({} bytes)",
pdf_bytes.len(),
font_bytes.len()
);
let pdftotext = Command::new("pdftotext")
.arg(&out)
.arg("-")
.output()
.expect("run pdftotext");
assert!(
pdftotext.status.success(),
"pdftotext failed: {pdftotext:?}"
);
let text = String::from_utf8_lossy(&pdftotext.stdout);
assert!(
text.contains("Hello, world!"),
"missing ASCII text: {text:?}"
);
assert!(
text.contains("café") && text.contains('→'),
"missing non-WinAnsi text (ToUnicode round-trip failed): {text:?}"
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn no_font_config_matches_base14_byte_for_byte() {
let work = tmpdir("no-font-config");
let out = work.join("out.pdf");
let font_free_root = font_free_lib_root(&work);
let output = Command::new(bin())
.arg(minimal_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), font_free_root.as_os_str()])
.args(["--no-cache"])
.output()
.expect("spawn rustyfi");
assert_ok(&output, "no-font-config compile");
let via_cli = std::fs::read(&out).expect("read cli output");
assert!(via_cli.starts_with(b"%PDF-"));
let program = rustyfi_loader::load(
&minimal_fixture(),
&rustyfi_loader::LoadOptions {
lib_root: Some(font_free_root.clone()),
..Default::default()
},
)
.expect("load fixture");
let mut files = program.files;
let entry = files.pop().expect("loader always yields the entry last");
let entry_cst = as_v006(entry.cst);
let mut prelude = Vec::new();
for lib in files {
prelude.extend(as_v006(lib.cst).prelude);
}
prelude.extend(entry_cst.prelude);
let merged = rustyfi_syntax::cst::File {
headers: Vec::new(),
prelude,
in_kw: entry_cst.in_kw,
body: entry_cst.body,
eoi: entry_cst.eoi,
};
let metrics = rustyfi_pdf::Base14Metrics;
let doc = rustyfi_lang::compile_document_cst(&merged, &metrics).expect("compile fixture");
let via_lib =
rustyfi_pdf::render_pdf(&doc.geometry, &doc.pages, &doc.images).expect("render fixture");
assert_eq!(
via_cli, via_lib,
"with no font configured, CLI output must be byte-identical to the direct base-14 path"
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn changing_font_config_invalidates_the_compile_cache() {
let font_path = need_font!();
let work = tmpdir("cache-invalidate");
let cache_dir = work.join("cache");
let out = work.join("out.pdf");
let font_free_root = font_free_lib_root(&work);
let was_cached = |out: &Output| String::from_utf8_lossy(&out.stderr).contains("(cached)");
let first = Command::new(bin())
.arg(minimal_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), font_free_root.as_os_str()])
.args(["--cache-dir".as_ref(), cache_dir.as_os_str()])
.output()
.expect("spawn rustyfi (first)");
assert_ok(&first, "first (base-14) run");
assert!(!was_cached(&first), "first run must be a miss");
let base14_bytes = std::fs::read(&out).expect("read first output");
let font_root = write_font_config(&work, &font_path);
let second = Command::new(bin())
.arg(minimal_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), font_free_root.as_os_str()])
.args(["--cache-dir".as_ref(), cache_dir.as_os_str()])
.args(["--font-dir".as_ref(), font_root.as_os_str()])
.output()
.expect("spawn rustyfi (second)");
assert_ok(&second, "second (font-configured) run");
assert!(
!was_cached(&second),
"a newly-configured font must not hit the base-14 cache entry"
);
let ttf_bytes = std::fs::read(&out).expect("read second output");
assert_ne!(
base14_bytes, ttf_bytes,
"the font-configured run must actually render through the TTF path"
);
let third = Command::new(bin())
.arg(minimal_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), font_free_root.as_os_str()])
.args(["--cache-dir".as_ref(), cache_dir.as_os_str()])
.args(["--font-dir".as_ref(), font_root.as_os_str()])
.output()
.expect("spawn rustyfi (third)");
assert_ok(&third, "third (font-configured, warm) run");
assert!(
was_cached(&third),
"the font-configured request should now hit its own cache entry"
);
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn font_flag_is_a_config_less_one_off() {
let font_path = need_font!();
if !font_supports_fixture_glyphs(&font_path) {
eprintln!("skipping: discovered font lacks café/→ glyphs");
return;
}
need_pdftotext!();
let work = tmpdir("font-flag");
let out = work.join("out.pdf");
let output = Command::new(bin())
.arg(realfont_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), lib_root().as_os_str()])
.args(["--font".as_ref(), font_path.as_os_str()])
.args(["--no-cache"])
.output()
.expect("spawn rustyfi");
assert_ok(&output, "--font one-off compile");
let pdf_bytes = std::fs::read(&out).expect("read output");
assert!(pdf_bytes.starts_with(b"%PDF-"));
let pdftotext = Command::new("pdftotext")
.arg(&out)
.arg("-")
.output()
.expect("pdftotext");
assert!(pdftotext.status.success());
let text = String::from_utf8_lossy(&pdftotext.stdout);
assert!(text.contains("café"), "missing café: {text:?}");
std::fs::remove_dir_all(&work).ok();
}
#[test]
fn font_bold_without_font_is_a_usage_error() {
let work = tmpdir("font-bold-without-font");
let out = work.join("out.pdf");
let output = Command::new(bin())
.arg(minimal_fixture())
.args(["-o".as_ref(), out.as_os_str()])
.args(["--lib-root".as_ref(), lib_root().as_os_str()])
.args(["--font-bold".as_ref(), std::ffi::OsStr::new("whatever.ttf")])
.output()
.expect("spawn rustyfi");
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(2), "clap usage error is exit 2");
std::fs::remove_dir_all(&work).ok();
}