1use crate::image::ImageInfo;
6use fop_layout::{AreaId, AreaTree, AreaType};
7use fop_types::{Color, Length, Result};
8use std::collections::HashMap;
9
10pub struct SvgRenderer {
12 #[allow(dead_code)]
14 page_width: Length,
15
16 #[allow(dead_code)]
18 page_height: Length,
19}
20
21impl SvgRenderer {
22 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 pub fn render_to_svg(&self, area_tree: &AreaTree) -> Result<String> {
32 let mut svg_doc = SvgDocument::new();
33
34 let mut image_map = HashMap::new();
36 self.collect_images(area_tree, &mut image_map)?;
37
38 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 pub fn render_to_svg_pages(&self, area_tree: &AreaTree) -> Result<Vec<String>> {
53 let mut image_map = HashMap::new();
55 self.collect_images(area_tree, &mut image_map)?;
56
57 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 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 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 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 let encoded = base64_encode(image_data);
101 Ok(format!("data:{};base64,{}", mime_type, encoded))
102 }
103
104 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_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
140pub struct SvgDocument {
142 pages: Vec<String>,
143}
144
145impl SvgDocument {
146 pub fn new() -> Self {
148 Self { pages: Vec::new() }
149 }
150
151 pub fn add_page_svg(&mut self, page_svg: String) {
153 self.pages.push(page_svg);
154 }
155
156 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 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 result.push_str(&self.pages[0]);
172 } else {
173 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 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
210pub struct SvgGraphics {
212 width: Length,
213 height: Length,
214 elements: Vec<String>,
215 gradients: Vec<String>,
216}
217
218impl SvgGraphics {
219 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 #[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 #[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 #[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 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 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 if let Some(weight) = font_weight {
330 if weight != 400 {
331 text_elem.push_str(&format!(r#" font-weight="{}""#, weight));
332 }
333 }
334
335 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 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 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 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 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 #[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 match style {
456 "dashed" => line.push_str(r#" stroke-dasharray="5,5""#),
457 "dotted" => line.push_str(r#" stroke-dasharray="2,2""#),
458 _ => {} }
460
461 line.push_str(" />");
462 self.elements.push(line);
463 }
464
465 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 pub fn end_clip(&mut self) {
483 self.elements.push("</g>".to_string());
484 }
485
486 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 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 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#[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 let abs_x = offset_x + child_node.area.geometry.x;
544 let abs_y = offset_y + child_node.area.geometry.y;
545
546 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 let opacity = child_node.area.traits.opacity;
565
566 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 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 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 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 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 render_children(area_tree, child_id, svg, abs_x, abs_y, image_map)?;
680 }
681 _ => {
682 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#[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 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 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 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 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#[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 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 }
827 _ => {}
828 }
829}
830
831fn color_to_svg(color: &Color) -> String {
833 format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b)
834}
835
836#[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
849fn escape_xml(text: &str) -> String {
851 text.replace('&', "&")
852 .replace('<', "<")
853 .replace('>', ">")
854 .replace('"', """)
855 .replace('\'', "'")
856}
857
858fn extract_svg_content(svg: &str) -> Option<String> {
860 let mut lines: Vec<&str> = svg.lines().collect();
861
862 if let Some(first) = lines.first() {
864 if first.starts_with("<?xml") {
865 lines.remove(0);
866 }
867 }
868
869 if let Some(first) = lines.first() {
871 if first.trim().starts_with("<svg") {
872 lines.remove(0);
873 }
874 }
875
876 if let Some(last) = lines.last() {
878 if last.trim() == "</svg>" {
879 lines.pop();
880 }
881 }
882
883 Some(lines.join("\n"))
884}
885
886fn 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 #[test]
932 fn test_svg_renderer_new() {
933 let renderer = SvgRenderer::new();
934 let _ = renderer; }
936
937 #[test]
938 fn test_svg_renderer_default() {
939 let _r1 = SvgRenderer::new();
940 let _r2 = SvgRenderer::default();
941 }
943
944 #[test]
947 fn test_svg_document_empty_output() {
948 let doc = SvgDocument::new();
949 let output = doc.to_string();
950 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 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 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 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("<"), "< must be escaped as <");
1157 assert!(output.contains("&"), "& must be escaped as &");
1158 assert!(output.contains(">"), "> must be escaped as >");
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 #[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 #[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}