use serde::Deserialize;
use crate::plot::{self, LegendPos};
#[derive(Deserialize)]
struct Preset {
kind: String,
title: String,
ylabel: String,
#[serde(default)]
legend: String,
#[serde(default)]
footnote: Option<String>,
#[serde(default)]
log_y: bool,
#[serde(default)]
group_labels: Vec<String>,
#[serde(default)]
series_names: Vec<String>,
#[serde(default)]
values: Vec<Vec<f64>>,
#[serde(default)]
mins: Vec<Vec<f64>>,
#[serde(default)]
maxs: Vec<Vec<f64>>,
#[serde(default)]
refline: Option<(f64, f64, f64, String)>,
#[serde(default)]
xlabel: String,
#[serde(default)]
xs: Vec<f64>,
#[serde(default)]
yss: Vec<Vec<f64>>,
#[serde(default)]
errs: Vec<Vec<f64>>,
}
fn esc_prose(s: &str) -> String {
s.replace('&', "\\&")
.replace('%', "\\%")
.replace('#', "\\#")
.replace('_', "\\_")
}
fn with_footnote(mut tikz: String, footnote: &Option<String>) -> String {
if let Some(f) = footnote {
tikz.push_str(&format!("{{\\footnotesize {}\\par}}\n", esc_prose(f)));
}
tikz
}
pub fn preset_to_tikz(src: &str) -> Result<String, crate::error::Error> {
use crate::error::Error;
let p: Preset =
serde_json::from_str(src).map_err(|e| Error::data(format!("preset: {e}"), String::new()))?;
let legend_src = if p.legend.is_empty() { "auto" } else { &p.legend };
let legend = LegendPos::parse(legend_src).map_err(|e| Error {
msg: format!("preset legend: {}", e.msg),
..e
})?;
match p.kind.as_str() {
"bar" => {
if p.group_labels.is_empty() || p.series_names.is_empty() {
return Err(Error::data("preset bar: no group_labels or no series_names", String::new()));
}
let (ng, ns) = (p.group_labels.len(), p.series_names.len());
for (tag, t) in [("values", &p.values), ("mins", &p.mins), ("maxs", &p.maxs)] {
if t.len() != ns || t.iter().any(|col| col.len() != ng) {
return Err(Error::data(
format!("preset bar: ragged {tag}"),
format!("{tag} shapes {:?}", t.iter().map(|c| c.len()).collect::<Vec<_>>()),
));
}
}
for s in 0..ns {
for i in 0..ng {
if !(p.mins[s][i] <= p.values[s][i] && p.values[s][i] <= p.maxs[s][i]) {
return Err(Error::data(
format!(
"preset bar: series {} group {i}: min > value or value > max",
p.series_names[s]
),
format!(
"min={} value={} max={}",
p.mins[s][i], p.values[s][i], p.maxs[s][i]
),
));
}
}
}
let tikz = plot::barchart(
&p.title,
&p.ylabel,
p.log_y,
&p.group_labels,
&p.series_names,
&p.values,
&p.mins,
&p.maxs,
p.refline,
legend,
)?;
Ok(with_footnote(tikz, &p.footnote))
}
"line" => {
if p.xs.is_empty() || p.series_names.is_empty() {
return Err(Error::data("preset line: no xs or no series_names", String::new()));
}
if p.series_names.len() != p.yss.len() || p.series_names.len() != p.errs.len() {
return Err(Error::data(
"preset line: series_names/yss/errs length mismatch",
format!(
"names={} yss={} errs={}",
p.series_names.len(),
p.yss.len(),
p.errs.len()
),
));
}
for w in 1..p.xs.len() {
if !(p.xs[w] > p.xs[w - 1]) {
return Err(Error::data(
"preset line: xs not strictly increasing",
format!("xs[{w}]={} <= xs[{}]={}", p.xs[w], w - 1, p.xs[w - 1]),
));
}
}
let series: Vec<(&str, Vec<f64>, Vec<f64>)> = p
.series_names
.iter()
.zip(p.yss.iter())
.zip(p.errs.iter())
.map(|((n, y), e)| (n.as_str(), y.clone(), e.clone()))
.collect();
for (name, ys, es) in &series {
if ys.len() != p.xs.len() || es.len() != p.xs.len() {
return Err(Error::data(
format!("preset line: ragged series {name}"),
format!("ys={} es={} xs={}", ys.len(), es.len(), p.xs.len()),
));
}
}
let tikz = plot::lineplot(&p.title, &p.xlabel, &p.ylabel, &p.xs, &series, legend)?;
Ok(with_footnote(tikz, &p.footnote))
}
other => Err(Error::data(
format!("preset: kind must be bar|line, got {other:?}"),
other.to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar_src() -> String {
serde_json::json!({
"kind": "bar", "title": "t", "ylabel": "y",
"group_labels": ["a", "b"], "series_names": ["s"],
"values": [[1.0, 2.0]], "mins": [[0.5, 1.5]], "maxs": [[1.5, 2.5]],
})
.to_string()
}
#[test]
fn bar_valid_golden() {
let got = preset_to_tikz(&bar_src()).unwrap();
assert!(got.contains("\\begin{axis}"));
assert!(got.contains("(1,1 )"));
assert!(got.contains("\\addlegendentry{s}"));
}
#[test]
fn line_valid_golden() {
let src = serde_json::json!({
"kind": "line", "title": "t", "xlabel": "x", "ylabel": "y",
"xs": [1.0, 2.0], "series_names": ["s"],
"yss": [[3.0, 4.0]], "errs": [[0.1, 0.2]],
})
.to_string();
let got = preset_to_tikz(&src).unwrap();
assert!(got.contains("(2,4) +- (0,0.2)"));
}
#[test]
fn bar_ragged_fails() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v["values"] = serde_json::json!([[1.0]]);
let err = preset_to_tikz(&v.to_string()).unwrap_err();
assert!(err.msg.contains("ragged values"), "{err}");
}
#[test]
fn bar_min_above_value_fails() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v["mins"] = serde_json::json!([[5.0, 1.5]]);
let err = preset_to_tikz(&v.to_string()).unwrap_err();
assert!(err.msg.contains("min > value"), "{err}");
}
#[test]
fn missing_key_names_it() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v.as_object_mut().unwrap().remove("title");
let err = preset_to_tikz(&v.to_string()).unwrap_err();
assert!(err.msg.contains("title"), "{err}");
}
#[test]
fn bad_kind_names_set() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v["kind"] = serde_json::json!("pie");
let err = preset_to_tikz(&v.to_string()).unwrap_err();
assert!(err.msg.contains("bar|line"), "{err}");
}
#[test]
fn empty_names_fail() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v["series_names"] = serde_json::json!([]);
let err = preset_to_tikz(&v.to_string()).unwrap_err();
assert!(err.msg.contains("no group_labels or no series_names"), "{err}");
}
#[test]
fn footnote_appends_group() {
let mut v: serde_json::Value = serde_json::from_str(&bar_src()).unwrap();
v["footnote"] = serde_json::json!("source: build.py & grep");
let got = preset_to_tikz(&v.to_string()).unwrap();
assert!(got.contains("{\\footnotesize source: build.py \\& grep\\par}"));
}
}