Skip to main content

runmat_plot/export/
vector.rs

1//! High-quality vector graphics export (SVG, PDF, etc.)
2//!
3//! Production-ready vector output using same rendering pipeline data.
4
5use crate::core::RenderData;
6use crate::plots::Figure;
7use base64::Engine;
8use image::ImageEncoder;
9use std::fmt::Write;
10use std::path::Path;
11
12/// High-quality vector graphics exporter
13pub struct VectorExporter {
14    /// Export settings
15    settings: VectorExportSettings,
16}
17
18/// Vector export configuration
19#[derive(Debug, Clone)]
20pub struct VectorExportSettings {
21    /// Output width in user units
22    pub width: f32,
23    /// Output height in user units
24    pub height: f32,
25    /// Background color [R, G, B, A] (0.0-1.0)
26    pub background_color: [f32; 4],
27    /// Stroke width for lines
28    pub stroke_width: f32,
29    /// Include metadata in output
30    pub include_metadata: bool,
31    /// Enable anti-aliasing
32    pub anti_aliasing: bool,
33}
34
35/// Supported vector formats
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum VectorFormat {
38    Svg,
39    Pdf,
40    Eps,
41}
42
43impl Default for VectorExportSettings {
44    fn default() -> Self {
45        Self {
46            width: 800.0,
47            height: 600.0,
48            background_color: [1.0, 1.0, 1.0, 1.0], // White background
49            stroke_width: 1.0,
50            include_metadata: true,
51            anti_aliasing: true,
52        }
53    }
54}
55
56impl VectorExporter {
57    /// Create a new vector exporter
58    pub fn new() -> Self {
59        Self {
60            settings: VectorExportSettings::default(),
61        }
62    }
63
64    /// Create exporter with custom settings
65    pub fn with_settings(settings: VectorExportSettings) -> Self {
66        Self { settings }
67    }
68
69    /// Export figure to SVG file
70    pub fn export_svg<P: AsRef<Path>>(&self, figure: &mut Figure, path: P) -> Result<(), String> {
71        let svg_content = self.render_to_svg(figure)?;
72        std::fs::write(path, svg_content).map_err(|e| format!("Failed to write SVG file: {e}"))?;
73        log::debug!(target: "runmat_plot", "svg export completed");
74        Ok(())
75    }
76
77    /// Export figure to PDF file (placeholder)
78    pub fn export_pdf<P: AsRef<Path>>(&self, _figure: &mut Figure, _path: P) -> Result<(), String> {
79        // TODO: Implement PDF export using a PDF library
80        Err("PDF export not yet implemented".to_string())
81    }
82
83    /// Render figure to SVG string using same rendering pipeline data
84    pub fn render_to_svg(&self, figure: &mut Figure) -> Result<String, String> {
85        log::debug!(target: "runmat_plot", "svg export render start");
86
87        let mut svg = String::new();
88
89        // SVG header
90        writeln!(
91            &mut svg,
92            r#"<?xml version="1.0" encoding="UTF-8"?>
93<svg width="{}" height="{}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">"#,
94            self.settings.width, self.settings.height
95        ).map_err(|e| format!("SVG write error: {e}"))?;
96
97        // Background
98        if self.settings.background_color[3] > 0.0 {
99            writeln!(
100                &mut svg,
101                r#"  <rect width="100%" height="100%" fill="{}"/>"#,
102                self.color_to_hex(&self.settings.background_color)
103            )
104            .map_err(|e| format!("SVG write error: {e}"))?;
105        }
106
107        // Add metadata if requested
108        if self.settings.include_metadata {
109            writeln!(
110                &mut svg,
111                "  <metadata>\n    <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n      <rdf:Description>\n        <dc:creator xmlns:dc=\"http://purl.org/dc/elements/1.1/\">RunMat Plot System</dc:creator>\n      </rdf:Description>\n    </rdf:RDF>\n  </metadata>"
112            ).map_err(|e| format!("SVG write error: {e}"))?;
113        }
114
115        let sg_title_band = if figure
116            .sg_title
117            .as_deref()
118            .map(str::trim)
119            .is_some_and(|text| !text.is_empty())
120        {
121            let fs = figure
122                .sg_title_style
123                .font_size
124                .map(|size| size.max(12.0))
125                .unwrap_or(18.0);
126            let fill = figure
127                .sg_title_style
128                .color
129                .map(|color| self.color_to_hex(&color.to_array()))
130                .unwrap_or_else(|| "#000000".to_string());
131            let weight = if figure
132                .sg_title_style
133                .font_weight
134                .as_deref()
135                .map(|weight| weight.eq_ignore_ascii_case("bold"))
136                .unwrap_or(false)
137            {
138                " font-weight=\"bold\""
139            } else {
140                ""
141            };
142            // Scale the band height and baseline with the font size so that
143            // large FontSize values don't overflow into subplot content.
144            let padding = (fs * 0.4).max(8.0f32);
145            let text_y = (fs + padding).round() as i32;
146            let band_height = text_y as f32 + padding;
147            writeln!(
148                &mut svg,
149                "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"{}\" fill=\"{}\" font-family=\"sans-serif\"{}>{}</text>",
150                self.settings.width * 0.5,
151                text_y,
152                fs,
153                fill,
154                weight,
155                xml_escape(figure.sg_title.as_deref().unwrap_or_default())
156            ).map_err(|e| format!("SVG write error: {e}"))?;
157            band_height
158        } else {
159            0.0f32
160        };
161
162        // Render each plot element grouped by axes (subplots)
163        let (rows, cols) = figure.axes_grid();
164        let gaps = (8.0f32, 8.0f32);
165        let (hgap, vgap) = gaps;
166        let total_hgap = hgap * (cols.saturating_sub(1) as f32);
167        let total_vgap = vgap * (rows.saturating_sub(1) as f32);
168        let cell_w = (self.settings.width - total_hgap).max(1.0) / (cols.max(1) as f32);
169        let cell_h =
170            (self.settings.height - total_vgap - sg_title_band).max(1.0) / (rows.max(1) as f32);
171
172        let axes_vps: Vec<(f32, f32, f32, f32)> = (0..rows)
173            .flat_map(|r| {
174                (0..cols).map(move |c| {
175                    (
176                        c as f32 * (cell_w + hgap),
177                        sg_title_band + r as f32 * (cell_h + vgap),
178                        cell_w,
179                        cell_h,
180                    )
181                })
182            })
183            .collect();
184
185        // Render per-axes titles from AxesMetadata (set by title(), not sgtitle()).
186        // This must happen before the plot elements loop to avoid duplicate rendering.
187        for (ax, vp) in axes_vps.iter().copied().enumerate().take(rows * cols) {
188            if let Some(meta) = figure.axes_metadata.get(ax) {
189                let title_text = meta
190                    .title
191                    .as_deref()
192                    .map(str::trim)
193                    .filter(|t| !t.is_empty());
194                if let Some(title) = title_text {
195                    let style = &meta.title_style;
196                    let fs = style.font_size.map(|s| s.max(10.0)).unwrap_or(14.0);
197                    let fill = style
198                        .color
199                        .map(|c| self.color_to_hex(&c.to_array()))
200                        .unwrap_or_else(|| "#000000".to_string());
201                    let weight = if style
202                        .font_weight
203                        .as_deref()
204                        .map(|w| w.eq_ignore_ascii_case("bold"))
205                        .unwrap_or(false)
206                    {
207                        " font-weight=\"bold\""
208                    } else {
209                        ""
210                    };
211                    writeln!(
212                        &mut svg,
213                        "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"{}\" fill=\"{}\" font-family=\"sans-serif\"{}>{}</text>",
214                        vp.0 + vp.2 * 0.5,
215                        vp.1 + fs + 2.0,
216                        fs,
217                        fill,
218                        weight,
219                        xml_escape(title)
220                    )
221                    .map_err(|e| format!("SVG write error: {e}"))?;
222                }
223            }
224        }
225
226        let axes_map = figure.plot_axes_indices().to_vec();
227        let rds = figure.render_data();
228        for (i, rd) in rds.iter().enumerate() {
229            let ax = axes_map.get(i).copied().unwrap_or(0).min(rows * cols - 1);
230            let vp = axes_vps[ax];
231            // Axes labels
232            if let Some(lbl) = &figure.x_label {
233                let cx = vp.0 + vp.2 * 0.5;
234                let cy = vp.1 + vp.3 + 20.0;
235                writeln!(
236                    &mut svg,
237                    "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
238                    cx, cy, xml_escape(lbl)
239                ).map_err(|e| format!("SVG write error: {e}"))?;
240            }
241            if let Some(lbl) = &figure.y_label {
242                let cx = vp.0 - 24.0;
243                let cy = vp.1 + vp.3 * 0.5;
244                writeln!(
245                    &mut svg,
246                    "  <text x=\"{}\" y=\"{}\" transform=\"rotate(-90, {}, {})\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
247                    cx, cy, cx, cy, xml_escape(lbl)
248                ).map_err(|e| format!("SVG write error: {e}"))?;
249            }
250            for pie_label in figure.pie_labels_for_axes(ax) {
251                let radius = vp.2.min(vp.3) * 0.4;
252                let screen_x = vp.0 + vp.2 * 0.5 + pie_label.position.x * radius;
253                let screen_y = vp.1 + vp.3 * 0.5 - pie_label.position.y * radius;
254                writeln!(
255                    &mut svg,
256                    "  <text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"12\" fill=\"#000000\" font-family=\"sans-serif\">{}</text>",
257                    screen_x,
258                    screen_y,
259                    xml_escape(&pie_label.label)
260                ).map_err(|e| format!("SVG write error: {e}"))?;
261            }
262            self.add_render_data_to_svg_viewport(&mut svg, rd, vp)?;
263        }
264
265        // SVG footer
266        writeln!(&mut svg, "</svg>").map_err(|e| format!("SVG write error: {e}"))?;
267
268        log::debug!(target: "runmat_plot", "svg export size chars={}", svg.len());
269        Ok(svg)
270    }
271
272    /// Add render data to SVG using same pipeline data
273    /// Note: Will be used when Figure iteration is implemented
274    #[allow(dead_code)]
275    fn add_render_data_to_svg_viewport(
276        &self,
277        svg: &mut String,
278        render_data: &RenderData,
279        viewport: (f32, f32, f32, f32),
280    ) -> Result<(), String> {
281        match render_data.pipeline_type {
282            crate::core::PipelineType::Lines => {
283                self.add_lines_to_svg_viewport(svg, render_data, viewport)?;
284            }
285            crate::core::PipelineType::Points => {
286                self.add_points_to_svg_viewport(svg, render_data, viewport)?;
287            }
288            crate::core::PipelineType::Triangles => {
289                self.add_triangles_to_svg_viewport(svg, render_data, viewport)?;
290            }
291            crate::core::PipelineType::Textured => {
292                self.add_textured_to_svg_viewport(svg, render_data, viewport)?;
293            }
294            crate::core::PipelineType::Scatter3 => {
295                self.add_points_to_svg_viewport(svg, render_data, viewport)?;
296            }
297        }
298        Ok(())
299    }
300
301    /// Add line data to SVG
302    #[allow(dead_code)]
303    fn add_lines_to_svg_viewport(
304        &self,
305        svg: &mut String,
306        render_data: &RenderData,
307        vp: (f32, f32, f32, f32),
308    ) -> Result<(), String> {
309        if render_data.vertices.len() < 2 {
310            return Ok(());
311        }
312
313        // Convert vertices to SVG path
314        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
315
316        // Material encodes style: roughness=line_width, metallic=style, emissive.x=cap, emissive.y=join
317        let lw = render_data.material.roughness.max(0.5);
318        let style_code = render_data.material.metallic as i32;
319        let cap_code = render_data.material.emissive.x as i32;
320        let join_code = render_data.material.emissive.y as i32;
321        let stroke = self.color_to_hex(
322            &render_data
323                .vertices
324                .first()
325                .map(|v| v.color)
326                .unwrap_or([0.0, 0.0, 0.0, 1.0]),
327        );
328        let stroke_linecap = match cap_code {
329            1 => "square",
330            2 => "round",
331            _ => "butt",
332        };
333        let stroke_linejoin = match join_code {
334            1 => "bevel",
335            2 => "round",
336            _ => "miter",
337        };
338        // Normalize dash spacing in SVG to on-screen units scaled by stroke width
339        // Patterns:
340        //  - Dashed:   on=6*lw, off=6*lw
341        //  - Dotted:   on=1*lw, off=6*lw
342        //  - DashDot:  on=6*lw, off=4*lw, dot=1*lw, gap=4*lw
343        let stroke_dasharray: Option<String> = match style_code {
344            1 => Some(format!("{},{}", 6.0 * lw, 6.0 * lw)),
345            2 => Some(format!("{},{}", 1.0 * lw, 6.0 * lw)),
346            3 => Some(format!(
347                "{},{},{},{}",
348                6.0 * lw,
349                4.0 * lw,
350                1.0 * lw,
351                4.0 * lw
352            )),
353            _ => None,
354        };
355
356        for chunk in render_data.vertices.chunks(2) {
357            if chunk.len() == 2 {
358                let start = &chunk[0];
359                let end = &chunk[1];
360
361                let start_screen = self.world_to_screen_viewport(start.position, vp);
362                let end_screen = self.world_to_screen_viewport(end.position, vp);
363
364                if let Some(ref dash) = stroke_dasharray {
365                    writeln!(
366                        svg,
367                        r#"    <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}" stroke-dasharray="{}"/>"#,
368                        start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin, dash
369                    ).map_err(|e| format!("SVG write error: {e}"))?;
370                } else {
371                    writeln!(
372                        svg,
373                        r#"    <line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}" stroke-linecap="{}" stroke-linejoin="{}"/>"#,
374                        start_screen[0], start_screen[1], end_screen[0], end_screen[1], stroke, lw, stroke_linecap, stroke_linejoin
375                    ).map_err(|e| format!("SVG write error: {e}"))?;
376                }
377            }
378        }
379
380        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
381        Ok(())
382    }
383
384    /// Add point data to SVG
385    #[allow(dead_code)]
386    fn add_points_to_svg_viewport(
387        &self,
388        svg: &mut String,
389        render_data: &RenderData,
390        vp: (f32, f32, f32, f32),
391    ) -> Result<(), String> {
392        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
393
394        let marker_shape = render_data.material.metallic as u32; // 0 circle, 1 square
395        let edge_color = render_data.material.emissive;
396        let edge_width = render_data.material.roughness.max(0.0);
397        for vertex in &render_data.vertices {
398            let screen_pos = self.world_to_screen_viewport(vertex.position, vp);
399            let radius = (vertex.normal[2] * 0.5).max(1.0);
400            // Map to viewport scale roughly (already in pixels; SVG units are px)
401
402            if marker_shape == 1u32 {
403                // square
404                let x = screen_pos[0] - radius;
405                let y = screen_pos[1] - radius;
406                writeln!(
407                    svg,
408                    r#"    <rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
409                    x,
410                    y,
411                    radius*2.0,
412                    radius*2.0,
413                    self.color_to_hex(&vertex.color),
414                    self.color_to_hex(&edge_color.to_array()),
415                    edge_width
416                ).map_err(|e| format!("SVG write error: {e}"))?;
417            } else {
418                // circle default
419                writeln!(
420                    svg,
421                    r#"    <circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{}" stroke-width="{}"/>"#,
422                    screen_pos[0],
423                    screen_pos[1],
424                    radius,
425                    self.color_to_hex(&vertex.color),
426                    self.color_to_hex(&edge_color.to_array()),
427                    edge_width
428                ).map_err(|e| format!("SVG write error: {e}"))?;
429            }
430        }
431
432        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
433        Ok(())
434    }
435
436    /// Add triangle data to SVG
437    #[allow(dead_code)]
438    fn add_triangles_to_svg_viewport(
439        &self,
440        svg: &mut String,
441        render_data: &RenderData,
442        vp: (f32, f32, f32, f32),
443    ) -> Result<(), String> {
444        writeln!(svg, "  <g>").map_err(|e| format!("SVG write error: {e}"))?;
445
446        for triangle in render_data.vertices.chunks(3) {
447            if triangle.len() == 3 {
448                let p1 = self.world_to_screen_viewport(triangle[0].position, vp);
449                let p2 = self.world_to_screen_viewport(triangle[1].position, vp);
450                let p3 = self.world_to_screen_viewport(triangle[2].position, vp);
451
452                writeln!(
453                    svg,
454                    r#"    <polygon points="{},{} {},{} {},{}" fill="{}"/>"#,
455                    p1[0],
456                    p1[1],
457                    p2[0],
458                    p2[1],
459                    p3[0],
460                    p3[1],
461                    self.color_to_hex(&triangle[0].color)
462                )
463                .map_err(|e| format!("SVG write error: {e}"))?;
464            }
465        }
466
467        writeln!(svg, "  </g>").map_err(|e| format!("SVG write error: {e}"))?;
468        Ok(())
469    }
470
471    /// Convert world coordinates to screen coordinates
472    #[allow(dead_code)]
473    fn world_to_screen_viewport(&self, world_pos: [f32; 3], vp: (f32, f32, f32, f32)) -> [f32; 2] {
474        let (vx, vy, vw, vh) = vp;
475        [
476            vx + (world_pos[0] + 1.0) * 0.5 * vw,
477            vy + (1.0 - (world_pos[1] + 1.0) * 0.5) * vh,
478        ]
479    }
480
481    /// Draw textured quad as a filled rectangle (placeholder), sampling average color if available
482    fn add_textured_to_svg_viewport(
483        &self,
484        svg: &mut String,
485        render_data: &RenderData,
486        vp: (f32, f32, f32, f32),
487    ) -> Result<(), String> {
488        // Compute screen-space bounding box from vertices
489        if render_data.vertices.is_empty() {
490            return Ok(());
491        }
492        let mut min_x = f32::INFINITY;
493        let mut min_y = f32::INFINITY;
494        let mut max_x = f32::NEG_INFINITY;
495        let mut max_y = f32::NEG_INFINITY;
496        for v in &render_data.vertices {
497            let p = self.world_to_screen_viewport(v.position, vp);
498            min_x = min_x.min(p[0]);
499            max_x = max_x.max(p[0]);
500            min_y = min_y.min(p[1]);
501            max_y = max_y.max(p[1]);
502        }
503        let w = (max_x - min_x).max(1.0);
504        let h = (max_y - min_y).max(1.0);
505        // Embed image as base64 data URI if available
506        if let Some(crate::core::scene::ImageData::Rgba8 {
507            width,
508            height,
509            data,
510        }) = &render_data.image
511        {
512            if !data.is_empty() {
513                // Encode RGBA8 as PNG in-memory
514                let mut png_buf: Vec<u8> = Vec::new();
515                {
516                    let encoder = image::codecs::png::PngEncoder::new(&mut png_buf);
517                    encoder
518                        .write_image(data, *width, *height, image::ColorType::Rgba8)
519                        .map_err(|e| format!("PNG encode failed: {e}"))?;
520                }
521                let b64 = base64::engine::general_purpose::STANDARD.encode(&png_buf);
522                let href = format!("data:image/png;base64,{}", b64);
523                writeln!(
524                    svg,
525                    r#"  <image x="{}" y="{}" width="{}" height="{}" xlink:href="{}" preserveAspectRatio="none"/>"#,
526                    min_x, min_y, w, h, href
527                ).map_err(|e| format!("SVG write error: {e}"))?;
528                return Ok(());
529            }
530        }
531        // Fallback: gray rect
532        writeln!(
533            svg,
534            "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#999999\"/>",
535            min_x, min_y, w, h
536        )
537        .map_err(|e| format!("SVG write error: {e}"))?;
538        Ok(())
539    }
540
541    /// Convert color array to hex string
542    fn color_to_hex(&self, color: &[f32; 4]) -> String {
543        format!(
544            "#{:02x}{:02x}{:02x}",
545            (color[0] * 255.0) as u8,
546            (color[1] * 255.0) as u8,
547            (color[2] * 255.0) as u8
548        )
549    }
550
551    /// Update export settings
552    pub fn set_settings(&mut self, settings: VectorExportSettings) {
553        self.settings = settings;
554    }
555
556    /// Get current export settings
557    pub fn settings(&self) -> &VectorExportSettings {
558        &self.settings
559    }
560}
561fn xml_escape(s: &str) -> String {
562    s.replace('&', "&amp;")
563        .replace('<', "&lt;")
564        .replace('>', "&gt;")
565        .replace('"', "&quot;")
566        .replace('\'', "&apos;")
567}
568
569impl Default for VectorExporter {
570    fn default() -> Self {
571        Self::new()
572    }
573}