//! A8: compares RustFig's rendering of each fixture with fig2dev's. //! //! fig2dev (xfig's converter) is the reference. It is optional: without it //! the test prints a note and passes. Its SVG output is rasterised with //! resvg, so ghostscript isn't needed. Each fixture's score is checked //! against `fixtures/scores.json`; a run with `UPDATE_REFERENCE=1` rewrites //! the baselines. The images go to `target/fig2dev-reference//` //! (`fig2dev.png`, `rustfig.png`, `diff.png`: red = only fig2dev, blue = //! only RustFig). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod support; use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; use support::{compare, fig2dev}; /// How much worse than its baseline a score may get. const MARGIN: f64 = 0.02; /// Longest side of the rasterised images, in pixels. const SIDE: f64 = 1000.0; fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } fn out_dir() -> PathBuf { let target = std::env::var_os("CARGO_TARGET_DIR") .map(PathBuf::from) .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target")); target.join("fig2dev-reference") } /// fig2dev's SVG, rasterised on white. fn reference_png(fig: &[u8], dir: &Path) -> Vec { let svg = fig2dev::convert(fig, "svg", &[], Some(dir)).unwrap(); let mut opt = resvg::usvg::Options::default(); let db = opt.fontdb_mut(); db.load_system_fonts(); // fig2dev names PostScript families without a generic fallback; where // they are missing (Linux CI has URW's clones, fonts-urw-base35), fall // back to the first clone installed. let has = |db: &resvg::usvg::fontdb::Database, f: &str| db.faces().any(|x| x.families.iter().any(|(n, _)| n == f)); let pick = |db: &resvg::usvg::fontdb::Database, names: &[&str]| names.iter().find(|n| has(db, n)).map(|n| n.to_string()); if let Some(f) = pick( db, &[ "Times", "Nimbus Roman", "Nimbus Roman No9 L", "Liberation Serif", "DejaVu Serif", ], ) { db.set_serif_family(f); } if let Some(f) = pick( db, &[ "Helvetica", "Nimbus Sans", "Nimbus Sans L", "Liberation Sans", "DejaVu Sans", ], ) { db.set_sans_serif_family(f); } if let Some(f) = pick( db, &[ "Courier", "Nimbus Mono PS", "Nimbus Mono L", "Liberation Mono", "DejaVu Sans Mono", ], ) { db.set_monospace_family(f); } let svg = String::from_utf8(svg) .unwrap() .replace("font-family=\"Times\"", "font-family=\"Times,serif\"") .replace("font-family=\"Times,Symbol\"", "font-family=\"Times,Symbol,serif\"") .replace("font-family=\"Helvetica\"", "font-family=\"Helvetica,sans-serif\"") .replace( "font-family=\"AvantGarde\"", "font-family=\"AvantGarde,URW Gothic,sans-serif\"", ) .replace("font-family=\"Courier\"", "font-family=\"Courier,monospace\""); let tree = resvg::usvg::Tree::from_data(svg.as_bytes(), &opt).unwrap(); let size = tree.size(); let s = SIDE / f64::from(size.width().max(size.height())); let (w, h) = ( (f64::from(size.width()) * s).ceil() as u32, (f64::from(size.height()) * s).ceil() as u32, ); let mut pix = resvg::tiny_skia::Pixmap::new(w.max(1), h.max(1)).unwrap(); pix.fill(resvg::tiny_skia::Color::WHITE); resvg::render( &tree, resvg::tiny_skia::Transform::from_scale(s as f32, s as f32), &mut pix.as_mut(), ); pix.encode_png().unwrap() } /// RustFig's PNG export at a similar size, on white, as `file.export` /// makes it at 150 dpi (fig2dev's `-m` for 80 dpi at 100 %) with the /// export panel's smoothing. fn rustfig_png(fig: &[u8]) -> Vec { let doc = rustfig_engine::format::read_fig(fig).unwrap(); let s = |v: &str| Some(v.to_string()); let args = [('F', None), ('m', s("1.875")), ('g', s("#ffffff")), ('S', s("4"))]; rustfig_engine::fig2dev::export(&doc, "png", &args, None).unwrap().0 } #[test] fn fixtures_match_fig2dev() { // Same glyphs on every machine, whatever fonts are installed (F1). rustfig_engine::text::set_builtin_fonts(true); let Some(tool) = fig2dev::find() else { eprintln!("fig2dev not installed; skipping the reference comparison"); return; }; let update = std::env::var_os("UPDATE_REFERENCE").is_some(); let scores_path = fixtures().join("scores.json"); let baseline: Map = std::fs::read_to_string(&scores_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let mut names: Vec = std::fs::read_dir(fixtures()) .unwrap() .filter_map(|e| e.ok()?.file_name().into_string().ok()) .filter_map(|n| n.strip_suffix(".fig").map(str::to_string)) .collect(); names.sort(); assert!(!names.is_empty()); let mut scores = Map::new(); let mut failures = Vec::new(); eprintln!("{}; images in {}", tool.version, out_dir().display()); for name in &names { let fig = std::fs::read(fixtures().join(format!("{name}.fig"))).unwrap(); let c = compare::compare(&reference_png(&fig, &fixtures()), &rustfig_png(&fig)).unwrap(); let dir = out_dir().join(name); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("fig2dev.png"), compare::png(&c.reference).unwrap()).unwrap(); std::fs::write(dir.join("rustfig.png"), compare::png(&c.candidate).unwrap()).unwrap(); std::fs::write(dir.join("diff.png"), compare::png(&c.diff).unwrap()).unwrap(); let (mean, strong) = (round(c.score.mean), round(c.score.strong)); eprintln!(" {name:10} mean {mean:.4} strong {strong:.4}"); scores.insert(name.clone(), json!({"mean": mean, "strong": strong})); match baseline.get(name) { Some(b) => { let bm = b["mean"].as_f64().unwrap_or(0.0); let bs = b["strong"].as_f64().unwrap_or(0.0); if mean > bm + MARGIN || strong > bs + MARGIN { failures.push(format!( "{name}: mean {mean:.4} (baseline {bm:.4}), strong {strong:.4} (baseline {bs:.4})" )); } } None if !update => failures.push(format!("{name}: no baseline (run with UPDATE_REFERENCE=1)")), None => {} } } if update { let text = serde_json::to_string_pretty(&Value::Object(scores)).unwrap(); std::fs::write(&scores_path, text + "\n").unwrap(); eprintln!("baselines written to {}", scores_path.display()); return; } assert!( failures.is_empty(), "renderings got worse than their baselines:\n{}", failures.join("\n") ); } fn round(x: f64) -> f64 { (x * 10_000.0).round() / 10_000.0 } .