Skip to main content

benday_core/
render.rs

1//! The public render entry point: compile the spec into a `Scene`, then
2//! rasterize that Scene into ANSI text plus the `--meta` payload. All the real
3//! work lives in `compile` and `raster`; this module only adapts options and
4//! owns the public option/output types.
5
6use crate::compile;
7use crate::error::Error;
8use crate::ingest::{self, DataDoc};
9use crate::raster::{self, Marker};
10use crate::spec::Spec;
11use crate::theme::Theme;
12
13pub struct RenderOptions {
14    /// Plot area width in cells; overrides spec.width.
15    pub width: Option<usize>,
16    /// Plot area height in cells; overrides spec.height.
17    pub height: Option<usize>,
18    pub marker: Marker,
19    pub bar_style: BarStyle,
20    pub theme: Theme,
21    pub color: bool,
22}
23
24/// How bar marks are filled. Dots (the house style) rasterize bars through
25/// the pixel canvas at 4 vertical levels per cell; blocks give a solid
26/// silhouette with finer 8-levels-per-cell caps.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum BarStyle {
29    Dots,
30    Blocks,
31}
32
33#[derive(Debug)]
34pub struct Rendered {
35    pub text: String,
36    pub meta: serde_json::Value,
37}
38
39pub fn render(spec: &Spec, data: Option<DataDoc>, opts: &RenderOptions) -> Result<Rendered, Error> {
40    // Resolve the spec's inline data and/or the piped data document into a
41    // Table, then compile to a Scene (which owns preflight validation) and
42    // rasterize. No per-mark branching here.
43    let table = ingest::resolve(spec, data)?;
44    let copts = compile::CompileOptions {
45        width: opts.width,
46        height: opts.height,
47        theme: opts.theme.clone(),
48    };
49    let scene = compile::compile(spec, &table, &copts)?;
50    let ropts = raster::RasterOptions {
51        marker: opts.marker,
52        bar_style: opts.bar_style,
53        color: opts.color,
54    };
55    Ok(raster::rasterize(&scene, &ropts))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn opts() -> RenderOptions {
63        RenderOptions {
64            width: None,
65            height: None,
66            marker: Marker::Braille,
67            bar_style: BarStyle::Dots,
68            theme: crate::theme::by_name("benday").unwrap(),
69            color: false,
70        }
71    }
72
73    fn spec(json: &str) -> Spec {
74        serde_json::from_str(json).unwrap()
75    }
76
77    fn is_braille(c: char) -> bool {
78        ('\u{2800}'..='\u{28FF}').contains(&c)
79    }
80
81    #[test]
82    fn bar_chart_dots_by_default() {
83        let s = spec(
84            r#"{"data":{"values":[{"m":"jan","v":3},{"m":"feb","v":7}]},
85                "mark":"bar","encoding":{"x":{"field":"m"},"y":{"field":"v"}}}"#,
86        );
87        let out = render(&s, None, &opts()).unwrap();
88        assert!(out.text.chars().any(is_braille));
89        assert!(out.text.contains("jan"));
90    }
91
92    #[test]
93    fn bar_chart_blocks_style() {
94        let s = spec(
95            r#"{"data":{"values":[{"m":"jan","v":3},{"m":"feb","v":7}]},
96                "mark":"bar","encoding":{"x":{"field":"m"},"y":{"field":"v"}}}"#,
97        );
98        let out = render(
99            &s,
100            None,
101            &RenderOptions {
102                bar_style: BarStyle::Blocks,
103                ..opts()
104            },
105        )
106        .unwrap();
107        assert!(out.text.contains('█'));
108    }
109
110    #[test]
111    fn line_chart_smoke() {
112        let s = spec(
113            r#"{"data":{"values":[{"x":0,"y":1},{"x":1,"y":4},{"x":2,"y":2}]},
114                "mark":"line","encoding":{"x":{"field":"x"},"y":{"field":"y"}}}"#,
115        );
116        let out = render(&s, None, &opts()).unwrap();
117        assert!(out.text.chars().any(is_braille));
118    }
119
120    #[test]
121    fn missing_field_is_actionable() {
122        let s = spec(
123            r#"{"data":{"values":[{"month":"jan","sales":3}]},
124                "mark":"bar","encoding":{"x":{"field":"month"},"y":{"field":"revenue"}}}"#,
125        );
126        let err = render(&s, None, &opts()).unwrap_err();
127        let msg = err.to_string();
128        assert_eq!(err.kind(), "data");
129        assert!(msg.contains("revenue") && msg.contains("available fields"));
130        assert!(msg.contains("month") && msg.contains("sales"));
131    }
132
133    #[test]
134    fn aggregate_on_categorical_channel_is_rejected() {
135        // A bar aggregate on the CATEGORICAL channel (x here, since m is nominal
136        // and the chart is vertical) is rejected post-orientation: aggregation
137        // runs over the quantitative value channel, so the fix points at y.
138        let s = spec(
139            r#"{"data":{"values":[{"m":"jan","v":3}]},
140                "mark":"bar",
141                "encoding":{"x":{"field":"m","aggregate":"sum"},"y":{"field":"v"}}}"#,
142        );
143        let err = render(&s, None, &opts()).unwrap_err();
144        assert_eq!(err.kind(), "spec");
145        assert!(err.to_string().contains("put `aggregate` on encoding.y"));
146    }
147
148    #[test]
149    fn bar_color_grouping_produces_grouped_series() {
150        // A color field distinct from x now groups the bars into series
151        // (legend below, a `series` array in meta) rather than erroring.
152        let s = spec(
153            r#"{"data":{"values":[
154                {"m":"jan","v":3,"region":"west"},{"m":"jan","v":2,"region":"east"},
155                {"m":"feb","v":4,"region":"west"},{"m":"feb","v":5,"region":"east"}]},
156                "mark":"bar",
157                "encoding":{"x":{"field":"m"},"y":{"field":"v"},"color":{"field":"region"}}}"#,
158        );
159        let out = render(&s, None, &opts()).unwrap();
160        let series = out.meta["series"]
161            .as_array()
162            .expect("grouped bars carry series");
163        assert_eq!(series.len(), 2);
164        assert_eq!(series[0]["name"], "west");
165        assert_eq!(series[1]["name"], "east");
166    }
167}