//! fig2dev's reader and drivers in the engine: old Fig files open (G1, //! S3), the ported drivers export (B9), pstex/pdftex write their two //! parts (B2), EPS pictures are embedded in EPS exports. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use rustfig_engine::Engine; use serde_json::json; use std::path::PathBuf; fn temp_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("fig2dev-bridge-{name}-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); dir } /// A Fig 2.1 file (80 ppi): a box, a dashed red line with an arrow, an /// approximated spline and a PostScript text. const FIG21: &str = "#FIG 2.1\n80 2\n\ 2 2 0 1 -1 0 0 0 0.000 7 0 0\n\t 10 10 90 10 90 50 10 50 10 10 9999 9999\n\ 2 1 1 2 4 0 0 0 4.000 -1 1 0\n\t0 0 1.000 4.000 8.000\n\t 10 70 50 70 9999 9999\n\ 3 0 0 1 -1 0 0 0 0.000 0 0\n\t 10 100 50 120 90 100 9999 9999\n\ 4 0 -1 0 0 0 12 0.00000 4 9 30 10 150 Hello\x01\n"; /// A Fig 1.3 file: a header of numbers, an open spline. const FIG13: &str = "80 2 9 9\n3 10 0 1 1 0 0 0 0 0 0\n10 10 40 0 9999 0\n"; fn types(e: &Engine) -> Vec<&'static str> { e.doc().objects.iter().map(|o| o.type_name()).collect() } #[test] fn fig_2_1_and_1_3_files_open() { let dir = temp_dir("old"); let p21 = dir.join("old21.fig"); std::fs::write(&p21, FIG21).unwrap(); let mut e = Engine::new(); let r = e.execute("file.open", &json!({"path": p21})).unwrap(); assert_eq!(r["objects"], 4); assert_eq!(types(&e), ["box", "polyline", "spline", "text"]); let x = e.doc_bounds().max.x; assert!((1350.0..1370.0).contains(&x), "80 ppi scaled to 1200: {x}"); // Saved as Fig 3.2, it reads back with the reader. let saved = dir.join("saved.fig"); e.execute("file.save", &json!({"path": saved})).unwrap(); let text = std::fs::read_to_string(&saved).unwrap(); assert!(text.starts_with("#FIG 3.2"), "{text}"); let mut e2 = Engine::new(); e2.execute("file.open", &json!({"path": saved})).unwrap(); assert_eq!(types(&e2), types(&e)); let p13 = dir.join("old13.fig"); std::fs::write(&p13, FIG13).unwrap(); let r = e.execute("file.open", &json!({"path": p13})).unwrap(); assert_eq!(r["objects"], 1); assert_eq!(types(&e), ["spline"]); // And from data, with the reader's messages as warnings: like xfig, it // deletes a box of fewer than 5 points. let open_box = "#FIG 2.1\n80 2\n2 2 0 1 -1 0 0 0 0.000 7 0 0\n\t 10 10 90 10 90 50 10 50 9999 9999\n"; let r = e.execute("file.open_data", &json!({"data": open_box})).unwrap(); assert_eq!(r["objects"], 0); let w = r["warnings"].as_array().unwrap(); assert!( w.iter().any(|m| m.as_str().unwrap().contains("Deleting zero-size box")), "{r}" ); e.execute("file.merge_data", &json!({"data": FIG21})).unwrap(); // Merge adds the file as one compound (xfig's merge_file). assert_eq!(e.doc().objects.len(), 1); let rustfig_model::ObjectKind::Compound(c) = &e.doc().objects[0].kind else { panic!("not a compound"); }; assert_eq!(c.objects.len(), 4); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn bad_old_files_are_errors_and_change_nothing() { let mut e = Engine::new(); e.execute("file.open_data", &json!({"data": FIG21})).unwrap(); for bad in [ "#FIG 2.1\n", "#FIG 2.1\n80 2\n2 9 9\n", "12 x\n", "#FIG 1.4\n80 2\n7 1 2 3\n", ] { let before = e.doc().clone(); assert!(e.execute("file.open_data", &json!({"data": bad})).is_err(), "{bad:?}"); assert!(e.execute("file.merge_data", &json!({"data": bad})).is_err(), "{bad:?}"); assert_eq!(*e.doc(), before); } } /// A box, a PostScript text and a special (LaTeX) text. const DRAWING: &str = "#FIG 3.2\nLandscape\nCenter\nInches\nLetter\n100.00\nSingle\n-2\n1200 2\n\ 2 2 0 1 0 7 50 -1 -1 0.000 0 0 -1 0 0 5\n\t 0 0 2400 0 2400 1200 0 1200 0 0\n\ 4 0 0 50 -1 0 12 0.0000 4 135 450 300 600 plain\\001\n\ 4 0 0 50 -1 0 12 0.0000 2 135 450 300 900 $\\\\alpha^2$\\001\n"; fn drawing() -> Engine { let mut e = Engine::new(); e.execute("file.open_data", &json!({"data": DRAWING})).unwrap(); e } fn special_count(e: &Engine) -> usize { let mut n = 0; e.doc().for_each_leaf(&mut |o| { if let rustfig_model::ObjectKind::Text(t) = &o.kind { n += usize::from(t.is_special()); } }); n } #[test] fn ported_drivers_export_and_are_listed() { let mut e = drawing(); let v = e.execute("file.export_formats", &json!({})).unwrap(); let latex = v["formats"] .as_array() .unwrap() .iter() .find(|f| f["id"] == "latex") .unwrap() .clone(); assert_eq!(latex["available"], true, "{latex}"); assert_eq!(latex["status"], "available"); let letters: Vec<&str> = latex["options"] .as_array() .unwrap() .iter() .filter(|o| o["available"] == true) .map(|o| o["letter"].as_str().unwrap()) .collect(); assert!(letters.contains(&"v") && letters.contains(&"m"), "{letters:?}"); let r = e.execute("file.export_data", &json!({"format": "latex"})).unwrap(); let text = r["data"].as_str().unwrap(); assert!( text.contains("\\begin{picture}") && text.contains("\\end{picture}"), "{text}" ); // Options by letter reach the driver; general ones by key too. let verbose = e .execute("file.export_data", &json!({"format": "latex", "options": {"v": true}})) .unwrap(); assert_ne!(verbose["data"], r["data"]); let big = e .execute( "file.export_data", &json!({"format": "latex", "options": {"magnification": 2}}), ) .unwrap(); assert_ne!(big["data"], r["data"]); let pct = e .execute("file.export_data", &json!({"format": "latex", "magnification": 200})) .unwrap(); assert_eq!(pct["data"], big["data"]); // Unknown letters and keys are refused. for bad in [json!({"q": true}), json!({"nonsense": 1}), json!({"v": [1]})] { let err = e .execute("file.export_data", &json!({"format": "latex", "options": bad})) .unwrap_err(); assert!(err.message.contains("option"), "{}", err.message); } // Every language the crate writes exports through file.export_data. for f in v["formats"].as_array().unwrap() { let id = f["id"].as_str().unwrap(); if f["available"] == true { // A driver may refuse a figure (shape wants comments), as fig2dev does. let r = e.execute("file.export_data", &json!({"format": id})); assert!( r.as_ref().map_or_else(|err| err.code == "format", |_| true), "{id}: {r:?}" ); } } } #[test] fn pstex_writes_graphics_without_special_text_and_the_latex_part() { let mut e = drawing(); assert_eq!(special_count(&e), 1); let dir = temp_dir("pstex"); let path = dir.join("fig.pstex").to_string_lossy().into_owned(); let r = e .execute("file.export", &json!({"path": path, "format": "pstex"})) .unwrap(); let paths: Vec<&str> = r["paths"] .as_array() .unwrap() .iter() .map(|p| p.as_str().unwrap()) .collect(); assert_eq!(paths, [path.clone(), format!("{path}_t")]); let eps = std::fs::read_to_string(&path).unwrap(); assert!(eps.starts_with("%!PS-Adobe-3.0 EPSF-3.0"), "{eps}"); assert!(eps.contains("(plain)") && !eps.contains("alpha"), "{eps}"); let tex = std::fs::read_to_string(format!("{path}_t")).unwrap(); assert!(tex.contains("\\includegraphics{fig.pstex}"), "{tex}"); assert!(tex.contains("$\\alpha^2$") && !tex.contains("plain"), "{tex}"); // The EPS and the overlay have the same size. let bb: Vec = eps .lines() .find(|l| l.starts_with("%%BoundingBox:")) .unwrap() .split_whitespace() .skip(1) .map(|n| n.parse().unwrap()) .collect(); assert!(bb.len() == 4 && bb[2] > 0.0, "{eps}"); assert!( tex.lines().filter(|l| l.starts_with("\\begin{picture}(")).count() == 2, "{tex}" ); // pdftex: NAME.pdf and NAME.pdf_t. let pdf = dir.join("fig.pdf").to_string_lossy().into_owned(); e.execute("file.export", &json!({"path": pdf, "format": "pdftex"})) .unwrap(); assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF")); let tex = std::fs::read_to_string(format!("{pdf}_t")).unwrap(); assert!(tex.contains("\\includegraphics{fig.pdf}"), "{tex}"); // pspdftex: NAME_pspdftex.eps, .pdf and the LaTeX part at PATH. let both = dir.join("fig.pspdftex").to_string_lossy().into_owned(); let r = e .execute( "file.export", &json!({"path": both, "format": "pspdftex", "options": {"border": 5}}), ) .unwrap(); assert_eq!(r["paths"].as_array().unwrap().len(), 3, "{r}"); assert!(dir.join("fig_pspdftex.eps").is_file() && dir.join("fig_pspdftex.pdf").is_file()); let tex = std::fs::read_to_string(&both).unwrap(); assert!(tex.contains("\\includegraphics{fig_pspdftex}"), "{tex}"); // export_data returns every part. let r = e.execute("file.export_data", &json!({"format": "pstex"})).unwrap(); let files = r["files"].as_array().unwrap(); let ext: Vec<&str> = files.iter().map(|f| f["extension"].as_str().unwrap()).collect(); assert_eq!(ext, ["pstex", "pstex_t"]); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn eps_pictures_are_embedded_in_eps() { let dir = temp_dir("embed"); std::fs::write( dir.join("inner.eps"), "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 10 20 110 70\nnewpath 10 20 moveto 110 70 lineto stroke\n%%EOF\n", ) .unwrap(); let fig = "#FIG 3.2\nLandscape\nCenter\nInches\nLetter\n100.00\nSingle\n-2\n1200 2\n\ 2 5 0 1 0 -1 50 -1 -1 0.000 0 0 -1 0 0 5\n\t0 inner.eps\n\t 0 0 2400 0 2400 1200 0 1200 0 0\n"; let path = dir.join("withpic.fig"); std::fs::write(&path, fig).unwrap(); let mut e = Engine::new(); e.execute("file.open", &json!({"path": path})).unwrap(); // (the canvas draws it through ghostscript or its preview when it can, // else warns; the export embeds the file either way) let eps = e.execute("file.export_data", &json!({"format": "eps"})).unwrap(); let eps = eps["data"].as_str().unwrap(); assert!(eps.contains("% Begin Imported EPS File: inner.eps"), "{eps}"); assert!(eps.contains("110 70 lineto stroke"), "{eps}"); assert!(eps.contains("/showpage {} def"), "{eps}"); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn compressed_and_xbm_pictures_load() { let dir = temp_dir("pics"); std::fs::write( dir.join("dot.xbm"), "#define dot_width 8\n#define dot_height 2\nstatic char dot_bits[] = {\n 0x01, 0x80 };\n", ) .unwrap(); let fig = "#FIG 3.2\nLandscape\nCenter\nInches\nLetter\n100.00\nSingle\n-2\n1200 2\n\ 2 5 0 1 0 -1 50 -1 -1 0.000 0 0 -1 0 0 5\n\t0 dot.xbm\n\t 0 0 2400 0 2400 1200 0 1200 0 0\n"; let path = dir.join("xbm.fig"); std::fs::write(&path, fig).unwrap(); let mut e = Engine::new(); let r = e.execute("file.open", &json!({"path": path})).unwrap(); assert_eq!(r["warnings"].as_array().unwrap().len(), 0, "{r}"); std::fs::remove_dir_all(&dir).unwrap(); } /// D2: the frame the engine fits and reports is the frame fig2dev's EPS /// driver exports (points: 72/1200 of a Fig unit, rounded outward). #[test] fn doc_bounds_are_the_eps_frame() { let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); let mut n = 0; for entry in std::fs::read_dir(dir).unwrap() { let p = entry.unwrap().path(); if p.extension().is_none_or(|x| x != "fig") { continue; } let mut e = Engine::new(); e.execute("file.open", &json!({"path": p})).unwrap(); let r = e .execute("file.export_data", &json!({"format": "eps", "options": {}})) .unwrap(); let eps = r["data"].as_str().unwrap(); let line = eps.lines().find(|l| l.starts_with("%%BoundingBox:")).unwrap(); let v: Vec = line.split_whitespace().skip(1).map(|n| n.parse().unwrap()).collect(); let b = e.doc_bounds(); // Metric drawings have 450 units per cm, 1143 per inch. let k = if e.doc().settings.units == rustfig_model::Units::Metric { 72.0 / 1143.0 } else { 0.06 }; let (w, h) = ((b.max.x - b.min.x) * k, (b.max.y - b.min.y) * k); // fig2dev rounds the corners outward; the extent differs by under 2 points. assert!( (v[2] - v[0] - w).abs() < 2.0, "{}: width {} vs {w}", p.display(), v[2] - v[0] ); assert!( (v[3] - v[1] - h).abs() < 2.0, "{}: height {} vs {h}", p.display(), v[3] - v[1] ); n += 1; } assert!(n >= 10); } /// D2: arrowheads count, and the selection box is fig2dev's. #[test] fn arrowheads_count_in_the_bounds() { let mut e = Engine::new(); e.execute("shape.polyline", &json!({"points": [[0, 0], [1200, 0]]})) .unwrap(); let plain = e.doc_bounds(); e.execute("edit.select_all", &json!({})).unwrap(); e.execute("object.set_attributes", &json!({"forward_arrow": true})) .unwrap(); let arrowed = e.doc_bounds(); assert!( arrowed.max.x > plain.max.x || arrowed.max.y > plain.max.y, "{plain:?} {arrowed:?}" ); assert_eq!(e.selection_bbox(), arrowed); } // ---- D7: export straight from the objects equals the text path ---- mod identity { use rustfig_engine::fig2dev::{driver_available, export, export_via_text, languages::LANGUAGES}; use rustfig_model::Document; use serde_json::Value; use std::path::Path; type Opts = Vec<(char, Option)>; type Exported = Result<(Vec, Vec), String>; fn languages() -> Vec<&'static str> { let mut v: Vec<&str> = LANGUAGES .iter() .map(|l| l.id) .filter(|id| driver_available(id)) .collect(); for extra in ["pstex_t", "pdftex_t"] { if driver_available(extra) && !v.contains(&extra) { v.push(extra); } } v } fn opt(c: char, a: &str) -> (char, Option) { (c, Some(a.to_string())) } fn option_sets() -> Vec { vec![ vec![], vec![opt('m', "0.5")], vec![opt('s', "14")], vec![opt('b', "10")], vec![opt('D', "-50")], vec![opt('Z', "3")], vec![('K', None)], vec![('c', None), opt('l', "portrait"), opt('p', "a4")], vec![opt('m', "1.5"), opt('s', "10"), opt('b', "5")], ] } fn run_both(doc: &Document, lang: &str, o: &Opts) -> (Exported, Exported) { (export(doc, lang, o, None), export_via_text(doc, lang, o, None)) } /// Check one export; the clock in the headers may tick between the two. fn check(doc: &Document, lang: &str, o: &Opts, what: &str) { let (a, b) = run_both(doc, lang, o); if a != b { let (a, b) = run_both(doc, lang, o); if a != b { let show = |r: &Exported| match r { Ok((out, m)) => format!("ok {} bytes, messages {m:?}", out.len()), Err(e) => format!("error {e}"), }; let at = match (&a, &b) { (Ok((x, _)), Ok((y, _))) => x.iter().zip(y).position(|(p, q)| p != q), _ => None, }; let around = |r: &Exported| match (r, at) { (Ok((out, _)), Some(i)) => String::from_utf8_lossy( out.get(i.saturating_sub(80)..(i + 80).min(out.len())) .unwrap_or_default(), ) .into_owned(), _ => String::new(), }; if let Some(l) = rustfig_format::write_fig(doc) .lines() .find(|l| l.contains(['\0', '\r'])) { eprintln!("a line with NUL or CR: {l:?}"); } panic!( "{what} -L {lang} {o:?}: objects {} / text {} (first difference at {at:?})\nobjects: {}\ntext: {}", show(&a), show(&b), around(&a), around(&b) ); } } } fn all_languages(doc: &Document, what: &str, sets: &[Opts]) -> usize { let mut n = 0; for lang in languages() { for o in sets { check(doc, lang, o, what); n += 1; } } n } fn root() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") } fn set_clock() { std::env::set_var("SOURCE_DATE_EPOCH", "1700000000"); } fn load(p: &Path) -> Option { rustfig_format::read_any(&std::fs::read(p).ok()?).ok() } fn objects_used(doc: &Document) -> bool { rustfig_format::bridge::export_objects(doc, &mut fig2dev::Ctx::default()).is_some() } #[test] fn fixtures_export_identically_in_every_language_and_option() { set_clock(); let dir = root().join("crates/engine/tests/fixtures"); let mut n = 0; let mut used = 0; for e in std::fs::read_dir(dir).unwrap() { let p = e.unwrap().path(); if p.extension().is_some_and(|x| x == "fig") { let doc = load(&p).unwrap(); used += usize::from(objects_used(&doc)); n += all_languages(&doc, &p.display().to_string(), &option_sets()); } } eprintln!("fixtures: {n} exports identical, {used} figures through objects"); assert!(used >= 10); } /// The 300-figure sample of the corpus checks (local, needs `old/`). #[test] fn corpus_sample_exports_identically() { set_clock(); let list = root().join("plan/corpus-sample.txt"); let (Ok(text), true) = (std::fs::read_to_string(list), root().join("old").is_dir()) else { eprintln!("skipped: no plan/corpus-sample.txt or old/"); return; }; let sets = option_sets(); let langs = languages(); let (mut n, mut figures, mut used) = (0, 0, 0); for (k, line) in text .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .enumerate() { let Some(doc) = load(&root().join(line)) else { continue }; figures += 1; used += usize::from(objects_used(&doc)); // Every language once, with a rotating set of options. for (j, lang) in langs.iter().enumerate() { check(&doc, lang, &sets[(k + j) % sets.len()], line); n += 1; } // And the main ones with all option sets. for lang in ["eps", "svg", "pdf"] { for o in &sets { check(&doc, lang, o, line); n += 1; } } } eprintln!("corpus sample: {figures} figures ({used} through objects), {n} exports identical"); } struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { self.0 = self .0 .wrapping_mul(6364136223846793005) .wrapping_add(1442695040888963407); self.0 >> 33 } fn below(&mut self, n: usize) -> usize { (self.next() % n.max(1) as u64) as usize } } /// JSON pointers of the numbers, strings and booleans in `v`. fn leaves(v: &Value, at: &mut String, out: &mut Vec) { let len = at.len(); match v { Value::Number(_) | Value::String(_) | Value::Bool(_) => out.push(at.clone()), Value::Array(a) => { for (i, x) in a.iter().enumerate() { at.push_str(&format!("/{i}")); leaves(x, at, out); at.truncate(len); } } Value::Object(m) => { for (k, x) in m { at.push_str(&format!("/{}", k.replace('~', "~0").replace('/', "~1"))); leaves(x, at, out); at.truncate(len); } } Value::Null => {} } } const NUMBERS: &[f64] = &[ 0.0, -1.0, 1.0, 0.5, 2.0, 3.0, 7.5, -7.5, 31.0, 32.0, 543.0, 544.0, 999.0, 1000.0, 1e9, -1e9, 1e12, 1e-9, 12345.678, ]; const STRINGS: &[&str] = &[ "", " lead", "tail\\", "a\r\nb", "x\u{1}y", "\\001", "é€ü", "line1\nline2", "\ttab", "a\\b\\", "# hash", " ", "back\\001slash", "\nlead newline", "\u{0}", ]; /// A document with a few random fields of its JSON changed: hostile but /// shaped like one (sizes zero or negative, texts with odd characters). fn mutate(doc: &Document, rng: &mut Rng) -> Option { let mut v = serde_json::to_value(doc).ok()?; let mut ls = Vec::new(); leaves(&v, &mut String::new(), &mut ls); for _ in 0..1 + rng.below(4) { let path = ls.get(rng.below(ls.len()))?; let leaf = v.pointer_mut(path)?; match leaf { Value::Number(_) => *leaf = serde_json::json!(NUMBERS[rng.below(NUMBERS.len())]), Value::String(_) => *leaf = Value::String(STRINGS[rng.below(STRINGS.len())].to_string()), Value::Bool(b) => *b = !*b, _ => {} } } serde_json::from_value(v).ok() } /// Fixtures and sample figures with random fields changed: through /// objects or text, the output is the same (or both fail the same way). #[test] fn hostile_documents_export_identically() { set_clock(); let dir = root().join("crates/engine/tests/fixtures"); let mut docs = Vec::new(); for e in std::fs::read_dir(dir).unwrap() { let p = e.unwrap().path(); if p.extension().is_some_and(|x| x == "fig") { docs.extend(load(&p)); } } let cases: usize = std::env::var("D7_CASES") .ok() .and_then(|n| n.parse().ok()) .unwrap_or(300); let langs = [ "eps", "svg", "pdf", "tikz", "latex", "pstricks", "box", "ps", "pic", "mf", "emf", "png", ]; let sets = option_sets(); let mut rng = Rng(std::env::var("D7_SEED") .ok() .and_then(|n| n.parse().ok()) .unwrap_or(20261020)); let (mut n, mut used, mut tried) = (0, 0, 0); for k in 0..cases { let Some(base) = docs.get(rng.below(docs.len())) else { break; }; let Some(doc) = mutate(base, &mut rng) else { continue }; tried += 1; used += usize::from(objects_used(&doc)); for lang in langs.iter().filter(|l| driver_available(l)) { check(&doc, lang, &sets[(k + n) % sets.len()], &format!("hostile case {k}")); n += 1; } } eprintln!("hostile: {tried} documents ({used} through objects), {n} exports identical"); } } .