Skip to main content

runmat_plot/export/
image.rs

1//! Image export (PNG and raw RGBA).
2//!
3//! This module renders with the native GPU surface path first and falls back to
4//! a CPU raster path when headless GPU initialization is unavailable or the
5//! platform graphics stack panics during setup.
6
7use crate::core::Camera;
8use crate::plots::Figure;
9use crate::styling::PlotThemeConfig;
10use std::io::Cursor;
11use std::path::Path;
12
13/// High-fidelity image exporter using the interactive render path.
14pub struct ImageExporter {
15    /// Export settings
16    settings: ImageExportSettings,
17    /// Optional theme override for export composition.
18    theme: Option<PlotThemeConfig>,
19    /// Optional export textmark.
20    textmark: Option<String>,
21}
22
23/// Image export configuration
24#[derive(Debug, Clone)]
25pub struct ImageExportSettings {
26    /// Output width in pixels
27    pub width: u32,
28    /// Output height in pixels
29    pub height: u32,
30    /// Samples for anti-aliasing (reserved; interactive path currently chooses its own)
31    pub samples: u32,
32    /// Background color [R, G, B, A] (reserved; theme/figure-driven in interactive path)
33    pub background_color: [f32; 4],
34    /// Image quality (0.0-1.0) for lossy formats (reserved)
35    pub quality: f32,
36    /// Include metadata in output (reserved)
37    pub include_metadata: bool,
38}
39
40/// Supported image formats
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub enum ImageFormat {
43    Png,
44    Jpeg,
45    WebP,
46    Bmp,
47}
48
49impl Default for ImageExportSettings {
50    fn default() -> Self {
51        Self {
52            width: 800,
53            height: 600,
54            samples: 4,
55            background_color: [1.0, 1.0, 1.0, 1.0],
56            quality: 0.95,
57            include_metadata: true,
58        }
59    }
60}
61
62impl ImageExporter {
63    /// Create a new image exporter.
64    pub async fn new() -> Result<Self, String> {
65        Self::with_settings(ImageExportSettings::default()).await
66    }
67
68    /// Create exporter with custom settings.
69    pub async fn with_settings(settings: ImageExportSettings) -> Result<Self, String> {
70        Ok(Self {
71            settings,
72            theme: None,
73            textmark: None,
74        })
75    }
76
77    /// Set the export theme configuration.
78    pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
79        self.theme = Some(theme);
80    }
81
82    /// Set an optional textmark rendered in export output.
83    pub fn set_textmark(&mut self, textmark: Option<&str>) {
84        self.textmark = textmark
85            .map(str::trim)
86            .filter(|s| !s.is_empty())
87            .map(ToOwned::to_owned);
88    }
89
90    /// Export figure to PNG file.
91    pub async fn export_png<P: AsRef<Path>>(
92        &self,
93        figure: &mut Figure,
94        path: P,
95    ) -> Result<(), String> {
96        let bytes = self.render_png_bytes(figure).await?;
97        std::fs::write(path, bytes).map_err(|e| format!("Failed to save PNG: {e}"))
98    }
99
100    /// Render figure into a PNG buffer.
101    pub async fn render_png_bytes(&self, figure: &mut Figure) -> Result<Vec<u8>, String> {
102        let width = self.settings.width.max(1);
103        let height = self.settings.height.max(1);
104        let gpu = if let Some(theme) = &self.theme {
105            crate::export::native_surface::render_figure_png_bytes_interactive_and_theme_and_textmark(
106                figure.clone(),
107                width,
108                height,
109                theme.clone(),
110                self.textmark.as_deref(),
111            )
112            .await
113        } else {
114            crate::export::native_surface::render_figure_png_bytes_interactive(
115                figure.clone(),
116                width,
117                height,
118            )
119            .await
120        };
121
122        match gpu {
123            Ok(bytes) => Ok(bytes),
124            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
125                crate::export::cpu_surface::render_figure_png_bytes(
126                    figure.clone(),
127                    width,
128                    height,
129                    self.theme.clone(),
130                    None,
131                    None,
132                    self.textmark.as_deref(),
133                )
134                .await
135            }
136            Err(err) => Err(err),
137        }
138    }
139
140    /// Render figure into raw RGBA8 bytes.
141    pub async fn render_rgba_bytes(&self, figure: &mut Figure) -> Result<Vec<u8>, String> {
142        let width = self.settings.width.max(1);
143        let height = self.settings.height.max(1);
144        let gpu = if let Some(theme) = &self.theme {
145            crate::export::native_surface::render_figure_rgba_bytes_interactive_and_theme(
146                figure.clone(),
147                width,
148                height,
149                theme.clone(),
150            )
151            .await
152        } else {
153            crate::export::native_surface::render_figure_rgba_bytes_interactive(
154                figure.clone(),
155                width,
156                height,
157            )
158            .await
159        };
160
161        match gpu {
162            Ok(bytes) => Ok(bytes),
163            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
164                crate::export::cpu_surface::render_figure_rgba_bytes(
165                    figure.clone(),
166                    width,
167                    height,
168                    self.theme.clone(),
169                    None,
170                    None,
171                    self.textmark.as_deref(),
172                )
173                .await
174            }
175            Err(err) => Err(err),
176        }
177    }
178
179    /// Render figure into a PNG buffer using an explicit camera override.
180    pub async fn render_png_bytes_with_camera(
181        &self,
182        figure: &mut Figure,
183        camera: &Camera,
184    ) -> Result<Vec<u8>, String> {
185        let width = self.settings.width.max(1);
186        let height = self.settings.height.max(1);
187        let gpu = if let Some(theme) = &self.theme {
188            crate::export::native_surface::render_figure_png_bytes_interactive_with_camera_and_theme_and_textmark(
189                figure.clone(),
190                width,
191                height,
192                camera,
193                theme.clone(),
194                self.textmark.as_deref(),
195            )
196            .await
197        } else {
198            crate::export::native_surface::render_figure_png_bytes_interactive_with_camera(
199                figure.clone(),
200                width,
201                height,
202                camera,
203            )
204            .await
205        };
206
207        match gpu {
208            Ok(bytes) => Ok(bytes),
209            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
210                crate::export::cpu_surface::render_figure_png_bytes(
211                    figure.clone(),
212                    width,
213                    height,
214                    self.theme.clone(),
215                    Some(camera),
216                    None,
217                    self.textmark.as_deref(),
218                )
219                .await
220            }
221            Err(err) => Err(err),
222        }
223    }
224
225    /// Render figure into raw RGBA8 bytes using an explicit camera override.
226    pub async fn render_rgba_bytes_with_camera(
227        &self,
228        figure: &mut Figure,
229        camera: &Camera,
230    ) -> Result<Vec<u8>, String> {
231        let width = self.settings.width.max(1);
232        let height = self.settings.height.max(1);
233        let gpu = if let Some(theme) = &self.theme {
234            crate::export::native_surface::render_figure_rgba_bytes_interactive_with_camera_and_theme(
235                figure.clone(),
236                width,
237                height,
238                camera,
239                theme.clone(),
240            )
241            .await
242        } else {
243            crate::export::native_surface::render_figure_rgba_bytes_interactive_with_camera(
244                figure.clone(),
245                width,
246                height,
247                camera,
248            )
249            .await
250        };
251
252        match gpu {
253            Ok(bytes) => Ok(bytes),
254            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
255                crate::export::cpu_surface::render_figure_rgba_bytes(
256                    figure.clone(),
257                    width,
258                    height,
259                    self.theme.clone(),
260                    Some(camera),
261                    None,
262                    self.textmark.as_deref(),
263                )
264                .await
265            }
266            Err(err) => Err(err),
267        }
268    }
269
270    /// Render figure into a PNG buffer using per-axes camera overrides.
271    pub async fn render_png_bytes_with_axes_cameras(
272        &self,
273        figure: &mut Figure,
274        axes_cameras: &[Camera],
275    ) -> Result<Vec<u8>, String> {
276        let width = self.settings.width.max(1);
277        let height = self.settings.height.max(1);
278        let gpu = if let Some(theme) = &self.theme {
279            crate::export::native_surface::render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark(
280                figure.clone(),
281                width,
282                height,
283                axes_cameras,
284                theme.clone(),
285                self.textmark.as_deref(),
286            )
287            .await
288        } else {
289            crate::export::native_surface::render_figure_png_bytes_interactive_with_axes_cameras(
290                figure.clone(),
291                width,
292                height,
293                axes_cameras,
294            )
295            .await
296        };
297
298        match gpu {
299            Ok(bytes) => Ok(bytes),
300            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
301                crate::export::cpu_surface::render_figure_png_bytes(
302                    figure.clone(),
303                    width,
304                    height,
305                    self.theme.clone(),
306                    None,
307                    Some(axes_cameras),
308                    self.textmark.as_deref(),
309                )
310                .await
311            }
312            Err(err) => Err(err),
313        }
314    }
315
316    /// Render figure into raw RGBA8 bytes using per-axes camera overrides.
317    pub async fn render_rgba_bytes_with_axes_cameras(
318        &self,
319        figure: &mut Figure,
320        axes_cameras: &[Camera],
321    ) -> Result<Vec<u8>, String> {
322        let width = self.settings.width.max(1);
323        let height = self.settings.height.max(1);
324        let gpu = if let Some(theme) = &self.theme {
325            crate::export::native_surface::render_figure_rgba_bytes_interactive_with_axes_cameras_and_theme(
326                figure.clone(),
327                width,
328                height,
329                axes_cameras,
330                theme.clone(),
331            )
332            .await
333        } else {
334            crate::export::native_surface::render_figure_rgba_bytes_interactive_with_axes_cameras(
335                figure.clone(),
336                width,
337                height,
338                axes_cameras,
339            )
340            .await
341        };
342
343        match gpu {
344            Ok(bytes) => Ok(bytes),
345            Err(err) if crate::export::native_surface::is_headless_gpu_unavailable_error(&err) => {
346                crate::export::cpu_surface::render_figure_rgba_bytes(
347                    figure.clone(),
348                    width,
349                    height,
350                    self.theme.clone(),
351                    None,
352                    Some(axes_cameras),
353                    self.textmark.as_deref(),
354                )
355                .await
356            }
357            Err(err) => Err(err),
358        }
359    }
360
361    /// Encode RGBA bytes as PNG.
362    pub fn encode_png_bytes(&self, data: &[u8]) -> Result<Vec<u8>, String> {
363        use image::{ImageBuffer, ImageOutputFormat, Rgba};
364
365        let image = ImageBuffer::<Rgba<u8>, _>::from_raw(
366            self.settings.width.max(1),
367            self.settings.height.max(1),
368            data.to_vec(),
369        )
370        .ok_or("Failed to create image buffer")?;
371
372        let mut cursor = Cursor::new(Vec::new());
373        image
374            .write_to(&mut cursor, ImageOutputFormat::Png)
375            .map_err(|e| format!("Failed to encode PNG: {e}"))?;
376        Ok(cursor.into_inner())
377    }
378
379    /// Update export settings.
380    pub fn set_settings(&mut self, settings: ImageExportSettings) {
381        self.settings = settings;
382    }
383
384    /// Get current export settings.
385    pub fn settings(&self) -> &ImageExportSettings {
386        &self.settings
387    }
388}