Skip to main content

egui_charts/widget/
export.rs

1//! Chart data export and renderer-independent image frames.
2
3use super::Chart;
4use crate::localization as l10n;
5use crate::scales::StudyFormat;
6use crate::theme::ThemeExt;
7use chrono::{DateTime, Utc};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11/// Image options shared by the export dialog and offscreen rendering.
12#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub struct ImageExportOptions {
15    /// Output width and height in pixels.
16    pub size: [u32; 2],
17    /// Include the symbol above the chart.
18    pub include_title: bool,
19    /// Include the export time in the chart's time zone above the chart.
20    pub include_timestamp: bool,
21    /// Draw the symbol watermark and the one set with [`Chart::set_watermark`], as they show on
22    /// screen, independently of the title and timestamp.
23    pub include_watermark: bool,
24    /// Omit the chart and header backgrounds, retaining plot and drawing colours.
25    pub transparent_background: bool,
26    /// Draw the crosshair where the pointer last placed it over the plots, or where
27    /// [`Chart::set_crosshair_position`] or a synchronized chart placed it since.
28    pub include_crosshair: bool,
29    /// Pixels of the textures the host binds itself, by the id its images use, so
30    /// `Chart::export_png` and [`Chart::export_svg`] can draw them with `png-export`.
31    /// Without an entry, an image of such a texture fails the export. Supplied textures use
32    /// linear filtering and clamp to their edges. Prefer [`egui::TextureId::User`]: ids that
33    /// collide with the offscreen frame's own textures return an error. [`ChartImage`] frames
34    /// include the supplied pixels too.
35    pub textures: HashMap<egui::TextureId, Arc<egui::ColorImage>>,
36}
37
38impl Default for ImageExportOptions {
39    fn default() -> Self {
40        Self {
41            size: [1440, 900],
42            include_title: true,
43            include_timestamp: true,
44            include_watermark: true,
45            transparent_background: false,
46            include_crosshair: false,
47            textures: HashMap::new(),
48        }
49    }
50}
51
52/// A complete offscreen frame for an egui renderer, at one pixel per point.
53/// Upload `textures_delta.set`, draw `primitives` over transparent black at `size`, then
54/// release the textures. The host owns GPU readback, PNG encoding and file/clipboard delivery.
55/// This uses its own texture namespace; render with a dedicated egui renderer. Hosts must also
56/// bind any user textures referenced by their image sources whose pixels were not supplied in
57/// [`ImageExportOptions::textures`] before drawing.
58#[must_use = "Render the frame and apply its texture deltas, or clear them before dropping"]
59#[non_exhaustive]
60pub struct ChartImage {
61    /// Exact output dimensions in pixels.
62    pub size: [u32; 2],
63    /// Tessellated chart, header, study panes and requested layout widgets.
64    pub primitives: Vec<egui::ClippedPrimitive>,
65    /// Textures referenced by the primitives.
66    pub textures_delta: egui::TexturesDelta,
67}
68
69/// The loaded bars and visible study plots as text, aligned by chart time: what
70/// `Chart::export_csv` (with `csv`) writes and the chart data table lists.
71#[derive(Debug, Clone, PartialEq)]
72#[non_exhaustive]
73pub struct DataTable {
74    /// Column titles: time, open, high, low, close and volume, then one per visible study plot.
75    pub headers: Vec<String>,
76    /// One row per loaded bar, oldest first. A missing or non-finite value is an empty cell.
77    pub rows: Vec<Vec<String>>,
78}
79
80impl DataTable {
81    /// CSV text of the `columns` (indices into [`Self::headers`]) in the given order.
82    #[cfg(feature = "csv")]
83    #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
84    pub fn csv(
85        &self,
86        columns: &[usize],
87    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
88        let mut writer = csv::Writer::from_writer(Vec::new());
89        writer.write_record(columns.iter().map(|&column| &self.headers[column]))?;
90        for row in &self.rows {
91            writer.write_record(columns.iter().map(|&column| &row[column]))?;
92        }
93        Ok(String::from_utf8(writer.into_inner()?)?)
94    }
95
96    /// Tab-separated text of the `columns` with a header line, for pasting into a spreadsheet.
97    pub fn tsv(&self, columns: &[usize]) -> String {
98        let line = |cells: &mut dyn Iterator<Item = &String>| {
99            cells.map(String::as_str).collect::<Vec<_>>().join("\t")
100        };
101        let mut lines = vec![line(
102            &mut columns.iter().map(|&column| &self.headers[column]),
103        )];
104        lines.extend(
105            self.rows
106                .iter()
107                .map(|row| line(&mut columns.iter().map(|&column| &row[column]))),
108        );
109        lines.join("\n")
110    }
111}
112
113/// Sets the host's textures in `delta` beside the frame's own, for the encoders that draw them.
114fn add_host_textures(
115    delta: &mut egui::TexturesDelta,
116    options: &ImageExportOptions,
117) -> Result<(), String> {
118    for (&id, image) in &options.textures {
119        if delta.set.contains_key(&id) {
120            return Err(format!(
121                "Host texture {id:?} conflicts with an offscreen texture; use TextureId::User"
122            ));
123        }
124        let [width, height] = image.size;
125        if width == 0
126            || height == 0
127            || width > u32::MAX as usize
128            || height > u32::MAX as usize
129            || width.checked_mul(height) != Some(image.pixels.len())
130        {
131            return Err(format!(
132                "Host texture {id:?} has invalid dimensions or pixels"
133            ));
134        }
135        delta.push(
136            id,
137            egui::epaint::ImageDelta::full(
138                egui::ImageData::Color(Arc::clone(image)),
139                egui::TextureOptions::LINEAR,
140            ),
141        );
142    }
143    Ok(())
144}
145
146/// Why [`Chart::export_image`], `Chart::export_png` (with `png-export`) or
147/// [`Chart::export_svg`] made no image.
148#[derive(Clone, Debug, PartialEq, Eq)]
149#[non_exhaustive]
150pub enum ExportError {
151    /// A side is outside 100 to 7680 pixels, or the image has more than 33,177,600 pixels.
152    InvalidSize {
153        /// The requested width in pixels.
154        width: u32,
155        /// The requested height in pixels.
156        height: u32,
157    },
158    /// The egui context has not run its first frame, so it has no fonts to lay the chart out.
159    NoFrame,
160    /// The frame holds content the format cannot take over unchanged, such as a host paint
161    /// callback or a texture the host binds itself; the text names it.
162    Unsupported(String),
163    /// The PNG encoder failed; the text is its message.
164    Encode(String),
165}
166
167impl std::fmt::Display for ExportError {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            Self::InvalidSize { width, height } => write!(
171                f,
172                "Image size {width} by {height} must be 100 to 7680 pixels per side and at most 33,177,600 pixels"
173            ),
174            Self::NoFrame => {
175                f.write_str("Run the egui context's first frame before exporting an image")
176            }
177            Self::Unsupported(reason) => f.write_str(reason),
178            Self::Encode(message) => write!(f, "PNG encoding failed: {message}"),
179        }
180    }
181}
182impl std::error::Error for ExportError {}
183
184impl Chart {
185    fn export_time(&self, time: DateTime<Utc>) -> String {
186        match self.display_timezone() {
187            crate::scales::TimezoneMode::Local => time.with_timezone(&chrono::Local).to_rfc3339(),
188            zone => time
189                .with_timezone(&zone.to_tz().unwrap_or(crate::model::TimezoneId::utc()))
190                .to_rfc3339(),
191        }
192    }
193
194    /// Exports all loaded source bars and visible study plots as UTF-8 CSV.
195    /// Recalculates copies of the studies so streaming updates and pending refreshes are included
196    /// without altering the live chart. Price-based charts export source-bar calculations, aligned
197    /// by source index and plot displacement, rather than mixing element values with source bars.
198    /// Times include the chart's UTC offset, including DST changes and subsecond timestamps; on a
199    /// [horizontal scale](Self::set_horizontal_scale) other than time, keys read as the crosshair
200    /// label does.
201    /// Prices inherit instrument precision (two decimals without metadata); study overrides and
202    /// non-price units retain their precision. Missing/non-finite values are empty cells.
203    #[cfg(feature = "csv")]
204    #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
205    pub fn export_csv(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
206        let table = self.data_table();
207        table.csv(&(0..table.headers.len()).collect::<Vec<_>>())
208    }
209
210    /// The cells of `Self::export_csv` (with `csv`) as a table, with the same bars, study plots,
211    /// times and precision. During replay it holds the bars the replay cutoff has completed,
212    /// and on price-based chart types still the source bars. Loads nothing: only loaded bars are listed.
213    pub fn data_table(&self) -> DataTable {
214        let bars = &self.state.data().bars;
215        let price_precision = self
216            .instrument()
217            .and_then(crate::scales::instrument_price_precision)
218            .map_or(2, usize::from);
219        let mut studies: Vec<_> = self
220            .indicators()
221            .map(|(_, study)| study.clone_box())
222            .collect();
223        let mut columns = Vec::new();
224        let mut header = vec![
225            "time".into(),
226            "open".into(),
227            "high".into(),
228            "low".into(),
229            "close".into(),
230            "volume".into(),
231        ];
232        let clock = self.bar_clock();
233        for study in &mut studies {
234            study.set_instrument(self.instrument());
235            study.set_time_scale_events(&self.time_scale_events);
236            if let Some(timeframe) = self.timeframe {
237                study.set_timeframe(timeframe);
238            }
239            study.set_bar_clock(&clock);
240        }
241        let requests = studies
242            .iter()
243            .flat_map(|study| study.data_requests())
244            .collect();
245        let sources =
246            self.study_data
247                .data(requests, &self.symbol, self.timeframe, self.replay_time());
248        super::studies::calculate(&mut studies, &self.study_ids, bars, &sources);
249        for (index, study) in studies
250            .iter_mut()
251            .enumerate()
252            .filter(|(_, study)| study.is_visible())
253        {
254            let names = study.line_names();
255            for line in (0..study.line_count()).filter(|line| study.plot_style(*line).visible) {
256                header.push(format!(
257                    "{}: {}",
258                    study.name(),
259                    names.get(line).map_or("", String::as_str)
260                ));
261                let precision = study.precision().unwrap_or(match study.value_format() {
262                    StudyFormat::Price => price_precision,
263                    StudyFormat::Fixed(decimals) => decimals.min(12),
264                    _ => 2,
265                });
266                columns.push((index, line, precision));
267            }
268        }
269        let number = |value: f64, precision: usize| {
270            if value.is_finite() {
271                format!("{value:.precision$}")
272            } else {
273                String::new()
274            }
275        };
276        let mut rows = Vec::with_capacity(bars.len());
277        for (index, bar) in bars.iter().enumerate() {
278            let mut row = vec![if self.horizontal_scale.is_some() {
279                self.format_time(bar.time)
280            } else {
281                self.export_time(bar.time)
282            }];
283            row.extend(
284                [bar.open, bar.high, bar.low, bar.close]
285                    .map(|value| number(value, price_precision)),
286            );
287            row.push(if bar.volume.is_finite() {
288                bar.volume.to_string()
289            } else {
290                String::new()
291            });
292            row.extend(columns.iter().map(|&(study, line, precision)| {
293                studies[study]
294                    .plot_value(line, index)
295                    .map_or_else(String::new, |value| number(value, precision))
296            }));
297            rows.push(row);
298        }
299        DataTable {
300            headers: header,
301            rows,
302        }
303    }
304
305    /// Renders an isolated image frame using the same chart, studies, primitives, drawings and
306    /// scales as the widget. Does not poll feeds, change live viewport state, call the lifecycle
307    /// hooks of primitives or capture host panels/dialogs.
308    /// `ctx` supplies the host's fonts and style after its first frame. Images use one pixel per point and dimensions
309    /// between 100 and 7680 on each axis, with at most 33,177,600 pixels.
310    /// Pass the returned frame to an egui renderer; see [`ChartImage`] for delivery responsibilities.
311    ///
312    /// # Errors
313    /// Returns [`ExportError::InvalidSize`] for dimensions outside the limits,
314    /// [`ExportError::NoFrame`] before the first frame, or [`ExportError::Unsupported`]
315    /// for invalid host textures.
316    pub fn export_image(
317        &self,
318        ctx: &egui::Context,
319        options: &ImageExportOptions,
320    ) -> Result<ChartImage, ExportError> {
321        self.export_layout_image(ctx, options, |ui, chart| {
322            chart.show(ui);
323        })
324    }
325
326    /// Like [`Self::export_image`] for a layout of library widgets: `layout` draws everything
327    /// inside the image, the toolbars, panels and trading overlays around the chart, and shows the
328    /// chart it is given, a frozen copy of this one, with [`Chart::show`] or
329    /// [`Chart::show_footprint`]. It runs once for each of several layout passes, on a context
330    /// of its own with no pointer, so its widgets show as at rest. The title and timestamp, if
331    /// requested, sit above it.
332    ///
333    /// ```no_run
334    /// # fn capture(chart: &egui_charts::Chart, ctx: &egui::Context) -> Result<(), egui_charts::widget::ExportError> {
335    /// let options = egui_charts::widget::ImageExportOptions::default();
336    /// let image = chart.export_layout_image(ctx, &options, |ui, snapshot| {
337    ///     ui.horizontal(|ui| {
338    ///         ui.label(snapshot.symbol());
339    ///         ui.label("1m");
340    ///     });
341    ///     snapshot.show(ui);
342    /// })?;
343    /// # Ok(())
344    /// # }
345    /// ```
346    pub fn export_layout_image(
347        &self,
348        ctx: &egui::Context,
349        options: &ImageExportOptions,
350        layout: impl FnMut(&mut egui::Ui, &mut Chart),
351    ) -> Result<ChartImage, ExportError> {
352        let (shapes, textures_delta, image_ctx) = self.export_shapes(ctx, options, layout)?;
353        Ok(ChartImage {
354            size: options.size,
355            primitives: image_ctx.tessellate(shapes, 1.0),
356            textures_delta,
357        })
358    }
359
360    /// The shapes of the last pass of the offscreen frame, the textures it sets and the context
361    /// whose fonts laid it out.
362    fn export_shapes(
363        &self,
364        ctx: &egui::Context,
365        options: &ImageExportOptions,
366        mut layout: impl FnMut(&mut egui::Ui, &mut Chart),
367    ) -> Result<
368        (
369            Vec<egui::epaint::ClippedShape>,
370            egui::TexturesDelta,
371            egui::Context,
372        ),
373        ExportError,
374    > {
375        let size = options.size;
376        if size.iter().any(|&side| !(100..=7680).contains(&side))
377            || u64::from(size[0]) * u64::from(size[1]) > 33_177_600
378        {
379            return Err(ExportError::InvalidSize {
380                width: size[0],
381                height: size[1],
382            });
383        }
384        if ctx.cumulative_pass_nr() == 0 {
385            return Err(ExportError::NoFrame);
386        }
387        let mut chart = self.export_snapshot(ctx, options);
388        let theme = chart
389            .theme_override
390            .clone()
391            .unwrap_or_else(|| ctx.active_theme());
392        let image_ctx = egui::Context::default();
393        image_ctx.set_global_style(ctx.global_style());
394        crate::theme::apply_to_egui(&image_ctx, &theme);
395        image_ctx.set_fonts(ctx.fonts(|fonts| fonts.definitions().clone()));
396        let timestamp = options
397            .include_timestamp
398            .then(|| self.export_time(Utc::now()));
399        let title = options.include_title.then(|| self.symbol().to_owned());
400        let input = egui::RawInput {
401            screen_rect: Some(egui::Rect::from_min_size(
402                egui::Pos2::ZERO,
403                egui::vec2(size[0] as f32, size[1] as f32),
404            )),
405            ..Default::default()
406        };
407        let mut textures_delta = egui::TexturesDelta::default();
408        // Resolve pane layout, axis measurement and each pending element study before the final frame.
409        let passes = chart.studies.indicators().len() + 3;
410        let mut shapes = Vec::new();
411        for pass in 0..passes {
412            let output = image_ctx.run_ui(
413                egui::RawInput {
414                    time: Some(pass as f64 * 3600.0),
415                    ..input.clone()
416                },
417                |ui| {
418                    if !options.transparent_background {
419                        ui.painter()
420                            .rect_filled(ui.max_rect(), 0.0, chart.config.background_color);
421                    }
422                    ui.set_min_size(ui.available_size());
423                    if title.is_some() || timestamp.is_some() {
424                        ui.horizontal(|ui| {
425                            if let Some(title) = &title {
426                                ui.label(l10n::text(ui.ctx(), title));
427                            }
428                            if let Some(timestamp) = &timestamp {
429                                ui.label(timestamp);
430                            }
431                        });
432                    }
433                    layout(ui, &mut chart);
434                },
435            );
436            textures_delta.append(output.textures_delta);
437            shapes = output.shapes;
438        }
439        if let Err(error) = add_host_textures(&mut textures_delta, options) {
440            textures_delta.clear();
441            return Err(ExportError::Unsupported(error));
442        }
443        Ok((shapes, textures_delta, image_ctx))
444    }
445
446    /// Renders the image of [`Self::export_image`] on the CPU and encodes it as PNG, so the host
447    /// needs no renderer or GPU readback. Fails with the reason for content it cannot draw, host
448    /// paint callbacks and images of textures the host binds itself, such as a legend badge from a
449    /// user texture, unless [`ImageExportOptions::textures`] holds their pixels, instead of
450    /// leaving it out.
451    ///
452    /// # Errors
453    /// Returns the errors of [`Self::export_image`], [`ExportError::Unsupported`] for content
454    /// that cannot be drawn, or [`ExportError::Encode`] if the PNG encoder fails.
455    #[cfg(feature = "png-export")]
456    #[cfg_attr(docsrs, doc(cfg(feature = "png-export")))]
457    pub fn export_png(
458        &self,
459        ctx: &egui::Context,
460        options: &ImageExportOptions,
461    ) -> Result<Vec<u8>, ExportError> {
462        self.export_layout_png(ctx, options, |ui, chart| {
463            chart.show(ui);
464        })
465    }
466
467    /// Like [`Self::export_png`] for a layout of library widgets, drawn by `layout` as
468    /// [`Self::export_layout_image`] describes.
469    #[cfg(feature = "png-export")]
470    #[cfg_attr(docsrs, doc(cfg(feature = "png-export")))]
471    pub fn export_layout_png(
472        &self,
473        ctx: &egui::Context,
474        options: &ImageExportOptions,
475        layout: impl FnMut(&mut egui::Ui, &mut Chart),
476    ) -> Result<Vec<u8>, ExportError> {
477        let mut image = self.export_layout_image(ctx, options, layout)?;
478        let pixels = super::raster::rasterize(image.size, &image.primitives, &image.textures_delta);
479        image.textures_delta.clear();
480        super::raster::png(image.size, &pixels.map_err(ExportError::Unsupported)?)
481            .map_err(ExportError::Encode)
482    }
483
484    /// Renders the frame of [`Self::export_image`] as an SVG document of the options' size, with
485    /// the same title, timestamp, watermark and background. Text stays text, in the font family
486    /// class of the on-screen font, and the frame's textures, such as legend icons, are embedded
487    /// as PNG with `png-export` enabled, with the host's [`ImageExportOptions::textures`].
488    /// Fails with the reason for content SVG cannot take over unchanged, such as host paint
489    /// callbacks and textures the host binds itself without supplying their pixels, instead of
490    /// leaving it out.
491    ///
492    /// # Errors
493    /// Returns the errors of [`Self::export_image`] or [`ExportError::Unsupported`] for content
494    /// the format cannot take over unchanged, including image textures when `png-export` is off.
495    pub fn export_svg(
496        &self,
497        ctx: &egui::Context,
498        options: &ImageExportOptions,
499    ) -> Result<String, ExportError> {
500        self.export_layout_svg(ctx, options, |ui, chart| {
501            chart.show(ui);
502        })
503    }
504
505    /// Like [`Self::export_svg`] for a layout of library widgets, drawn by `layout` as
506    /// [`Self::export_layout_image`] describes.
507    pub fn export_layout_svg(
508        &self,
509        ctx: &egui::Context,
510        options: &ImageExportOptions,
511        layout: impl FnMut(&mut egui::Ui, &mut Chart),
512    ) -> Result<String, ExportError> {
513        let (shapes, mut textures_delta, _) = self.export_shapes(ctx, options, layout)?;
514        let svg = super::svg::svg(options.size, &shapes, &textures_delta);
515        textures_delta.clear();
516        // shortcut: an encoder failure while embedding a texture arrives as Unsupported; split it when svg.rs returns ExportError.
517        svg.map_err(ExportError::Unsupported)
518    }
519
520    /// A frozen chart for a multi-chart image layout, with the same background, title,
521    /// watermark and crosshair choices as [`Self::export_image`]. Show it inside the callback
522    /// of [`Self::export_layout_image`], `Self::export_layout_png` (with `png-export`) or
523    /// [`Self::export_layout_svg`].
524    /// No storage or live subscriptions are attached; the original chart stays unchanged.
525    pub fn export_snapshot(&self, ctx: &egui::Context, options: &ImageExportOptions) -> Self {
526        let mut chart = self.frozen_export_snapshot();
527        chart.config.skip_background = options.transparent_background;
528        chart.config.show_symbol_labels &= options.include_title;
529        if !options.include_watermark {
530            chart.config.show_watermark = false;
531            chart.watermark = None;
532        }
533        let mut theme = self
534            .theme_override
535            .clone()
536            .unwrap_or_else(|| ctx.active_theme());
537        if options.transparent_background {
538            theme.chart.bg = egui::Color32::TRANSPARENT;
539        }
540        chart.theme_override = Some(theme);
541        if let Some((time, price)) = self.last_crosshair.filter(|_| options.include_crosshair) {
542            match price {
543                Some(price) => chart.set_crosshair_position(Some((time, price))),
544                None => {
545                    chart.set_synced_crosshair_time(Some(time));
546                    chart.chart_options.crosshair.horz_line_visible = false;
547                    chart.chart_options.crosshair.horz_label_visible = false;
548                }
549            }
550        }
551        chart
552    }
553
554    fn frozen_export_snapshot(&self) -> Self {
555        let mut chart = Chart::with_config(Default::default(), self.config.clone());
556        chart.state = self.state.clone();
557        chart.chart_options = self.chart_options.clone();
558        chart
559            .chart_options
560            .time_scale
561            .lock_visible_time_range_on_resize = true;
562        chart.prev_width = self.prev_width;
563        chart.chart_options.time_scale.timezone = self.display_timezone();
564        chart.chart_type = self.chart_type;
565        chart.chart_type_params = self.chart_type_params.clone();
566        #[cfg(feature = "studies")]
567        {
568            chart.trade_profiles = self.trade_profiles.clone();
569        }
570        chart.pending_box_sizes = self.pending_box_sizes;
571        chart.main_series_visible = self.main_series_visible;
572        chart.main_series_present = self.main_series_present;
573        chart.main_series_scale = self.main_series_scale;
574        chart.price_series = self
575            .price_series
576            .iter()
577            .map(|series| super::series::PriceSeries {
578                id: series.id,
579                options: series.options.clone(),
580                visible: series.visible,
581                fed: series.fed,
582                bars: series.bars.clone(),
583                revision: series.revision,
584                heikin_ashi: Default::default(),
585                feed: series.feed.clone(),
586                markers: series.markers.clone(),
587                marker_options: series.marker_options,
588                change_marks: None,
589                lines: series.lines.clone(),
590            })
591            .collect();
592        chart.series_scales = self.series_scales.clone();
593        chart.symbol = self.symbol.clone();
594        chart.timeframe = self.timeframe;
595        chart.feed_link = self.feed_link.as_ref().map(super::feed::FeedLink::frozen);
596        chart.legend_instrument = self.instrument().cloned();
597        chart.legend_badge = self.legend_badge.clone();
598        chart.watermark = self.watermark.clone();
599        chart.legend_statuses = self.legend_statuses.clone();
600        chart.quote = self.quote.clone();
601        chart.session_quote = self.session_quote.clone();
602        chart.session_provider = self.session_provider.clone();
603        chart.horizontal_scale = self.horizontal_scale.clone();
604        chart.locale = self.locale.clone();
605        chart.date_format = self.date_format.clone();
606        chart.time_formatter = self.time_formatter.clone();
607        chart.price_formatter = self.price_formatter.clone();
608        chart.percentage_formatter = self.percentage_formatter.clone();
609        chart.volume_formatter = self.volume_formatter.clone();
610        chart.series_formatters = self.series_formatters.clone();
611        chart.extended_hours = self.extended_hours;
612        chart.theme_override = self.theme_override.clone();
613        chart.inherited_theme = self.inherited_theme.clone();
614        #[cfg(feature = "drawings")]
615        {
616            chart.drawings.registry = self.drawings.registry.clone();
617            chart.drawings.drawings = self.drawings.drawings.clone();
618            chart.drawings.curr_timeframe = self.drawings.curr_timeframe.clone();
619        }
620        chart.marks = self.marks.clone();
621        chart.marker_options = self.marker_options;
622        chart.price_lines = self.price_lines.clone();
623        chart.events = self.events.clone();
624        chart.time_scale_events = self.time_scale_events.clone();
625        chart.event_visibility = self.event_visibility.clone();
626        // Freeze loaded source marks without connecting the offscreen chart to the host.
627        if let Some(source) = &self.mark_source {
628            chart.marks = self
629                .marks
630                .iter()
631                .chain(&source.page.bar_marks)
632                .cloned()
633                .collect::<Vec<_>>()
634                .into();
635            chart.events = self
636                .events
637                .iter()
638                .chain(&source.page.time_scale_marks)
639                .cloned()
640                .collect::<Vec<_>>()
641                .into();
642            chart.time_scale_events = self
643                .time_scale_events
644                .iter()
645                .chain(&source.page.events)
646                .cloned()
647                .collect::<Vec<_>>()
648                .into();
649            chart.frozen_source_events = source.page.events.len();
650        }
651        chart.desired_visible_bars = self.desired_visible_bars;
652        chart.apply_visible_bars_once = self.apply_visible_bars_once;
653        chart.start_idx = self.start_idx;
654        for (_, study) in self.indicators() {
655            chart.add_indicator(study.clone_box());
656        }
657        chart.study_ids = self.study_ids.clone();
658        chart.study_inputs = self.study_inputs.clone();
659        chart.study_autoscale = self.study_autoscale.clone();
660        chart.next_series = self.next_series;
661        // Copied after the studies, replacing the panes that adding them created.
662        chart.pane_layout = self.pane_layout.clone();
663        chart.pane_options = self.pane_options;
664        #[cfg(feature = "studies")]
665        {
666            chart.volume_study = self.volume_study;
667            chart.volume_setting = self.volume_setting;
668        }
669        chart.primitives = self.primitives.clone();
670        chart.next_primitive = self.next_primitive;
671        chart.settle_primitives();
672        chart.study_data = self.study_data.snapshot();
673        chart.recalculate_studies();
674        chart
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    #[cfg(feature = "csv")]
682    use crate::model::Instrument;
683    #[cfg(any(feature = "csv", feature = "drawings"))]
684    use crate::model::{Bar, BarData, Timeframe};
685    #[cfg(any(feature = "csv", feature = "drawings"))]
686    use crate::studies::CustomIndicator;
687    #[cfg(feature = "csv")]
688    use crate::studies::IndicatorValue;
689    use crate::widget::SeriesId;
690
691    #[test]
692    #[cfg(feature = "csv")]
693    fn csv_aligns_fresh_visible_plots_and_formats_chart_times_and_prices() {
694        let times: Vec<_> = [
695            "2024-11-03T05:30:00.125Z",
696            "2024-11-03T06:30:00.125Z",
697            "2024-11-03T07:30:00.125Z",
698        ]
699        .map(|time| DateTime::parse_from_rfc3339(time).unwrap().to_utc())
700        .into();
701        let zones = [
702            (
703                crate::scales::TimezoneMode::Utc,
704                [
705                    "2024-11-03T05:30:00.125+00:00",
706                    "2024-11-03T06:30:00.125+00:00",
707                ],
708            ),
709            #[cfg(feature = "timezones")]
710            (
711                crate::scales::TimezoneMode::Timezone("America/New_York".parse().unwrap()),
712                [
713                    "2024-11-03T01:30:00.125-04:00",
714                    "2024-11-03T01:30:00.125-05:00",
715                ],
716            ),
717            #[cfg(feature = "timezones")]
718            (
719                crate::scales::TimezoneMode::Exchange(crate::model::TimezoneId::utc()),
720                [
721                    "2024-11-03T01:30:00.125-04:00",
722                    "2024-11-03T01:30:00.125-05:00",
723                ],
724            ),
725        ];
726        for (zone, expected_times) in zones {
727            let bars: Vec<_> = times
728                .iter()
729                .zip([10.0, 14.0, 18.0])
730                .map(|(&time, close)| {
731                    Bar::new(time, close, close + 1.0, close - 1.0, close, 1234.5)
732                })
733                .collect();
734            let mut chart = Chart::new(BarData::from_bars(bars.clone()));
735            chart.set_timeframe(Timeframe::Hour1);
736            chart.chart_options.time_scale.timezone = zone;
737            let mut instrument = Instrument::new("TEST");
738            instrument.price_decimals = Some(2);
739            instrument.time_zone = crate::model::TimezoneId::named("America/New_York");
740            chart.set_instrument(Some(instrument.clone()));
741            let study = chart.add_indicator(Box::new(
742                CustomIndicator::new("Average, pair", |bars| {
743                    bars.iter()
744                        .enumerate()
745                        .map(|(index, bar)| {
746                            let mean = if index == 0 {
747                                f64::NAN
748                            } else {
749                                (bars[index - 1].close + bar.close) / 2.0
750                            };
751                            IndicatorValue::Multiple(vec![mean, mean, bar.close * 2.0])
752                        })
753                        .collect()
754                })
755                .with_line_count(3)
756                .with_line_names(vec!["mean".into(), "shifted".into(), "hidden".into()])
757                .with_offsets(vec![0, -1, 0]),
758            ));
759            let settings_study = chart.indicator_mut(study).unwrap();
760            let mut settings = settings_study.settings();
761            settings.plots[2].visible = false;
762            settings_study.apply_settings(&[], settings).unwrap();
763            let hidden = chart
764                .add_indicator(Box::new(CustomIndicator::new("Hidden study", |bars| {
765                    vec![IndicatorValue::Single(999.0); bars.len()]
766                })));
767            chart.set_indicator_visible(hidden, false);
768            chart.add_indicator(Box::new(
769                CustomIndicator::new("Fixed", |bars| {
770                    vec![IndicatorValue::Single(1.23456); bars.len()]
771                })
772                .with_value_format(StudyFormat::Fixed(4)),
773            ));
774            let override_id = chart
775                .add_indicator(Box::new(CustomIndicator::new("Override", |bars| {
776                    vec![IndicatorValue::Single(9.876); bars.len()]
777                })));
778            let indicator = chart.indicator_mut(override_id).unwrap();
779            let mut settings = indicator.settings();
780            settings.precision = Some(1);
781            indicator.apply_settings(&[], settings).unwrap();
782            instrument.price_decimals = Some(3);
783            chart.set_instrument(Some(instrument));
784            let mut last = bars[2].clone();
785            last.close = 22.0;
786            last.high = 23.0;
787            chart.update(SeriesId::MAIN, last);
788            let csv = chart.export_csv();
789            fn assert_send_sync<T: Send + Sync>(_: &T) {}
790            assert_send_sync(&csv);
791            let csv = csv.unwrap();
792            let mut reader = csv::Reader::from_reader(csv.as_bytes());
793            assert_eq!(
794                reader.headers().unwrap().iter().collect::<Vec<_>>(),
795                [
796                    "time",
797                    "open",
798                    "high",
799                    "low",
800                    "close",
801                    "volume",
802                    "Average, pair: mean",
803                    "Average, pair: shifted",
804                    "Fixed: Fixed",
805                    "Override: Override"
806                ]
807            );
808            let rows = reader.records().collect::<Result<Vec<_>, _>>().unwrap();
809            assert_eq!(rows.len(), 3);
810            assert_eq!(&rows[0][0], expected_times[0]);
811            assert_eq!(&rows[1][0], expected_times[1]);
812            assert_eq!(&rows[0][1], "10.000");
813            assert_eq!(&rows[2][4], "22.000");
814            assert_eq!(&rows[0][5], "1234.5");
815            // Two-bar means are 12 and (14 + 22) / 2 = 18; displacement -1 moves them left.
816            assert_eq!(
817                rows.iter().map(|row| &row[6]).collect::<Vec<_>>(),
818                ["", "12.000", "18.000"]
819            );
820            assert_eq!(
821                rows.iter().map(|row| &row[7]).collect::<Vec<_>>(),
822                ["12.000", "18.000", ""]
823            );
824            assert_eq!(&rows[0][8], "1.2346");
825            assert_eq!(&rows[0][9], "9.9");
826            // Export must not refresh or mutate the chart's scheduled study results.
827            assert!(chart.pending_from[0].is_some());
828        }
829        let empty = Chart::new(BarData::new()).export_csv().unwrap();
830        assert_eq!(empty, "time,open,high,low,close,volume\n");
831    }
832
833    /// An export's studies read the events the chart holds, as on screen, not the mark source page
834    /// frozen into its event badges.
835    #[cfg(feature = "studies")]
836    #[test]
837    fn exported_studies_do_not_restart_at_a_frozen_mark_source_page() {
838        use crate::data::Reply;
839        use crate::model::{EventKind, TimeScaleEvent};
840        use crate::services::{Marks, MarksPage, MarksRequest};
841        use crate::studies::{VolumeWeightedAvgPrice, VwapAnchor};
842        use crate::widget::test_frames;
843        use std::sync::Arc;
844
845        struct Source(MarksPage);
846        impl Marks for Source {
847            fn marks(&self, _: MarksRequest, reply: Reply<MarksPage>) {
848                reply.send(Ok(self.0.clone()));
849            }
850        }
851        let ctx = egui::Context::default();
852        let mut chart = test_frames::chart(&[1.0, 5.0, 9.0], &[2.0, 6.0, 10.0]);
853        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
854        let time = chart.state.data().bars[70].time;
855        let id = chart.add_indicator(Box::new(
856            VolumeWeightedAvgPrice::new().with_anchor(VwapAnchor::Earnings),
857        ));
858        chart.set_mark_source(Some(Arc::new(Source(MarksPage {
859            bar_marks: vec![],
860            time_scale_marks: vec![],
861            events: vec![TimeScaleEvent {
862                id: "earnings".into(),
863                time,
864                kind: EventKind::Earnings,
865                title: String::new(),
866                details: String::new(),
867            }],
868        }))));
869        chart.pump_marks(&ctx);
870
871        let mut snapshot = chart.frozen_export_snapshot();
872        assert_eq!(snapshot.time_scale_events.len(), 1);
873        test_frames::frame(&ctx, &mut snapshot, 0.1, vec![]);
874        let value = |chart: &Chart| {
875            let (_, study) = chart.indicators().find(|(study, _)| *study == id).unwrap();
876            study.values().value(0, 80)
877        };
878        assert!(value(&chart).is_some());
879        assert_eq!(value(&snapshot), value(&chart));
880    }
881
882    #[test]
883    fn images_retain_loaded_marks_and_event_visibility_without_fetching() {
884        use crate::data::Reply;
885        use crate::model::{EventKind, EventVisibility, Marker, TimeScaleEvent};
886        use crate::services::{Marks, MarksPage, MarksRequest};
887        use crate::widget::test_frames;
888        use std::sync::{
889            Arc,
890            atomic::{AtomicUsize, Ordering},
891        };
892
893        struct Source {
894            page: MarksPage,
895            calls: AtomicUsize,
896        }
897        impl Marks for Source {
898            fn marks(&self, _: MarksRequest, reply: Reply<MarksPage>) {
899                self.calls.fetch_add(1, Ordering::Relaxed);
900                reply.send(Ok(self.page.clone()));
901            }
902        }
903        let ctx = egui::Context::default();
904        let mut chart = test_frames::chart(&[95.0], &[105.0]);
905        test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
906        let time = chart.state.shown()[70].time;
907        let event = TimeScaleEvent {
908            id: "earnings".into(),
909            time,
910            kind: EventKind::Earnings,
911            title: "Host earnings".into(),
912            details: String::new(),
913        };
914        chart.set_markers(SeriesId::MAIN, vec![Marker::new(time).with_text("Manual")]);
915        chart.set_events(vec![Marker::event(time, "Manual axis")]);
916        chart.set_time_scale_events(vec![event.clone()]);
917        chart.set_event_visibility(EventVisibility {
918            show_earnings: true,
919            show_news: false,
920            ..Default::default()
921        });
922        let source = Arc::new(Source {
923            page: MarksPage {
924                bar_marks: vec![Marker::new(time).with_text("Source bar")],
925                time_scale_marks: vec![Marker::event(time, "Source axis")],
926                events: vec![TimeScaleEvent {
927                    kind: EventKind::News,
928                    ..event
929                }],
930            },
931            calls: AtomicUsize::new(0),
932        });
933        chart.set_mark_source(Some(source.clone()));
934        chart.pump_marks(&ctx);
935        assert_eq!(source.calls.load(Ordering::Relaxed), 1);
936
937        let mut snapshot = chart.frozen_export_snapshot();
938        assert!(snapshot.mark_source.is_none());
939        assert_eq!(snapshot.event_visibility(), chart.event_visibility());
940        assert_eq!(snapshot.time_scale_events.len(), 2);
941        let shapes = test_frames::shapes(&ctx, &mut snapshot, 0.1, vec![]);
942        for label in ["Manual", "Manual axis", "Source bar", "Source axis", "E"] {
943            assert!(
944                shapes.iter().any(|shape| matches!(shape,
945                egui::Shape::Text(text) if text.galley.text() == label)),
946                "missing {label}"
947            );
948        }
949        assert!(!shapes.iter().any(|shape| matches!(shape,
950            egui::Shape::Text(text) if text.galley.text() == "N")));
951        let mut image = chart
952            .export_image(&ctx, &ImageExportOptions::default())
953            .unwrap();
954        image.textures_delta.clear();
955        assert_eq!(source.calls.load(Ordering::Relaxed), 1);
956        assert_eq!(chart.marks.len(), 1);
957        assert_eq!(chart.events.len(), 1);
958        assert_eq!(chart.time_scale_events.len(), 1);
959    }
960
961    #[test]
962    #[cfg(feature = "drawings")]
963    fn images_apply_options_and_drawing_visibility_without_changing_the_live_view() {
964        let ctx = egui::Context::default();
965        let mut chart = Chart::new(BarData::new());
966        chart.symbol = "EXPORT TEST".into();
967        chart.add_indicator(Box::new(
968            CustomIndicator::new("Pane", |_| vec![]).with_overlay(false),
969        ));
970        ctx.run_ui(Default::default(), |_| {})
971            .drop_without_applying_deltas();
972        let before = chart.get_time_scale_state();
973        let vertices = |image: &ChartImage| {
974            image
975                .primitives
976                .iter()
977                .map(|primitive| match &primitive.primitive {
978                    egui::epaint::Primitive::Mesh(mesh) => mesh.vertices.len(),
979                    _ => 0,
980                })
981                .sum::<usize>()
982        };
983        let base = ImageExportOptions {
984            size: [480, 320],
985            include_title: false,
986            include_timestamp: false,
987            include_watermark: true,
988            transparent_background: false,
989            include_crosshair: false,
990            ..Default::default()
991        };
992        let mut opaque = chart.export_image(&ctx, &base).unwrap();
993        let has_textures = !opaque.textures_delta.set.is_empty();
994        opaque.textures_delta.clear();
995        assert_eq!(opaque.size, [480, 320]);
996        assert!(has_textures);
997        for (title, timestamp) in [(true, false), (false, true)] {
998            let mut image = chart
999                .export_image(
1000                    &ctx,
1001                    &ImageExportOptions {
1002                        include_title: title,
1003                        include_timestamp: timestamp,
1004                        ..base.clone()
1005                    },
1006                )
1007                .unwrap();
1008            image.textures_delta.clear();
1009            assert!(
1010                vertices(&image) > vertices(&opaque),
1011                "missing requested header"
1012            );
1013        }
1014        let mut transparent = chart
1015            .export_image(
1016                &ctx,
1017                &ImageExportOptions {
1018                    transparent_background: true,
1019                    ..base.clone()
1020                },
1021            )
1022            .unwrap();
1023        transparent.textures_delta.clear();
1024        assert!(
1025            vertices(&transparent) < vertices(&opaque),
1026            "backgrounds were retained"
1027        );
1028        assert_eq!(chart.get_time_scale_state(), before);
1029        assert!(
1030            chart.last_rendered_price_rect.is_negative(),
1031            "live geometry changed"
1032        );
1033        use crate::drawings::{ChartPoint, Drawing, DrawingToolType, TimeframeVisibility};
1034        let mut drawing = Drawing::new(1, DrawingToolType::HorizontalLine);
1035        drawing.completed = true;
1036        drawing.chart_points = vec![ChartPoint::new(0.0, 10.0)];
1037        drawing.color = [71, 89, 123, 255];
1038        drawing.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
1039        chart.drawings.drawings.push(drawing);
1040        chart.set_data(
1041            SeriesId::MAIN,
1042            vec![Bar::new(
1043                DateTime::from_timestamp(0, 0).unwrap(),
1044                10.0,
1045                11.0,
1046                9.0,
1047                10.0,
1048                1.0,
1049            )],
1050        );
1051        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1052            chart.theme_override = Some(theme);
1053            for (timeframe, visible) in [(Timeframe::Min1, false), (Timeframe::Hour1, true)] {
1054                chart.set_timeframe(timeframe);
1055                let mut image = chart.export_image(&ctx, &base).unwrap();
1056                image.textures_delta.clear();
1057                assert_eq!(image.primitives.iter().any(|primitive| {
1058                    matches!(&primitive.primitive, egui::epaint::Primitive::Mesh(mesh)
1059                        if mesh.vertices.iter().any(|vertex| vertex.color == egui::Color32::from_rgb(71, 89, 123)))
1060                }), visible, "drawing visibility at {timeframe:?}");
1061            }
1062        }
1063        for [width, height] in [[0, 320], [480, u32::MAX], [7680, 7680]] {
1064            assert_eq!(
1065                chart
1066                    .export_image(
1067                        &ctx,
1068                        &ImageExportOptions {
1069                            size: [width, height],
1070                            ..base.clone()
1071                        }
1072                    )
1073                    .err(),
1074                Some(ExportError::InvalidSize { width, height })
1075            );
1076        }
1077    }
1078
1079    #[test]
1080    fn table_text_holds_the_chosen_columns_with_their_missing_cells() {
1081        let table = DataTable {
1082            headers: vec!["time".into(), "close".into(), "SMA: 5".into()],
1083            rows: vec![
1084                vec!["t0".into(), "1.50".into(), String::new()],
1085                vec!["t1".into(), "2.50".into(), "2.00".into()],
1086            ],
1087        };
1088        #[cfg(feature = "csv")]
1089        assert_eq!(table.csv(&[0, 2]).unwrap(), "time,SMA: 5\nt0,\nt1,2.00\n");
1090        assert_eq!(table.tsv(&[2, 1]), "SMA: 5\tclose\n\t1.50\n2.00\t2.50");
1091    }
1092
1093    #[test]
1094    #[cfg(feature = "png-export")]
1095    fn svg_images_apply_the_options_and_report_what_they_cannot_hold() {
1096        use crate::widget::test_frames::{chart, frame};
1097        let ctx = egui::Context::default();
1098        let mut chart = chart(&[10.0, 12.0], &[14.0, 13.0]);
1099        chart.symbol = "EXPORT TEST".into();
1100        chart.config.show_watermark = true;
1101        chart.config.watermark_text = Some("WATERMARK".into());
1102        frame(&ctx, &mut chart, 0.0, vec![]);
1103        let base = ImageExportOptions {
1104            size: [480, 320],
1105            include_title: false,
1106            include_timestamp: false,
1107            include_watermark: true,
1108            transparent_background: false,
1109            include_crosshair: false,
1110            ..Default::default()
1111        };
1112        let svg = |options: &ImageExportOptions| chart.export_svg(&ctx, options).unwrap();
1113        let opaque = svg(&base);
1114        assert!(opaque.starts_with("<svg") && opaque.contains(r#"width="480" height="320""#));
1115        assert!(opaque.contains("WATERMARK"), "watermark was dropped");
1116        #[cfg(feature = "icons")]
1117        assert!(
1118            opaque.contains("data:image/png;base64,"),
1119            "frame textures were dropped"
1120        );
1121        assert!(!opaque.contains("EXPORT TEST"));
1122        let no_watermark = svg(&ImageExportOptions {
1123            include_watermark: false,
1124            include_title: true,
1125            ..base.clone()
1126        });
1127        assert!(!no_watermark.contains("WATERMARK") && no_watermark.contains("EXPORT TEST"));
1128        let [r, g, b, _] = chart.config.background_color.to_srgba_unmultiplied();
1129        let bg = format!(r#"fill="rgb({r},{g},{b})""#);
1130        assert!(opaque.contains(&bg));
1131        let transparent = svg(&ImageExportOptions {
1132            transparent_background: true,
1133            ..base.clone()
1134        });
1135        assert!(!transparent.contains(&bg), "background was retained");
1136        let callback = |ui: &mut egui::Ui, _: &mut Chart| {
1137            ui.painter().add(egui::PaintCallback {
1138                rect: ui.max_rect(),
1139                callback: std::sync::Arc::new(()),
1140            });
1141        };
1142        let host_texture = |ui: &mut egui::Ui, _: &mut Chart| {
1143            ui.painter().image(
1144                egui::TextureId::User(7),
1145                ui.max_rect(),
1146                egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1147                egui::Color32::WHITE,
1148            );
1149        };
1150        for (error, reason) in [
1151            (chart.export_layout_svg(&ctx, &base, callback), "callbacks"),
1152            (
1153                chart
1154                    .export_layout_png(&ctx, &base, callback)
1155                    .map(|_| String::new()),
1156                "callbacks",
1157            ),
1158            (
1159                chart.export_layout_svg(&ctx, &base, host_texture),
1160                "host textures",
1161            ),
1162            (
1163                chart
1164                    .export_layout_png(&ctx, &base, host_texture)
1165                    .map(|_| String::new()),
1166                "host textures",
1167            ),
1168        ] {
1169            let Err(ExportError::Unsupported(error)) = error else {
1170                panic!("{reason}")
1171            };
1172            assert!(error.contains(reason), "{error}");
1173        }
1174        let red = egui::Color32::from_rgb(200, 30, 30);
1175        let green = egui::Color32::from_rgb(30, 200, 30);
1176        let high_id = egui::TextureId::User(7 | 1 << 63);
1177        let supplied = ImageExportOptions {
1178            textures: HashMap::from([
1179                (
1180                    egui::TextureId::User(7),
1181                    Arc::new(egui::ColorImage::new([2, 1], vec![red, green])),
1182                ),
1183                (high_id, Arc::new(egui::ColorImage::filled([1, 1], green))),
1184            ]),
1185            ..base.clone()
1186        };
1187        let additive = ImageExportOptions {
1188            textures: HashMap::from([(
1189                egui::TextureId::User(7),
1190                Arc::new(egui::ColorImage::filled(
1191                    [1, 1],
1192                    egui::Color32::from_rgb_additive(200, 0, 0),
1193                )),
1194            )]),
1195            ..base.clone()
1196        };
1197        let error = chart
1198            .export_layout_svg(&ctx, &additive, host_texture)
1199            .unwrap_err();
1200        let ExportError::Unsupported(error) = error else {
1201            panic!("expected an unsupported texture")
1202        };
1203        assert!(error.contains("Additive texture User(7)"), "{error}");
1204        let layout = |ui: &mut egui::Ui, chart: &mut Chart| {
1205            let (rect, _) = ui.allocate_exact_size(egui::vec2(128.0, 32.0), egui::Sense::hover());
1206            ui.painter().image(
1207                egui::TextureId::User(7),
1208                rect,
1209                egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1210                egui::Color32::WHITE,
1211            );
1212            ui.label("LAYOUT TOOLBAR");
1213            ui.painter().add(
1214                egui::epaint::TextShape::new(
1215                    egui::pos2(300.0, 20.0),
1216                    ui.painter().layout_no_wrap(
1217                        "TURNED LABEL".into(),
1218                        egui::FontId::proportional(12.0),
1219                        egui::Color32::WHITE,
1220                    ),
1221                    egui::Color32::WHITE,
1222                )
1223                .with_angle(-std::f32::consts::FRAC_PI_2),
1224            );
1225            for (id, x) in [(high_id, 160.0), (egui::TextureId::User(7), 208.0)] {
1226                ui.painter().image(
1227                    id,
1228                    egui::Rect::from_min_size(egui::pos2(x, 8.0), egui::vec2(32.0, 32.0)),
1229                    egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1230                    egui::Color32::from_rgba_premultiplied(128, 64, 32, 128),
1231                );
1232            }
1233            chart.show(ui);
1234        };
1235        let svg = chart.export_layout_svg(&ctx, &supplied, layout).unwrap();
1236        assert!(svg.contains("LAYOUT TOOLBAR") && svg.contains("WATERMARK"));
1237        // egui's clockwise -90 degrees is SVG's clockwise rotate(-90), about the text's position.
1238        let (_, group) = svg.split_once(r#"<g transform="rotate("#).unwrap();
1239        let (rotation, group) = group.split_once(r#")">"#).unwrap();
1240        let rotation: Vec<f32> = rotation.split(' ').map(|n| n.parse().unwrap()).collect();
1241        for (value, expected) in rotation.iter().zip([-90.0, 300.0, 20.0]) {
1242            assert!((value - expected).abs() < 1e-3, "{rotation:?}");
1243        }
1244        assert!(group.split_once("</g>").unwrap().0.contains("TURNED LABEL"));
1245        assert!(svg.contains(r#"width="2" height="1""#));
1246        for id in [7, 7 | 1u64 << 63] {
1247            assert!(svg.contains(&format!(r#"id="tu{id}_"#)));
1248        }
1249        #[cfg(feature = "icons")]
1250        {
1251            let tree = resvg::usvg::Tree::from_str(&svg, &Default::default()).unwrap();
1252            let mut groups = vec![tree.root()];
1253            let mut colors = Vec::new();
1254            while let Some(group) = groups.pop() {
1255                for node in group.children() {
1256                    match node {
1257                        resvg::usvg::Node::Group(group) => groups.push(group),
1258                        resvg::usvg::Node::Image(image) => {
1259                            let resvg::usvg::ImageKind::PNG(data) = image.kind() else {
1260                                panic!("expected an embedded PNG");
1261                            };
1262                            let mut reader =
1263                                png::Decoder::new(data.as_slice()).read_info().unwrap();
1264                            let mut pixels = vec![0; reader.output_buffer_size()];
1265                            reader.next_frame(&mut pixels).unwrap();
1266                            let [r, g, b, a] = pixels[..4].try_into().unwrap();
1267                            colors
1268                                .push(egui::Color32::from_rgba_unmultiplied(r, g, b, a).to_array());
1269                        }
1270                        _ => {}
1271                    }
1272                }
1273            }
1274            // Rounded products of source and tint channels / 255, with alpha 255 * 128 / 255.
1275            for expected in [[15, 50, 4, 128], [100, 8, 4, 128], red.to_array()] {
1276                assert!(
1277                    colors.contains(&expected),
1278                    "missing {expected:?} in {colors:?}"
1279                );
1280            }
1281        }
1282        let png = chart.export_layout_png(&ctx, &supplied, layout).unwrap();
1283        let mut reader = png::Decoder::new(png.as_slice()).read_info().unwrap();
1284        let mut pixels = vec![0; reader.output_buffer_size()];
1285        reader.next_frame(&mut pixels).unwrap();
1286        // Both samples lie in the edge-clamped halves of the two-texel image.
1287        assert_eq!(pixels[(20 * 480 + 20) * 4..][..4], red.to_array());
1288        assert_eq!(pixels[(20 * 480 + 120) * 4..][..4], green.to_array());
1289        for (id, size, pixels, reason) in [
1290            (
1291                egui::TextureId::User(7),
1292                [0, 1],
1293                vec![],
1294                "invalid dimensions",
1295            ),
1296            (
1297                egui::TextureId::User(7),
1298                [2, 1],
1299                vec![red],
1300                "invalid dimensions",
1301            ),
1302            (egui::TextureId::Managed(0), [1, 1], vec![red], "conflicts"),
1303        ] {
1304            let options = ImageExportOptions {
1305                textures: HashMap::from([(
1306                    id,
1307                    Arc::new(egui::ColorImage {
1308                        size,
1309                        source_size: egui::vec2(size[0] as f32, size[1] as f32),
1310                        pixels,
1311                    }),
1312                )]),
1313                ..base.clone()
1314            };
1315            for error in [
1316                chart.export_svg(&ctx, &options).unwrap_err(),
1317                chart.export_png(&ctx, &options).unwrap_err(),
1318            ] {
1319                let ExportError::Unsupported(error) = error else {
1320                    panic!("{reason}")
1321                };
1322                assert!(
1323                    error.contains(reason) && error.contains(&format!("{id:?}")),
1324                    "{error}"
1325                );
1326            }
1327        }
1328    }
1329
1330    #[test]
1331    #[cfg(not(feature = "png-export"))]
1332    fn svg_exports_vectors_and_refuses_textures_without_png_export() {
1333        use crate::widget::test_frames::{chart, frame};
1334        let ctx = egui::Context::default();
1335        let mut chart = chart(&[10.0, 12.0], &[14.0, 13.0]);
1336        frame(&ctx, &mut chart, 0.0, vec![]);
1337        let options = ImageExportOptions {
1338            size: [480, 320],
1339            include_timestamp: false,
1340            ..Default::default()
1341        };
1342        assert!(
1343            chart
1344                .export_layout_svg(&ctx, &options, |ui, _| {
1345                    ui.label("Vector text");
1346                })
1347                .unwrap()
1348                .contains("Vector text")
1349        );
1350        let id = egui::TextureId::User(7);
1351        let options = ImageExportOptions {
1352            textures: HashMap::from([(
1353                id,
1354                Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::RED)),
1355            )]),
1356            ..options
1357        };
1358        assert_eq!(
1359            chart.export_layout_svg(&ctx, &options, |ui, _| {
1360                ui.image((id, egui::vec2(32.0, 32.0)));
1361            }),
1362            Err(ExportError::Unsupported(
1363                "SVG image texture User(7) requires the png-export feature".into()
1364            ))
1365        );
1366    }
1367
1368    #[test]
1369    #[cfg(feature = "png-export")]
1370    fn png_images_rasterise_the_chart_with_the_last_crosshair_on_request() {
1371        use crate::widget::test_frames::{chart, frame};
1372        use egui::{Event, Pos2};
1373        let ctx = egui::Context::default();
1374        let mut chart = chart(&[100.0], &[110.0]);
1375        frame(
1376            &ctx,
1377            &mut chart,
1378            0.0,
1379            vec![Event::PointerMoved(Pos2::new(400.0, 200.0))],
1380        );
1381        frame(&ctx, &mut chart, 0.1, vec![Event::PointerGone]);
1382        let decode = |chart: &Chart, include_crosshair| {
1383            let png = chart
1384                .export_png(
1385                    &ctx,
1386                    &ImageExportOptions {
1387                        size: [480, 320],
1388                        include_title: false,
1389                        include_timestamp: false,
1390                        include_crosshair,
1391                        ..Default::default()
1392                    },
1393                )
1394                .unwrap();
1395            let mut reader = png::Decoder::new(png.as_slice()).read_info().unwrap();
1396            let mut pixels = vec![0; reader.output_buffer_size()];
1397            let info = reader.next_frame(&mut pixels).unwrap();
1398            assert_eq!(
1399                (info.width, info.height, info.color_type),
1400                (480, 320, png::ColorType::Rgba)
1401            );
1402            pixels.as_chunks::<4>().0.to_vec()
1403        };
1404        let (plain, crosshair) = (decode(&chart, false), decode(&chart, true));
1405        // Candle bodies keep their exact colour through rasterisation and encoding.
1406        let bullish = chart.config().bullish_color.to_srgba_unmultiplied();
1407        assert!(plain.iter().filter(|pixel| **pixel == bullish).count() > 1000);
1408        // The crosshair where the pointer left adds a vertical and a horizontal line.
1409        let changed = |x: usize, y: usize| plain[y * 480 + x] != crosshair[y * 480 + x];
1410        let column = (0..480).map(|x| (0..320).filter(|&y| changed(x, y)).count());
1411        let row = (0..320).map(|y| (0..480).filter(|&x| changed(x, y)).count());
1412        assert!(column.max() > Some(60) && row.max() > Some(100));
1413
1414        chart.chart_options.crosshair.vert_label_background_color = egui::Color32::RED;
1415        chart.chart_options.crosshair.horz_label_background_color = egui::Color32::BLUE;
1416        let (time, _) = chart.last_crosshair.unwrap();
1417        for price in [Some(105.0), None] {
1418            chart.last_crosshair = Some((time, price));
1419            let crosshair = decode(&chart, true);
1420            assert!(crosshair.contains(&[255, 0, 0, 255]));
1421            assert_eq!(crosshair.contains(&[0, 0, 255, 255]), price.is_some());
1422        }
1423    }
1424}