//! Writer for the Fig 3.2 format. // // Object order ported from xfig src/f_save.c (xfig licence, see // LICENSE-xfig). use rustfig_geom::Point; use rustfig_model::{Arrow, Document, Object, ObjectKind, Orientation, PolyKind, Style, Units}; use std::fmt::Write as _; /// The whole-figure comment line that carries [`Document::export_on_save`] /// through the Fig format (plan E15). xfig and fig2dev keep such comment /// lines but never interpret them; the line is written after the real /// comments and read back into the model, not shown as a user comment. const EXPORT_ON_SAVE_MARKER: &str = "RustFigExportOnSave"; fn i(v: f64) -> i64 { if v.is_finite() { v.round().clamp(-2.0e9, 2.0e9) as i64 } else { 0 } } fn f(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } fn comments(out: &mut String, c: &[String]) { for line in c { for l in line.split('\n') { let _ = writeln!(out, "# {l}"); } } } fn style_common(out: &mut String, s: &Style) { let _ = write!( out, " {} {} {} {} {} {} {} {:.3}", s.line.to_fig(), s.thickness, s.pen_color.0, s.fill_color.0, s.depth, s.pen_style, s.area_fill, f(s.style_val) ); } fn arrow(out: &mut String, a: &Option) { if let Some(a) = a { let _ = writeln!( out, "\t{} {} {:.2} {:.2} {:.2}", a.kind, a.style, f(a.thickness), f(a.width), f(a.height) ); } } fn points(out: &mut String, pts: &[Point]) { for chunk in pts.chunks(6) { out.push('\t'); for (k, p) in chunk.iter().enumerate() { if k > 0 { out.push(' '); } let _ = write!(out, "{} {}", i(p.x), i(p.y)); } out.push('\n'); } } fn escape_text(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '\\' => out.push_str("\\\\"), '\u{1}' => {} c => out.push(c), } } out } /// An angle in radians as the reader takes it (between -7 and 7). fn angle(a: f64) -> f64 { if (-7.0..=7.0).contains(&a) { a } else { a.rem_euclid(std::f64::consts::TAU) } } fn object(out: &mut String, o: &Object) { // As xfig's write_ellipse: "get rid of any evil ellipses which have // either radius = 0" (fig2dev's reader rejects the whole file for one). if let ObjectKind::Ellipse(e) = &o.kind { if i(e.radii.x) == 0 || i(e.radii.y) == 0 { return; } } comments(out, &rustfig_model::comments_with_local_font(o)); match &o.kind { ObjectKind::Ellipse(e) => { let _ = write!(out, "1 {}", e.kind.to_fig()); style_common(out, &e.style); let _ = writeln!( out, " {} {:.4} {} {} {} {} {} {} {} {}", e.direction, f(angle(e.angle)), i(e.center.x), i(e.center.y), i(e.radii.x), i(e.radii.y), i(e.start.x), i(e.start.y), i(e.end.x), i(e.end.y) ); } ObjectKind::Polyline(p) => { let mut pts = p.points.clone(); if p.kind.is_closed() { if let Some(first) = pts.first().copied() { pts.push(first); } } let (fa, ba) = if p.kind == PolyKind::Polyline { (&p.forward_arrow, &p.backward_arrow) } else { (&None, &None) }; let _ = write!(out, "2 {}", p.kind.to_fig()); style_common(out, &p.style); let _ = writeln!( out, " {} {} {} {} {} {}", p.style.join.to_fig(), p.style.cap.to_fig(), p.radius, i32::from(fa.is_some()), i32::from(ba.is_some()), pts.len() ); arrow(out, fa); arrow(out, ba); if p.kind == PolyKind::Picture { let (flipped, file) = p .picture .as_ref() .map(|pic| (pic.flipped, pic.file.replace('\n', " "))) .unwrap_or((false, String::new())); let _ = writeln!(out, "\t{} {}", i32::from(flipped), file); } points(out, &pts); } ObjectKind::Spline(s) => { let (fa, ba) = if s.closed { (&None, &None) } else { (&s.forward_arrow, &s.backward_arrow) }; let _ = write!(out, "3 {}", s.fig_subtype()); style_common(out, &s.style); let _ = writeln!( out, " {} {} {} {}", s.style.cap.to_fig(), i32::from(fa.is_some()), i32::from(ba.is_some()), s.points.len() ); arrow(out, fa); arrow(out, ba); points(out, &s.points); let shapes: Vec = (0..s.points.len()) .map(|k| f(s.shapes.get(k).copied().unwrap_or(0.0)).clamp(-1.0, 1.0)) .collect(); for chunk in shapes.chunks(8) { out.push('\t'); let line: Vec = chunk.iter().map(|v| format!("{v:.3}")).collect(); out.push_str(&line.join(" ")); out.push('\n'); } } ObjectKind::Text(t) => { let _ = writeln!( out, "4 {} {} {} {} {} {} {:.4} {} {} {} {} {} {}\\001", t.justify.to_fig(), t.color.0, t.depth, t.pen_style, t.font, f(t.size), f(angle(t.angle)), t.flags, i(t.height).max(0), i(t.length).max(0), i(t.origin.x), i(t.origin.y), escape_text(&t.text) ); } ObjectKind::Arc(a) => { // Arrows are kept on pie wedges too, as fig2dev reads them. let (fa, ba) = (&a.forward_arrow, &a.backward_arrow); let _ = write!(out, "5 {}", if a.pie { 2 } else { 1 }); style_common(out, &a.style); let _ = writeln!( out, " {} {} {} {} {:.3} {:.3} {} {} {} {} {} {}", a.style.cap.to_fig(), i32::from(a.ccw), i32::from(fa.is_some()), i32::from(ba.is_some()), f(a.center.x), f(a.center.y), i(a.points[0].x), i(a.points[0].y), i(a.points[1].x), i(a.points[1].y), i(a.points[2].x), i(a.points[2].y) ); arrow(out, fa); arrow(out, ba); } ObjectKind::Compound(c) => { let b = o.geometry_bbox(); let (a, z) = if b.is_empty() { (Point::ZERO, Point::ZERO) } else { (b.min, b.max) }; let _ = writeln!(out, "6 {} {} {} {}", i(a.x), i(a.y), i(z.x), i(z.y)); for child in rustfig_model::xfig_order(&c.objects) { object(out, child); } out.push_str("-6\n"); } } } /// Serialise a document as Fig 3.2. pub fn write_fig(doc: &Document) -> String { let mut out = String::new(); out.push_str("#FIG 3.2 Produced by RustFig\n#encoding: UTF-8\n"); let s = &doc.settings; out.push_str(match s.orientation { Orientation::Landscape => "Landscape\n", Orientation::Portrait => "Portrait\n", }); out.push_str(if s.center { "Center\n" } else { "Flush Left\n" }); out.push_str(match s.units { Units::Inches => "Inches\n", Units::Metric => "Metric\n", }); let _ = writeln!(out, "{}", s.paper.name()); let _ = writeln!(out, "{:.2}", f(s.magnification)); out.push_str(if s.multiple_pages { "Multiple\n" } else { "Single\n" }); let _ = writeln!(out, "{}", s.transparent_color); comments(&mut out, &doc.comments); write_export_on_save(&mut out, doc); out.push_str("1200 2\n"); let mut colors = doc.colors.clone(); colors.sort_by_key(|c| c.number); for c in &colors { let _ = writeln!(out, "0 {} {}", c.number, c.rgb.to_hex()); } // xfig's order: by type, as its object lists are saved (`f_save.c`). for o in rustfig_model::xfig_order(&doc.objects) { object(&mut out, o); } out } /// The one-line representation of [`Document::export_on_save`]. The JSON is /// compact and escaped, so it stays on one line; `serde_json` failing to /// serialise the already-JSON values it holds is impossible, and a missing /// setting writes nothing. fn write_export_on_save(out: &mut String, doc: &Document) { let Some(setting) = &doc.export_on_save else { return; }; if let Ok(json) = serde_json::to_string(setting) { let _ = writeln!(out, "# {EXPORT_ON_SAVE_MARKER} {json}"); } } /// Take [`Document::export_on_save`] out of a Fig file's whole-figure /// comments (which [`rustfig_model`] otherwise keeps as user comments). /// A comment that names the marker but does not parse is left untouched: /// it is the user's text, not our setting. pub(crate) fn restore_export_on_save(doc: &mut Document) { if doc.export_on_save.is_some() { return; } let mut kept = Vec::with_capacity(doc.comments.len()); let prefix = format!("{EXPORT_ON_SAVE_MARKER} "); for line in std::mem::take(&mut doc.comments) { match line.strip_prefix(&prefix) { Some(json) if doc.export_on_save.is_none() => { match serde_json::from_str::(json) { Ok(setting) if !setting.path.is_empty() && !setting.path.contains('\0') => { doc.export_on_save = Some(setting); } _ => kept.push(line), } } _ => kept.push(line), } } doc.comments = kept; } .