//! Plot a curve (plan E12; an addition, xfig has no such mode): a small //! expression parser and the geometry of a plot made of Fig objects. //! //! A plot is one compound: the frame, grid, ticks and numbers, axis labels //! and the curve as polylines. The plot's parameters ([`PlotSpec`]) are kept //! as a comment line of the compound ([`PLOT_COMMENT`]), so Fig 3.2 files //! keep them and "Edit plot..." can redraw it. //! //! Everything here is pure (no files, no engine): the input is untrusted //! (typed expressions, data files, JSON), so lengths, depths, sample counts //! and points are capped and nothing panics. use rustfig_geom::{Point, Rect}; use rustfig_model::{ text_flags, ColorRef, Compound, Justify, LineKind, Object, ObjectKind, PolyKind, Polyline, Style, Text, MAX_DEPTH, }; use serde::{Deserialize, Serialize}; use serde_json::Value; /// Longest expression, in characters. pub const MAX_EXPR_LEN: usize = 500; /// Deepest nesting of parentheses, signs and powers. pub const MAX_NEST: usize = 64; /// Most nodes of one expression. pub const MAX_NODES: usize = 2_000; /// Most samples the user may ask for. pub const MAX_SAMPLES: usize = 20_000; /// Most points of the curve, adaptive refinement included. pub const MAX_POINTS: usize = 100_000; /// Largest data file read, in bytes. pub const MAX_DATA_BYTES: u64 = 16 * 1024 * 1024; /// Most data rows used. pub const MAX_DATA_ROWS: usize = 200_000; /// Most ticks on one axis. const MAX_TICKS: i64 = 200; /// Longest label or file name kept. const MAX_TEXT: usize = 300; /// Values beyond this count as infinite (no overflow in later arithmetic). const BIG: f64 = 1e150; /// The comment line that marks a compound as a plot: prefix, then JSON. pub const PLOT_COMMENT: &str = "rustfig-plot:"; /// Longest comment line Fig files keep (xfig reads 1024 characters a line). const MAX_COMMENT: usize = 1000; // ---- expressions --------------------------------------------------------- type F1 = fn(f64) -> f64; type F2 = fn(f64, f64) -> f64; #[derive(Debug, Clone)] enum Expr { Num(f64), Var(usize), Neg(Box), Bin(char, Box, Box), Call1(F1, Box), Call2(F2, Box, Box), } /// A parsed expression in one or more variables. #[derive(Debug, Clone)] pub struct Formula { root: Expr, } impl Formula { /// The value for the variables (in the order given to [`parse`]); may be /// NaN or infinite. pub fn eval(&self, vars: &[f64]) -> f64 { eval(&self.root, vars) } } fn eval(e: &Expr, vars: &[f64]) -> f64 { match e { Expr::Num(v) => *v, Expr::Var(i) => vars.get(*i).copied().unwrap_or(f64::NAN), Expr::Neg(a) => -eval(a, vars), Expr::Bin(op, a, b) => { let (x, y) = (eval(a, vars), eval(b, vars)); match op { '+' => x + y, '-' => x - y, '*' => x * y, '/' => x / y, _ => x.powf(y), } } Expr::Call1(f, a) => f(eval(a, vars)), Expr::Call2(f, a, b) => f(eval(a, vars), eval(b, vars)), } } fn sign(x: f64) -> f64 { if x.is_nan() || x == 0.0 { x } else { x.signum() } } const FUNCS1: &[(&str, F1)] = &[ ("sin", f64::sin), ("cos", f64::cos), ("tan", f64::tan), ("asin", f64::asin), ("acos", f64::acos), ("atan", f64::atan), ("sinh", f64::sinh), ("cosh", f64::cosh), ("tanh", f64::tanh), ("exp", f64::exp), ("ln", f64::ln), ("log", f64::ln), ("log10", f64::log10), ("log2", f64::log2), ("sqrt", f64::sqrt), ("abs", f64::abs), ("floor", f64::floor), ("ceil", f64::ceil), ("sign", sign), ]; const FUNCS2: &[(&str, F2)] = &[ ("atan2", f64::atan2), ("min", f64::min), ("max", f64::max), ("pow", f64::powf), ]; #[derive(Debug, Clone, PartialEq)] enum Tok { Num(f64), Name(String), Op(char), Pow, LParen, RParen, Comma, } fn tokenize(src: &str) -> Result, String> { let chars: Vec = src.chars().collect(); let mut out = Vec::new(); let mut i = 0; while let Some(&c) = chars.get(i) { let at = i + 1; if c.is_whitespace() { i += 1; } else if c.is_ascii_digit() || c == '.' { let start = i; while chars.get(i).is_some_and(|c| c.is_ascii_digit()) { i += 1; } if chars.get(i) == Some(&'.') { i += 1; while chars.get(i).is_some_and(|c| c.is_ascii_digit()) { i += 1; } } // An exponent only when digits follow ("2e" is 2 times e). if matches!(chars.get(i), Some('e' | 'E')) { let mut j = i + 1; if matches!(chars.get(j), Some('+' | '-')) { j += 1; } if chars.get(j).is_some_and(|c| c.is_ascii_digit()) { while chars.get(j).is_some_and(|c| c.is_ascii_digit()) { j += 1; } i = j; } } let text: String = chars.get(start..i).unwrap_or_default().iter().collect(); let v: f64 = text.parse().map_err(|_| format!("bad number {text:?} at {at}"))?; if !v.is_finite() { return Err(format!("number {text:?} at {at} is too large")); } out.push((Tok::Num(v), at)); } else if c.is_alphabetic() || c == '_' { let start = i; while chars.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') { i += 1; } out.push((Tok::Name(chars.get(start..i).unwrap_or_default().iter().collect()), at)); } else { i += 1; let t = match c { '+' => Tok::Op('+'), '-' | '\u{2212}' => Tok::Op('-'), '*' | '\u{d7}' | '\u{b7}' => { if c == '*' && chars.get(i) == Some(&'*') { i += 1; Tok::Pow } else { Tok::Op('*') } } '/' | '\u{f7}' => Tok::Op('/'), '^' => Tok::Pow, '(' => Tok::LParen, ')' => Tok::RParen, ',' => Tok::Comma, _ => return Err(format!("unexpected {c:?} at {at}")), }; out.push((t, at)); } } Ok(out) } struct Parser<'a> { toks: Vec<(Tok, usize)>, pos: usize, nest: usize, nodes: usize, vars: &'a [&'a str], } impl Parser<'_> { fn peek(&self) -> Option<&Tok> { self.toks.get(self.pos).map(|(t, _)| t) } fn here(&self) -> usize { self.toks.get(self.pos).or(self.toks.last()).map_or(1, |(_, p)| *p) } fn node(&mut self, e: Expr) -> Result { self.nodes += 1; if self.nodes > MAX_NODES { return Err("the expression is too long".into()); } Ok(e) } fn enter(&mut self) -> Result<(), String> { self.nest += 1; if self.nest > MAX_NEST { return Err("the expression is nested too deeply".into()); } Ok(()) } fn expr(&mut self) -> Result { let mut a = self.term()?; while let Some(Tok::Op(op @ ('+' | '-'))) = self.peek().cloned() { self.pos += 1; let b = self.term()?; a = self.node(Expr::Bin(op, Box::new(a), Box::new(b)))?; } Ok(a) } fn term(&mut self) -> Result { let mut a = self.unary()?; while let Some(Tok::Op(op @ ('*' | '/'))) = self.peek().cloned() { self.pos += 1; let b = self.unary()?; a = self.node(Expr::Bin(op, Box::new(a), Box::new(b)))?; } Ok(a) } fn unary(&mut self) -> Result { match self.peek() { Some(Tok::Op('-')) => { self.pos += 1; self.enter()?; let a = self.unary()?; self.nest -= 1; self.node(Expr::Neg(Box::new(a))) } Some(Tok::Op('+')) => { self.pos += 1; self.enter()?; let a = self.unary()?; self.nest -= 1; Ok(a) } _ => self.power(), } } /// `a ^ b` binds tighter than a sign on the left (`-x^2` is `-(x^2)`) /// and is right associative; a sign is allowed in the exponent. fn power(&mut self) -> Result { let a = self.primary()?; if self.peek() == Some(&Tok::Pow) { self.pos += 1; self.enter()?; let b = self.unary()?; self.nest -= 1; return self.node(Expr::Bin('^', Box::new(a), Box::new(b))); } Ok(a) } fn expect(&mut self, t: Tok, what: &str) -> Result<(), String> { if self.peek() == Some(&t) { self.pos += 1; Ok(()) } else { Err(format!("expected {what} at {}", self.here())) } } fn primary(&mut self) -> Result { let at = self.here(); let Some(tok) = self.peek().cloned() else { return Err("the expression ends too early".into()); }; self.pos += 1; match tok { Tok::Num(v) => self.node(Expr::Num(v)), Tok::LParen => { self.enter()?; let e = self.expr()?; self.nest -= 1; self.expect(Tok::RParen, "\")\"")?; Ok(e) } Tok::Name(name) => { if self.peek() == Some(&Tok::LParen) { self.pos += 1; self.enter()?; let a = self.expr()?; let r = if let Some((_, f)) = FUNCS1.iter().find(|(n, _)| *n == name) { Expr::Call1(*f, Box::new(a)) } else if let Some((_, f)) = FUNCS2.iter().find(|(n, _)| *n == name) { self.expect(Tok::Comma, "\",\" (two arguments)")?; let b = self.expr()?; Expr::Call2(*f, Box::new(a), Box::new(b)) } else { return Err(format!("unknown function {name:?} at {at}")); }; self.nest -= 1; self.expect(Tok::RParen, "\")\"")?; return self.node(r); } if let Some(i) = self.vars.iter().position(|v| *v == name) { return self.node(Expr::Var(i)); } match name.as_str() { "pi" => self.node(Expr::Num(std::f64::consts::PI)), "e" => self.node(Expr::Num(std::f64::consts::E)), _ => Err(format!("unknown name {name:?} at {at}")), } } Tok::Comma => Err(format!("unexpected \",\" at {at}")), Tok::RParen => Err(format!("unexpected \")\" at {at}")), Tok::Op(c) => Err(format!("unexpected {c:?} at {at}")), Tok::Pow => Err(format!("unexpected \"^\" at {at}")), } } } /// Parse `src` with the variables `vars` (their values are given to /// [`Formula::eval`] in this order). Operators + − × ÷ ^ (also `*`, `/`, /// `**`), parentheses, `pi`, `e`, and the functions sin cos tan asin acos /// atan sinh cosh tanh exp ln log (natural) log10 log2 sqrt abs floor ceil /// sign, and atan2 min max pow with two arguments. pub fn parse(src: &str, vars: &[&str]) -> Result { if src.chars().count() > MAX_EXPR_LEN { return Err(format!("the expression is longer than {MAX_EXPR_LEN} characters")); } let toks = tokenize(src)?; if toks.is_empty() { return Err("the expression is empty".into()); } let mut p = Parser { toks, pos: 0, nest: 0, nodes: 0, vars, }; let root = p.expr()?; if p.pos < p.toks.len() { return Err(format!("unexpected input at {}", p.here())); } Ok(Formula { root }) } // ---- the plot's parameters ----------------------------------------------- /// What is plotted. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PlotMode { /// y = f(x) Function, /// x(t), y(t) Parametric, /// Columns of a CSV or whitespace separated file. Data, } /// Where the ticks stand relative to the axis. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TickDir { In, Out, Both, } /// How tick numbers are written. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NumFormat { Auto, /// A fixed number of decimals (`decimals`). Fixed, Scientific, /// Multiples of pi: 0, pi/2, pi, 3pi/2. Pi, } /// The ticks of one axis. The default is the automatic ticks of plots made /// before this setting existed. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct TickSpec { /// Manual ticks: `first`, `step` or `count` apply (else automatic). pub manual: bool, /// Position of the first major tick (default: the first multiple of the step in range). pub first: Option, /// Distance of major ticks (log axes: decades). pub step: Option, /// Number of intervals, instead of a step. pub count: Option, /// Minor ticks between two majors, 0-9; `None` is automatic (none on a /// linear axis, 2-9 on a log axis with a decade step). On a log axis /// n gives the minors at 2..n+1 times the decade. pub minor: Option, /// Length of a major tick in Fig units (minor ticks are half). pub length: f64, pub dir: TickDir, pub format: NumFormat, pub decimals: u8, /// Tick marks on this axis. pub ticks: bool, /// Numbers at the major ticks. pub numbers: bool, } impl Default for TickSpec { fn default() -> Self { TickSpec { manual: false, first: None, step: None, count: None, minor: None, length: 105.0, dir: TickDir::In, format: NumFormat::Auto, decimals: 2, ticks: true, numbers: true, } } } impl TickSpec { fn check(&self, axis: &str) -> Result<(), String> { if let Some(v) = self.first { if !v.is_finite() || v.abs() > BIG { return Err(format!("{axis} ticks: the first tick must be a finite number")); } } if let Some(v) = self.step { if !v.is_finite() || v <= 0.0 || v > BIG { return Err(format!("{axis} ticks: the step must be a positive number")); } } if let Some(n) = self.count { if !(1..=MAX_TICKS as usize).contains(&n) { return Err(format!("{axis} ticks: the count must be between 1 and {MAX_TICKS}")); } } if self.minor.is_some_and(|m| m > 9) { return Err(format!("{axis} ticks: minor ticks per major are 0 to 9")); } if !self.length.is_finite() || !(0.0..=3000.0).contains(&self.length) { return Err(format!("{axis} ticks: the length must be between 0 and 3000")); } if self.decimals > 9 { return Err(format!("{axis} ticks: at most 9 decimals")); } Ok(()) } } /// The parameters of a plot, as the dialog and `plot.add` give them and as /// the compound's comment keeps them. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct PlotSpec { pub mode: PlotMode, /// y = f(x) (variable `x`). pub expr: String, /// x(t) and y(t) (variable `t`). pub xt: String, pub yt: String, /// Data file and its 1-based columns (x column 0: the row number). pub file: String, pub xcol: usize, pub ycol: usize, /// Ranges; `None` fits the data (function: x defaults to -10..10). pub xmin: Option, pub xmax: Option, pub ymin: Option, pub ymax: Option, /// Parameter range of a parametric curve (default 0..2π). pub tmin: Option, pub tmax: Option, /// Samples before adaptive refinement. pub samples: usize, /// The box around the plot. pub frame: bool, /// Old plots' all-in-one switch: `false` turns the frame, ticks and /// numbers off when read (`from_value`), then it is cleared. Never written. #[serde(skip_serializing)] pub axes: Option, pub grid: bool, pub log_x: bool, pub log_y: bool, pub xlabel: String, pub ylabel: String, /// Axis labels are LaTeX (special text). pub latex: bool, /// The tick numbers are LaTeX too: special texts in `$...$`. pub latex_numbers: bool, pub xticks: TickSpec, pub yticks: TickSpec, /// The plot's frame in Fig units (min x, min y, max x, max y); written /// by the command, used by "Edit plot" when the frame is missing. pub rect: Option<[f64; 4]>, } impl Default for PlotSpec { fn default() -> Self { PlotSpec { mode: PlotMode::Function, expr: "sin(x)".into(), xt: "cos(t)".into(), yt: "sin(t)".into(), file: String::new(), xcol: 1, ycol: 2, xmin: Some(0.0), xmax: Some(std::f64::consts::TAU), ymin: None, ymax: None, tmin: None, tmax: None, samples: 400, frame: true, axes: None, grid: false, log_x: false, log_y: false, xlabel: String::new(), ylabel: String::new(), latex: false, latex_numbers: false, xticks: TickSpec::default(), yticks: TickSpec::default(), rect: None, } } } impl PlotSpec { /// A spec from JSON params (unknown keys are ignored), checked. pub fn from_value(v: &Value) -> Result { let spec: PlotSpec = serde_json::from_value(v.clone()).map_err(|e| format!("bad plot parameters: {e}"))?; spec.check()?; Ok(spec.migrate()) } /// Old comments' `axes: false` means no frame, ticks or numbers. fn migrate(mut self) -> PlotSpec { if self.axes.take() == Some(false) { self.frame = false; for t in [&mut self.xticks, &mut self.yticks] { t.ticks = false; t.numbers = false; } } self } fn check(&self) -> Result<(), String> { self.xticks.check("x")?; self.yticks.check("y")?; let nums = [self.xmin, self.xmax, self.ymin, self.ymax, self.tmin, self.tmax]; if nums.iter().flatten().any(|v| !v.is_finite() || v.abs() > BIG) { return Err("ranges must be finite numbers".into()); } if !(2..=MAX_SAMPLES).contains(&self.samples) { return Err(format!("samples must be between 2 and {MAX_SAMPLES}")); } for s in [&self.expr, &self.xt, &self.yt, &self.file, &self.xlabel, &self.ylabel] { if s.chars().count() > MAX_TEXT.max(MAX_EXPR_LEN) { return Err("a text of the plot is too long".into()); } } if self.xcol > 1_000 || self.ycol == 0 || self.ycol > 1_000 { return Err("columns are numbered from 1 (x column 0 means the row number)".into()); } if let Some(r) = self.rect { if r.iter().any(|v| !v.is_finite() || v.abs() > 1e9) { return Err("rect must be finite".into()); } } Ok(()) } /// The comment line that keeps this spec (only values that differ from /// the defaults), or an error when it would be too long for a Fig file. pub fn comment(&self) -> Result { let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; let d = serde_json::to_value(PlotSpec::default()).map_err(|e| e.to_string())?; // Which expression fields matter depends on the mode. let mode = self.mode; if let (Some(m), Some(dm)) = (v.as_object_mut(), d.as_object()) { m.retain(|k, x| k == "mode" || k == "rect" || dm.get(k) != Some(x)); let drop: &[&str] = match mode { PlotMode::Function => &["xt", "yt", "file", "xcol", "ycol", "tmin", "tmax"], PlotMode::Parametric => &["expr", "file", "xcol", "ycol"], PlotMode::Data => &["expr", "xt", "yt", "tmin", "tmax", "samples"], }; for k in drop { m.remove(*k); } } let line = format!("{PLOT_COMMENT} {v}"); if line.chars().count() > MAX_COMMENT { return Err("the plot's description is too long to keep in the figure".into()); } Ok(line) } /// The spec a comment line keeps, if it is a plot comment. pub fn from_comment(line: &str) -> Option { let json = line.trim().strip_prefix(PLOT_COMMENT)?; let v: Value = serde_json::from_str(json.trim()).ok()?; PlotSpec::from_value(&v).ok() } /// The spec of a plot compound (its comment), if it is one. pub fn of_object(o: &Object) -> Option { if !matches!(o.kind, ObjectKind::Compound(_)) { return None; } o.comments.iter().find_map(|c| PlotSpec::from_comment(c)) } } // ---- data ---------------------------------------------------------------- /// The numbers of a data file: lines of values separated by commas, /// semicolons, tabs or spaces; `#`/`%` lines and lines that are not numbers /// (a header) are skipped. Returns the (x, y) of the chosen columns. pub fn parse_table(text: &str, xcol: usize, ycol: usize) -> Result, String> { let need = xcol.max(ycol); let mut out = Vec::new(); for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') || line.starts_with('%') { continue; } let cells: Vec<&str> = line.split([',', ';', '\t', ' ']).filter(|c| !c.is_empty()).collect(); if cells.len() < need { continue; } let num = |col: usize| -> Option { let v: f64 = cells.get(col.checked_sub(1)?)?.parse().ok()?; (v.is_finite() && v.abs() <= BIG).then_some(v) }; let y = num(ycol); let x = if xcol == 0 { Some(out.len() as f64 + 1.0) } else { num(xcol) }; if let (Some(x), Some(y)) = (x, y) { out.push((x, y)); if out.len() >= MAX_DATA_ROWS { break; } } } if out.is_empty() { return Err(format!( "no rows with numbers in columns {xcol} and {ycol} (columns are numbered from 1)" )); } Ok(out) } // ---- sampling ------------------------------------------------------------ /// A point of the plot's own coordinates (log axes already transformed). type P = (f64, f64); /// `a < b`, false for NaN too (a range must have min below max). fn ordered(a: f64, b: f64) -> bool { a < b } fn finite(v: f64) -> Option { (v.is_finite() && v.abs() <= BIG).then_some(v) } /// The axis transform: logarithms of positive values on a log axis. fn tr(v: f64, log: bool) -> Option { if log { if v > 0.0 { finite(v.log10()) } else { None } } else { finite(v) } } struct Refiner<'a> { f: &'a dyn Fn(f64) -> Option

