Skip to main content

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