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