, /// Normalising spans of the two axes (curvature is judged on screen). sx: f64, sy: f64, budget: usize, } impl Refiner<'_> { /// Points between `u0` and `u1` where the curve bends or breaks off. fn refine(&mut self, u0: f64, p0: Option

, u1: f64, p1: Option

, depth: u32, out: &mut Vec<(f64, Option

)>) { if depth == 0 || self.budget == 0 || (p0.is_none() && p1.is_none()) { return; } let um = 0.5 * (u0 + u1); if um <= u0 || um >= u1 { return; } self.budget -= 1; let pm = (self.f)(um); let split = match (p0, p1, pm) { (Some(a), Some(b), Some(m)) => { let dx = (m.0 - 0.5 * (a.0 + b.0)) / self.sx; let dy = (m.1 - 0.5 * (a.1 + b.1)) / self.sy; dx.abs().max(dy.abs()) > 0.001 } // An end that is missing: look for where the curve stops. _ => true, }; if split { self.refine(u0, p0, um, pm, depth - 1, out); out.push((um, pm)); self.refine(um, pm, u1, p1, depth - 1, out); } } } /// A range from the values, for an axis with no range given: the full range /// unless a few outliers (poles) stretch it, then the bulk. fn fit_range(vals: &mut Vec) -> Option<(f64, f64)> { vals.retain(|v| v.is_finite()); if vals.is_empty() { return None; } vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let n = vals.len(); let at = |q: f64| vals.get(((n - 1) as f64 * q).round() as usize).copied().unwrap_or(0.0); let (lo, hi) = (at(0.0), at(1.0)); let (qlo, qhi) = (at(0.02), at(0.98)); if qhi > qlo && hi - lo > 3.0 * (qhi - qlo) { let pad = 0.1 * (qhi - qlo); Some((qlo - pad, qhi + pad)) } else { Some((lo, hi)) } } /// A step of 1, 2 or 5 times a power of ten that gives about `target` steps. fn nice_step(span: f64, target: f64) -> f64 { let raw = span / target; if !(raw.is_finite() && raw > 0.0) { return 1.0; } let p = 10f64.powf(raw.log10().floor()); let m = raw / p; let n = if m < 1.5 { 1.0 } else if m < 3.5 { 2.0 } else if m < 7.5 { 5.0 } else { 10.0 }; n * p } /// An automatic range widened to tick positions (decades on a log axis). fn nice_range(lo: f64, hi: f64, log: bool) -> (f64, f64) { let (mut lo, mut hi) = (lo, hi); if hi - lo <= 1e-12 * lo.abs().max(hi.abs()).max(1e-300) { lo -= 1.0; hi += 1.0; } if log { let (a, b) = (lo.floor(), hi.ceil()); return if b > a { (a, b) } else { (a, a + 1.0) }; } let step = nice_step(hi - lo, 6.0); ((lo / step).floor() * step, (hi / step).ceil() * step) } struct Ranges { x0: f64, x1: f64, y0: f64, y1: f64, } /// The curve as pieces of points in the plot's coordinates (transformed by /// the axes' logarithms), and the ranges. fn sample(spec: &PlotSpec, data: Option<&str>) -> Result<(Vec>, Ranges), String> { let (lx, ly) = (spec.log_x, spec.log_y); let given = |v: Option, log: bool, name: &str| -> Result, String> { match v { None => Ok(None), Some(v) => tr(v, log) .map(Some) .ok_or_else(|| format!("{name} must be positive on a logarithmic axis")), } }; let (gx0, gx1) = (given(spec.xmin, lx, "x range")?, given(spec.xmax, lx, "x range")?); let (gy0, gy1) = (given(spec.ymin, ly, "y range")?, given(spec.ymax, ly, "y range")?); // Raw samples: (parameter, point) with the parameter ordering them. let mut pts: Vec<(f64, Option

)> = Vec::new(); let mut curve: Option Option

>> = None; let (mut u0, mut u1) = (0.0, 1.0); match spec.mode { PlotMode::Data => { let text = data.ok_or("the plot has no data (give a file)")?; for (i, (x, y)) in parse_table(text, spec.xcol, spec.ycol)?.into_iter().enumerate() { let p = tr(x, lx).zip(tr(y, ly)); pts.push((i as f64, p)); } } PlotMode::Function => { let f = parse(&spec.expr, &["x"])?; let (a, b) = ( gx0.unwrap_or(if lx { -1.0 } else { -10.0 }), gx1.unwrap_or(if lx { 2.0 } else { 10.0 }), ); if !ordered(a, b) { return Err("the x range must have min below max".into()); } (u0, u1) = (a, b); curve = Some(Box::new(move |u| { let x = if lx { 10f64.powf(u) } else { u }; tr(f.eval(&[x]), ly).map(|y| (u, y)) })); } PlotMode::Parametric => { let fx = parse(&spec.xt, &["t"])?; let fy = parse(&spec.yt, &["t"])?; let (a, b) = (spec.tmin.unwrap_or(0.0), spec.tmax.unwrap_or(std::f64::consts::TAU)); if !ordered(a, b) { return Err("the t range must have min below max".into()); } (u0, u1) = (a, b); curve = Some(Box::new(move |t| { let x = tr(fx.eval(&[t]), lx)?; let y = tr(fy.eval(&[t]), ly)?; Some((x, y)) })); } } if let Some(f) = &curve { let n = spec.samples; for i in 0..=n { let u = if i == n { u1 } else { u0 + (u1 - u0) * i as f64 / n as f64 }; pts.push((u, f(u))); } } // Ranges. let mut xs: Vec = pts.iter().filter_map(|(_, p)| p.map(|p| p.0)).collect(); let mut ys: Vec = pts.iter().filter_map(|(_, p)| p.map(|p| p.1)).collect(); if xs.is_empty() { return Err("the curve has no finite values in this range".into()); } let auto = |given: (Option, Option), vals: &mut Vec, log: bool| { if let (Some(a), Some(b)) = given { return Ok((a, b)); } let (a, b) = fit_range(vals).ok_or("the curve has no finite values in this range")?; let (lo, hi) = nice_range(a, b, log); Ok::<_, String>((given.0.unwrap_or(lo), given.1.unwrap_or(hi))) }; let (x0, x1) = auto((gx0, gx1), &mut xs, lx)?; let (y0, y1) = auto((gy0, gy1), &mut ys, ly)?; if !ordered(x0, x1) || !ordered(y0, y1) { return Err("a range must have min below max".into()); } let ranges = Ranges { x0, x1, y0, y1 }; // Adaptive refinement of a sampled curve, then the pieces. let mut all: Vec<(f64, Option

)> = Vec::with_capacity(pts.len()); if let Some(f) = &curve { let mut r = Refiner { f: &**f, sx: x1 - x0, sy: y1 - y0, budget: MAX_POINTS.saturating_sub(pts.len()) / 2, }; for w in pts.windows(2) { if let [(ua, pa), (ub, pb)] = *w { all.push((ua, pa)); r.refine(ua, pa, ub, pb, 10, &mut all); } } if let Some(last) = pts.last() { all.push(*last); } } else { all = pts; } let mut pieces: Vec> = Vec::new(); let mut cur: Vec

