Skip to main content

fop_render/svg/
mod.rs

1//! SVG rendering backend
2//!
3//! Generates SVG documents from area trees.
4
5use crate::image::ImageInfo;
6use fop_layout::{AreaId, AreaTree, AreaType};
7use fop_types::{Color, Length, Result};
8use std::collections::HashMap;
9
10/// SVG renderer
11pub struct SvgRenderer {
12    /// Default page width (A4)
13    #[allow(dead_code)]
14    page_width: Length,
15
16    /// Default page height (A4)
17    #[allow(dead_code)]
18    page_height: Length,
19}
20
21impl SvgRenderer {
22    /// Create a new SVG renderer
23    pub fn new() -> Self {
24        Self {
25            page_width: Length::from_mm(210.0),
26            page_height: Length::from_mm(297.0),
27        }
28    }
29
30    /// Render an area tree to an SVG document (returns SVG XML as String)
31    pub fn render_to_svg(&self, area_tree: &AreaTree) -> Result<String> {
32        let mut svg_doc = SvgDocument::new();
33
34        // First pass: collect all images and convert to data URIs
35        let mut image_map = HashMap::new();
36        self.collect_images(area_tree, &mut image_map)?;
37
38        // Second pass: render pages
39        for (id, node) in area_tree.iter() {
40            if matches!(node.area.area_type, AreaType::Page) {
41                let page_svg = self.render_page(area_tree, id, &image_map)?;
42                svg_doc.add_page_svg(page_svg);
43            }
44        }
45
46        Ok(svg_doc.to_string())
47    }
48
49    /// Render an area tree to separate SVG documents, one per page
50    ///
51    /// Returns a vector of SVG strings, one for each page in the area tree.
52    pub fn render_to_svg_pages(&self, area_tree: &AreaTree) -> Result<Vec<String>> {
53        // First pass: collect all images and convert to data URIs
54        let mut image_map = HashMap::new();
55        self.collect_images(area_tree, &mut image_map)?;
56
57        // Second pass: render each page as a separate SVG document
58        let mut pages = Vec::new();
59        for (id, node) in area_tree.iter() {
60            if matches!(node.area.area_type, AreaType::Page) {
61                let page_svg = self.render_page(area_tree, id, &image_map)?;
62
63                // Create a standalone SVG document for this page
64                let mut svg_doc = SvgDocument::new();
65                svg_doc.add_page_svg(page_svg);
66                pages.push(svg_doc.to_string());
67            }
68        }
69
70        Ok(pages)
71    }
72
73    /// Collect all images from the area tree and convert to data URIs
74    fn collect_images(
75        &self,
76        area_tree: &AreaTree,
77        image_map: &mut HashMap<AreaId, String>,
78    ) -> Result<()> {
79        for (id, node) in area_tree.iter() {
80            if matches!(node.area.area_type, AreaType::Viewport) {
81                if let Some(image_data) = node.area.image_data() {
82                    let data_uri = self.create_data_uri(image_data)?;
83                    image_map.insert(id, data_uri);
84                }
85            }
86        }
87        Ok(())
88    }
89
90    /// Create a data URI from image data
91    fn create_data_uri(&self, image_data: &[u8]) -> Result<String> {
92        let image_info = ImageInfo::from_bytes(image_data)?;
93        let mime_type = match image_info.format {
94            crate::image::ImageFormat::PNG => "image/png",
95            crate::image::ImageFormat::JPEG => "image/jpeg",
96            crate::image::ImageFormat::Unknown => "application/octet-stream",
97        };
98
99        // Base64 encode the image data
100        let encoded = base64_encode(image_data);
101        Ok(format!("data:{};base64,{}", mime_type, encoded))
102    }
103
104    /// Render a single page
105    fn render_page(
106        &self,
107        area_tree: &AreaTree,
108        page_id: AreaId,
109        image_map: &HashMap<AreaId, String>,
110    ) -> Result<String> {
111        let page_node = area_tree
112            .get(page_id)
113            .ok_or_else(|| fop_types::FopError::Generic("Page not found".to_string()))?;
114
115        let width = page_node.area.width();
116        let height = page_node.area.height();
117
118        let mut svg = SvgGraphics::new(width, height);
119
120        // Render all child areas recursively with absolute positioning
121        render_children(
122            area_tree,
123            page_id,
124            &mut svg,
125            Length::ZERO,
126            Length::ZERO,
127            image_map,
128        )?;
129
130        Ok(svg.to_string())
131    }
132}
133
134impl Default for SvgRenderer {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140/// SVG document builder - manages multiple pages
141pub struct SvgDocument {
142    pages: Vec<String>,
143}
144
145impl SvgDocument {
146    /// Create a new SVG document
147    pub fn new() -> Self {
148        Self { pages: Vec::new() }
149    }
150
151    /// Add a page SVG to the document
152    pub fn add_page_svg(&mut self, page_svg: String) {
153        self.pages.push(page_svg);
154    }
155
156    /// Convert to SVG XML string
157    fn build_svg(&self) -> String {
158        if self.pages.is_empty() {
159            return String::from(
160                r#"<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg"/>"#,
161            );
162        }
163
164        // For multi-page SVG, we'll stack them vertically
165        // Each page is a separate <g> group
166        let mut result = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
167        result.push('\n');
168
169        if self.pages.len() == 1 {
170            // Single page - just return it
171            result.push_str(&self.pages[0]);
172        } else {
173            // Multiple pages - stack them vertically
174            result.push_str(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">"#);
175            result.push('\n');
176
177            for (i, page) in self.pages.iter().enumerate() {
178                result.push_str(&format!(r#"  <g id="page-{}">"#, i + 1));
179                result.push('\n');
180                // Extract content from page SVG (skip XML declaration and outer svg tag)
181                if let Some(content) = extract_svg_content(page) {
182                    for line in content.lines() {
183                        result.push_str("    ");
184                        result.push_str(line);
185                        result.push('\n');
186                    }
187                }
188                result.push_str("  </g>\n");
189            }
190
191            result.push_str("</svg>\n");
192        }
193
194        result
195    }
196}
197
198impl Default for SvgDocument {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl std::fmt::Display for SvgDocument {
205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206        write!(f, "{}", self.build_svg())
207    }
208}
209
210/// SVG graphics builder for a single page
211pub struct SvgGraphics {
212    width: Length,
213    height: Length,
214    elements: Vec<String>,
215    gradients: Vec<String>,
216}
217
218impl SvgGraphics {
219    /// Create a new SVG graphics builder
220    pub fn new(width: Length, height: Length) -> Self {
221        Self {
222            width,
223            height,
224            elements: Vec::new(),
225            gradients: Vec::new(),
226        }
227    }
228
229    /// Add a rectangle (for backgrounds, borders)
230    #[allow(clippy::too_many_arguments)]
231    pub fn add_rect(
232        &mut self,
233        x: Length,
234        y: Length,
235        width: Length,
236        height: Length,
237        fill: Option<Color>,
238        stroke: Option<Color>,
239        stroke_width: Option<Length>,
240        opacity: Option<f64>,
241        rx: Option<Length>,
242    ) {
243        let mut rect = format!(
244            r#"<rect x="{}" y="{}" width="{}" height="{}""#,
245            x.to_pt(),
246            y.to_pt(),
247            width.to_pt(),
248            height.to_pt()
249        );
250
251        if let Some(color) = fill {
252            rect.push_str(&format!(r#" fill="{}""#, color_to_svg(&color)));
253        } else {
254            rect.push_str(r#" fill="none""#);
255        }
256
257        if let Some(color) = stroke {
258            rect.push_str(&format!(r#" stroke="{}""#, color_to_svg(&color)));
259        }
260
261        if let Some(sw) = stroke_width {
262            rect.push_str(&format!(r#" stroke-width="{}""#, sw.to_pt()));
263        }
264
265        if let Some(op) = opacity {
266            if (op - 1.0).abs() > f64::EPSILON {
267                rect.push_str(&format!(r#" opacity="{}""#, op));
268            }
269        }
270
271        if let Some(radius) = rx {
272            if radius.to_pt() > 0.0 {
273                rect.push_str(&format!(r#" rx="{}""#, radius.to_pt()));
274            }
275        }
276
277        rect.push_str(" />");
278        self.elements.push(rect);
279    }
280
281    /// Add text with full font styling
282    #[allow(clippy::too_many_arguments)]
283    pub fn add_text(
284        &mut self,
285        text: &str,
286        x: Length,
287        y: Length,
288        font_size: Length,
289        color: Option<Color>,
290    ) {
291        self.add_text_styled(text, x, y, font_size, color, None, None, None, None);
292    }
293
294    /// Add text with full styling (font-family, font-weight, font-style, text-decoration)
295    #[allow(clippy::too_many_arguments)]
296    pub fn add_text_styled(
297        &mut self,
298        text: &str,
299        x: Length,
300        y: Length,
301        font_size: Length,
302        color: Option<Color>,
303        font_family: Option<&str>,
304        font_weight: Option<u16>,
305        font_style_italic: Option<bool>,
306        text_decoration: Option<(bool, bool, bool)>,
307    ) {
308        let fill_color = color.unwrap_or(Color::BLACK);
309
310        // Escape XML special characters
311        let escaped_text = escape_xml(text);
312
313        let mut text_elem = format!(
314            r#"<text x="{}" y="{}" font-size="{}" fill="{}""#,
315            x.to_pt(),
316            y.to_pt(),
317            font_size.to_pt(),
318            color_to_svg(&fill_color),
319        );
320
321        // Add font-family
322        if let Some(family) = font_family {
323            if !family.is_empty() {
324                text_elem.push_str(&format!(r#" font-family="{}""#, escape_xml(family)));
325            }
326        }
327
328        // Add font-weight
329        if let Some(weight) = font_weight {
330            if weight != 400 {
331                text_elem.push_str(&format!(r#" font-weight="{}""#, weight));
332            }
333        }
334
335        // Add font-style
336        if let Some(is_italic) = font_style_italic {
337            if is_italic {
338                text_elem.push_str(r#" font-style="italic""#);
339            }
340        }
341
342        // Add text-decoration
343        if let Some((underline, overline, line_through)) = text_decoration {
344            let mut decorations = Vec::new();
345            if underline {
346                decorations.push("underline");
347            }
348            if overline {
349                decorations.push("overline");
350            }
351            if line_through {
352                decorations.push("line-through");
353            }
354            if !decorations.is_empty() {
355                text_elem.push_str(&format!(r#" text-decoration="{}""#, decorations.join(" ")));
356            }
357        }
358
359        text_elem.push_str(&format!(">{}</text>", escaped_text));
360        self.elements.push(text_elem);
361    }
362
363    /// Add a linear gradient to the defs section
364    pub fn add_linear_gradient(
365        &mut self,
366        id: &str,
367        x1: f64,
368        y1: f64,
369        x2: f64,
370        y2: f64,
371        stops: &[(f64, Color)],
372    ) {
373        let mut grad = format!(
374            r#"<linearGradient id="{}" x1="{}%" y1="{}%" x2="{}%" y2="{}%">"#,
375            id,
376            x1 * 100.0,
377            y1 * 100.0,
378            x2 * 100.0,
379            y2 * 100.0
380        );
381        for (offset, color) in stops {
382            grad.push_str(&format!(
383                r#"<stop offset="{}%" stop-color="{}"/>"#,
384                offset * 100.0,
385                color_to_svg(color)
386            ));
387        }
388        grad.push_str("</linearGradient>");
389        self.gradients.push(grad);
390    }
391
392    /// Add a rect filled with a gradient (reference gradient by id)
393    pub fn add_gradient_rect(
394        &mut self,
395        x: Length,
396        y: Length,
397        width: Length,
398        height: Length,
399        gradient_id: &str,
400    ) {
401        let rect = format!(
402            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="url(#{})" />"#,
403            x.to_pt(),
404            y.to_pt(),
405            width.to_pt(),
406            height.to_pt(),
407            gradient_id
408        );
409        self.elements.push(rect);
410    }
411
412    /// Add an image
413    pub fn add_image(
414        &mut self,
415        data_uri: &str,
416        x: Length,
417        y: Length,
418        width: Length,
419        height: Length,
420    ) {
421        let image_elem = format!(
422            r#"<image x="{}" y="{}" width="{}" height="{}" href="{}" />"#,
423            x.to_pt(),
424            y.to_pt(),
425            width.to_pt(),
426            height.to_pt(),
427            data_uri
428        );
429        self.elements.push(image_elem);
430    }
431
432    /// Add a line (for rules, borders)
433    #[allow(clippy::too_many_arguments)]
434    pub fn add_line(
435        &mut self,
436        x1: Length,
437        y1: Length,
438        x2: Length,
439        y2: Length,
440        color: Color,
441        width: Length,
442        style: &str,
443    ) {
444        let mut line = format!(
445            r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{}""#,
446            x1.to_pt(),
447            y1.to_pt(),
448            x2.to_pt(),
449            y2.to_pt(),
450            color_to_svg(&color),
451            width.to_pt()
452        );
453
454        // Handle line styles
455        match style {
456            "dashed" => line.push_str(r#" stroke-dasharray="5,5""#),
457            "dotted" => line.push_str(r#" stroke-dasharray="2,2""#),
458            _ => {} // solid is default
459        }
460
461        line.push_str(" />");
462        self.elements.push(line);
463    }
464
465    /// Start a clipping path
466    pub fn start_clip(&mut self, x: Length, y: Length, width: Length, height: Length) {
467        let clip = format!(
468            r#"<g clip-path="url(#clip-{}-{})"><defs><clipPath id="clip-{}-{}"><rect x="{}" y="{}" width="{}" height="{}"/></clipPath></defs>"#,
469            x.to_pt(),
470            y.to_pt(),
471            x.to_pt(),
472            y.to_pt(),
473            x.to_pt(),
474            y.to_pt(),
475            width.to_pt(),
476            height.to_pt()
477        );
478        self.elements.push(clip);
479    }
480
481    /// End a clipping path
482    pub fn end_clip(&mut self) {
483        self.elements.push("</g>".to_string());
484    }
485
486    /// Convert to SVG XML string
487    fn build_svg(&self) -> String {
488        let mut svg = String::new();
489
490        svg.push_str(&format!(
491            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{}" height="{}" viewBox="0 0 {} {}">"#,
492            self.width.to_pt(),
493            self.height.to_pt(),
494            self.width.to_pt(),
495            self.height.to_pt()
496        ));
497        svg.push('\n');
498
499        // Add gradients if any
500        if !self.gradients.is_empty() {
501            svg.push_str("  <defs>\n");
502            for gradient in &self.gradients {
503                svg.push_str("    ");
504                svg.push_str(gradient);
505                svg.push('\n');
506            }
507            svg.push_str("  </defs>\n");
508        }
509
510        // Add elements
511        for element in &self.elements {
512            svg.push_str("  ");
513            svg.push_str(element);
514            svg.push('\n');
515        }
516
517        svg.push_str("</svg>");
518        svg
519    }
520}
521
522impl std::fmt::Display for SvgGraphics {
523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        write!(f, "{}", self.build_svg())
525    }
526}
527
528/// Render child areas recursively
529#[allow(clippy::too_many_arguments)]
530fn render_children(
531    area_tree: &AreaTree,
532    parent_id: AreaId,
533    svg: &mut SvgGraphics,
534    offset_x: Length,
535    offset_y: Length,
536    image_map: &HashMap<AreaId, String>,
537) -> Result<()> {
538    let children = area_tree.children(parent_id);
539
540    for child_id in children {
541        if let Some(child_node) = area_tree.get(child_id) {
542            // Calculate absolute position
543            let abs_x = offset_x + child_node.area.geometry.x;
544            let abs_y = offset_y + child_node.area.geometry.y;
545
546            // Check if clipping is needed
547            let needs_clipping = child_node
548                .area
549                .traits
550                .overflow
551                .map(|o| o.clips_content())
552                .unwrap_or(false);
553
554            if needs_clipping {
555                svg.start_clip(
556                    abs_x,
557                    abs_y,
558                    child_node.area.width(),
559                    child_node.area.height(),
560                );
561            }
562
563            // Get opacity if set
564            let opacity = child_node.area.traits.opacity;
565
566            // Render background color if present
567            if let Some(bg_color) = child_node.area.traits.background_color {
568                let border_radius = child_node.area.traits.border_radius.map(|r| r[0]);
569                svg.add_rect(
570                    abs_x,
571                    abs_y,
572                    child_node.area.width(),
573                    child_node.area.height(),
574                    Some(bg_color),
575                    None,
576                    None,
577                    opacity,
578                    border_radius,
579                );
580            }
581
582            // Render borders if present
583            if let (Some(border_widths), Some(border_colors), Some(border_styles)) = (
584                child_node.area.traits.border_width,
585                child_node.area.traits.border_color,
586                child_node.area.traits.border_style,
587            ) {
588                render_borders(
589                    svg,
590                    abs_x,
591                    abs_y,
592                    child_node.area.width(),
593                    child_node.area.height(),
594                    border_widths,
595                    border_colors,
596                    border_styles,
597                    opacity,
598                );
599            }
600
601            match child_node.area.area_type {
602                AreaType::Text => {
603                    // Check if this is a leader area
604                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
605                        render_leader(
606                            svg,
607                            leader_pattern,
608                            abs_x,
609                            abs_y,
610                            child_node.area.width(),
611                            child_node.area.height(),
612                            &child_node.area.traits,
613                        );
614                    } else if let Some(text_content) = child_node.area.text_content() {
615                        let font_size = child_node
616                            .area
617                            .traits
618                            .font_size
619                            .unwrap_or(Length::from_pt(12.0));
620
621                        // SVG text baseline is at the bottom, so we add font_size to y
622                        let text_y = abs_y + font_size;
623
624                        let font_family = child_node.area.traits.font_family.as_deref();
625                        let font_weight = child_node.area.traits.font_weight;
626                        let font_style_italic = child_node.area.traits.font_style.map(|s| {
627                            matches!(
628                                s,
629                                fop_layout::area::FontStyle::Italic
630                                    | fop_layout::area::FontStyle::Oblique
631                            )
632                        });
633                        let text_deco = child_node
634                            .area
635                            .traits
636                            .text_decoration
637                            .map(|td| (td.underline, td.overline, td.line_through));
638
639                        svg.add_text_styled(
640                            text_content,
641                            abs_x,
642                            text_y,
643                            font_size,
644                            child_node.area.traits.color,
645                            font_family,
646                            font_weight,
647                            font_style_italic,
648                            text_deco,
649                        );
650                    }
651                }
652                AreaType::Inline => {
653                    if let Some(leader_pattern) = &child_node.area.traits.is_leader {
654                        render_leader(
655                            svg,
656                            leader_pattern,
657                            abs_x,
658                            abs_y,
659                            child_node.area.width(),
660                            child_node.area.height(),
661                            &child_node.area.traits,
662                        );
663                    } else {
664                        render_children(area_tree, child_id, svg, abs_x, abs_y, image_map)?;
665                    }
666                }
667                AreaType::Viewport => {
668                    // Render image if available
669                    if let Some(data_uri) = image_map.get(&child_id) {
670                        svg.add_image(
671                            data_uri,
672                            abs_x,
673                            abs_y,
674                            child_node.area.width(),
675                            child_node.area.height(),
676                        );
677                    }
678                    // Still recurse for child areas
679                    render_children(area_tree, child_id, svg, abs_x, abs_y, image_map)?;
680                }
681                _ => {
682                    // Recursively render other areas
683                    render_children(area_tree, child_id, svg, abs_x, abs_y, image_map)?;
684                }
685            }
686
687            if needs_clipping {
688                svg.end_clip();
689            }
690        }
691    }
692
693    Ok(())
694}
695
696/// Render borders
697#[allow(clippy::too_many_arguments)]
698fn render_borders(
699    svg: &mut SvgGraphics,
700    x: Length,
701    y: Length,
702    width: Length,
703    height: Length,
704    border_widths: [Length; 4],
705    border_colors: [Color; 4],
706    border_styles: [fop_layout::area::BorderStyle; 4],
707    opacity: Option<f64>,
708) {
709    use fop_layout::area::BorderStyle;
710
711    let [top_w, right_w, bottom_w, left_w] = border_widths;
712    let [top_c, right_c, bottom_c, left_c] = border_colors;
713    let [top_s, right_s, bottom_s, left_s] = border_styles;
714
715    // Top border
716    if top_w.to_pt() > 0.0 && !matches!(top_s, BorderStyle::None | BorderStyle::Hidden) {
717        let mut rect = format!(
718            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="none""#,
719            x.to_pt(),
720            y.to_pt(),
721            width.to_pt(),
722            top_w.to_pt(),
723            color_to_svg(&top_c)
724        );
725        if let Some(op) = opacity {
726            if (op - 1.0).abs() > f64::EPSILON {
727                rect.push_str(&format!(r#" opacity="{}""#, op));
728            }
729        }
730        rect.push_str(" />");
731        svg.elements.push(rect);
732    }
733
734    // Right border
735    if right_w.to_pt() > 0.0 && !matches!(right_s, BorderStyle::None | BorderStyle::Hidden) {
736        let mut rect = format!(
737            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="none""#,
738            (x + width - right_w).to_pt(),
739            y.to_pt(),
740            right_w.to_pt(),
741            height.to_pt(),
742            color_to_svg(&right_c)
743        );
744        if let Some(op) = opacity {
745            if (op - 1.0).abs() > f64::EPSILON {
746                rect.push_str(&format!(r#" opacity="{}""#, op));
747            }
748        }
749        rect.push_str(" />");
750        svg.elements.push(rect);
751    }
752
753    // Bottom border
754    if bottom_w.to_pt() > 0.0 && !matches!(bottom_s, BorderStyle::None | BorderStyle::Hidden) {
755        let mut rect = format!(
756            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="none""#,
757            x.to_pt(),
758            (y + height - bottom_w).to_pt(),
759            width.to_pt(),
760            bottom_w.to_pt(),
761            color_to_svg(&bottom_c)
762        );
763        if let Some(op) = opacity {
764            if (op - 1.0).abs() > f64::EPSILON {
765                rect.push_str(&format!(r#" opacity="{}""#, op));
766            }
767        }
768        rect.push_str(" />");
769        svg.elements.push(rect);
770    }
771
772    // Left border
773    if left_w.to_pt() > 0.0 && !matches!(left_s, BorderStyle::None | BorderStyle::Hidden) {
774        let mut rect = format!(
775            r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" stroke="none""#,
776            x.to_pt(),
777            y.to_pt(),
778            left_w.to_pt(),
779            height.to_pt(),
780            color_to_svg(&left_c)
781        );
782        if let Some(op) = opacity {
783            if (op - 1.0).abs() > f64::EPSILON {
784                rect.push_str(&format!(r#" opacity="{}""#, op));
785            }
786        }
787        rect.push_str(" />");
788        svg.elements.push(rect);
789    }
790}
791
792/// Render a leader
793#[allow(clippy::too_many_arguments)]
794fn render_leader(
795    svg: &mut SvgGraphics,
796    leader_pattern: &str,
797    x: Length,
798    y: Length,
799    width: Length,
800    height: Length,
801    traits: &fop_layout::area::TraitSet,
802) {
803    match leader_pattern {
804        "rule" => {
805            let thickness = traits.rule_thickness.unwrap_or(Length::from_pt(0.5));
806            let style = traits.rule_style.as_deref().unwrap_or("solid");
807            let color = traits.color.unwrap_or(Color::BLACK);
808
809            // Center the rule vertically
810            let half_diff = Length::from_millipoints((height - thickness).millipoints() / 2);
811            let rule_y = y + half_diff;
812
813            let half_thickness = Length::from_millipoints(thickness.millipoints() / 2);
814            svg.add_line(
815                x,
816                rule_y + half_thickness,
817                x + width,
818                rule_y + half_thickness,
819                color,
820                thickness,
821                style,
822            );
823        }
824        "dots" | "space" => {
825            // These are handled by text rendering or render nothing
826        }
827        _ => {}
828    }
829}
830
831/// Convert Color to SVG color string
832fn color_to_svg(color: &Color) -> String {
833    format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
834}
835
836/// Convert BorderStyle to SVG stroke-dasharray style
837#[allow(dead_code)]
838fn border_style_to_svg(style: &fop_layout::area::BorderStyle) -> &'static str {
839    use fop_layout::area::BorderStyle;
840    match style {
841        BorderStyle::Solid => "solid",
842        BorderStyle::Dashed => "dashed",
843        BorderStyle::Dotted => "dotted",
844        BorderStyle::Double => "double",
845        _ => "solid",
846    }
847}
848
849/// Escape XML special characters
850fn escape_xml(text: &str) -> String {
851    text.replace('&', "&amp;")
852        .replace('<', "&lt;")
853        .replace('>', "&gt;")
854        .replace('"', "&quot;")
855        .replace('\'', "&apos;")
856}
857
858/// Extract content from SVG (remove XML declaration and outer svg tags)
859fn extract_svg_content(svg: &str) -> Option<String> {
860    let mut lines: Vec<&str> = svg.lines().collect();
861
862    // Skip XML declaration
863    if let Some(first) = lines.first() {
864        if first.starts_with("<?xml") {
865            lines.remove(0);
866        }
867    }
868
869    // Skip opening <svg> tag
870    if let Some(first) = lines.first() {
871        if first.trim().starts_with("<svg") {
872            lines.remove(0);
873        }
874    }
875
876    // Skip closing </svg> tag
877    if let Some(last) = lines.last() {
878        if last.trim() == "</svg>" {
879            lines.pop();
880        }
881    }
882
883    Some(lines.join("\n"))
884}
885
886/// Simple base64 encoding (without external dependency)
887fn base64_encode(data: &[u8]) -> String {
888    const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
889
890    let mut result = String::new();
891    let mut i = 0;
892
893    while i < data.len() {
894        let b1 = data[i];
895        let b2 = if i + 1 < data.len() { data[i + 1] } else { 0 };
896        let b3 = if i + 2 < data.len() { data[i + 2] } else { 0 };
897
898        let c1 = (b1 >> 2) as usize;
899        let c2 = (((b1 & 0x03) << 4) | (b2 >> 4)) as usize;
900        let c3 = (((b2 & 0x0F) << 2) | (b3 >> 6)) as usize;
901        let c4 = (b3 & 0x3F) as usize;
902
903        result.push(CHARSET[c1] as char);
904        result.push(CHARSET[c2] as char);
905
906        if i + 1 < data.len() {
907            result.push(CHARSET[c3] as char);
908        } else {
909            result.push('=');
910        }
911
912        if i + 2 < data.len() {
913            result.push(CHARSET[c4] as char);
914        } else {
915            result.push('=');
916        }
917
918        i += 3;
919    }
920
921    result
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use fop_types::{Color, Length};
928
929    // ── SvgRenderer ──────────────────────────────────────────────────────────
930
931    #[test]
932    fn test_svg_renderer_new() {
933        let renderer = SvgRenderer::new();
934        let _ = renderer; // construction must not panic
935    }
936
937    #[test]
938    fn test_svg_renderer_default() {
939        let _r1 = SvgRenderer::new();
940        let _r2 = SvgRenderer::default();
941        // both variants must be constructible
942    }
943
944    // ── SvgDocument ──────────────────────────────────────────────────────────
945
946    #[test]
947    fn test_svg_document_empty_output() {
948        let doc = SvgDocument::new();
949        let output = doc.to_string();
950        // empty document should still produce valid SVG
951        assert!(
952            output.contains("<svg"),
953            "empty document must have <svg element"
954        );
955    }
956
957    #[test]
958    fn test_svg_document_default_equals_new() {
959        let doc_new = SvgDocument::new();
960        let doc_default = SvgDocument::default();
961        assert_eq!(doc_new.to_string(), doc_default.to_string());
962    }
963
964    #[test]
965    fn test_svg_document_single_page() {
966        let mut doc = SvgDocument::new();
967        doc.add_page_svg(r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"/></svg>"#.to_string());
968        let output = doc.to_string();
969        assert!(
970            output.contains("<svg"),
971            "single-page document must contain <svg"
972        );
973    }
974
975    #[test]
976    fn test_svg_document_multi_page_contains_svg() {
977        let mut doc = SvgDocument::new();
978        doc.add_page_svg(r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#.to_string());
979        doc.add_page_svg(r#"<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>"#.to_string());
980        let output = doc.to_string();
981        assert!(
982            output.contains("<svg"),
983            "multi-page document must contain <svg"
984        );
985        // multi-page wraps in groups
986        assert!(output.contains("page-1"), "must label first page group");
987        assert!(output.contains("page-2"), "must label second page group");
988    }
989
990    #[test]
991    fn test_svg_document_xml_declaration() {
992        let doc = SvgDocument::new();
993        let output = doc.to_string();
994        assert!(
995            output.starts_with("<?xml"),
996            "SVG document must start with XML declaration"
997        );
998    }
999
1000    // ── SvgGraphics ──────────────────────────────────────────────────────────
1001
1002    fn make_graphics() -> SvgGraphics {
1003        SvgGraphics::new(Length::from_mm(210.0), Length::from_mm(297.0))
1004    }
1005
1006    #[test]
1007    fn test_svg_graphics_new_produces_svg_element() {
1008        let g = make_graphics();
1009        let output = g.to_string();
1010        assert!(
1011            output.contains("<svg"),
1012            "graphics must produce <svg element"
1013        );
1014        assert!(output.contains("</svg>"), "graphics must close </svg>");
1015    }
1016
1017    #[test]
1018    fn test_svg_graphics_width_height_in_output() {
1019        let g = SvgGraphics::new(Length::from_pt(200.0), Length::from_pt(400.0));
1020        let output = g.to_string();
1021        // viewBox and width/height should reflect the supplied values
1022        assert!(output.contains("200"), "width should appear in SVG output");
1023        assert!(output.contains("400"), "height should appear in SVG output");
1024    }
1025
1026    #[test]
1027    fn test_svg_graphics_add_rect_with_fill() {
1028        let mut g = make_graphics();
1029        g.add_rect(
1030            Length::from_pt(10.0),
1031            Length::from_pt(20.0),
1032            Length::from_pt(100.0),
1033            Length::from_pt(50.0),
1034            Some(Color::RED),
1035            None,
1036            None,
1037            None,
1038            None,
1039        );
1040        let output = g.to_string();
1041        assert!(output.contains("<rect"), "must contain rect element");
1042        assert!(output.contains("fill"), "must include fill attribute");
1043        assert!(
1044            output.contains("#ff0000"),
1045            "red fill should be #ff0000 but got: {output}"
1046        );
1047    }
1048
1049    #[test]
1050    fn test_svg_graphics_add_rect_no_fill() {
1051        let mut g = make_graphics();
1052        g.add_rect(
1053            Length::ZERO,
1054            Length::ZERO,
1055            Length::from_pt(50.0),
1056            Length::from_pt(50.0),
1057            None,
1058            None,
1059            None,
1060            None,
1061            None,
1062        );
1063        let output = g.to_string();
1064        assert!(
1065            output.contains(r#"fill="none""#),
1066            "unfilled rect should have fill=\"none\""
1067        );
1068    }
1069
1070    #[test]
1071    fn test_svg_graphics_add_rect_with_stroke() {
1072        let mut g = make_graphics();
1073        g.add_rect(
1074            Length::ZERO,
1075            Length::ZERO,
1076            Length::from_pt(80.0),
1077            Length::from_pt(40.0),
1078            None,
1079            Some(Color::BLACK),
1080            Some(Length::from_pt(1.0)),
1081            None,
1082            None,
1083        );
1084        let output = g.to_string();
1085        assert!(output.contains("stroke"), "must include stroke attribute");
1086    }
1087
1088    #[test]
1089    fn test_svg_graphics_add_rect_with_opacity() {
1090        let mut g = make_graphics();
1091        g.add_rect(
1092            Length::ZERO,
1093            Length::ZERO,
1094            Length::from_pt(80.0),
1095            Length::from_pt(40.0),
1096            Some(Color::BLUE),
1097            None,
1098            None,
1099            Some(0.5),
1100            None,
1101        );
1102        let output = g.to_string();
1103        assert!(
1104            output.contains("opacity"),
1105            "partial opacity should appear in output"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_svg_graphics_add_rect_with_radius() {
1111        let mut g = make_graphics();
1112        g.add_rect(
1113            Length::ZERO,
1114            Length::ZERO,
1115            Length::from_pt(80.0),
1116            Length::from_pt(40.0),
1117            Some(Color::GREEN),
1118            None,
1119            None,
1120            None,
1121            Some(Length::from_pt(5.0)),
1122        );
1123        let output = g.to_string();
1124        assert!(
1125            output.contains("rx"),
1126            "border-radius must produce rx attribute"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_svg_graphics_add_text() {
1132        let mut g = make_graphics();
1133        g.add_text(
1134            "Hello SVG",
1135            Length::from_pt(50.0),
1136            Length::from_pt(100.0),
1137            Length::from_pt(12.0),
1138            Some(Color::BLACK),
1139        );
1140        let output = g.to_string();
1141        assert!(output.contains("<text"), "must produce <text element");
1142        assert!(output.contains("Hello SVG"), "text content must be present");
1143    }
1144
1145    #[test]
1146    fn test_svg_graphics_add_text_xml_escape() {
1147        let mut g = make_graphics();
1148        g.add_text(
1149            "a < b & c > d",
1150            Length::from_pt(10.0),
1151            Length::from_pt(20.0),
1152            Length::from_pt(10.0),
1153            None,
1154        );
1155        let output = g.to_string();
1156        assert!(output.contains("&lt;"), "< must be escaped as &lt;");
1157        assert!(output.contains("&amp;"), "& must be escaped as &amp;");
1158        assert!(output.contains("&gt;"), "> must be escaped as &gt;");
1159    }
1160
1161    #[test]
1162    fn test_svg_graphics_add_text_styled_bold() {
1163        let mut g = make_graphics();
1164        g.add_text_styled(
1165            "Bold Text",
1166            Length::from_pt(10.0),
1167            Length::from_pt(20.0),
1168            Length::from_pt(14.0),
1169            Some(Color::BLACK),
1170            Some("Helvetica"),
1171            Some(700),
1172            None,
1173            None,
1174        );
1175        let output = g.to_string();
1176        assert!(output.contains("font-weight"), "bold must set font-weight");
1177        assert!(output.contains("Bold Text"), "text content must be present");
1178    }
1179
1180    #[test]
1181    fn test_svg_graphics_add_text_styled_italic() {
1182        let mut g = make_graphics();
1183        g.add_text_styled(
1184            "Italic Text",
1185            Length::from_pt(10.0),
1186            Length::from_pt(20.0),
1187            Length::from_pt(12.0),
1188            None,
1189            None,
1190            None,
1191            Some(true),
1192            None,
1193        );
1194        let output = g.to_string();
1195        assert!(output.contains("font-style"), "italic must set font-style");
1196        assert!(output.contains("italic"), "font-style must be italic");
1197    }
1198
1199    #[test]
1200    fn test_svg_graphics_add_line_solid() {
1201        let mut g = make_graphics();
1202        g.add_line(
1203            Length::from_pt(0.0),
1204            Length::from_pt(0.0),
1205            Length::from_pt(100.0),
1206            Length::from_pt(100.0),
1207            Color::BLACK,
1208            Length::from_pt(1.0),
1209            "solid",
1210        );
1211        let output = g.to_string();
1212        assert!(output.contains("<line"), "must produce <line element");
1213        assert!(
1214            !output.contains("stroke-dasharray"),
1215            "solid line must not have dasharray"
1216        );
1217    }
1218
1219    #[test]
1220    fn test_svg_graphics_add_line_dashed() {
1221        let mut g = make_graphics();
1222        g.add_line(
1223            Length::from_pt(0.0),
1224            Length::from_pt(0.0),
1225            Length::from_pt(50.0),
1226            Length::from_pt(0.0),
1227            Color::BLACK,
1228            Length::from_pt(1.0),
1229            "dashed",
1230        );
1231        let output = g.to_string();
1232        assert!(
1233            output.contains("stroke-dasharray"),
1234            "dashed line must have dasharray"
1235        );
1236        assert!(output.contains("5,5"), "dashed dasharray should be 5,5");
1237    }
1238
1239    #[test]
1240    fn test_svg_graphics_add_line_dotted() {
1241        let mut g = make_graphics();
1242        g.add_line(
1243            Length::from_pt(0.0),
1244            Length::from_pt(0.0),
1245            Length::from_pt(50.0),
1246            Length::from_pt(0.0),
1247            Color::BLACK,
1248            Length::from_pt(1.0),
1249            "dotted",
1250        );
1251        let output = g.to_string();
1252        assert!(output.contains("2,2"), "dotted dasharray should be 2,2");
1253    }
1254
1255    #[test]
1256    fn test_svg_graphics_start_end_clip() {
1257        let mut g = make_graphics();
1258        g.start_clip(
1259            Length::from_pt(10.0),
1260            Length::from_pt(10.0),
1261            Length::from_pt(80.0),
1262            Length::from_pt(60.0),
1263        );
1264        g.end_clip();
1265        let output = g.to_string();
1266        assert!(
1267            output.contains("clip-path"),
1268            "clip must produce clip-path attribute"
1269        );
1270        assert!(output.contains("clipPath"), "clip must define a <clipPath>");
1271        assert!(output.contains("</g>"), "end_clip must close the group");
1272    }
1273
1274    // ── color_to_svg helper (private, tested via add_rect) ───────────────────
1275
1276    #[test]
1277    fn test_color_to_svg_black() {
1278        let mut g = make_graphics();
1279        g.add_rect(
1280            Length::ZERO,
1281            Length::ZERO,
1282            Length::from_pt(10.0),
1283            Length::from_pt(10.0),
1284            Some(Color::BLACK),
1285            None,
1286            None,
1287            None,
1288            None,
1289        );
1290        assert!(g.to_string().contains("#000000"));
1291    }
1292
1293    #[test]
1294    fn test_color_to_svg_white() {
1295        let mut g = make_graphics();
1296        g.add_rect(
1297            Length::ZERO,
1298            Length::ZERO,
1299            Length::from_pt(10.0),
1300            Length::from_pt(10.0),
1301            Some(Color::WHITE),
1302            None,
1303            None,
1304            None,
1305            None,
1306        );
1307        assert!(g.to_string().contains("#ffffff"));
1308    }
1309
1310    // ── Namespace / structure validity ────────────────────────────────────────
1311
1312    #[test]
1313    fn test_svg_graphics_has_xmlns() {
1314        let g = make_graphics();
1315        let output = g.to_string();
1316        assert!(
1317            output.contains(r#"xmlns="http://www.w3.org/2000/svg""#),
1318            "SVG must declare the SVG namespace"
1319        );
1320    }
1321
1322    #[test]
1323    fn test_svg_graphics_has_xlink_ns() {
1324        let g = make_graphics();
1325        let output = g.to_string();
1326        assert!(
1327            output.contains("xmlns:xlink"),
1328            "SVG must declare the xlink namespace for image support"
1329        );
1330    }
1331
1332    #[test]
1333    fn test_svg_graphics_multiple_elements_order() {
1334        let mut g = make_graphics();
1335        g.add_rect(
1336            Length::ZERO,
1337            Length::ZERO,
1338            Length::from_pt(50.0),
1339            Length::from_pt(50.0),
1340            Some(Color::RED),
1341            None,
1342            None,
1343            None,
1344            None,
1345        );
1346        g.add_text(
1347            "First",
1348            Length::from_pt(5.0),
1349            Length::from_pt(10.0),
1350            Length::from_pt(12.0),
1351            None,
1352        );
1353        let output = g.to_string();
1354        let rect_pos = output.find("<rect").expect("test: should succeed");
1355        let text_pos = output.find("<text").expect("test: should succeed");
1356        assert!(
1357            rect_pos < text_pos,
1358            "rect added first must appear before text in output"
1359        );
1360    }
1361}