Skip to main content

datui_lib/
chart_export.rs

1//! Chart export: the chart is drawn once as SVG, in the bundled font (IBM Plex Sans,
2//! under the SIL Open Font License, in `assets/fonts`), then written as SVG with
3//! its text as outlines, rasterized to PNG, or written as PDF from the same
4//! outlines (`chart_pdf`). The same figure comes out the same on every machine;
5//! text the bundled font lacks falls back to a system font.
6
7use std::sync::{Arc, OnceLock};
8
9use color_eyre::Result;
10use resvg::{tiny_skia, usvg};
11
12use crate::chart_data::{
13    AxisFormat, AxisNumbers, BarData, BoxPlotData, HeatmapData, HistogramData, KdeData,
14    XAxisTemporalKind, segments, x_axis_label_at,
15};
16use crate::widgets::ticks;
17
18const FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-Regular.ttf");
19const FONT_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/IBMPlexSans-SemiBold.ttf");
20const FONT_FAMILY: &str = "IBM Plex Sans";
21
22/// Export format for a chart.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ChartExportFormat {
26    Png,
27    Svg,
28    Pdf,
29}
30
31impl ChartExportFormat {
32    pub const ALL: [Self; 3] = [Self::Png, Self::Svg, Self::Pdf];
33
34    pub fn extension(self) -> &'static str {
35        match self {
36            Self::Png => "png",
37            Self::Svg => "svg",
38            Self::Pdf => "pdf",
39        }
40    }
41
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::Png => "PNG",
45            Self::Svg => "SVG",
46            Self::Pdf => "PDF",
47        }
48    }
49
50    /// The format a path's extension names, if it names one.
51    pub fn from_extension(path: &std::path::Path) -> Option<Self> {
52        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
53        Self::ALL.into_iter().find(|f| f.extension() == ext)
54    }
55}
56
57/// The colors an export is drawn in.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum ExportStyle {
61    /// White, with a print-safe palette that stays apart for color-blind readers.
62    Light,
63    /// The terminal theme's colors.
64    Dark,
65    /// The light palette with no background, for a slide or page of any color.
66    Transparent,
67}
68
69impl ExportStyle {
70    pub const ALL: [Self; 3] = [Self::Light, Self::Dark, Self::Transparent];
71
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::Light => "Light",
75            Self::Dark => "Dark",
76            Self::Transparent => "Transparent",
77        }
78    }
79}
80
81/// A size an export is made at: pixels, and the resolution that makes them a
82/// physical size (a PDF's page, an SVG's inches, the text's points).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum SizePreset {
86    Slide,
87    Document,
88    Square,
89    /// One column of a two-column journal page, about 3.5 in at 300 dpi.
90    SingleColumn,
91    /// The width of a journal page, about 7 in at 300 dpi.
92    DoubleColumn,
93    Custom,
94}
95
96impl SizePreset {
97    pub const ALL: [Self; 6] = [
98        Self::Slide,
99        Self::Document,
100        Self::Square,
101        Self::SingleColumn,
102        Self::DoubleColumn,
103        Self::Custom,
104    ];
105
106    pub fn label(self) -> &'static str {
107        match self {
108            Self::Slide => "Slide 16:9",
109            Self::Document => "Document",
110            Self::Square => "Square",
111            Self::SingleColumn => "Single column",
112            Self::DoubleColumn => "Double column",
113            Self::Custom => "Custom",
114        }
115    }
116
117    /// Width and height in pixels; `None` for a custom size.
118    pub fn size(self) -> Option<(u32, u32)> {
119        match self {
120            Self::Slide => Some((1920, 1080)),
121            Self::Document => Some((1600, 1000)),
122            Self::Square => Some((1200, 1200)),
123            Self::SingleColumn => Some((1050, 788)),
124            Self::DoubleColumn => Some((2100, 1300)),
125            Self::Custom => None,
126        }
127    }
128
129    /// Pixels per inch: print presets at 300, screen sizes as a 10 in wide page
130    /// at twice a screen's density.
131    pub fn dpi(self) -> f32 {
132        match self {
133            Self::SingleColumn | Self::DoubleColumn => 300.0,
134            Self::Slide => 192.0,
135            Self::Document => 160.0,
136            Self::Square => 150.0,
137            Self::Custom => 96.0,
138        }
139    }
140}
141
142/// Where the series are named.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
144#[serde(rename_all = "snake_case")]
145pub enum LegendPlace {
146    /// Each line named at its right end; a chart without lines takes a box at the
147    /// top right.
148    LineEnds,
149    TopRight,
150    TopLeft,
151    BottomRight,
152    BottomLeft,
153    Off,
154}
155
156impl LegendPlace {
157    pub const ALL: [Self; 6] = [
158        Self::LineEnds,
159        Self::TopRight,
160        Self::TopLeft,
161        Self::BottomRight,
162        Self::BottomLeft,
163        Self::Off,
164    ];
165
166    pub fn label(self) -> &'static str {
167        match self {
168            Self::LineEnds => "Line ends",
169            Self::TopRight => "Top right",
170            Self::TopLeft => "Top left",
171            Self::BottomRight => "Bottom right",
172            Self::BottomLeft => "Bottom left",
173            Self::Off => "Off",
174        }
175    }
176}
177
178/// How opaque a scatter's points are.
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum PointOpacity {
182    /// Fainter as there are more points, so a dense cloud shows where it is densest.
183    #[default]
184    Auto,
185    Full,
186    Half,
187    Fifth,
188}
189
190impl PointOpacity {
191    pub const ALL: [Self; 4] = [Self::Auto, Self::Full, Self::Half, Self::Fifth];
192
193    pub fn label(self) -> &'static str {
194        match self {
195            Self::Auto => "Auto",
196            Self::Full => "100%",
197            Self::Half => "50%",
198            Self::Fifth => "20%",
199        }
200    }
201
202    /// The fill opacity for a scatter of `points` points.
203    pub fn of(self, points: usize) -> f64 {
204        match self {
205            Self::Auto => auto_opacity(points),
206            Self::Full => 1.0,
207            Self::Half => 0.5,
208            Self::Fifth => 0.2,
209        }
210    }
211}
212
213/// Opaque up to 1,000 points, 0.15 from 100,000, and between them along the log of
214/// the count: each tenfold more points is drawn as much fainter.
215pub fn auto_opacity(points: usize) -> f64 {
216    const FAINTEST: f64 = 0.15;
217    let t = (((points.max(1) as f64).log10() - 3.0) / 2.0).clamp(0.0, 1.0);
218    1.0 + t * (FAINTEST - 1.0)
219}
220
221/// A scatter point's radius.
222#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum PointSize {
225    Small,
226    #[default]
227    Medium,
228    Large,
229}
230
231impl PointSize {
232    pub const ALL: [Self; 3] = [Self::Small, Self::Medium, Self::Large];
233
234    pub fn label(self) -> &'static str {
235        match self {
236            Self::Small => "Small",
237            Self::Medium => "Medium",
238            Self::Large => "Large",
239        }
240    }
241
242    /// The radius in points.
243    pub fn pt(self) -> f64 {
244        match self {
245            Self::Small => 1.6,
246            Self::Medium => 2.4,
247            Self::Large => 3.6,
248        }
249    }
250}
251
252/// A line chart's stroke.
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum LineWidth {
256    Thin,
257    #[default]
258    Normal,
259    Bold,
260}
261
262impl LineWidth {
263    pub const ALL: [Self; 3] = [Self::Thin, Self::Normal, Self::Bold];
264
265    pub fn label(self) -> &'static str {
266        match self {
267            Self::Thin => "Thin",
268            Self::Normal => "Normal",
269            Self::Bold => "Bold",
270        }
271    }
272
273    /// The width in points.
274    pub fn pt(self) -> f64 {
275        match self {
276            Self::Thin => 1.0,
277            Self::Normal => 1.5,
278            Self::Bold => 2.5,
279        }
280    }
281}
282
283/// An sRGB color.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct Rgb(pub u8, pub u8, pub u8);
286
287impl Rgb {
288    fn hex(self) -> String {
289        format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
290    }
291
292    /// A ratatui color as sRGB: true colors as they are, the 256 and 16 colors as
293    /// xterm draws them. `None` for the terminal's own default.
294    pub fn of(color: ratatui::style::Color) -> Option<Self> {
295        use ratatui::style::Color;
296        const ANSI: [(u8, u8, u8); 16] = [
297            (0, 0, 0),
298            (205, 0, 0),
299            (0, 205, 0),
300            (205, 205, 0),
301            (0, 0, 238),
302            (205, 0, 205),
303            (0, 205, 205),
304            (229, 229, 229),
305            (127, 127, 127),
306            (255, 0, 0),
307            (0, 255, 0),
308            (255, 255, 0),
309            (92, 92, 255),
310            (255, 0, 255),
311            (0, 255, 255),
312            (255, 255, 255),
313        ];
314        let ansi = |i: usize| {
315            let (r, g, b) = ANSI[i];
316            Some(Rgb(r, g, b))
317        };
318        match color {
319            Color::Rgb(r, g, b) => Some(Rgb(r, g, b)),
320            Color::Reset => None,
321            Color::Black => ansi(0),
322            Color::Red => ansi(1),
323            Color::Green => ansi(2),
324            Color::Yellow => ansi(3),
325            Color::Blue => ansi(4),
326            Color::Magenta => ansi(5),
327            Color::Cyan => ansi(6),
328            Color::Gray => ansi(7),
329            Color::DarkGray => ansi(8),
330            Color::LightRed => ansi(9),
331            Color::LightGreen => ansi(10),
332            Color::LightYellow => ansi(11),
333            Color::LightBlue => ansi(12),
334            Color::LightMagenta => ansi(13),
335            Color::LightCyan => ansi(14),
336            Color::White => ansi(15),
337            Color::Indexed(i) if i < 16 => ansi(i as usize),
338            Color::Indexed(i) if i < 232 => {
339                let i = i - 16;
340                let level = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
341                Some(Rgb(level(i / 36), level((i / 6) % 6), level(i % 6)))
342            }
343            Color::Indexed(i) => {
344                let v = 8 + (i - 232) * 10;
345                Some(Rgb(v, v, v))
346            }
347        }
348    }
349}
350
351/// The colors a figure is drawn in.
352#[derive(Debug, Clone, PartialEq)]
353pub struct Palette {
354    /// `None` leaves the background transparent.
355    pub background: Option<Rgb>,
356    pub text: Rgb,
357    pub text_secondary: Rgb,
358    pub grid: Rgb,
359    /// The series colors, a color repeated in the theme given once: fewer than
360    /// [`SERIES`] when it repeats.
361    pub series: Vec<Rgb>,
362    /// Other: the rows of every value of a color without a series of its own.
363    pub other: Rgb,
364    /// A heatmap's cells, from the fewest rows to the most.
365    pub ramp: [Rgb; 7],
366    /// Whether the background is dark: the ramp then starts dark.
367    pub dark: bool,
368}
369
370/// The light style's series colors: a categorical order whose neighbors stay
371/// apart under the common color-vision deficiencies (checked with a CVD
372/// simulation: worst adjacent pair 9.1 ΔE). Three of them sit under 3:1 on white,
373/// so lines are named at their ends and bars carry a legend. The last three (a
374/// teal, a plum, a dark olive) are 22 ΔE or more from every other, 11 under
375/// deuteranopia.
376const LIGHT_SERIES: [Rgb; SERIES] = [
377    Rgb(0x2a, 0x78, 0xd6),
378    Rgb(0xeb, 0x68, 0x34),
379    Rgb(0x1b, 0xaf, 0x7a),
380    Rgb(0xed, 0xa1, 0x00),
381    Rgb(0xe8, 0x7b, 0xa4),
382    Rgb(0x00, 0x83, 0x00),
383    Rgb(0x4a, 0x3a, 0xa7),
384    Rgb(0x0f, 0x8a, 0x96),
385    Rgb(0x9c, 0x1f, 0x6e),
386    Rgb(0x5c, 0x4a, 0x00),
387];
388
389/// Series colors a palette has: as many as the theme's chart slots.
390pub const SERIES: usize = 10;
391
392/// One blue, light to dark.
393const BLUE_RAMP: [Rgb; 7] = [
394    Rgb(0xcd, 0xe2, 0xfb),
395    Rgb(0x9e, 0xc5, 0xf4),
396    Rgb(0x6d, 0xa7, 0xec),
397    Rgb(0x39, 0x87, 0xe5),
398    Rgb(0x25, 0x6a, 0xbf),
399    Rgb(0x18, 0x4f, 0x95),
400    Rgb(0x0d, 0x36, 0x6b),
401];
402
403impl Palette {
404    pub fn light() -> Self {
405        Self {
406            background: Some(Rgb(0xff, 0xff, 0xff)),
407            text: Rgb(0x1f, 0x24, 0x30),
408            text_secondary: Rgb(0x5b, 0x61, 0x70),
409            grid: Rgb(0xe3, 0xe5, 0xea),
410            series: LIGHT_SERIES.to_vec(),
411            other: Rgb(0xa8, 0xad, 0xb8),
412            ramp: BLUE_RAMP,
413            dark: false,
414        }
415    }
416
417    pub fn transparent() -> Self {
418        Self {
419            background: None,
420            ..Self::light()
421        }
422    }
423
424    /// The terminal theme's colors as configured (before any terminal's fewer
425    /// colors): its background (or a dark surface where the theme leaves it to the
426    /// terminal), text, grid and chart series.
427    pub fn dark(colors: &crate::config::ColorConfig) -> Self {
428        let parser = crate::config::ColorParser::new();
429        // A hex value as written, whatever the terminal shows of it; a name or an
430        // index as xterm draws it.
431        let get = |value: &str, fallback: Rgb| {
432            let value = value.trim();
433            let hex = value
434                .strip_prefix('#')
435                .filter(|h| h.len() == 6)
436                .and_then(|h| u32::from_str_radix(h, 16).ok())
437                .map(|n| Rgb((n >> 16) as u8, (n >> 8) as u8, n as u8));
438            hex.or_else(|| parser.parse(value).ok().and_then(Rgb::of))
439                .unwrap_or(fallback)
440        };
441        let defaults = [
442            Rgb(0x7d, 0xcf, 0xff),
443            Rgb(0xbb, 0x9a, 0xf7),
444            Rgb(0x9e, 0xce, 0x6a),
445            Rgb(0xe0, 0xaf, 0x68),
446            Rgb(0x7a, 0xa2, 0xf7),
447            Rgb(0xf7, 0x76, 0x8e),
448            Rgb(0xff, 0x9e, 0x64),
449            Rgb(0x1a, 0xbc, 0x9c),
450            Rgb(0xff, 0x5f, 0xd2),
451            Rgb(0xf4, 0xef, 0x8a),
452        ];
453        let configured = [
454            &colors.chart_1,
455            &colors.chart_2,
456            &colors.chart_3,
457            &colors.chart_4,
458            &colors.chart_5,
459            &colors.chart_6,
460            &colors.chart_7,
461            &colors.chart_8,
462            &colors.chart_9,
463            &colors.chart_10,
464        ];
465        // A color the theme gives two slots draws one series, as on screen.
466        let mut series: Vec<Rgb> = Vec::with_capacity(SERIES);
467        for (fallback, value) in defaults.into_iter().zip(configured) {
468            let color = get(value, fallback);
469            if !series.contains(&color) {
470                series.push(color);
471            }
472        }
473        let mut ramp = BLUE_RAMP;
474        ramp.reverse();
475        Self {
476            background: Some(get(&colors.background, Rgb(0x1a, 0x1b, 0x26))),
477            text: get(&colors.text_primary, Rgb(0xc0, 0xca, 0xf5)),
478            text_secondary: get(&colors.text_secondary, Rgb(0x9a, 0xa5, 0xce)),
479            grid: get(&colors.chart_grid, Rgb(0x3d, 0x47, 0x85)),
480            series,
481            other: get(&colors.dimmed, Rgb(0x56, 0x5f, 0x89)),
482            ramp,
483            dark: true,
484        }
485    }
486
487    pub fn for_style(style: ExportStyle, colors: &crate::config::ColorConfig) -> Self {
488        match style {
489            ExportStyle::Light => Self::light(),
490            ExportStyle::Dark => Self::dark(colors),
491            ExportStyle::Transparent => Self::transparent(),
492        }
493    }
494}
495
496/// How an export is made: its size, colors, legend and words.
497#[derive(Debug, Clone, PartialEq)]
498pub struct ExportOptions {
499    pub width: u32,
500    pub height: u32,
501    pub dpi: f32,
502    pub palette: Palette,
503    pub legend: LegendPlace,
504    pub title: String,
505    pub description: String,
506    pub notes: String,
507    pub source: String,
508    pub byline: String,
509    pub point_opacity: PointOpacity,
510    pub point_size: PointSize,
511    pub line_width: LineWidth,
512    /// Whether a line chart's Y axis takes in zero; `None` leaves it as the chart
513    /// draws it. Bars always start at zero.
514    pub y_from_zero: Option<bool>,
515    /// How the chart was made, as a view's JSON, kept in the file's metadata: a PNG
516    /// `iTXt` chunk, an SVG `<metadata>` element, the PDF's document info. `None`
517    /// writes no datui metadata at all.
518    pub recipe: Option<String>,
519}
520
521impl Default for ExportOptions {
522    fn default() -> Self {
523        let preset = SizePreset::Document;
524        let (width, height) = preset.size().unwrap_or((1600, 1000));
525        Self {
526            width,
527            height,
528            dpi: preset.dpi(),
529            palette: Palette::light(),
530            legend: LegendPlace::LineEnds,
531            title: String::new(),
532            description: String::new(),
533            notes: String::new(),
534            source: String::new(),
535            byline: String::new(),
536            point_opacity: PointOpacity::default(),
537            point_size: PointSize::default(),
538            line_width: LineWidth::default(),
539            y_from_zero: None,
540            recipe: None,
541        }
542    }
543}
544
545/// What a chart export asks for: where, in what format, how.
546#[derive(Debug, Clone)]
547pub struct ChartExportRequest {
548    pub path: std::path::PathBuf,
549    pub format: ChartExportFormat,
550    pub options: ExportOptions,
551    /// Whether an existing file may be replaced (asked before the export started).
552    pub overwrite: crate::output_file::Overwrite,
553    /// Whether the file carries the chart's recipe, which the app writes in when the
554    /// export starts.
555    pub recipe: bool,
556}
557
558/// One axis: its title, what its numbers are, and whether they are dates.
559#[derive(Debug, Clone, Default)]
560pub struct Axis {
561    pub title: String,
562    pub numbers: AxisNumbers,
563    pub kind: XAxisTemporalKind,
564    /// Values are `ln(1 + y)`; ticks name `y`.
565    pub log: bool,
566}
567
568/// One line or scatter series.
569#[derive(Debug, Clone)]
570pub struct Series {
571    pub name: String,
572    pub points: Vec<(f64, f64)>,
573    /// Where a line starts again after a gap (see `chart_data::segments`).
574    pub breaks: Vec<usize>,
575    /// Other: every value of a color without a series of its own.
576    pub other: bool,
577}
578
579/// What a figure plots.
580#[derive(Debug, Clone)]
581pub enum Plot {
582    Lines {
583        series: Vec<Series>,
584        scatter: bool,
585        x: Axis,
586        y: Axis,
587        y_from_zero: bool,
588    },
589    Bars {
590        data: BarData,
591        value: Axis,
592    },
593    Histogram {
594        data: HistogramData,
595        x: Axis,
596        y: Axis,
597    },
598    Kde {
599        data: KdeData,
600        x: Axis,
601        y: Axis,
602    },
603    Box {
604        data: BoxPlotData,
605        x_title: String,
606        y: Axis,
607    },
608    Heatmap {
609        data: HeatmapData,
610        x: Axis,
611        y: Axis,
612    },
613}
614
615/// A chart ready to draw: what it plots, and what it says about its rows (a
616/// sample, values a range left out).
617#[derive(Debug, Clone)]
618pub struct Figure {
619    pub plot: Plot,
620    pub chart_notes: Vec<String>,
621    pub grid: bool,
622}
623
624/// The figure in `format`, as the bytes of the file.
625pub fn render(
626    figure: &Figure,
627    options: &ExportOptions,
628    format: ChartExportFormat,
629) -> Result<Vec<u8>> {
630    let tree = tree(&svg(figure, options)?)?;
631    let recipe = options.recipe.as_deref();
632    Ok(match format {
633        // usvg writes the size in pixels; the page's inches say how large it prints.
634        ChartExportFormat::Svg => with_svg_recipe(
635            tree.to_string(&usvg::WriteOptions::default()).replacen(
636                &format!("width=\"{}\" height=\"{}\"", options.width, options.height),
637                &format!(
638                    "width=\"{:.3}in\" height=\"{:.3}in\" viewBox=\"0 0 {} {}\"",
639                    f64::from(options.width) / f64::from(options.dpi),
640                    f64::from(options.height) / f64::from(options.dpi),
641                    options.width,
642                    options.height,
643                ),
644                1,
645            ),
646            recipe,
647        )
648        .into_bytes(),
649        ChartExportFormat::Png => {
650            let mut pixmap = tiny_skia::Pixmap::new(options.width, options.height)
651                .ok_or_else(|| color_eyre::eyre::eyre!("cannot draw a chart of that size"))?;
652            resvg::render(
653                &tree,
654                tiny_skia::Transform::identity(),
655                &mut pixmap.as_mut(),
656            );
657            let png = pixmap
658                .encode_png()
659                .map_err(|e| color_eyre::eyre::eyre!("PNG: {e}"))?;
660            let png = with_resolution(png, options.dpi);
661            match recipe {
662                Some(recipe) => with_png_recipe(png, recipe),
663                None => png,
664            }
665        }
666        ChartExportFormat::Pdf => {
667            crate::chart_pdf::write(&tree, (options.width, options.height), options.dpi, recipe)?
668        }
669    })
670}
671
672/// The key a recipe is kept under: a PNG chunk's keyword, the SVG element's name,
673/// the PDF's info entry.
674pub const RECIPE_KEY: &str = "datui-recipe";
675
676/// The namespace of the SVG recipe element.
677const RECIPE_NAMESPACE: &str = "https://derekwisong.github.io/datui/recipe";
678
679/// `png` with `recipe` in an `iTXt` chunk (UTF-8, uncompressed) before its end.
680fn with_png_recipe(mut png: Vec<u8>, recipe: &str) -> Vec<u8> {
681    // IEND is the last chunk: its length, type and checksum, no data.
682    const IEND: usize = 12;
683    if png.len() < IEND || &png[png.len() - 8..png.len() - 4] != b"IEND" {
684        return png;
685    }
686    let mut chunk = b"iTXt".to_vec();
687    chunk.extend_from_slice(RECIPE_KEY.as_bytes());
688    // The keyword's end, uncompressed, no language tag, no translated keyword.
689    chunk.extend_from_slice(&[0, 0, 0, 0, 0]);
690    chunk.extend_from_slice(recipe.as_bytes());
691    let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
692    let mut bytes = u32::try_from(chunk.len() - 4)
693        .unwrap_or(u32::MAX)
694        .to_be_bytes()
695        .to_vec();
696    bytes.extend_from_slice(&chunk);
697    bytes.extend_from_slice(&crc.to_be_bytes());
698    let end = png.len() - IEND;
699    png.splice(end..end, bytes);
700    png
701}
702
703/// `svg` with `recipe` in a `<metadata>` element, the root's first child.
704fn with_svg_recipe(svg: String, recipe: Option<&str>) -> String {
705    let Some(recipe) = recipe else {
706        return svg;
707    };
708    let Some(open) = svg
709        .find("<svg")
710        .and_then(|at| svg[at..].find('>').map(|end| at + end + 1))
711    else {
712        return svg;
713    };
714    let element = format!(
715        "<metadata><{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">{}</{RECIPE_KEY}></metadata>",
716        esc(recipe)
717    );
718    let mut out = svg;
719    out.insert_str(open, &element);
720    out
721}
722
723/// The recipe an exported chart carries, if it carries one.
724pub fn recipe_in(bytes: &[u8]) -> Option<String> {
725    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
726        let mut at = 8;
727        while at + 12 <= bytes.len() {
728            let len = u32::from_be_bytes(bytes[at..at + 4].try_into().ok()?) as usize;
729            let kind = &bytes[at + 4..at + 8];
730            let data = bytes.get(at + 8..at + 8 + len)?;
731            if kind == b"iTXt" && data.starts_with(RECIPE_KEY.as_bytes()) {
732                let text = data.get(RECIPE_KEY.len() + 5..)?;
733                return String::from_utf8(text.to_vec()).ok();
734            }
735            at += 12 + len;
736        }
737        return None;
738    }
739    if bytes.starts_with(b"%PDF") {
740        return crate::chart_pdf::recipe_in(bytes);
741    }
742    let text = std::str::from_utf8(bytes).ok()?;
743    let open = format!("<{RECIPE_KEY} xmlns=\"{RECIPE_NAMESPACE}\">");
744    let start = text.find(&open)? + open.len();
745    let end = start + text[start..].find(&format!("</{RECIPE_KEY}>"))?;
746    Some(unescape(&text[start..end]))
747}
748
749/// Text from SVG, as [`esc`] wrote it.
750fn unescape(s: &str) -> String {
751    s.replace("&lt;", "<")
752        .replace("&gt;", ">")
753        .replace("&quot;", "\"")
754        .replace("&apos;", "'")
755        .replace("&amp;", "&")
756}
757
758/// `png` with its resolution recorded (a `pHYs` chunk after `IHDR`), so a column
759/// figure prints at its inches.
760fn with_resolution(mut png: Vec<u8>, dpi: f32) -> Vec<u8> {
761    // The signature, then IHDR: its length, type, 13 bytes and checksum.
762    const AFTER_IHDR: usize = 8 + 4 + 4 + 13 + 4;
763    if png.len() < AFTER_IHDR || &png[12..16] != b"IHDR" {
764        return png;
765    }
766    let per_meter = (f64::from(dpi) / 0.0254).round() as u32;
767    let mut chunk = b"pHYs".to_vec();
768    chunk.extend_from_slice(&per_meter.to_be_bytes());
769    chunk.extend_from_slice(&per_meter.to_be_bytes());
770    chunk.push(1); // the unit is the meter
771    let crc = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC).checksum(&chunk);
772    let mut bytes = 9u32.to_be_bytes().to_vec();
773    bytes.extend_from_slice(&chunk);
774    bytes.extend_from_slice(&crc.to_be_bytes());
775    png.splice(AFTER_IHDR..AFTER_IHDR, bytes);
776    png
777}
778
779/// The fonts an export draws with: the bundled family, then the system's for what it
780/// lacks. Read once.
781fn fonts() -> Arc<usvg::fontdb::Database> {
782    static FONTS: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
783    FONTS
784        .get_or_init(|| {
785            let mut db = usvg::fontdb::Database::new();
786            db.load_font_data(FONT_REGULAR.to_vec());
787            db.load_font_data(FONT_SEMIBOLD.to_vec());
788            db.load_system_fonts();
789            db.set_sans_serif_family(FONT_FAMILY);
790            Arc::new(db)
791        })
792        .clone()
793}
794
795/// The SVG read with the bundled fonts.
796fn tree(svg: &str) -> Result<usvg::Tree> {
797    let options = usvg::Options {
798        font_family: FONT_FAMILY.to_string(),
799        fontdb: fonts(),
800        ..Default::default()
801    };
802    usvg::Tree::from_str(svg, &options).map_err(|e| color_eyre::eyre::eyre!("chart SVG: {e}"))
803}
804
805/// Text in SVG.
806fn esc(s: &str) -> String {
807    let mut out = String::with_capacity(s.len());
808    for c in s.chars() {
809        match c {
810            '&' => out.push_str("&amp;"),
811            '<' => out.push_str("&lt;"),
812            '>' => out.push_str("&gt;"),
813            '"' => out.push_str("&quot;"),
814            // Control characters are not allowed in XML.
815            c if c.is_control() => out.push(' '),
816            c => out.push(c),
817        }
818    }
819    out
820}
821
822/// About how wide `text` sets at `size` px: the bundled face's average advance.
823fn text_width(text: &str, size: f64) -> f64 {
824    text.chars()
825        .map(|c| match c {
826            'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '|' | '!' | ' ' => 0.3,
827            'm' | 'w' | 'M' | 'W' => 0.85,
828            c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62,
829            c if c.is_ascii() => 0.53,
830            // Wide scripts set about square.
831            _ => 0.95,
832        })
833        .sum::<f64>()
834        * size
835}
836
837/// `text` broken into lines no wider than `width` at `size`, at spaces.
838fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
839    let mut lines = Vec::new();
840    for paragraph in text.lines() {
841        let mut line = String::new();
842        for word in paragraph.split_whitespace() {
843            let candidate = if line.is_empty() {
844                word.to_string()
845            } else {
846                format!("{line} {word}")
847            };
848            if !line.is_empty() && text_width(&candidate, size) > width {
849                lines.push(std::mem::take(&mut line));
850                line = word.to_string();
851            } else {
852                line = candidate;
853            }
854        }
855        if !line.is_empty() {
856            lines.push(line);
857        }
858    }
859    lines
860}
861
862/// The SVG being written, with the sizes it is set in.
863struct Canvas<'a> {
864    out: String,
865    palette: &'a Palette,
866    /// Pixels per point.
867    pt: f64,
868    /// Body text size in px.
869    body: f64,
870    /// Where Other is among the series, drawn in the palette's `other`.
871    other: Option<usize>,
872    /// The width a line legend's swatch is drawn at, in px.
873    swatch_stroke: f64,
874}
875
876impl Canvas<'_> {
877    /// `text` at `(x, y)`, anchored `start`, `middle` or `end`, in `weight`.
878    fn text(
879        &mut self,
880        (x, y): (f64, f64),
881        size: f64,
882        color: Rgb,
883        (anchor, weight): (&str, u16),
884        text: &str,
885    ) {
886        self.out.push_str(&format!(
887            "<text x=\"{x:.1}\" y=\"{y:.1}\" font-size=\"{size:.1}\" font-weight=\"{weight}\" \
888             text-anchor=\"{anchor}\" fill=\"{}\">{}</text>\n",
889            color.hex(),
890            esc(text)
891        ));
892    }
893
894    fn line(&mut self, (x1, y1): (f64, f64), (x2, y2): (f64, f64), color: Rgb, width: f64) {
895        self.out.push_str(&format!(
896            "<line x1=\"{x1:.1}\" y1=\"{y1:.1}\" x2=\"{x2:.1}\" y2=\"{y2:.1}\" stroke=\"{}\" \
897             stroke-width=\"{width:.2}\"/>\n",
898            color.hex()
899        ));
900    }
901
902    fn rect(&mut self, x: f64, y: f64, w: f64, h: f64, fill: Rgb, opacity: f64) {
903        if w <= 0.0 || h <= 0.0 {
904            return;
905        }
906        self.out.push_str(&format!(
907            "<rect x=\"{x:.2}\" y=\"{y:.2}\" width=\"{w:.2}\" height=\"{h:.2}\" fill=\"{}\" \
908             fill-opacity=\"{opacity:.2}\"/>\n",
909            fill.hex()
910        ));
911    }
912
913    fn polyline(&mut self, points: &[(f64, f64)], color: Rgb, width: f64) {
914        if points.len() < 2 {
915            if let Some(&(x, y)) = points.first() {
916                self.dot(x, y, width, color, 1.0);
917            }
918            return;
919        }
920        let pts: Vec<String> = points
921            .iter()
922            .map(|(x, y)| format!("{x:.1},{y:.1}"))
923            .collect();
924        self.out.push_str(&format!(
925            "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{width:.2}\" \
926             stroke-linejoin=\"round\" stroke-linecap=\"round\"/>\n",
927            pts.join(" "),
928            color.hex()
929        ));
930    }
931
932    fn dot(&mut self, x: f64, y: f64, r: f64, color: Rgb, opacity: f64) {
933        let opacity = if opacity < 1.0 {
934            format!(" fill-opacity=\"{opacity:.2}\"")
935        } else {
936            String::new()
937        };
938        self.out.push_str(&format!(
939            "<circle cx=\"{x:.1}\" cy=\"{y:.1}\" r=\"{r:.2}\" fill=\"{}\"{opacity}/>\n",
940            color.hex()
941        ));
942    }
943
944    fn color(&self, i: usize) -> Rgb {
945        if self.other == Some(i) {
946            return self.palette.other;
947        }
948        self.palette.series[i % self.palette.series.len()]
949    }
950}
951
952/// Where the plot is drawn, in px.
953#[derive(Clone, Copy, Debug)]
954struct Area {
955    left: f64,
956    top: f64,
957    right: f64,
958    bottom: f64,
959}
960
961impl Area {
962    fn width(&self) -> f64 {
963        self.right - self.left
964    }
965    fn height(&self) -> f64 {
966        self.bottom - self.top
967    }
968}
969
970/// A linear scale from data to px.
971#[derive(Clone, Copy, Debug)]
972struct Scale {
973    lo: f64,
974    hi: f64,
975    from: f64,
976    to: f64,
977}
978
979impl Scale {
980    fn at(&self, v: f64) -> f64 {
981        let span = self.hi - self.lo;
982        if span.abs() < f64::EPSILON {
983            return (self.from + self.to) / 2.0;
984        }
985        self.from + (v - self.lo) / span * (self.to - self.from)
986    }
987}
988
989/// An axis's ticks and their labels from `lo` to `hi`, at most `most` of them: on
990/// nice numbers, or on calendar boundaries for dates.
991fn axis_ticks(lo: f64, hi: f64, axis: &Axis, most: usize) -> Vec<(f64, String)> {
992    let most = most.max(2);
993    if axis.kind != XAxisTemporalKind::Numeric
994        && axis.kind != XAxisTemporalKind::Time
995        && let (Some(a), Some(b)) = (
996            ticks::to_datetime(lo, axis.kind),
997            ticks::to_datetime(hi, axis.kind),
998        )
999    {
1000        for step in ticks::calendar_steps(axis.kind) {
1001            let at = ticks::calendar_ticks(a, b, step);
1002            if at.is_empty() || at.len() > most {
1003                continue;
1004            }
1005            let labels = ticks::calendar_labels(&at, step.unit, axis.kind, true);
1006            return at
1007                .iter()
1008                .zip(labels)
1009                .filter_map(|(t, label)| Some((ticks::from_datetime(*t, axis.kind)?, label)))
1010                .collect();
1011        }
1012    }
1013    let (show_lo, show_hi) = if axis.log {
1014        (lo.exp_m1(), hi.exp_m1())
1015    } else {
1016        (lo, hi)
1017    };
1018    let whole = axis.numbers.whole && !axis.log;
1019    let steps = ticks::nice_steps(show_lo, show_hi, 0.0, whole);
1020    let step = steps
1021        .iter()
1022        .copied()
1023        .find(|s| (((show_hi - show_lo) / s) as usize) < most)
1024        .or_else(|| steps.last().copied());
1025    let values = match step {
1026        Some(step) => ticks::multiples(show_lo, show_hi, step),
1027        None => vec![show_lo],
1028    };
1029    let format = AxisFormat::new(&values, &axis.numbers);
1030    values
1031        .iter()
1032        .filter_map(|&v| {
1033            let label = x_axis_label_at(v, axis.kind, (show_lo, show_hi), 0, &format)?;
1034            let at = if axis.log { v.max(0.0).ln_1p() } else { v };
1035            Some((at, label))
1036        })
1037        .collect()
1038}
1039
1040/// `lo..hi` padded so a flat series or a single point still has room.
1041fn span(lo: f64, hi: f64) -> (f64, f64) {
1042    if !(lo.is_finite() && hi.is_finite()) {
1043        return (0.0, 1.0);
1044    }
1045    if hi > lo {
1046        (lo, hi)
1047    } else {
1048        (lo - 0.5, hi + 0.5)
1049    }
1050}
1051
1052/// The SVG for `figure`, before its text is set.
1053pub fn svg(figure: &Figure, options: &ExportOptions) -> Result<String> {
1054    let (w, h) = (f64::from(options.width), f64::from(options.height));
1055    if options.width == 0 || options.height == 0 {
1056        return Err(color_eyre::eyre::eyre!(
1057            "a chart needs a width and a height"
1058        ));
1059    }
1060    let palette = &options.palette;
1061    let pt = f64::from(options.dpi) / 72.0;
1062    // Text size follows the page: small on a journal column, larger on a slide.
1063    let width_in = w / f64::from(options.dpi);
1064    let base_pt = (width_in * 1.25).clamp(7.0, 13.0);
1065    let body = base_pt * pt;
1066    let mut c = Canvas {
1067        out: String::new(),
1068        palette,
1069        pt,
1070        body,
1071        other: None,
1072        swatch_stroke: 1.75 * pt,
1073    };
1074    let margin = (body * 2.0).min(w / 10.0);
1075    if let Some(bg) = palette.background {
1076        c.rect(0.0, 0.0, w, h, bg, 1.0);
1077    }
1078
1079    // Header: title, then the description under it.
1080    let title_size = body * 1.45;
1081    let small = body * 0.82;
1082    let text_width_max = w - 2.0 * margin;
1083    let mut y = margin;
1084    for line in wrap(&options.title, title_size, text_width_max) {
1085        y += title_size;
1086        c.text((margin, y), title_size, palette.text, ("start", 600), &line);
1087        y += title_size * 0.25;
1088    }
1089    for line in wrap(&options.description, body, text_width_max) {
1090        y += body * 1.1;
1091        c.text(
1092            (margin, y),
1093            body,
1094            palette.text_secondary,
1095            ("start", 400),
1096            &line,
1097        );
1098    }
1099    if y > margin {
1100        y += body * 0.9;
1101    }
1102
1103    // Footer, from the bottom up: the source and byline, notes, what the chart
1104    // says about its rows.
1105    let mut footer: Vec<(String, Rgb)> = Vec::new();
1106    if !figure.chart_notes.is_empty() {
1107        footer.push((figure.chart_notes.join(" · "), palette.text_secondary));
1108    }
1109    for line in wrap(&options.notes, small, text_width_max) {
1110        footer.push((line, palette.text_secondary));
1111    }
1112    let mut credit = Vec::new();
1113    if !options.source.trim().is_empty() {
1114        credit.push(format!("Source: {}", options.source.trim()));
1115    }
1116    if !options.byline.trim().is_empty() {
1117        credit.push(options.byline.trim().to_string());
1118    }
1119    if !credit.is_empty() {
1120        for line in wrap(&credit.join(" · "), small, text_width_max) {
1121            footer.push((line, palette.text_secondary));
1122        }
1123    }
1124    let line_h = small * 1.35;
1125    let footer_top = h - margin - footer.len() as f64 * line_h;
1126    for (i, (line, color)) in footer.iter().enumerate() {
1127        let baseline = footer_top + (i as f64 + 1.0) * line_h - small * 0.3;
1128        c.text((margin, baseline), small, *color, ("start", 400), line);
1129    }
1130    let bottom = if footer.is_empty() {
1131        h - margin
1132    } else {
1133        footer_top - body * 0.8
1134    };
1135    let frame = Area {
1136        left: margin,
1137        top: y,
1138        right: w - margin,
1139        bottom,
1140    };
1141    if frame.height() < body * 4.0 || frame.width() < body * 6.0 {
1142        return Err(color_eyre::eyre::eyre!(
1143            "the chart does not fit at {}x{}: make it larger or the text shorter",
1144            options.width,
1145            options.height
1146        ));
1147    }
1148    draw_plot(&mut c, figure, options, frame);
1149    Ok(format!(
1150        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" \
1151         viewBox=\"0 0 {w} {h}\" font-family=\"{FONT_FAMILY}\">\n{}</svg>\n",
1152        c.out,
1153    ))
1154}
1155
1156/// The names a legend lists, with each one's color index.
1157fn legend_names(figure: &Figure) -> Vec<String> {
1158    match &figure.plot {
1159        Plot::Lines { series, .. } => series.iter().map(|s| s.name.clone()).collect(),
1160        Plot::Bars { data, .. } => data.groups.clone(),
1161        Plot::Histogram { data, .. } => data.groups.iter().map(|g| g.name.clone()).collect(),
1162        Plot::Kde { data, .. } => data.series.iter().map(|s| s.name.clone()).collect(),
1163        Plot::Box { .. } | Plot::Heatmap { .. } => Vec::new(),
1164    }
1165}
1166
1167/// Where Other is among the figure's series: last, when it has one.
1168fn other_at(figure: &Figure) -> Option<usize> {
1169    let (other, n) = match &figure.plot {
1170        Plot::Lines { series, .. } => return series.iter().position(|s| s.other),
1171        Plot::Bars { data, .. } => (data.other, data.groups.len()),
1172        Plot::Histogram { data, .. } => (data.other, data.groups.len()),
1173        Plot::Kde { data, .. } => (data.other, data.series.len()),
1174        Plot::Box { .. } | Plot::Heatmap { .. } => (false, 0),
1175    };
1176    (other && n > 0).then(|| n - 1)
1177}
1178
1179/// The plot in `frame`: axes, grid, marks, and the legend.
1180fn draw_plot(c: &mut Canvas<'_>, figure: &Figure, options: &ExportOptions, frame: Area) {
1181    let names = legend_names(figure);
1182    c.other = other_at(figure);
1183    let is_lines = matches!(
1184        figure.plot,
1185        Plot::Lines { scatter: false, .. } | Plot::Kde { .. }
1186    );
1187    // A line's swatch is drawn a little heavier than its line, as the line is.
1188    if matches!(figure.plot, Plot::Lines { scatter: false, .. }) {
1189        c.swatch_stroke = 1.75 / 1.5 * options.line_width.pt() * c.pt;
1190    }
1191    // A single series is named by the axis title; two or more get a legend.
1192    let legend = if names.len() < 2 {
1193        LegendPlace::Off
1194    } else if options.legend == LegendPlace::LineEnds && !is_lines {
1195        LegendPlace::TopRight
1196    } else {
1197        options.legend
1198    };
1199    let tick = c.body * 0.9;
1200    let mut frame = frame;
1201    if legend == LegendPlace::LineEnds {
1202        let widest = names
1203            .iter()
1204            .map(|n| text_width(n, tick))
1205            .fold(0.0, f64::max);
1206        frame.right -= (widest + tick).min(frame.width() / 3.0);
1207    }
1208    match &figure.plot {
1209        Plot::Lines {
1210            series,
1211            scatter,
1212            x,
1213            y,
1214            y_from_zero,
1215        } => {
1216            let all = series.iter().flat_map(|s| s.points.iter());
1217            let (x_lo, x_hi) = all
1218                .clone()
1219                .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| {
1220                    (a.min(p.0), b.max(p.0))
1221                });
1222            let (y_lo, y_hi) = all.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| {
1223                (a.min(p.1), b.max(p.1))
1224            });
1225            let (mut y_lo, mut y_hi) = (y_lo, y_hi);
1226            if options.y_from_zero.unwrap_or(*y_from_zero) {
1227                y_lo = y_lo.min(0.0);
1228                y_hi = y_hi.max(0.0);
1229            }
1230            let (sx, sy, plot) = axes(
1231                c,
1232                frame,
1233                span(x_lo, x_hi),
1234                span(y_lo, y_hi),
1235                x,
1236                y,
1237                figure.grid,
1238            );
1239            let width = options.line_width.pt() * c.pt;
1240            let radius = options.point_size.pt() * c.pt;
1241            let opacity = options
1242                .point_opacity
1243                .of(series.iter().map(|s| s.points.len()).sum());
1244            let mut ends = Vec::new();
1245            // Other first, under the series drawn over it.
1246            let mut order: Vec<(usize, &Series)> = series.iter().enumerate().collect();
1247            order.sort_by_key(|(_, s)| !s.other);
1248            for (i, s) in order {
1249                let color = c.color(i);
1250                if *scatter {
1251                    for &(px, py) in &s.points {
1252                        c.dot(sx.at(px), sy.at(py), radius, color, opacity);
1253                    }
1254                } else {
1255                    for run in segments(&s.points, &s.breaks) {
1256                        let pts: Vec<(f64, f64)> =
1257                            run.iter().map(|&(px, py)| (sx.at(px), sy.at(py))).collect();
1258                        c.polyline(&pts, color, width);
1259                    }
1260                }
1261                if let Some(&(px, py)) = s.points.last() {
1262                    ends.push((sy.at(py), sx.at(px), i));
1263                }
1264            }
1265            if legend == LegendPlace::LineEnds {
1266                line_end_labels(c, &names, ends, plot);
1267            }
1268        }
1269        Plot::Kde { data, x, y } => {
1270            let (sx, sy, plot) = axes(
1271                c,
1272                frame,
1273                span(data.x_min, data.x_max),
1274                (0.0, data.y_max),
1275                x,
1276                y,
1277                figure.grid,
1278            );
1279            let mut ends = Vec::new();
1280            for (i, s) in data.series.iter().enumerate() {
1281                let pts: Vec<(f64, f64)> = s
1282                    .points
1283                    .iter()
1284                    .map(|&(px, py)| (sx.at(px), sy.at(py)))
1285                    .collect();
1286                if let Some(&(px, py)) = pts.last() {
1287                    ends.push((py, px, i));
1288                }
1289                c.polyline(&pts, c.color(i), 1.5 * c.pt);
1290            }
1291            if legend == LegendPlace::LineEnds {
1292                line_end_labels(c, &names, ends, plot);
1293            }
1294        }
1295        Plot::Histogram { data, x, y } => {
1296            let max = if data.max_count > 0.0 {
1297                data.max_count
1298            } else {
1299                1.0
1300            };
1301            let (sx, sy, _) = axes(
1302                c,
1303                frame,
1304                span(data.x_min, data.x_max),
1305                (0.0, max),
1306                x,
1307                y,
1308                figure.grid,
1309            );
1310            let n = data.bins.len().max(1);
1311            let bin = (data.x_max - data.x_min) / n as f64;
1312            if data.groups.is_empty() {
1313                // Filled bars, a hairline of the background between them.
1314                let gap = 1.0 * c.pt;
1315                for (i, b) in data.bins.iter().enumerate() {
1316                    let x0 = sx.at(data.x_min + i as f64 * bin);
1317                    let x1 = sx.at(data.x_min + (i + 1) as f64 * bin);
1318                    let top = sy.at(b.count);
1319                    c.rect(
1320                        x0 + gap / 2.0,
1321                        top,
1322                        (x1 - x0 - gap).max(0.5),
1323                        sy.at(0.0) - top,
1324                        c.color(0),
1325                        1.0,
1326                    );
1327                }
1328            } else {
1329                // Groups overlaid as step outlines: filled bars would hide each other.
1330                for (g, group) in data.groups.iter().enumerate() {
1331                    let mut pts = vec![(sx.at(data.x_min), sy.at(0.0))];
1332                    for (i, &count) in group.counts.iter().enumerate() {
1333                        let x0 = sx.at(data.x_min + i as f64 * bin);
1334                        let x1 = sx.at(data.x_min + (i + 1) as f64 * bin);
1335                        pts.push((x0, sy.at(count)));
1336                        pts.push((x1, sy.at(count)));
1337                    }
1338                    pts.push((sx.at(data.x_max), sy.at(0.0)));
1339                    c.polyline(&pts, c.color(g), 1.5 * c.pt);
1340                }
1341            }
1342        }
1343        Plot::Box { data, x_title, y } => {
1344            let n = data.stats.len().max(1);
1345            let x_axis = Axis {
1346                title: x_title.clone(),
1347                ..Default::default()
1348            };
1349            let (lo, hi) = span(data.y_min, data.y_max);
1350            let pad = (hi - lo) * 0.04;
1351            let (_, sy, plot) = category_axes(
1352                c,
1353                frame,
1354                &data
1355                    .stats
1356                    .iter()
1357                    .map(|s| s.name.clone())
1358                    .collect::<Vec<_>>(),
1359                (lo - pad, hi + pad),
1360                &x_axis,
1361                y,
1362                figure.grid,
1363            );
1364            let slot = plot.width() / n as f64;
1365            for (i, s) in data.stats.iter().enumerate() {
1366                let color = c.color(i);
1367                let mid = plot.left + slot * (i as f64 + 0.5);
1368                let half = (slot * 0.3).min(c.body * 3.0);
1369                let stroke = 1.25 * c.pt;
1370                c.line((mid, sy.at(s.max)), (mid, sy.at(s.q3)), color, stroke);
1371                c.line((mid, sy.at(s.q1)), (mid, sy.at(s.min)), color, stroke);
1372                c.line(
1373                    (mid - half / 2.0, sy.at(s.max)),
1374                    (mid + half / 2.0, sy.at(s.max)),
1375                    color,
1376                    stroke,
1377                );
1378                c.line(
1379                    (mid - half / 2.0, sy.at(s.min)),
1380                    (mid + half / 2.0, sy.at(s.min)),
1381                    color,
1382                    stroke,
1383                );
1384                let top = sy.at(s.q3);
1385                c.rect(mid - half, top, half * 2.0, sy.at(s.q1) - top, color, 0.18);
1386                c.out.push_str(&format!(
1387                    "<rect x=\"{:.2}\" y=\"{top:.2}\" width=\"{:.2}\" height=\"{:.2}\" \
1388                     fill=\"none\" stroke=\"{}\" stroke-width=\"{stroke:.2}\"/>\n",
1389                    mid - half,
1390                    half * 2.0,
1391                    (sy.at(s.q1) - top).max(0.0),
1392                    color.hex()
1393                ));
1394                c.line(
1395                    (mid - half, sy.at(s.median)),
1396                    (mid + half, sy.at(s.median)),
1397                    color,
1398                    stroke * 2.0,
1399                );
1400            }
1401        }
1402        Plot::Heatmap { data, x, y } => {
1403            let (sx, sy, _) = axes(
1404                c,
1405                frame,
1406                span(data.x_min, data.x_max),
1407                span(data.y_min, data.y_max),
1408                x,
1409                y,
1410                false,
1411            );
1412            let xw = (data.x_max - data.x_min) / data.x_bins.max(1) as f64;
1413            let yh = (data.y_max - data.y_min) / data.y_bins.max(1) as f64;
1414            let ramp = c.palette.ramp;
1415            for (yi, row) in data.counts.iter().enumerate() {
1416                for (xi, &count) in row.iter().enumerate() {
1417                    if count <= 0.0 || data.max_count <= 0.0 {
1418                        continue;
1419                    }
1420                    let level =
1421                        ((count / data.max_count) * (ramp.len() - 1) as f64).round() as usize;
1422                    let x0 = sx.at(data.x_min + xi as f64 * xw);
1423                    let x1 = sx.at(data.x_min + (xi + 1) as f64 * xw);
1424                    let y0 = sy.at(data.y_min + (yi + 1) as f64 * yh);
1425                    let y1 = sy.at(data.y_min + yi as f64 * yh);
1426                    c.rect(
1427                        x0,
1428                        y0,
1429                        x1 - x0,
1430                        y1 - y0,
1431                        ramp[level.min(ramp.len() - 1)],
1432                        1.0,
1433                    );
1434                }
1435            }
1436        }
1437        Plot::Bars { data, value } => draw_bars(c, frame, data, value, figure.grid),
1438    }
1439    match legend {
1440        LegendPlace::Off | LegendPlace::LineEnds => {}
1441        // Above the plot, on the axis title's row, where it covers nothing.
1442        LegendPlace::TopRight | LegendPlace::TopLeft => {
1443            legend_row(c, &names, legend, frame, axis_title(figure), is_lines)
1444        }
1445        LegendPlace::BottomRight | LegendPlace::BottomLeft => {
1446            legend_box(c, &names, legend, frame, is_lines)
1447        }
1448    }
1449}
1450
1451/// The title written over the plot's left edge: the Y axis's, or a bar chart's
1452/// category.
1453fn axis_title(figure: &Figure) -> &str {
1454    match &figure.plot {
1455        Plot::Lines { y, .. }
1456        | Plot::Histogram { y, .. }
1457        | Plot::Kde { y, .. }
1458        | Plot::Box { y, .. }
1459        | Plot::Heatmap { y, .. } => &y.title,
1460        Plot::Bars { data, .. } => &data.category,
1461    }
1462}
1463
1464/// The legend as one row over the plot: a swatch and a name per series, at the
1465/// right edge, or after the axis title at the left.
1466fn legend_row(
1467    c: &mut Canvas<'_>,
1468    names: &[String],
1469    place: LegendPlace,
1470    frame: Area,
1471    title: &str,
1472    lines: bool,
1473) {
1474    let tick = c.body * 0.85;
1475    let swatch = tick * 1.2;
1476    let gap = tick * 1.2;
1477    let item = |name: &str| swatch + tick * 0.4 + text_width(name, tick);
1478    let width: f64 =
1479        names.iter().map(|n| item(n)).sum::<f64>() + gap * names.len().saturating_sub(1) as f64;
1480    let mut x = match place {
1481        LegendPlace::TopLeft => frame.left + text_width(title, c.body * 0.9) + gap * 1.5,
1482        _ => (frame.right - width).max(frame.left),
1483    };
1484    let baseline = frame.top + c.body * 0.9;
1485    let middle = baseline - tick * 0.35;
1486    for (i, name) in names.iter().enumerate() {
1487        let color = c.color(i);
1488        if lines {
1489            c.line((x, middle), (x + swatch, middle), color, c.swatch_stroke);
1490        } else {
1491            c.rect(x, middle - tick * 0.35, swatch, tick * 0.7, color, 1.0);
1492        }
1493        c.text(
1494            (x + swatch + tick * 0.4, baseline),
1495            tick,
1496            c.palette.text,
1497            ("start", 400),
1498            name,
1499        );
1500        x += item(name) + gap;
1501    }
1502}
1503
1504/// Axes in `frame` for X over `xs` and Y over `ys`: ticks, labels, the grid, the
1505/// baseline, and the titles. Returns the scales and the plot's own area.
1506fn axes(
1507    c: &mut Canvas<'_>,
1508    frame: Area,
1509    xs: (f64, f64),
1510    ys: (f64, f64),
1511    x: &Axis,
1512    y: &Axis,
1513    grid: bool,
1514) -> (Scale, Scale, Area) {
1515    axes_with(c, frame, xs, ys, (x, true), y, grid)
1516}
1517
1518/// [`axes`], with X's ticks left out when `x.1` is false (a category axis names its
1519/// slots itself).
1520fn axes_with(
1521    c: &mut Canvas<'_>,
1522    frame: Area,
1523    xs: (f64, f64),
1524    ys: (f64, f64),
1525    (x, x_ticked): (&Axis, bool),
1526    y: &Axis,
1527    grid: bool,
1528) -> (Scale, Scale, Area) {
1529    let tick = c.body * 0.9;
1530    let palette = c.palette.clone();
1531    // The Y title sits over the axis, not turned on its side.
1532    let top = frame.top + tick * 2.2;
1533    let x_title_h = if x.title.is_empty() { 0.0 } else { tick * 1.5 };
1534    let bottom = frame.bottom - tick * 1.6 - x_title_h;
1535    let y_ticks = axis_ticks(ys.0, ys.1, y, ((bottom - top) / (tick * 3.0)) as usize);
1536    let y_label_w = y_ticks
1537        .iter()
1538        .map(|(_, l)| text_width(l, tick))
1539        .fold(0.0, f64::max);
1540    let plot = Area {
1541        left: frame.left + y_label_w + tick * 0.8,
1542        top,
1543        right: frame.right,
1544        bottom,
1545    };
1546    let sx = Scale {
1547        lo: xs.0,
1548        hi: xs.1,
1549        from: plot.left,
1550        to: plot.right,
1551    };
1552    let sy = Scale {
1553        lo: ys.0,
1554        hi: ys.1,
1555        from: plot.bottom,
1556        to: plot.top,
1557    };
1558    let widest_x = |labels: &[(f64, String)]| {
1559        labels
1560            .iter()
1561            .map(|(_, l)| text_width(l, tick))
1562            .fold(0.0, f64::max)
1563    };
1564    let mut most = (plot.width() / (tick * 6.0)) as usize;
1565    let mut x_ticks = if x_ticked {
1566        axis_ticks(xs.0, xs.1, x, most)
1567    } else {
1568        Vec::new()
1569    };
1570    while x_ticked && most > 2 && widest_x(&x_ticks) * x_ticks.len() as f64 * 1.4 > plot.width() {
1571        most -= 1;
1572        x_ticks = axis_ticks(xs.0, xs.1, x, most);
1573    }
1574    let hair = 0.6 * c.pt;
1575    for (v, label) in &y_ticks {
1576        let py = sy.at(*v);
1577        if grid {
1578            c.line((plot.left, py), (plot.right, py), palette.grid, hair);
1579        }
1580        c.text(
1581            (plot.left - tick * 0.5, py + tick * 0.35),
1582            tick,
1583            palette.text_secondary,
1584            ("end", 400),
1585            label,
1586        );
1587    }
1588    for (v, label) in &x_ticks {
1589        let px = sx.at(*v);
1590        if grid {
1591            c.line((px, plot.top), (px, plot.bottom), palette.grid, hair);
1592        }
1593        c.line(
1594            (px, plot.bottom),
1595            (px, plot.bottom + tick * 0.35),
1596            palette.text_secondary,
1597            hair,
1598        );
1599        c.text(
1600            (px, plot.bottom + tick * 1.35),
1601            tick,
1602            palette.text_secondary,
1603            ("middle", 400),
1604            label,
1605        );
1606    }
1607    c.line(
1608        (plot.left, plot.bottom),
1609        (plot.right, plot.bottom),
1610        palette.text_secondary,
1611        hair,
1612    );
1613    if !y.title.is_empty() {
1614        c.text(
1615            (frame.left, frame.top + tick),
1616            tick,
1617            palette.text,
1618            ("start", 600),
1619            &y.title,
1620        );
1621    }
1622    if !x.title.is_empty() {
1623        c.text(
1624            ((plot.left + plot.right) / 2.0, frame.bottom - tick * 0.2),
1625            tick,
1626            palette.text,
1627            ("middle", 600),
1628            &x.title,
1629        );
1630    }
1631    (sx, sy, plot)
1632}
1633
1634/// Axes with a category on X, one slot per name, and Y as `axes` draws it.
1635fn category_axes(
1636    c: &mut Canvas<'_>,
1637    frame: Area,
1638    names: &[String],
1639    ys: (f64, f64),
1640    x: &Axis,
1641    y: &Axis,
1642    grid: bool,
1643) -> (Scale, Scale, Area) {
1644    let n = names.len().max(1) as f64;
1645    // The names go under their slots, in place of ticks.
1646    let (sx, sy, plot) = axes_with(c, frame, (0.0, n), ys, (x, false), y, grid);
1647    let tick = c.body * 0.9;
1648    let slot = plot.width() / n;
1649    let max_chars = (slot / (tick * 0.55)).max(3.0) as usize;
1650    for (i, name) in names.iter().enumerate() {
1651        let label = if name.chars().count() > max_chars {
1652            let kept: String = name.chars().take(max_chars.saturating_sub(1)).collect();
1653            format!("{kept}…")
1654        } else {
1655            name.clone()
1656        };
1657        c.text(
1658            (
1659                plot.left + slot * (i as f64 + 0.5),
1660                plot.bottom + tick * 1.35,
1661            ),
1662            tick,
1663            c.palette.text_secondary,
1664            ("middle", 400),
1665            &label,
1666        );
1667    }
1668    (sx, sy, plot)
1669}
1670
1671/// Each line named at its right end, in its color, nudged apart so no two names
1672/// overlap.
1673fn line_end_labels(
1674    c: &mut Canvas<'_>,
1675    names: &[String],
1676    mut ends: Vec<(f64, f64, usize)>,
1677    plot: Area,
1678) {
1679    let tick = c.body * 0.9;
1680    ends.sort_by(|a, b| a.0.total_cmp(&b.0));
1681    let mut last = f64::NEG_INFINITY;
1682    for (y, _, _) in &mut ends {
1683        *y = y.max(last + tick * 1.15).max(plot.top);
1684        last = *y;
1685    }
1686    // Pushed past the bottom: shift the whole stack back up.
1687    if let Some(over) = ends
1688        .last()
1689        .map(|(y, _, _)| *y - plot.bottom)
1690        .filter(|o| *o > 0.0)
1691    {
1692        for (y, _, _) in &mut ends {
1693            *y -= over;
1694        }
1695    }
1696    for (y, _, i) in ends {
1697        c.text(
1698            (plot.right + tick * 0.5, y + tick * 0.35),
1699            tick,
1700            c.color(i),
1701            ("start", 600),
1702            &names[i],
1703        );
1704    }
1705}
1706
1707/// A legend box in a corner of the plot: a swatch and a name per series, on the
1708/// background so marks under it do not show through.
1709fn legend_box(c: &mut Canvas<'_>, names: &[String], place: LegendPlace, frame: Area, lines: bool) {
1710    let tick = c.body * 0.85;
1711    let row = tick * 1.4;
1712    let swatch = tick * 1.2;
1713    let width = names
1714        .iter()
1715        .map(|n| text_width(n, tick))
1716        .fold(0.0, f64::max)
1717        + swatch
1718        + tick * 1.5;
1719    let height = row * names.len() as f64 + tick * 0.6;
1720    let pad = tick * 0.6;
1721    let plot_top = frame.top + tick * 2.4;
1722    let plot_bottom = frame.bottom - tick * 3.3;
1723    let (x, y) = match place {
1724        LegendPlace::TopLeft => (frame.left + tick * 4.0, plot_top + pad),
1725        LegendPlace::BottomRight => (frame.right - width - pad, plot_bottom - height - pad),
1726        LegendPlace::BottomLeft => (frame.left + tick * 4.0, plot_bottom - height - pad),
1727        _ => (frame.right - width - pad, plot_top + pad),
1728    };
1729    if let Some(bg) = c.palette.background {
1730        c.rect(x, y, width, height, bg, 0.9);
1731    }
1732    for (i, name) in names.iter().enumerate() {
1733        let cy = y + tick * 0.3 + row * (i as f64 + 0.5);
1734        let color = c.color(i);
1735        if lines {
1736            c.line(
1737                (x + pad, cy),
1738                (x + pad + swatch, cy),
1739                color,
1740                c.swatch_stroke,
1741            );
1742        } else {
1743            c.rect(x + pad, cy - tick * 0.35, swatch, tick * 0.7, color, 1.0);
1744        }
1745        c.text(
1746            (x + pad + swatch + tick * 0.5, cy + tick * 0.35),
1747            tick,
1748            c.palette.text,
1749            ("start", 400),
1750            name,
1751        );
1752    }
1753}
1754
1755/// Horizontal bars: a row per category, its name on the left, the value axis under
1756/// them; split by a color, a thin bar per group in each row.
1757fn draw_bars(c: &mut Canvas<'_>, frame: Area, data: &BarData, value: &Axis, grid: bool) {
1758    let tick = c.body * 0.9;
1759    let palette = c.palette.clone();
1760    let top = frame.top + tick * 2.2;
1761    let x_title_h = if value.title.is_empty() {
1762        0.0
1763    } else {
1764        tick * 1.5
1765    };
1766    let bottom = frame.bottom - tick * 1.6 - x_title_h;
1767    let groups = data.groups.len().max(1);
1768    // Rows no thinner than the text, so a long chart is cut and counted rather
1769    // than squeezed.
1770    let row_min = (tick * 1.3).max(tick * 0.5 * groups as f64);
1771    let fits = (((bottom - top) / row_min) as usize).max(1);
1772    let mut bars: Vec<&crate::chart_data::Bar> = data.bars.iter().collect();
1773    let mut more = data.more;
1774    if bars.len() > fits {
1775        more += bars.len() - (fits - 1);
1776        bars.truncate(fits - 1);
1777    }
1778    let null = "null".to_string();
1779    let label_of = |b: &crate::chart_data::Bar| b.label.clone().unwrap_or_else(|| null.clone());
1780    let more_label = format!("+ {} more", crate::numfmt::group_chrome(more));
1781    let label_w = bars
1782        .iter()
1783        .map(|b| text_width(&label_of(b), tick))
1784        .chain((more > 0).then(|| text_width(&more_label, tick)))
1785        .fold(0.0, f64::max)
1786        .min(frame.width() * 0.35);
1787    let values = || {
1788        bars.iter().flat_map(|b| {
1789            if b.by_group.is_empty() {
1790                vec![b.value]
1791            } else {
1792                b.by_group.iter().flatten().copied().collect()
1793            }
1794        })
1795    };
1796    let lo = values().fold(0.0_f64, f64::min);
1797    let hi = values().fold(0.0_f64, f64::max);
1798    let (lo, hi) = if hi > lo { (lo, hi) } else { (lo, lo + 1.0) };
1799    let plot = Area {
1800        left: frame.left + label_w + tick,
1801        top,
1802        right: frame.right,
1803        bottom,
1804    };
1805    let sx = Scale {
1806        lo,
1807        hi,
1808        from: plot.left,
1809        to: plot.right,
1810    };
1811    let x_ticks = axis_ticks(lo, hi, value, (plot.width() / (tick * 6.0)) as usize);
1812    let hair = 0.6 * c.pt;
1813    for (v, label) in &x_ticks {
1814        let px = sx.at(*v);
1815        if grid {
1816            c.line((px, plot.top), (px, plot.bottom), palette.grid, hair);
1817        }
1818        c.text(
1819            (px, plot.bottom + tick * 1.35),
1820            tick,
1821            palette.text_secondary,
1822            ("middle", 400),
1823            label,
1824        );
1825    }
1826    if !value.title.is_empty() {
1827        c.text(
1828            ((plot.left + plot.right) / 2.0, frame.bottom - tick * 0.2),
1829            tick,
1830            palette.text,
1831            ("middle", 600),
1832            &value.title,
1833        );
1834    }
1835    c.text(
1836        (frame.left, frame.top + tick),
1837        tick,
1838        palette.text,
1839        ("start", 600),
1840        &data.category,
1841    );
1842    let rows = bars.len() + usize::from(more > 0);
1843    let row_h = (plot.height() / rows.max(1) as f64).min(tick * 2.5 * groups as f64);
1844    let zero = sx.at(0.0);
1845    let gap = 1.0 * c.pt;
1846    for (i, bar) in bars.iter().enumerate() {
1847        let y0 = plot.top + row_h * i as f64;
1848        let label = label_of(bar);
1849        let max_chars = (label_w / (tick * 0.5)).max(3.0) as usize;
1850        let label = if label.chars().count() > max_chars {
1851            let kept: String = label.chars().take(max_chars.saturating_sub(1)).collect();
1852            format!("{kept}…")
1853        } else {
1854            label
1855        };
1856        c.text(
1857            (plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
1858            tick,
1859            palette.text,
1860            ("end", 400),
1861            &label,
1862        );
1863        let body = row_h * 0.75;
1864        let pieces: Vec<(usize, f64)> = if bar.by_group.is_empty() {
1865            vec![(0, bar.value)]
1866        } else {
1867            bar.by_group
1868                .iter()
1869                .enumerate()
1870                .filter_map(|(g, v)| v.map(|v| (g, v)))
1871                .collect()
1872        };
1873        let each = body / groups as f64;
1874        for (g, v) in pieces {
1875            let slot = if bar.by_group.is_empty() { 0 } else { g };
1876            let y = y0 + (row_h - body) / 2.0 + each * slot as f64;
1877            let end = sx.at(v);
1878            let (x, w) = if end >= zero {
1879                (zero, end - zero)
1880            } else {
1881                (end, zero - end)
1882            };
1883            c.rect(
1884                x,
1885                y + gap / 2.0,
1886                w.max(hair),
1887                (each - gap).max(hair),
1888                c.color(g),
1889                1.0,
1890            );
1891        }
1892    }
1893    if more > 0 {
1894        let y0 = plot.top + row_h * bars.len() as f64;
1895        c.text(
1896            (plot.left - tick * 0.5, y0 + row_h / 2.0 + tick * 0.35),
1897            tick,
1898            palette.text_secondary,
1899            ("end", 400),
1900            &more_label,
1901        );
1902    }
1903    c.line(
1904        (zero, plot.top),
1905        (zero, plot.bottom),
1906        palette.text_secondary,
1907        hair,
1908    );
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913    use super::*;
1914    use crate::chart_data::{Bar, BoxPlotStats, HistogramBin, HistogramGroup, OTHER, RowsRead};
1915
1916    fn lines(names: &[&str]) -> Figure {
1917        Figure {
1918            plot: Plot::Lines {
1919                series: names
1920                    .iter()
1921                    .enumerate()
1922                    .map(|(i, n)| Series {
1923                        name: n.to_string(),
1924                        points: (0..10).map(|x| (x as f64, (x * (i + 1)) as f64)).collect(),
1925                        breaks: Vec::new(),
1926                        other: *n == OTHER,
1927                    })
1928                    .collect(),
1929                scatter: false,
1930                x: Axis {
1931                    title: "x".to_string(),
1932                    ..Default::default()
1933                },
1934                y: Axis {
1935                    title: "value".to_string(),
1936                    ..Default::default()
1937                },
1938                y_from_zero: false,
1939            },
1940            chart_notes: vec!["sample of 1,000 of 50k rows".to_string()],
1941            grid: true,
1942        }
1943    }
1944
1945    fn options() -> ExportOptions {
1946        ExportOptions {
1947            title: "Cumulative return by symbol".to_string(),
1948            description: "Mean monthly return, compounded".to_string(),
1949            notes: "Illustrative values".to_string(),
1950            source: "NYC flights, public domain".to_string(),
1951            byline: "Chart: datui".to_string(),
1952            ..Default::default()
1953        }
1954    }
1955
1956    /// Every word of the dialog lands in the file, and lines are named at their ends
1957    /// by default.
1958    #[test]
1959    fn the_svg_carries_the_words_and_names_line_ends() {
1960        let svg = svg(&lines(&["AAPL", "MSFT"]), &options()).unwrap();
1961        for text in [
1962            "Cumulative return by symbol",
1963            "Mean monthly return, compounded",
1964            "Illustrative values",
1965            "Source: NYC flights, public domain · Chart: datui",
1966            "sample of 1,000 of 50k rows",
1967            ">AAPL<",
1968            ">MSFT<",
1969        ] {
1970            assert!(svg.contains(text), "{text} missing:\n{svg}");
1971        }
1972        assert!(svg.contains("fill=\"#2a78d6\""), "light palette: {svg}");
1973        assert!(svg.contains("fill=\"#ffffff\""), "a white background");
1974        roxmltree_ok(&svg);
1975    }
1976
1977    /// Other is the legend's last entry, in the palette's neutral color and not a
1978    /// series color, and a scatter draws it under the rest.
1979    #[test]
1980    fn an_export_names_other_last_in_its_neutral_color() {
1981        let mut figure = lines(&["AAPL", "MSFT", OTHER]);
1982        if let Plot::Lines { scatter, .. } = &mut figure.plot {
1983            *scatter = true;
1984        }
1985        let svg = svg(
1986            &figure,
1987            &ExportOptions {
1988                legend: LegendPlace::TopRight,
1989                ..options()
1990            },
1991        )
1992        .unwrap();
1993        let (aapl, other) = (svg.find(">AAPL<").unwrap(), svg.find(">Other<").unwrap());
1994        assert!(aapl < other, "Other last: {svg}");
1995        let grey = format!("fill=\"{}\"", Palette::light().other.hex());
1996        let third = format!("fill=\"{}\"", Palette::light().series[2].hex());
1997        assert!(svg.contains(&grey), "{svg}");
1998        assert!(!svg.contains(&third), "Other takes no series color: {svg}");
1999        // Its dots come before the first series' dots.
2000        let blue = format!("fill=\"{}\"", Palette::light().series[0].hex());
2001        assert!(svg.find(&grey).unwrap() < svg.find(&blue).unwrap(), "{svg}");
2002        roxmltree_ok(&svg);
2003    }
2004
2005    fn roxmltree_ok(svg: &str) {
2006        tree(svg).expect("usvg reads the SVG");
2007    }
2008
2009    /// Legend off draws no names; a box legend draws them once, at the corner.
2010    #[test]
2011    fn legend_off_draws_no_legend() {
2012        let figure = lines(&["AAPL", "MSFT"]);
2013        let off = svg(
2014            &figure,
2015            &ExportOptions {
2016                legend: LegendPlace::Off,
2017                ..options()
2018            },
2019        )
2020        .unwrap();
2021        assert!(!off.contains(">AAPL<") && !off.contains(">MSFT<"), "{off}");
2022        let boxed = svg(
2023            &figure,
2024            &ExportOptions {
2025                legend: LegendPlace::BottomLeft,
2026                ..options()
2027            },
2028        )
2029        .unwrap();
2030        assert_eq!(boxed.matches(">AAPL<").count(), 1);
2031        // One series is named by its axis.
2032        let one = svg(&lines(&["AAPL"]), &options()).unwrap();
2033        assert!(!one.contains(">AAPL<"));
2034    }
2035
2036    #[test]
2037    fn transparent_has_no_background_and_dark_uses_the_theme() {
2038        let clear = svg(
2039            &lines(&["a", "b"]),
2040            &ExportOptions {
2041                palette: Palette::transparent(),
2042                ..options()
2043            },
2044        )
2045        .unwrap();
2046        assert!(!clear.contains("fill=\"#ffffff\" fill-opacity=\"1.00\"/>\n<text"));
2047        assert!(!clear.contains("width=\"1600.00\""), "no full-page rect");
2048        let config = crate::config::AppConfig::default();
2049        let dark = Palette::dark(&config.theme.colors);
2050        assert_eq!(dark.series[0], Rgb(0x7d, 0xcf, 0xff), "chart_1");
2051        assert!(dark.dark);
2052    }
2053
2054    /// The three formats come out as what they say: a PNG of the size asked, an
2055    /// SVG whose text is outlines, a PDF.
2056    /// Ten series colors in every style; a theme that gives two slots one color
2057    /// exports them as one, as the screen draws them.
2058    #[test]
2059    fn palettes_have_ten_series_and_a_repeat_counts_once() {
2060        let mut colors = crate::config::ColorConfig::default();
2061        assert_eq!(Palette::light().series.len(), SERIES);
2062        assert_eq!(Palette::dark(&colors).series.len(), SERIES);
2063        colors.chart_5 = colors.chart_1.clone();
2064        let dark = Palette::dark(&colors);
2065        assert_eq!(dark.series.len(), SERIES - 1);
2066        assert_eq!(dark.series[4], Rgb(0xf7, 0x76, 0x8e), "chart_6 moves up");
2067        // On screen too, whatever this terminal makes of the colors.
2068        let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig {
2069            colors,
2070            ..Default::default()
2071        })
2072        .unwrap();
2073        let shown = theme.series_colors();
2074        for (i, color) in shown.iter().enumerate() {
2075            assert!(!shown[..i].contains(color), "{shown:?}");
2076        }
2077        assert!(shown.len() < SERIES, "{shown:?}");
2078    }
2079
2080    #[test]
2081    fn png_svg_and_pdf_are_what_they_say() {
2082        let figure = lines(&["AAPL", "MSFT"]);
2083        let options = ExportOptions {
2084            width: 600,
2085            height: 400,
2086            dpi: 96.0,
2087            ..options()
2088        };
2089        let png = render(&figure, &options, ChartExportFormat::Png).unwrap();
2090        assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
2091        // IHDR: width and height, big-endian, at bytes 16..24.
2092        assert_eq!(&png[16..20], &600u32.to_be_bytes());
2093        assert_eq!(&png[20..24], &400u32.to_be_bytes());
2094        // pHYs: 96 dpi is 3,780 pixels a meter.
2095        assert_eq!(&png[37..41], b"pHYs");
2096        assert_eq!(&png[41..45], &3780u32.to_be_bytes());
2097        let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
2098        assert_eq!((decoded.width(), decoded.height()), (600, 400));
2099
2100        let svg =
2101            String::from_utf8(render(&figure, &options, ChartExportFormat::Svg).unwrap()).unwrap();
2102        assert!(svg.starts_with("<svg"), "{svg}");
2103        assert!(svg.contains("width=\"6.250in\""), "printed size: {svg}");
2104        assert!(!svg.contains("<text"), "text set as outlines");
2105        usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
2106
2107        let pdf = render(&figure, &options, ChartExportFormat::Pdf).unwrap();
2108        assert!(pdf.starts_with(b"%PDF-"));
2109        // 600 x 400 px at 96 dpi: 450 x 300 pt.
2110        assert!(String::from_utf8_lossy(&pdf).contains("/MediaBox [0 0 450 300]"));
2111    }
2112
2113    /// Include writes the recipe into each format's own metadata, where a reader
2114    /// finds it whole, and the file stays valid; Omit writes no datui metadata at all.
2115    #[test]
2116    fn the_recipe_rides_in_each_format_and_omit_writes_none() {
2117        let figure = lines(&["AAPL", "MSFT"]);
2118        let recipe = "{\"datui\": \"0.4.0\", \"settings\": {\"query\": \"select where a < 3 & b > \\\"x\\\"\"}, \"note\": \"Zürich\"}";
2119        let with = ExportOptions {
2120            width: 300,
2121            height: 200,
2122            dpi: 96.0,
2123            recipe: Some(recipe.to_string()),
2124            ..ExportOptions::default()
2125        };
2126        let without = ExportOptions {
2127            recipe: None,
2128            ..with.clone()
2129        };
2130        for format in ChartExportFormat::ALL {
2131            let bytes = render(&figure, &with, format).unwrap();
2132            assert_eq!(recipe_in(&bytes).as_deref(), Some(recipe), "{format:?}");
2133            let bare = render(&figure, &without, format).unwrap();
2134            assert_eq!(recipe_in(&bare), None, "{format:?}");
2135            assert!(
2136                !bare.windows(5).any(|w| w.eq_ignore_ascii_case(b"datui")),
2137                "{format:?}: no datui metadata with Omit"
2138            );
2139        }
2140        let png = render(&figure, &with, ChartExportFormat::Png).unwrap();
2141        let decoded = resvg::tiny_skia::Pixmap::decode_png(&png).expect("a valid PNG");
2142        assert_eq!((decoded.width(), decoded.height()), (300, 200));
2143        let svg =
2144            String::from_utf8(render(&figure, &with, ChartExportFormat::Svg).unwrap()).unwrap();
2145        assert!(svg.contains("<metadata>"), "{svg}");
2146        usvg::Tree::from_str(&svg, &usvg::Options::default()).expect("valid SVG");
2147    }
2148
2149    #[test]
2150    fn presets_set_sizes() {
2151        assert_eq!(SizePreset::Slide.size(), Some((1920, 1080)));
2152        assert_eq!(SizePreset::Document.size(), Some((1600, 1000)));
2153        assert_eq!(SizePreset::Square.size(), Some((1200, 1200)));
2154        // 3.5 in and 7 in at 300 dpi.
2155        let (w, _) = SizePreset::SingleColumn.size().unwrap();
2156        assert_eq!(
2157            f64::from(w) / f64::from(SizePreset::SingleColumn.dpi()),
2158            3.5
2159        );
2160        let (w, _) = SizePreset::DoubleColumn.size().unwrap();
2161        assert_eq!(
2162            f64::from(w) / f64::from(SizePreset::DoubleColumn.dpi()),
2163            7.0
2164        );
2165        assert_eq!(SizePreset::Custom.size(), None);
2166    }
2167
2168    /// Every plot draws into a valid SVG, at the smallest preset too.
2169    #[test]
2170    fn every_plot_draws() {
2171        let rows = RowsRead::default();
2172        let bars = BarData {
2173            category: "carrier".to_string(),
2174            value_column: "mean delay".to_string(),
2175            bars: vec![
2176                Bar {
2177                    label: Some("UA".to_string()),
2178                    value: 12.0,
2179                    by_group: vec![Some(5.0), Some(7.0)],
2180                },
2181                Bar {
2182                    label: None,
2183                    value: -3.0,
2184                    by_group: vec![Some(-3.0), None],
2185                },
2186            ],
2187            more: 3,
2188            no_value: 0,
2189            rows,
2190            value_dtype: polars::prelude::DataType::Float64,
2191            counted: None,
2192            groups: vec!["EWR".to_string(), "Other".to_string()],
2193            other: true,
2194            rows_note: None,
2195        };
2196        let histogram = HistogramData {
2197            column: "delay".to_string(),
2198            bins: (0..4)
2199                .map(|i| HistogramBin {
2200                    center: i as f64 + 0.5,
2201                    count: i as f64,
2202                })
2203                .collect(),
2204            groups: vec![
2205                HistogramGroup {
2206                    name: "a".to_string(),
2207                    counts: vec![0.1, 0.2, 0.3, 0.4],
2208                },
2209                HistogramGroup {
2210                    name: "b".to_string(),
2211                    counts: vec![0.4, 0.3, 0.2, 0.1],
2212                },
2213            ],
2214            other: true,
2215            share: true,
2216            x_min: 0.0,
2217            x_max: 4.0,
2218            max_count: 0.4,
2219            rows,
2220            clipped: None,
2221        };
2222        let boxes = BoxPlotData {
2223            stats: vec![BoxPlotStats {
2224                name: "UA".to_string(),
2225                min: 0.0,
2226                q1: 1.0,
2227                median: 2.0,
2228                q3: 3.0,
2229                max: 4.0,
2230            }],
2231            y_min: 0.0,
2232            y_max: 4.0,
2233            rows,
2234            clipped: None,
2235            of: 0,
2236        };
2237        let heatmap = HeatmapData {
2238            x_column: "a".to_string(),
2239            y_column: "b".to_string(),
2240            x_min: 0.0,
2241            x_max: 1.0,
2242            y_min: 0.0,
2243            y_max: 1.0,
2244            x_bins: 2,
2245            y_bins: 2,
2246            counts: vec![vec![1.0, 2.0], vec![0.0, 4.0]],
2247            max_count: 4.0,
2248            rows,
2249        };
2250        let plots = [
2251            Plot::Bars {
2252                data: bars,
2253                value: Axis::default(),
2254            },
2255            Plot::Histogram {
2256                data: histogram,
2257                x: Axis::default(),
2258                y: Axis::default(),
2259            },
2260            Plot::Box {
2261                data: boxes,
2262                x_title: "carrier".to_string(),
2263                y: Axis::default(),
2264            },
2265            Plot::Heatmap {
2266                data: heatmap,
2267                x: Axis::default(),
2268                y: Axis::default(),
2269            },
2270        ];
2271        let (w, h) = SizePreset::SingleColumn.size().unwrap();
2272        for plot in plots {
2273            let figure = Figure {
2274                plot,
2275                chart_notes: Vec::new(),
2276                grid: true,
2277            };
2278            let options = ExportOptions {
2279                width: w,
2280                height: h,
2281                dpi: SizePreset::SingleColumn.dpi(),
2282                ..options()
2283            };
2284            let svg = svg(&figure, &options).unwrap();
2285            roxmltree_ok(&svg);
2286        }
2287    }
2288
2289    #[test]
2290    fn auto_opacity_fades_with_the_points() {
2291        assert_eq!(auto_opacity(0), 1.0);
2292        assert_eq!(auto_opacity(1_000), 1.0);
2293        assert!((auto_opacity(10_000) - 0.575).abs() < 1e-9);
2294        assert!((auto_opacity(100_000) - 0.15).abs() < 1e-9);
2295        assert!((auto_opacity(5_000_000) - 0.15).abs() < 1e-9);
2296        assert!(auto_opacity(3_000) < 1.0 && auto_opacity(3_000) > auto_opacity(30_000));
2297        assert_eq!(PointOpacity::Half.of(1), 0.5);
2298    }
2299
2300    /// Each mark option changes what is drawn: the line's stroke and its legend
2301    /// swatch, a point's radius and opacity, and where a line's Y axis starts.
2302    #[test]
2303    fn mark_options_change_the_marks() {
2304        let pt = f64::from(options().dpi) / 72.0;
2305        let stroke = |w: f64| format!("stroke-width=\"{:.2}\"", w * pt);
2306        let figure = lines(&["AAPL", "MSFT"]);
2307        let draw = |options: ExportOptions| svg(&figure, &options).unwrap();
2308        let normal = draw(ExportOptions {
2309            legend: LegendPlace::TopRight,
2310            ..options()
2311        });
2312        let bold = draw(ExportOptions {
2313            legend: LegendPlace::TopRight,
2314            line_width: LineWidth::Bold,
2315            ..options()
2316        });
2317        assert!(normal.contains(&stroke(1.5)) && !normal.contains(&stroke(2.5)));
2318        assert!(bold.contains(&stroke(2.5)), "{bold}");
2319        assert!(
2320            normal.contains(&stroke(1.75)),
2321            "the swatch, a little heavier"
2322        );
2323        assert!(
2324            bold.contains(&stroke(1.75 / 1.5 * 2.5)),
2325            "the swatch follows"
2326        );
2327
2328        let mut scatter = lines(&["AAPL"]);
2329        if let Plot::Lines {
2330            scatter: is_scatter,
2331            ..
2332        } = &mut scatter.plot
2333        {
2334            *is_scatter = true;
2335        }
2336        let radius = |r: f64| format!("r=\"{:.2}\"", r * pt);
2337        let dots = |options: ExportOptions| svg(&scatter, &options).unwrap();
2338        let medium = dots(options());
2339        assert!(medium.contains(&radius(2.4)), "{medium}");
2340        assert!(
2341            !medium.contains("fill-opacity=\"0.50\""),
2342            "ten points: opaque"
2343        );
2344        let large_half = dots(ExportOptions {
2345            point_size: PointSize::Large,
2346            point_opacity: PointOpacity::Half,
2347            ..options()
2348        });
2349        assert!(large_half.contains(&radius(3.6)), "{large_half}");
2350        assert!(
2351            large_half.contains("fill-opacity=\"0.50\"/>"),
2352            "{large_half}"
2353        );
2354        assert!(
2355            dots(ExportOptions {
2356                point_size: PointSize::Small,
2357                ..options()
2358            })
2359            .contains(&radius(1.6))
2360        );
2361        // The PDF, written from the same SVG, keeps the points' opacity.
2362        let pdf = render(
2363            &scatter,
2364            &ExportOptions {
2365                point_opacity: PointOpacity::Half,
2366                ..options()
2367            },
2368            ChartExportFormat::Pdf,
2369        )
2370        .unwrap();
2371        assert!(
2372            String::from_utf8_lossy(&pdf).contains("/ca 0.5 "),
2373            "the PDF's fill opacity"
2374        );
2375
2376        // Y from zero: a line of 100 to 109 takes in 0 only when asked.
2377        let mut high = lines(&["AAPL"]);
2378        if let Plot::Lines { series, .. } = &mut high.plot {
2379            series[0].points = (0..10).map(|x| (x as f64, 100.0 + x as f64)).collect();
2380        }
2381        let y_zero = "text-anchor=\"end\" fill=\"#5b6170\">0</text>";
2382        let off = svg(&high, &options()).unwrap();
2383        assert!(!off.contains(y_zero), "{off}");
2384        let on = svg(
2385            &high,
2386            &ExportOptions {
2387                y_from_zero: Some(true),
2388                ..options()
2389            },
2390        )
2391        .unwrap();
2392        assert!(on.contains(y_zero), "{on}");
2393
2394        roxmltree_ok(&on);
2395    }
2396
2397    #[test]
2398    fn dates_tick_on_the_calendar() {
2399        // 2024-01-01 to 2026-01-01, as days since the epoch.
2400        let axis = Axis {
2401            kind: XAxisTemporalKind::Date,
2402            ..Default::default()
2403        };
2404        let ticks = axis_ticks(19723.0, 20454.0, &axis, 6);
2405        let labels: Vec<&str> = ticks.iter().map(|(_, l)| l.as_str()).collect();
2406        assert!(labels.contains(&"2025"), "{labels:?}");
2407        assert!(ticks.len() <= 6);
2408    }
2409
2410    #[test]
2411    fn too_small_says_so() {
2412        let err = svg(
2413            &lines(&["a"]),
2414            &ExportOptions {
2415                width: 60,
2416                height: 40,
2417                dpi: 96.0,
2418                ..options()
2419            },
2420        )
2421        .unwrap_err();
2422        assert!(err.to_string().contains("does not fit"), "{err}");
2423    }
2424}