//! fig2dev's figure as a RustFig document: the objects `read.c` returns //! ([`Compound`]) and the header values it sets in [`Ctx`] become a //! [`rustfig_model::Document`], so that whatever fig2dev reads (Fig 1.3 to //! 3.2, with its repairs) RustFig can draw, export and edit. //! //! The conversion undoes what the reader does to the file's numbers: //! coordinates go to 1200 per inch (the file's `ppi` scaled), thicknesses //! and arc-box radii back to 1/80 inch, arrowhead thicknesses back to //! 1/80 inch, user colours back to their numbers in the file, closed //! polylines and old closed splines lose their repeated end point (as //! RustFig's own reader keeps them), and boxes become their corners. //! //! What is lost, because `read.c` drops it before the conversion sees it: //! - Fig 3.2 splines arrive as polylines (fig2dev converts every X-spline //! with `create_line_with_spline`), so their control points and shape //! factors are gone, unless the reader ran with `Ctx::editing` (as //! [`crate::read_fig`] does: xfig's reading, nothing repaired); files before 3.2 keep their splines (approximated or //! interpolated, with the default shape factors RustFig's reader gives //! them; interpolated splines' Bézier controls are dropped, as there). //! - Objects come in fig2dev's list order (per compound: arcs, compounds, //! ellipses, lines, splines, texts), which is xfig's drawing order within //! a depth, but not necessarily the file's order. //! - The transparent-colour number of the header: `read.c` keeps only its //! colour, so it is found again by colour (`Ctx::header_transparent`, not //! in fig2dev, tells "none" from "background"; "default" comes back as //! none). //! - With fig2dev's `-m`, `-l`/`-p`, `-z`, `-M` given, the header's //! values were not read; the options' values are taken instead. //! - Comments beyond `read.c`'s 100 per object. //! - Text flags as fig2dev reads them: before Fig 2.1, texts in the //! default font are special (LaTeX) texts, the others PostScript texts. use fig2dev::gendev::{FIG_COLOR_NAMES, NUM_STD_COLS}; use fig2dev::object::{self as f, Compound}; use fig2dev::Ctx; use rustfig_geom::Point; use rustfig_model::{ pt, Arc, Arrow, CapStyle, ColorRef, DocSettings, Document, Ellipse, EllipseKind, JoinStyle, Justify, LineKind, Object, ObjectKind, Orientation, PaperSize, Picture, PolyKind, Polyline, Rgb, Spline, SplineKind, Style, Text, Units, UserColor, FIRST_USER_COLOR, LAST_USER_COLOR, }; /// How the reader's numbers convert back to the file's. struct Conv<'a> { ctx: &'a Ctx, /// Fig units (1200 per inch) per file unit. scale: f64, /// `round(THICK_SCALE)`, by which `read.c` multiplied thicknesses and /// radii; 0 for Fig 1.3, which keeps them. thick_factor: i32, } /// The figure `objects` read by [`fig2dev::read::readfp_fig`] into `ctx`, as a /// RustFig document (see the module documentation for what is lost). pub fn to_document(ctx: &Ctx, objects: &Compound) -> Document { let scale = if ctx.ppi.is_finite() && ctx.ppi > 0.0 { 1200.0 / ctx.ppi } else { 1.0 }; let c = Conv { ctx, scale, thick_factor: fig2dev::round(f64::from(ctx.thick_scale)), }; let mut doc = Document::new(); doc.settings = settings(ctx); doc.comments = objects.comments.iter().map(|c| c.text.clone()).collect(); for (i, u) in ctx.user_colors.iter().enumerate() { let Some(&number) = ctx.user_col_indx.get(i) else { continue; }; if !(FIRST_USER_COLOR..=LAST_USER_COLOR).contains(&number) || doc.colors.iter().any(|c| c.number == number) { continue; } doc.colors.push(UserColor { number, rgb: Rgb::new(byte(u.r), byte(u.g), byte(u.b)), }); } doc.objects = c.compound(objects).into_iter().map(std::sync::Arc::new).collect(); doc.assign_ids(); doc } fn byte(v: i32) -> u8 { v.clamp(0, 255) as u8 } /// The header values `read.c` set (or the options that overrode them). fn settings(ctx: &Ctx) -> DocSettings { // The file prints the magnification with two decimals. let mag = (ctx.mag * 10_000.0).round() / 100.0; DocSettings { orientation: if ctx.landscape { Orientation::Landscape } else { Orientation::Portrait }, center: ctx.center, units: if ctx.metric { Units::Metric } else { Units::Inches }, paper: PaperSize::from_name(ctx.papersize.trim()).unwrap_or_default(), magnification: if mag.is_finite() && mag > 0.0 { mag.min(100_000.0) } else { 100.0 }, multiple_pages: ctx.multi_page, transparent_color: transparent_number(ctx), } } /// The colour number of `gif_transparent` (a standard colour's name, or a /// user colour as `read.c` formats it), −2 (none) otherwise. fn transparent_number(ctx: &Ctx) -> i32 { let name = ctx.gif_transparent.as_str(); if name.is_empty() { return if ctx.header_transparent == -1 { -1 } else { -2 }; } if let Some(i) = FIG_COLOR_NAMES.iter().position(|n| *n == name) { return i as i32; } ctx.user_colors .iter() .zip(&ctx.user_col_indx) .find(|(u, _)| format!("#{:2x}{:2x}{:2x}", u.r, u.g, u.b) == name) .map_or(-2, |(_, &n)| n) } impl Conv<'_> { fn coord(&self, v: f64) -> f64 { let v = v * self.scale; if v.is_finite() { v } else { 0.0 } } fn point(&self, p: f::Point) -> Point { pt(self.coord(f64::from(p.x)), self.coord(f64::from(p.y))) } fn points(&self, ps: &[f::Point]) -> Vec { ps.iter().map(|&p| self.point(p)).collect() } /// A picture's name as the file gives it: `read.c` put the directory /// of `from` before relative names. fn picture_name(&self, file: &str) -> String { let Some(from) = self.ctx.from.as_deref() else { return file.to_string(); }; let sep = |c: char| c == '/' || (cfg!(windows) && c == '\\'); match from.rfind(sep).and_then(|i| from.get(..=i)) { Some(dir) => file.strip_prefix(dir).unwrap_or(file).to_string(), None => file.to_string(), } } /// A thickness or radius back in 1/80 inch. fn thickness(&self, t: i32) -> i32 { if self.thick_factor > 0 { let q = f64::from(t) / f64::from(self.thick_factor); q.round() as i32 } else { t } } /// The colour number in the file: user colours were renumbered to /// `NUM_STD_COLS` + their index. fn color(&self, c: i32) -> ColorRef { if c < NUM_STD_COLS as i32 { return ColorRef(c.max(-1)); } let idx = usize::try_from(c - NUM_STD_COLS as i32).ok(); match idx.and_then(|i| self.ctx.user_col_indx.get(i)) { Some(&n) => ColorRef(n), None => ColorRef::DEFAULT, } } #[allow(clippy::too_many_arguments)] fn style( &self, line: i32, thickness: i32, pen: i32, fill: i32, depth: i32, pen_style: i32, fill_style: i32, style_val: f64, cap: i32, join: i32, ) -> Style { Style { line: LineKind::from_fig(line), thickness: self.thickness(thickness).clamp(0, 10_000), pen_color: self.color(pen), fill_color: self.color(fill), depth: depth.clamp(0, rustfig_model::MAX_DEPTH), pen_style, area_fill: fill_style.clamp(-1, 62), style_val: if style_val.is_finite() { style_val } else { 0.0 }, cap: CapStyle::from_fig(cap), join: JoinStyle::from_fig(join), } } fn arrow(&self, a: &Option) -> Option { let a = a.as_ref()?; let ts = f64::from(self.ctx.thick_scale); // The file prints it with two decimals. let thickness = if ts > 0.0 { (a.thickness / ts * 100.0).round() / 100.0 } else { 1.0 }; let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; Some(Arrow { kind: a.type_.clamp(0, 30), style: a.style.clamp(0, 1), thickness: finite(thickness).clamp(0.0, 10_000.0), width: finite(a.wid * self.scale).abs().min(1.0e7), height: finite(a.ht * self.scale).abs().min(1.0e7), }) } /// The objects of a compound in fig2dev's list order. fn compound(&self, com: &Compound) -> Vec { let mut out = Vec::new(); for a in &com.arcs { out.push(with_comments(ObjectKind::Arc(self.arc(a)), &a.comments)); } for c in &com.compounds { let kind = ObjectKind::Compound(rustfig_model::Compound { objects: self.compound(c), }); out.push(with_comments(kind, &c.comments)); } for e in &com.ellipses { out.push(with_comments(ObjectKind::Ellipse(self.ellipse(e)), &e.comments)); } for l in &com.lines { out.push(with_comments(ObjectKind::Polyline(self.line(l)), &l.comments)); } for s in &com.splines { out.push(with_comments(ObjectKind::Spline(self.spline(s)), &s.comments)); } for t in &com.texts { out.push(with_comments(ObjectKind::Text(self.text(t)), &t.comments).take_local_font()); } out } fn arc(&self, a: &f::Arc) -> Arc { let style = self.style( a.style, a.thickness, a.pen_color, a.fill_color, a.depth, a.pen, a.fill_style, a.style_val, a.cap_style, 0, ); // The file's centre (floats) and direction are kept, not refitted // from the rounded points. Arc { pie: a.type_ == f::T_PIE_WEDGE_ARC, style, ccw: a.direction == 1, forward_arrow: self.arrow(&a.for_arrow), backward_arrow: self.arrow(&a.back_arrow), center: pt(self.coord(a.center.x), self.coord(a.center.y)), points: a.point.map(|p| self.point(p)), } } fn ellipse(&self, e: &f::Ellipse) -> Ellipse { let radii = self.point(e.radiuses); Ellipse { kind: EllipseKind::from_fig(e.type_), style: self.style( e.style, e.thickness, e.pen_color, e.fill_color, e.depth, e.pen, e.fill_style, e.style_val, 0, 0, ), direction: e.direction, angle: if e.angle.is_finite() { e.angle } else { 0.0 }, center: self.point(e.center), radii: pt(radii.x.abs(), radii.y.abs()), start: self.point(e.start), end: self.point(e.end), } } fn line(&self, l: &f::Line) -> Polyline { let kind = PolyKind::from_fig(l.type_); let mut points = self.points(&l.points); if kind.is_closed() && points.len() > 1 && points.first() == points.last() { points.pop(); } if kind.is_rect() && !points.is_empty() { let mut lo = pt(f64::INFINITY, f64::INFINITY); let mut hi = pt(f64::NEG_INFINITY, f64::NEG_INFINITY); for p in &points { lo = pt(lo.x.min(p.x), lo.y.min(p.y)); hi = pt(hi.x.max(p.x), hi.y.max(p.y)); } points = vec![lo, pt(hi.x, lo.y), hi, pt(lo.x, hi.y)]; } Polyline { kind, style: self.style( l.style, l.thickness, l.pen_color, l.fill_color, l.depth, l.pen, l.fill_style, l.style_val, l.cap_style, l.join_style, ), // xfig writes -1 for a line without rounded corners. radius: self.thickness(l.radius).clamp(-1, 100_000), forward_arrow: self.arrow(&l.for_arrow), backward_arrow: self.arrow(&l.back_arrow), points, picture: l.pic.as_ref().map(|p| Picture { flipped: p.flipped != 0, file: self.picture_name(&p.file), image: None, }), } } fn spline(&self, s: &f::Spline) -> Spline { let kind = if s.x_spline() { SplineKind::X } else if s.int_spline() { SplineKind::Interpolated } else { SplineKind::Approximated }; let closed = s.closed_spline(); let mut points = self.points(&s.points); let mut shapes: Vec = if s.controls.len() == s.points.len() && self.ctx.v32_flag { s.controls.iter().map(|c| c.s.clamp(-1.0, 1.0)).collect() } else { let mut v = vec![kind.default_shape(); points.len()]; if !closed { if let Some(first) = v.first_mut() { *first = 0.0; } if let Some(last) = v.last_mut() { *last = 0.0; } } v }; if closed && points.len() > 2 && points.first() == points.last() && !self.ctx.v32_flag { points.pop(); } shapes.truncate(points.len()); Spline { kind, closed, style: self.style( s.style, s.thickness, s.pen_color, s.fill_color, s.depth, s.pen, s.fill_style, s.style_val, s.cap_style, 0, ), forward_arrow: self.arrow(&s.for_arrow), backward_arrow: self.arrow(&s.back_arrow), points, shapes, } } fn text(&self, t: &f::Text) -> Text { let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; Text { justify: Justify::from_fig(t.type_), color: self.color(t.color), depth: t.depth.clamp(0, rustfig_model::MAX_DEPTH), pen_style: t.pen, font: t.font, size: finite(t.size, 12.0).clamp(0.1, 5000.0), angle: finite(t.angle, 0.0), flags: t.flags, height: self.coord(finite(t.height, 0.0)), length: self.coord(finite(t.length, 0.0)), origin: pt(self.coord(f64::from(t.base_x)), self.coord(f64::from(t.base_y))), text: t.cstring.clone(), local_font: None, } } } fn with_comments(kind: ObjectKind, comments: &[f::Comment]) -> Object { let mut o = Object::new(kind); o.comments = comments.iter().map(|c| c.text.clone()).collect(); o } #[cfg(test)] mod tests { use super::*; use fig2dev::util::readpics::{load_picture as read_picture, pic_to_raster}; fn read(src: &str, from: Option<&str>) -> (Ctx, Document) { let mut ctx = Ctx { from: from.map(str::to_string), ..Ctx::default() }; let objs = fig2dev::read::readfp_fig(&mut ctx, src.as_bytes()).unwrap(); let doc = to_document(&ctx, &objs); (ctx, doc) } 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"; #[test] fn fig_2_1_in_fig_units() { let (_, doc) = read(FIG21, None); assert_eq!(doc.settings.orientation, Orientation::Portrait); let kinds: Vec<&str> = doc.objects.iter().map(|o| o.type_name()).collect(); assert_eq!(kinds, ["box", "polyline", "spline", "text"]); let ObjectKind::Polyline(b) = &doc.objects[0].kind else { panic!() }; assert_eq!( b.points, vec![pt(150.0, 150.0), pt(1350.0, 150.0), pt(1350.0, 750.0), pt(150.0, 750.0)] ); assert_eq!((b.style.thickness, b.style.area_fill), (1, -1)); let ObjectKind::Polyline(l) = &doc.objects[1].kind else { panic!() }; assert_eq!( (l.style.thickness, l.style.pen_color, l.style.line), (2, ColorRef(4), LineKind::Dashed) ); let a = l.forward_arrow.unwrap(); assert_eq!((a.thickness, a.width, a.height), (1.0, 60.0, 120.0)); let ObjectKind::Spline(s) = &doc.objects[2].kind else { panic!() }; assert_eq!( (s.kind, s.closed, s.shapes.clone()), (SplineKind::Approximated, false, vec![0.0, 1.0, 0.0]) ); let ObjectKind::Text(t) = &doc.objects[3].kind else { panic!() }; assert_eq!((t.text.as_str(), t.size, t.origin), ("Hello", 12.0, pt(150.0, 2250.0))); assert!(t.is_postscript() && !t.is_special()); assert_eq!((t.height, t.length), (135.0, 450.0)); } #[test] fn fig_1_3() { let (_, doc) = read("1200 2 9 9\n3 10 0 1 1 0 0 0 0 0 0\n100 100 200 0 9999 0\n", None); assert_eq!(doc.objects.len(), 1); let ObjectKind::Spline(s) = &doc.objects[0].kind else { panic!() }; // The header line holds the first point. assert_eq!(s.points, vec![pt(0.0, 0.0), pt(100.0, 100.0), pt(200.0, 0.0)]); assert_eq!(s.style.thickness, 1); } #[test] fn fig_3_2_header_colours_and_pictures() { let src = "#FIG 3.2\nPortrait\nFlush left\nMetric\nA4\n50.00\nMultiple\n32\n# whole figure\n1200 2\n\ 0 32 #123456\n\ 2 5 0 1 0 -1 50 -1 -1 0.000 0 0 -1 0 0 5\n\t0 pics/a.png\n\t 0 0 1200 0 1200 600 0 600 0 0\n\ 2 4 0 3 32 32 40 -1 20 0.000 0 0 7 0 0 5\n\t 0 0 600 0 600 600 0 600 0 0\n\ 1 3 0 2 0 7 50 -1 -1 0.000 1 0.0000 600 600 300 300 600 600 900 600\n"; let (_, doc) = read(src, Some("/data/figs/x.fig")); let s = &doc.settings; assert_eq!( ( s.orientation, s.center, s.units, s.paper, s.magnification, s.multiple_pages, s.transparent_color ), ( Orientation::Portrait, false, Units::Metric, PaperSize::A4, 50.0, true, 32 ) ); assert_eq!(doc.comments, ["whole figure"]); assert_eq!( doc.colors, [UserColor { number: 32, rgb: Rgb::new(0x12, 0x34, 0x56) }] ); let pic = doc .objects .iter() .find_map(|o| match &o.kind { ObjectKind::Polyline(p) => p.picture.clone(), _ => None, }) .unwrap(); assert_eq!(pic.file, "pics/a.png", "the name as in the file"); let arcbox = doc .objects .iter() .find_map(|o| match &o.kind { ObjectKind::Polyline(p) if p.kind == PolyKind::ArcBox => Some(p.clone()), _ => None, }) .unwrap(); assert_eq!((arcbox.radius, arcbox.style.thickness), (7, 3)); assert_eq!( (arcbox.style.pen_color, arcbox.style.fill_color, arcbox.style.depth), (ColorRef(32), ColorRef(32), 40) ); assert_eq!(doc.leaf_count(), 3); } #[test] fn xbm_pictures_become_pixels() { let pic = f::Pic { subtype: f::P_XBM, bitmap: vec![0b1010_0000, 0b0100_0000], bit_size: f::Point::new(3, 2), ..f::Pic::default() }; let img = pic_to_raster(&pic).unwrap(); assert_eq!((img.width, img.height), (3, 2)); let alpha: Vec = img.rgba.chunks(4).map(|p| p[3]).collect(); assert_eq!(alpha, [255, 0, 255, 0, 255, 0]); assert!(pic_to_raster(&f::Pic::default()).is_err()); } #[test] fn missing_pictures_say_so_as_fig2dev() { let e = load_picture_err("no/such/picture.png"); assert_eq!(e, "No such picture file: no/such/picture.png"); } fn load_picture_err(name: &str) -> String { read_picture(&mut Ctx::default(), name).unwrap_err() } } .