= Vec::new(); let mut prev: Option

= None; for (_, p) in all { match p { None => { pieces.push(std::mem::take(&mut cur)); prev = None; } Some(p) => { if let Some(q) = prev { // No line across a pole: the two ends are beyond the // range on opposite sides. let above = |p: P| p.1 > y1; let below = |p: P| p.1 < y0; if (above(p) && below(q)) || (below(p) && above(q)) { pieces.push(std::mem::take(&mut cur)); } } cur.push(p); prev = Some(p); } } } pieces.push(cur); pieces.retain(|p| !p.is_empty()); Ok((pieces, ranges)) } /// The part of the segment a-b inside the rectangle (Liang-Barsky). fn clip(a: P, b: P, r: &Ranges) -> Option<(P, P)> { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64); for (p, q) in [(-dx, a.0 - r.x0), (dx, r.x1 - a.0), (-dy, a.1 - r.y0), (dy, r.y1 - a.1)] { if p == 0.0 { if q < 0.0 { return None; } } else { let t = q / p; if p < 0.0 { if t > t1 { return None; } t0 = t0.max(t); } else { if t < t0 { return None; } t1 = t1.min(t); } } } // Ends that were not cut are kept exactly, so pieces stay joined. let c = if t0 > 0.0 { (a.0 + t0 * dx, a.1 + t0 * dy) } else { a }; let d = if t1 < 1.0 { (a.0 + t1 * dx, a.1 + t1 * dy) } else { b }; Some((c, d)) } /// Cut the pieces to the plot's rectangle: runs of points inside it. fn clip_pieces(pieces: &[Vec

], r: &Ranges) -> Vec> { let mut out: Vec> = Vec::new(); for piece in pieces { let mut cur: Vec

= Vec::new(); if let [only] = piece.as_slice() { if only.0 >= r.x0 && only.0 <= r.x1 && only.1 >= r.y0 && only.1 <= r.y1 { out.push(vec![*only, *only]); } continue; } for w in piece.windows(2) { let [a, b] = *w else { continue }; match clip(a, b, r) { Some((c, d)) => { // A new run unless this segment starts where the last ended. if cur.last() != Some(&c) { if cur.len() > 1 { out.push(std::mem::take(&mut cur)); } else { cur.clear(); } cur.push(c); } cur.push(d); } None => { if cur.len() > 1 { out.push(std::mem::take(&mut cur)); } else { cur.clear(); } } } } if cur.len() > 1 { out.push(cur); } } out } // ---- ticks and numbers ----------------------------------------------------- /// A number for a tick label. pub fn format_tick(v: f64) -> String { if v == 0.0 || !v.is_finite() { return "0".into(); } let a = v.abs(); if !(1e-4..1e6).contains(&a) { return format!("{v:e}"); } let s = format!("{v:.6}"); let s = s.trim_end_matches('0').trim_end_matches('.'); if s == "-0" { "0".into() } else { s.into() } } /// How far a tick of length `len` reaches outside and inside the frame. fn tick_reach(dir: TickDir, len: f64) -> (f64, f64) { match dir { TickDir::In => (0.0, len), TickDir::Out => (len, 0.0), TickDir::Both => (len, len), } } /// A tick number as LaTeX math: `-2.5` is `$-2.5$`, `1e-5` is `$10^{-5}$` /// and `2e6` is `$2\\times10^{6}$`. pub fn latex_number(l: &str) -> String { match l.split_once('e') { Some(("1", exp)) => format!("$10^{{{exp}}}$"), Some(("-1", exp)) => format!("$-10^{{{exp}}}$"), Some((m, exp)) => format!("${m}\\times10^{{{exp}}}$"), None if l.contains('\u{3c0}') => format!("${}$", l.replace('\u{3c0}', "\\pi{}")), None => format!("${l}$"), } } /// A tick: the position in plot coordinates, its number (major ticks only). struct Tick { at: f64, major: bool, label: Option, } fn pi_label(v: f64) -> String { let r = v / std::f64::consts::PI; for d in 1..=12i64 { let n = (r * d as f64).round(); if (r * d as f64 - n).abs() < 1e-9 && n.abs() < 1e6 { let n = n as i64; if n == 0 { return "0".into(); } let g = { let (mut a, mut b) = (n.abs(), d); while b != 0 { (a, b) = (b, a % b); } a.max(1) }; let (n, d) = (n / g, d / g); let coef = match n { 1 => String::new(), -1 => "-".into(), n => n.to_string(), }; return if d == 1 { format!("{coef}\u{3c0}") } else { format!("{coef}\u{3c0}/{d}") }; } } format!("{}\u{3c0}", format_tick(r)) } fn format_with(spec: &TickSpec, v: f64) -> String { match spec.format { NumFormat::Auto => format_tick(v), NumFormat::Fixed => { let s = format!("{:.*}", spec.decimals as usize, v); if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } } NumFormat::Scientific => { if v == 0.0 || !v.is_finite() { "0".into() } else { format!("{v:e}") } } NumFormat::Pi => pi_label(v), } } fn ticks(lo: f64, hi: f64, log: bool, spec: &TickSpec) -> Result, String> { let mut out = Vec::new(); let label = |v: f64| spec.numbers.then(|| format_with(spec, v)); if !(lo.is_finite() && hi.is_finite()) { return Ok(out); } if log { let (a, b) = (lo.floor() as i64, hi.ceil() as i64); let step = match (spec.manual, spec.step) { (true, Some(s)) => (s.round() as i64).max(1), _ => ((b - a) / 10 + 1).max(1), }; if (b - a) / step > MAX_TICKS { return Err("too many ticks on the logarithmic axis: use a larger step".into()); } let minors = match spec.minor { Some(n) => i64::from(n.min(8)), None if step == 1 => 8, None => 0, }; let mut e = a; while e <= b { let v = e as f64; if v >= lo - 1e-9 && v <= hi + 1e-9 { out.push(Tick { at: v, major: true, label: label(10f64.powf(v)), }); } for m in 2..(2 + minors) { let w = v + (m as f64).log10(); if w >= lo && w <= hi { out.push(Tick { at: w, major: false, label: None, }); } } e += step; } return Ok(out); } let span = hi - lo; let auto_step = if spec.format == NumFormat::Pi { if span <= 4.0 * std::f64::consts::PI { std::f64::consts::FRAC_PI_2 } else { std::f64::consts::PI } } else { nice_step(span, 6.0) }; let step = match (spec.manual, spec.step, spec.count) { (true, Some(s), _) => s, (true, None, Some(n)) => span / n as f64, _ => auto_step, }; if !(step.is_finite() && step > 0.0) { return Err("the tick step must be positive".into()); } let first = match (spec.manual, spec.first) { (true, Some(f)) => f, _ => (lo / step - 1e-9).ceil() * step, }; let minor = i64::from(spec.minor.unwrap_or(0)); // Majors k with first + k*step in range (also one before, for minors). let k0 = ((lo - first) / step).floor() - 1.0; let k1 = ((hi - first) / step).ceil() + 1.0; if !(k0.is_finite() && k1.is_finite()) || k1 - k0 > (MAX_TICKS + 2) as f64 { return Err(format!("too many ticks (more than {MAX_TICKS}): use a larger step")); } let eps = 1e-9 * span.abs().max(1e-300); // A manual first tick starts the series. let lo = if spec.manual && spec.first.is_some() { lo.max(first) } else { lo }; let mut k = k0 as i64; while k <= k1 as i64 { let v = first + k as f64 * step; if v >= lo - eps && v <= hi + eps { out.push(Tick { at: v, major: true, label: label(if v.abs() < eps { 0.0 } else { v }), }); } for j in 1..=minor { let w = v + j as f64 * step / (minor + 1) as f64; if w >= lo - eps && w <= hi + eps { out.push(Tick { at: w, major: false, label: None, }); } } k += 1; } Ok(out) } // ---- the compound ------------------------------------------------------------ /// The look of the plot: the indicator's line style for the curve and its /// text settings for numbers and labels. #[derive(Debug, Clone)] pub struct PlotLook { pub curve: Style, pub text_color: ColorRef, pub font: i32, pub font_size: f64, pub postscript: bool, /// Dot gap of the grid in 1/80 inch. pub grid_gap: f64, } /// A plot ready to add. pub struct Plot { pub object: Object, pub curves: usize, pub points: usize, } fn fig(v: f64) -> f64 { v.round() } /// Build the plot compound for `spec` in the frame `rect` (Fig units). /// `data` is the text of the data file in data mode. pub fn build(spec: &PlotSpec, rect: Rect, data: Option<&str>, look: &PlotLook) -> Result { spec.check()?; let (w, h) = (rect.max.x - rect.min.x, rect.max.y - rect.min.y); if !(w.is_finite() && h.is_finite() && w >= 10.0 && h >= 10.0 && w <= 1e7 && h <= 1e7) { return Err("the plot's frame is too small or too large".into()); } let (pieces, r) = sample(spec, data)?; let pieces = clip_pieces(&pieces, &r); let mx = |x: f64| rect.min.x + (x - r.x0) / (r.x1 - r.x0) * w; let my = |y: f64| rect.max.y - (y - r.y0) / (r.y1 - r.y0) * h; let thin = |line: LineKind, depth: i32| Style { line, thickness: 1, pen_color: look.curve.pen_color, fill_color: look.curve.fill_color, depth: depth.clamp(0, MAX_DEPTH), pen_style: -1, area_fill: -1, style_val: if line == LineKind::Dotted { look.grid_gap } else { 0.0 }, cap: look.curve.cap, join: look.curve.join, }; let line = |style: Style, pts: Vec| { Object::new(ObjectKind::Polyline(Polyline::new(PolyKind::Polyline, style, pts))) }; let depth = look.curve.depth; let mut members: Vec = Vec::new(); if spec.frame { let a = Point::new(fig(rect.min.x), fig(rect.min.y)); let b = Point::new(fig(rect.max.x), fig(rect.max.y)); let mut frame = thin(LineKind::Solid, depth); frame.thickness = look.curve.thickness.clamp(1, 3); push_part( &mut members, "frame", Object::new(ObjectKind::Polyline(Polyline::rect(frame, a, b))), ); } let size = look.font_size.clamp(6.0, 72.0); let tick_size = (size * 0.8).max(6.0); let unit = 1200.0 / 72.0; let text = |s: &str, justify: Justify, origin: Point, angle: f64, size: f64, special: bool| { let flags = if special { text_flags::SPECIAL } else if look.postscript { text_flags::POSTSCRIPT } else { 0 }; Object::new(ObjectKind::Text(Text { justify, color: look.text_color, depth: depth.clamp(0, MAX_DEPTH), pen_style: -1, font: if special { 0 } else { look.font }, size, angle, flags, height: 0.0, length: 0.0, origin, text: s.to_string(), local_font: None, })) }; // A tick number as typed on the canvas, or as math for LaTeX. let tick_text = |l: &str| { if spec.latex_numbers { latex_number(l) } else { l.to_string() } }; let mut left_width = 0.0f64; { let tick_h = tick_size * unit * 0.75; let gap = 0.5 * tick_h; // Ticks start on the frame's edges whether or not the box is drawn. let xout = if spec.xticks.ticks { tick_reach(spec.xticks.dir, spec.xticks.length).0 } else { 0.0 }; let yout = if spec.yticks.ticks { tick_reach(spec.yticks.dir, spec.yticks.length).0 } else { 0.0 }; for t in ticks(r.x0, r.x1, spec.log_x, &spec.xticks)? { let x = fig(mx(t.at)); let len = if t.major { spec.xticks.length } else { 0.5 * spec.xticks.length }; let (o, i) = tick_reach(spec.xticks.dir, len); if spec.grid && t.major { let g = thin(LineKind::Dotted, depth + 1); push_part( &mut members, "grid", line(g, vec![Point::new(x, fig(rect.min.y)), Point::new(x, fig(rect.max.y))]), ); } if spec.xticks.ticks { push_part( &mut members, "tick", line( thin(LineKind::Solid, depth), vec![Point::new(x, fig(rect.max.y + o)), Point::new(x, fig(rect.max.y - i))], ), ); } if let Some(l) = &t.label { push_part( &mut members, "number", text( &tick_text(l), Justify::Center, Point::new(x, fig(rect.max.y + xout + gap + tick_h)), 0.0, tick_size, spec.latex_numbers, ), ); } } for t in ticks(r.y0, r.y1, spec.log_y, &spec.yticks)? { let y = fig(my(t.at)); let len = if t.major { spec.yticks.length } else { 0.5 * spec.yticks.length }; let (o, i) = tick_reach(spec.yticks.dir, len); if spec.grid && t.major { let g = thin(LineKind::Dotted, depth + 1); push_part( &mut members, "grid", line(g, vec![Point::new(fig(rect.min.x), y), Point::new(fig(rect.max.x), y)]), ); } if spec.yticks.ticks { push_part( &mut members, "tick", line( thin(LineKind::Solid, depth), vec![Point::new(fig(rect.min.x - o), y), Point::new(fig(rect.min.x + i), y)], ), ); } if let Some(l) = &t.label { left_width = left_width.max(l.chars().count() as f64 * tick_size * unit * 0.55); push_part( &mut members, "number", text( &tick_text(l), Justify::Right, Point::new(fig(rect.min.x - yout - gap), fig(y + 0.35 * tick_h)), 0.0, tick_size, spec.latex_numbers, ), ); } } let label_size = size; let label_h = label_size * unit * 0.75; if !spec.xlabel.trim().is_empty() { let below = if spec.xticks.numbers { gap + tick_h } else { xout }; let y = rect.max.y + below + gap + label_h * 1.1; push_part( &mut members, "label", text( &spec.xlabel, Justify::Center, Point::new(fig(0.5 * (rect.min.x + rect.max.x)), fig(y)), 0.0, label_size, spec.latex, ), ); } if !spec.ylabel.trim().is_empty() { // Reads upwards; the baseline lies right of the glyphs. let x = rect.min.x - yout - gap - left_width - gap; push_part( &mut members, "label", text( &spec.ylabel, Justify::Center, Point::new(fig(x), fig(0.5 * (rect.min.y + rect.max.y))), std::f64::consts::FRAC_PI_2, label_size, spec.latex, ), ); } } let mut count = 0; let mut total = 0; for piece in &pieces { let mut pts: Vec = Vec::with_capacity(piece.len()); for &(x, y) in piece { let p = Point::new(fig(mx(x)), fig(my(y))); if pts.last() != Some(&p) { pts.push(p); } } if pts.len() == 1 { // A single dot: a zero-length line draws it. pts.push(pts[0]); } if pts.len() >= 2 { total += pts.len(); count += 1; push_part(&mut members, "curve", line(look.curve, pts)); } } if count == 0 { return Err("nothing of the curve lies inside the ranges".into()); } let mut object = Object::new(ObjectKind::Compound(Compound { objects: members })); let mut keep = spec.clone(); keep.rect = Some([rect.min.x, rect.min.y, rect.max.x, rect.max.y]); object.comments = vec![keep.comment()?]; Ok(Plot { object, curves: count, points: total, }) } /// The comment line that marks a generated part of a plot (frame, grid, /// tick, number, label, curve); "Redraw" replaces exactly the marked parts. pub const PART_COMMENT: &str = "rustfig-plot-part:"; fn push_part(members: &mut Vec, kind: &str, mut o: Object) { o.comments = vec![format!("{PART_COMMENT} {kind}")]; members.push(o); } /// Whether the object is a generated part of a plot. pub fn is_part(o: &Object) -> bool { part_kind(o).is_some() } fn part_kind(o: &Object) -> Option<&str> { o.comments .iter() .find_map(|c| c.trim_start().strip_prefix(PART_COMMENT)) .map(str::trim) } /// Remove the part markers of a member of a compound that is being broken: /// the pieces become an ordinary drawing. pub fn strip_part_marker(o: &mut Object) { o.comments.retain(|c| !c.trim_start().starts_with(PART_COMMENT)); } /// The frame of an existing plot: its first member when that is a box (the /// frame, wherever the user moved or scaled it), else the stored rectangle. pub fn frame_of(o: &Object, spec: &PlotSpec) -> Option { if let ObjectKind::Compound(c) = &o.kind { let frame = c.objects.iter().find(|m| part_kind(m) == Some("frame")); if let Some(Object { kind: ObjectKind::Polyline(p), .. }) = frame { if let (PolyKind::Box, Some(first)) = (p.kind, p.points.first()) { let mut r = Rect::new(*first, *first); for q in &p.points { r = Rect::new( Point::new(r.min.x.min(q.x), r.min.y.min(q.y)), Point::new(r.max.x.max(q.x), r.max.y.max(q.y)), ); } return Some(r); } } } spec.rect .map(|[a, b, c, d]| Rect::new(Point::new(a, b), Point::new(c, d))) } #[cfg(test)] mod tests { use super::*; fn ev(src: &str) -> f64 { parse(src, &["x"]).unwrap().eval(&[2.0]) } #[test] fn precedence_and_associativity() { assert_eq!(ev("1+2*3"), 7.0); assert_eq!(ev("(1+2)*3"), 9.0); assert_eq!(ev("2^3^2"), 512.0); assert_eq!(ev("-x^2"), -4.0); assert_eq!(ev("2^-1"), 0.5); assert_eq!(ev("10-4-3"), 3.0); assert_eq!(ev("8/4/2"), 1.0); assert_eq!(ev("2 \u{d7} 3 \u{f7} 4 \u{2212} 1"), 0.5); assert_eq!(ev("2**3"), 8.0); assert_eq!(ev("--x"), 2.0); } #[test] fn functions_and_constants() { assert!((ev("sin(pi/2)") - 1.0).abs() < 1e-12); assert!((ev("ln(e)") - 1.0).abs() < 1e-12); assert_eq!(ev("sqrt(16)+abs(-3)"), 7.0); assert_eq!(ev("max(1,x)+min(5,x)"), 4.0); assert!((ev("exp(0)*cos(0)") - 1.0).abs() < 1e-12); assert_eq!(ev("1.5e1"), 15.0); assert_eq!(ev(".5+2e0"), 2.5); // No implicit multiplication. assert!(parse("2e", &["x"]).is_err()); assert!(ev("1/0").is_infinite()); assert!(ev("sqrt(-1)").is_nan()); } #[test] fn errors() { for bad in [ "", "1+", "(1", "1)", "foo(1)", "y", "sin", "sin()", "1 2", "1,2", "max(1)", "$", "1..2.", "()", "*2", ] { assert!(parse(bad, &["x"]).is_err(), "{bad:?} should fail"); } assert!(parse(&"(".repeat(200), &["x"]).is_err()); assert!(parse(&"-".repeat(300), &["x"]).is_err()); assert!(parse(&"1+".repeat(2_000), &["x"]).is_err()); assert!(parse(&"x".repeat(MAX_EXPR_LEN + 1), &["x"]).is_err()); assert!(parse("1e999", &["x"]).is_err()); } fn spec(expr: &str, a: f64, b: f64) -> PlotSpec { PlotSpec { expr: expr.into(), xmin: Some(a), xmax: Some(b), ..PlotSpec::default() } } #[test] fn sin_samples() { let (pieces, r) = sample(&spec("sin(x)", 0.0, std::f64::consts::TAU), None).unwrap(); assert_eq!(pieces.len(), 1); let p = &pieces[0]; assert!(p.len() >= 401); assert!((p[0].0).abs() < 1e-12 && p[0].1.abs() < 1e-12); assert!((p[p.len() - 1].0 - std::f64::consts::TAU).abs() < 1e-12); assert!(r.y0 <= -1.0 && r.y1 >= 1.0 && r.y1 < 2.0); } #[test] fn pole_splits_the_curve() { let mut s = spec("1/x", -2.0, 2.0); s.ymin = Some(-5.0); s.ymax = Some(5.0); let (pieces, r) = sample(&s, None).unwrap(); assert!(pieces.len() >= 2, "{} pieces", pieces.len()); // No piece crosses x = 0. for p in &pieces { assert!(p.iter().all(|q| q.0 < 0.0) || p.iter().all(|q| q.0 > 0.0)); } let clipped = clip_pieces(&pieces, &r); assert!(clipped.len() >= 2); // Auto range keeps the bulk, not 1e5. let (_, r) = sample(&spec("1/x", -2.0, 2.0), None).unwrap(); assert!(r.y1 < 100.0 && r.y0 > -100.0); // tan too. let (pieces, _) = sample(&spec("tan(x)", -4.0, 4.0), None).unwrap(); assert!(pieces.len() >= 3); } #[test] fn log_axes() { let mut s = spec("x^2", 1.0, 100.0); s.log_x = true; s.log_y = true; let (pieces, r) = sample(&s, None).unwrap(); assert_eq!(pieces.len(), 1); assert!((r.x0 - 0.0).abs() < 1e-9 && (r.x1 - 2.0).abs() < 1e-9); assert!(spec_err(PlotSpec { log_x: true, ..spec("x", -1.0, 1.0) })); let t = ticks(0.0, 2.0, true, &TickSpec::default()).unwrap(); assert_eq!(t.iter().filter(|t| t.label.is_some()).count(), 3); } fn spec_err(s: PlotSpec) -> bool { sample(&s, None).is_err() } #[test] fn csv_columns() { let text = "x,a,b\n# comment\n1,10,100\n2,20,200\n3;30;300\n\nbad,row,here\n4 40 400\n"; assert_eq!( parse_table(text, 1, 3).unwrap(), vec![(1.0, 100.0), (2.0, 200.0), (3.0, 300.0), (4.0, 400.0)] ); assert_eq!(parse_table(text, 0, 2).unwrap()[1], (2.0, 20.0)); assert!(parse_table(text, 1, 9).is_err()); assert!(parse_table("", 1, 2).is_err()); assert!(parse_table("1,nan\n2,inf\n", 1, 2).is_err()); } #[test] fn data_mode_draws() { let s = PlotSpec { mode: PlotMode::Data, xmin: None, xmax: None, ..PlotSpec::default() }; let (pieces, r) = sample(&s, Some("0 0\n1 1\n2 4\n3 9\n")).unwrap(); assert_eq!(pieces[0].len(), 4); assert!(r.x0 <= 0.0 && r.x1 >= 3.0 && r.y1 >= 9.0); assert!(sample(&s, None).is_err()); } #[test] fn comment_roundtrip() { let mut s = spec("sin(3*x)", -1.0, 1.0); s.xlabel = "time".into(); s.grid = true; s.xmax = None; let c = s.comment().unwrap(); assert!(c.starts_with(PLOT_COMMENT)); let back = PlotSpec::from_comment(&c).unwrap(); assert_eq!(back.expr, "sin(3*x)"); assert_eq!(back.xmin, Some(-1.0)); assert_eq!(back.xmax, None); assert!(back.grid); assert_eq!(back.xlabel, "time"); assert!(PlotSpec::from_comment("hello").is_none()); assert!(PlotSpec::from_comment("rustfig-plot: {").is_none()); } fn labels(t: &[Tick]) -> Vec { t.iter().filter_map(|t| t.label.clone()).collect() } #[test] fn manual_ticks() { let mut ts = TickSpec { manual: true, first: Some(1.0), step: Some(2.5), minor: Some(4), ..TickSpec::default() }; let t = ticks(0.0, 10.0, false, &ts).unwrap(); let majors: Vec = t.iter().filter(|t| t.major).map(|t| t.at).collect(); assert_eq!(majors, vec![1.0, 3.5, 6.0, 8.5]); assert!(t.iter().filter(|t| !t.major).count() >= 14); // A count of intervals. ts = TickSpec { manual: true, count: Some(4), ..TickSpec::default() }; let t = ticks(0.0, 8.0, false, &ts).unwrap(); assert_eq!(labels(&t), ["0", "2", "4", "6", "8"]); // No numbers, fixed decimals. ts.numbers = false; assert!(labels(&ticks(0.0, 8.0, false, &ts).unwrap()).is_empty()); ts.numbers = true; ts.format = NumFormat::Fixed; ts.decimals = 1; assert_eq!(labels(&ticks(0.0, 8.0, false, &ts).unwrap())[1], "2.0"); // Log: decades only. let ts = TickSpec { minor: Some(0), ..TickSpec::default() }; assert_eq!(ticks(0.0, 3.0, true, &ts).unwrap().len(), 4); } #[test] fn pi_ticks() { let ts = TickSpec { manual: true, first: Some(std::f64::consts::FRAC_PI_2), step: Some(std::f64::consts::FRAC_PI_2), format: NumFormat::Pi, ..TickSpec::default() }; let t = ticks(0.0, 2.0 * std::f64::consts::PI, false, &ts).unwrap(); assert_eq!(labels(&t), ["\u{3c0}/2", "\u{3c0}", "3\u{3c0}/2", "2\u{3c0}"]); let auto = TickSpec { format: NumFormat::Pi, ..TickSpec::default() }; let t = ticks(0.0, std::f64::consts::TAU, false, &auto).unwrap(); assert_eq!(labels(&t)[0], "0"); assert_eq!(labels(&t)[2], "\u{3c0}"); assert_eq!(latex_number("3\u{3c0}/2"), "$3\\pi{}/2$"); } #[test] fn hostile_ticks_are_errors() { let d = TickSpec::default; for ts in [ TickSpec { step: Some(0.0), ..d() }, TickSpec { step: Some(-1.0), ..d() }, TickSpec { step: Some(f64::NAN), ..d() }, TickSpec { first: Some(f64::INFINITY), ..d() }, TickSpec { count: Some(0), ..d() }, TickSpec { count: Some(1_000_000), ..d() }, TickSpec { minor: Some(10), ..d() }, TickSpec { length: -5.0, ..d() }, TickSpec { decimals: 50, ..d() }, ] { assert!(ts.check("x").is_err(), "{ts:?}"); } // Valid but too many ticks for the range. let ts = TickSpec { manual: true, step: Some(1e-9), ..d() }; assert!(ticks(0.0, 10.0, false, &ts).is_err()); } #[test] fn nice_numbers() { assert_eq!(format_tick(0.5), "0.5"); assert_eq!(format_tick(-0.0), "0"); assert_eq!(format_tick(1e-5), "1e-5"); assert_eq!(format_tick(100.0), "100"); assert_eq!(nice_range(0.0, 0.93, false), (0.0, 1.0)); let t = ticks(0.0, 6.5, false, &TickSpec::default()).unwrap(); assert!(t.len() >= 5 && t.len() <= 12); } #[test] fn builds_a_compound() { let look = PlotLook { curve: Style::default(), text_color: ColorRef::BLACK, font: 0, font_size: 12.0, postscript: true, grid_gap: 3.0, }; let mut s = spec("sin(x)", 0.0, std::f64::consts::TAU); s.xlabel = "$x$".into(); s.ylabel = "y".into(); s.latex = true; s.grid = true; let r = Rect::new(Point::new(0.0, 0.0), Point::new(4800.0, 3000.0)); let p = build(&s, r, None, &look).unwrap(); let ObjectKind::Compound(c) = &p.object.kind else { panic!() }; assert!(c.objects.len() > 10); assert!(p.points > 400); assert_eq!(frame_of(&p.object, &s), Some(r)); let special = c .objects .iter() .filter(|o| matches!(&o.kind, ObjectKind::Text(t) if t.is_special())) .count(); assert_eq!(special, 2); assert!(PlotSpec::of_object(&p.object).is_some()); // A frame too small is an error. assert!(build(&s, Rect::new(Point::ZERO, Point::new(1.0, 1.0)), None, &look).is_err()); } fn kinds(s: &PlotSpec) -> std::collections::BTreeMap { let look = PlotLook { curve: Style::default(), text_color: ColorRef::BLACK, font: 0, font_size: 12.0, postscript: true, grid_gap: 3.0, }; let r = Rect::new(Point::new(0.0, 0.0), Point::new(4800.0, 3000.0)); let p = build(s, r, None, &look).unwrap(); let ObjectKind::Compound(c) = &p.object.kind else { panic!() }; let mut m = std::collections::BTreeMap::new(); for o in &c.objects { *m.entry(part_kind(o).unwrap_or("").to_string()).or_insert(0) += 1; } m } #[test] fn switches_are_independent() { let base = spec("sin(x)", 0.0, 6.0); let all = kinds(&base); assert!(all.contains_key("frame") && all.contains_key("tick") && all.contains_key("number")); let mut s = base.clone(); s.frame = false; let m = kinds(&s); assert!(!m.contains_key("frame") && m.contains_key("tick") && m.contains_key("number")); let mut s = base.clone(); s.xticks.ticks = false; s.yticks.ticks = false; let m = kinds(&s); assert!(m.contains_key("frame") && !m.contains_key("tick") && m.contains_key("number")); let mut s = base.clone(); s.xticks.ticks = false; let m = kinds(&s); assert!(m["tick"] < all["tick"] && m["tick"] > 0); let mut s = base.clone(); s.xticks.numbers = false; s.yticks.numbers = false; let m = kinds(&s); assert!(m.contains_key("tick") && !m.contains_key("number")); let mut s = base.clone(); s.yticks.numbers = false; assert!(kinds(&s)["number"] < all["number"]); // Grid and labels do not depend on the others. let mut s = base; s.frame = false; s.xticks.ticks = false; s.yticks.ticks = false; s.xticks.numbers = false; s.yticks.numbers = false; s.grid = true; s.xlabel = "x".into(); let m = kinds(&s); assert!(m.contains_key("grid") && m.contains_key("label") && !m.contains_key("frame")); } #[test] fn old_axes_switch_is_read() { let off = PlotSpec::from_comment(&format!("{PLOT_COMMENT} {{\"axes\":false}}")).unwrap(); assert!(!off.frame && !off.xticks.ticks && !off.yticks.numbers && off.axes.is_none()); let on = PlotSpec::from_comment(&format!("{PLOT_COMMENT} {{\"axes\":true}}")).unwrap(); assert!(on.frame && on.xticks.ticks && on.yticks.numbers); let none = PlotSpec::from_comment(&format!("{PLOT_COMMENT} {{}}")).unwrap(); assert!(none.frame && none.xticks.ticks && none.xticks.numbers); // Written with the new fields only. let mut s = PlotSpec::default(); s.frame = false; let c = s.comment().unwrap(); assert!(c.contains("\"frame\":false") && !c.contains("axes")); assert!(!PlotSpec::from_comment(&c).unwrap().frame); } } .