Skip to main content

pdfplumber_core/
svg.rs

1//! SVG rendering for visual debugging of PDF pages.
2//!
3//! Generates SVG representations of PDF pages showing page boundaries,
4//! coordinate system, and overlaid extracted objects (chars, lines, rects,
5//! edges, tables). This is pdfplumber's visual debugging system — Python
6//! pdfplumber's most unique feature.
7
8use crate::edges::Edge;
9use crate::geometry::BBox;
10use crate::shapes::{Line, Rect};
11use crate::table::{Cell, Intersection, Table};
12use crate::text::Char;
13
14/// Style options for drawing overlays on the SVG page.
15#[derive(Debug, Clone)]
16pub struct DrawStyle {
17    /// Fill color (CSS color string). `None` means no fill.
18    pub fill: Option<String>,
19    /// Stroke color (CSS color string). `None` means no stroke.
20    pub stroke: Option<String>,
21    /// Stroke width in points.
22    pub stroke_width: f64,
23    /// Opacity (0.0 = fully transparent, 1.0 = fully opaque).
24    pub opacity: f64,
25}
26
27impl Default for DrawStyle {
28    fn default() -> Self {
29        Self {
30            fill: None,
31            stroke: Some("black".to_string()),
32            stroke_width: 0.5,
33            opacity: 1.0,
34        }
35    }
36}
37
38impl DrawStyle {
39    /// Default style for character bounding boxes (blue outline).
40    pub fn chars_default() -> Self {
41        Self {
42            fill: None,
43            stroke: Some("blue".to_string()),
44            stroke_width: 0.3,
45            opacity: 0.7,
46        }
47    }
48
49    /// Default style for lines (red).
50    pub fn lines_default() -> Self {
51        Self {
52            fill: None,
53            stroke: Some("red".to_string()),
54            stroke_width: 1.0,
55            opacity: 0.8,
56        }
57    }
58
59    /// Default style for rectangles (green outline).
60    pub fn rects_default() -> Self {
61        Self {
62            fill: None,
63            stroke: Some("green".to_string()),
64            stroke_width: 0.5,
65            opacity: 0.8,
66        }
67    }
68
69    /// Default style for edges (orange).
70    pub fn edges_default() -> Self {
71        Self {
72            fill: None,
73            stroke: Some("orange".to_string()),
74            stroke_width: 0.5,
75            opacity: 0.8,
76        }
77    }
78
79    /// Default style for table cell boundaries (lightblue fill).
80    pub fn tables_default() -> Self {
81        Self {
82            fill: Some("lightblue".to_string()),
83            stroke: Some("steelblue".to_string()),
84            stroke_width: 0.5,
85            opacity: 0.3,
86        }
87    }
88
89    /// Default style for intersection points (red filled circles).
90    pub fn intersections_default() -> Self {
91        Self {
92            fill: Some("red".to_string()),
93            stroke: Some("darkred".to_string()),
94            stroke_width: 0.5,
95            opacity: 0.9,
96        }
97    }
98
99    /// Default style for cell boundaries (dashed pink outline).
100    pub fn cells_default() -> Self {
101        Self {
102            fill: None,
103            stroke: Some("magenta".to_string()),
104            stroke_width: 0.5,
105            opacity: 0.6,
106        }
107    }
108
109    /// Build the SVG style attribute string.
110    fn to_svg_style(&self) -> String {
111        let mut parts = Vec::new();
112        match &self.fill {
113            Some(color) => parts.push(format!("fill:{color}")),
114            None => parts.push("fill:none".to_string()),
115        }
116        if let Some(color) = &self.stroke {
117            parts.push(format!("stroke:{color}"));
118            parts.push(format!("stroke-width:{}", self.stroke_width));
119        } else {
120            parts.push("stroke:none".to_string());
121        }
122        if (self.opacity - 1.0).abs() > f64::EPSILON {
123            parts.push(format!("opacity:{}", self.opacity));
124        }
125        parts.join(";")
126    }
127}
128
129/// Options for SVG generation.
130#[derive(Debug, Clone)]
131pub struct SvgOptions {
132    /// Optional fixed width for the SVG output. If `None`, uses the page width.
133    pub width: Option<f64>,
134    /// Optional fixed height for the SVG output. If `None`, uses the page height.
135    pub height: Option<f64>,
136    /// Scale factor for the SVG output. Default is `1.0`.
137    pub scale: f64,
138}
139
140impl Default for SvgOptions {
141    fn default() -> Self {
142        Self {
143            width: None,
144            height: None,
145            scale: 1.0,
146        }
147    }
148}
149
150/// Options for the debug_tablefinder SVG output.
151///
152/// Controls which pipeline stages are rendered in the debug SVG.
153/// All flags default to `true`.
154#[derive(Debug, Clone)]
155pub struct SvgDebugOptions {
156    /// Show detected edges (red lines).
157    pub show_edges: bool,
158    /// Show intersection points (small circles).
159    pub show_intersections: bool,
160    /// Show cell boundaries (dashed lines).
161    pub show_cells: bool,
162    /// Show table bounding boxes (light blue rectangles).
163    pub show_tables: bool,
164}
165
166impl Default for SvgDebugOptions {
167    fn default() -> Self {
168        Self {
169            show_edges: true,
170            show_intersections: true,
171            show_cells: true,
172            show_tables: true,
173        }
174    }
175}
176
177/// Renders PDF page content as SVG markup for visual debugging.
178///
179/// `SvgRenderer` takes page dimensions and produces valid SVG 1.1 markup.
180/// The SVG coordinate system matches pdfplumber's top-left origin system.
181///
182/// Use the `draw_*` methods to add overlay elements, then call `to_svg()`
183/// to produce the final SVG string.
184pub struct SvgRenderer {
185    /// Page width in points.
186    page_width: f64,
187    /// Page height in points.
188    page_height: f64,
189    /// Bounding box of the page.
190    page_bbox: BBox,
191    /// Accumulated SVG overlay elements.
192    elements: Vec<String>,
193}
194
195impl SvgRenderer {
196    /// Create a new `SvgRenderer` for a page with the given dimensions.
197    pub fn new(page_width: f64, page_height: f64) -> Self {
198        let page_bbox = BBox::new(0.0, 0.0, page_width, page_height);
199        Self {
200            page_width,
201            page_height,
202            page_bbox,
203            elements: Vec::new(),
204        }
205    }
206
207    /// Draw character bounding boxes onto the SVG.
208    pub fn draw_chars(&mut self, chars: &[Char], style: &DrawStyle) {
209        let style_attr = style.to_svg_style();
210        for ch in chars {
211            self.elements.push(format!(
212                "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" style=\"{style_attr}\"/>\n",
213                ch.bbox.x0,
214                ch.bbox.top,
215                ch.bbox.width(),
216                ch.bbox.height(),
217            ));
218        }
219    }
220
221    /// Draw rectangle outlines/fills onto the SVG.
222    pub fn draw_rects(&mut self, rects: &[Rect], style: &DrawStyle) {
223        let style_attr = style.to_svg_style();
224        for r in rects {
225            self.elements.push(format!(
226                "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" style=\"{style_attr}\"/>\n",
227                r.x0,
228                r.top,
229                r.x1 - r.x0,
230                r.bottom - r.top,
231            ));
232        }
233    }
234
235    /// Draw line segments onto the SVG.
236    pub fn draw_lines(&mut self, lines: &[Line], style: &DrawStyle) {
237        let style_attr = style.to_svg_style();
238        for l in lines {
239            self.elements.push(format!(
240                "  <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" style=\"{style_attr}\"/>\n",
241                l.x0, l.top, l.x1, l.bottom,
242            ));
243        }
244    }
245
246    /// Draw detected edges onto the SVG.
247    pub fn draw_edges(&mut self, edges: &[Edge], style: &DrawStyle) {
248        let style_attr = style.to_svg_style();
249        for e in edges {
250            self.elements.push(format!(
251                "  <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" style=\"{style_attr}\"/>\n",
252                e.x0, e.top, e.x1, e.bottom,
253            ));
254        }
255    }
256
257    /// Draw intersection points as small circles onto the SVG.
258    pub fn draw_intersections(&mut self, intersections: &[Intersection], style: &DrawStyle) {
259        let style_attr = style.to_svg_style();
260        let radius = 3.0;
261        for pt in intersections {
262            self.elements.push(format!(
263                "  <circle cx=\"{}\" cy=\"{}\" r=\"{radius}\" style=\"{style_attr}\"/>\n",
264                pt.x, pt.y,
265            ));
266        }
267    }
268
269    /// Draw cell boundaries as dashed rectangles onto the SVG.
270    pub fn draw_cells(&mut self, cells: &[Cell], style: &DrawStyle) {
271        let style_attr = style.to_svg_style();
272        for cell in cells {
273            self.elements.push(format!(
274                "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" style=\"{style_attr}\" stroke-dasharray=\"4,2\"/>\n",
275                cell.bbox.x0,
276                cell.bbox.top,
277                cell.bbox.width(),
278                cell.bbox.height(),
279            ));
280        }
281    }
282
283    /// Draw table cell boundaries onto the SVG.
284    pub fn draw_tables(&mut self, tables: &[Table], style: &DrawStyle) {
285        let style_attr = style.to_svg_style();
286        for table in tables {
287            // Draw each cell
288            for cell in &table.cells {
289                self.elements.push(format!(
290                    "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" style=\"{style_attr}\"/>\n",
291                    cell.bbox.x0,
292                    cell.bbox.top,
293                    cell.bbox.width(),
294                    cell.bbox.height(),
295                ));
296            }
297        }
298    }
299
300    /// Generate SVG markup for the page.
301    ///
302    /// The output is a complete, valid SVG 1.1 document including:
303    /// - Proper `viewBox` matching page dimensions
304    /// - Page boundary rectangle
305    /// - All overlay elements added via `draw_*` methods
306    /// - SVG coordinate system matching top-left origin
307    pub fn to_svg(&self, options: &SvgOptions) -> String {
308        let view_width = self.page_width;
309        let view_height = self.page_height;
310
311        let svg_width = options.width.unwrap_or(self.page_width * options.scale);
312        let svg_height = options.height.unwrap_or(self.page_height * options.scale);
313
314        let mut svg = String::new();
315
316        // SVG header
317        svg.push_str(&format!(
318            "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" \
319             width=\"{svg_width}\" height=\"{svg_height}\" \
320             viewBox=\"0 0 {view_width} {view_height}\">\n"
321        ));
322
323        // Page boundary rectangle
324        svg.push_str(&format!(
325            "  <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
326             fill=\"white\" stroke=\"black\" stroke-width=\"0.5\"/>\n",
327            self.page_bbox.x0,
328            self.page_bbox.top,
329            self.page_bbox.width(),
330            self.page_bbox.height(),
331        ));
332
333        // Overlay elements
334        for element in &self.elements {
335            svg.push_str(element);
336        }
337
338        // Close SVG
339        svg.push_str("</svg>\n");
340
341        svg
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use crate::edges::EdgeSource;
349    use crate::geometry::Orientation;
350    use crate::painting::Color;
351    use crate::table::Cell;
352    use crate::text::TextDirection;
353
354    // --- Existing US-067 tests ---
355
356    #[test]
357    fn test_svg_default_options() {
358        let opts = SvgOptions::default();
359        assert!(opts.width.is_none());
360        assert!(opts.height.is_none());
361        assert!((opts.scale - 1.0).abs() < f64::EPSILON);
362    }
363
364    #[test]
365    fn test_svg_generation_simple_page() {
366        let renderer = SvgRenderer::new(612.0, 792.0); // US Letter
367        let svg = renderer.to_svg(&SvgOptions::default());
368
369        // Must be valid SVG with proper namespace
370        assert!(svg.contains("xmlns=\"http://www.w3.org/2000/svg\""));
371        assert!(svg.contains("version=\"1.1\""));
372        // Must start with <svg and end with </svg>
373        assert!(svg.starts_with("<svg"));
374        assert!(svg.trim_end().ends_with("</svg>"));
375    }
376
377    #[test]
378    fn test_svg_has_correct_viewbox() {
379        let renderer = SvgRenderer::new(612.0, 792.0);
380        let svg = renderer.to_svg(&SvgOptions::default());
381
382        assert!(svg.contains("viewBox=\"0 0 612 792\""));
383    }
384
385    #[test]
386    fn test_svg_has_correct_dimensions_default() {
387        let renderer = SvgRenderer::new(612.0, 792.0);
388        let svg = renderer.to_svg(&SvgOptions::default());
389
390        // Default scale=1.0, so SVG width/height match page dimensions
391        assert!(svg.contains("width=\"612\""));
392        assert!(svg.contains("height=\"792\""));
393    }
394
395    #[test]
396    fn test_svg_has_correct_dimensions_with_scale() {
397        let renderer = SvgRenderer::new(612.0, 792.0);
398        let svg = renderer.to_svg(&SvgOptions {
399            scale: 2.0,
400            ..Default::default()
401        });
402
403        // Scale=2.0, so SVG width/height are doubled
404        assert!(svg.contains("width=\"1224\""));
405        assert!(svg.contains("height=\"1584\""));
406        // viewBox stays the same (page coordinates)
407        assert!(svg.contains("viewBox=\"0 0 612 792\""));
408    }
409
410    #[test]
411    fn test_svg_has_correct_dimensions_with_explicit_size() {
412        let renderer = SvgRenderer::new(612.0, 792.0);
413        let svg = renderer.to_svg(&SvgOptions {
414            width: Some(800.0),
415            height: Some(600.0),
416            scale: 1.0,
417        });
418
419        assert!(svg.contains("width=\"800\""));
420        assert!(svg.contains("height=\"600\""));
421        // viewBox still matches page dimensions
422        assert!(svg.contains("viewBox=\"0 0 612 792\""));
423    }
424
425    #[test]
426    fn test_svg_has_page_boundary_rect() {
427        let renderer = SvgRenderer::new(612.0, 792.0);
428        let svg = renderer.to_svg(&SvgOptions::default());
429
430        // Must contain a rectangle for the page boundary
431        assert!(svg.contains("<rect"));
432        assert!(svg.contains("width=\"612\""));
433        assert!(svg.contains("height=\"792\""));
434        assert!(svg.contains("fill=\"white\""));
435        assert!(svg.contains("stroke=\"black\""));
436    }
437
438    #[test]
439    fn test_svg_valid_markup() {
440        let renderer = SvgRenderer::new(100.0, 200.0);
441        let svg = renderer.to_svg(&SvgOptions::default());
442
443        // Basic structural validity
444        let open_tags = svg.matches("<svg").count();
445        let close_tags = svg.matches("</svg>").count();
446        assert_eq!(open_tags, 1, "Should have exactly one <svg> opening tag");
447        assert_eq!(close_tags, 1, "Should have exactly one </svg> closing tag");
448
449        // Self-closing rect tag
450        assert!(svg.contains("/>"), "Rect should be self-closing");
451    }
452
453    #[test]
454    fn test_svg_coordinate_system_top_left_origin() {
455        let renderer = SvgRenderer::new(400.0, 300.0);
456        let svg = renderer.to_svg(&SvgOptions::default());
457
458        // viewBox starts at 0,0 (top-left origin)
459        assert!(svg.contains("viewBox=\"0 0 400 300\""));
460        // Page rect starts at x=0, y=0
461        assert!(svg.contains("x=\"0\""));
462        assert!(svg.contains("y=\"0\""));
463    }
464
465    #[test]
466    fn test_svg_small_page() {
467        let renderer = SvgRenderer::new(50.0, 50.0);
468        let svg = renderer.to_svg(&SvgOptions::default());
469
470        assert!(svg.contains("viewBox=\"0 0 50 50\""));
471        assert!(svg.contains("width=\"50\""));
472        assert!(svg.contains("height=\"50\""));
473    }
474
475    // --- US-068 tests: DrawStyle ---
476
477    #[test]
478    fn test_draw_style_default() {
479        let style = DrawStyle::default();
480        assert!(style.fill.is_none());
481        assert_eq!(style.stroke.as_deref(), Some("black"));
482        assert!((style.stroke_width - 0.5).abs() < f64::EPSILON);
483        assert!((style.opacity - 1.0).abs() < f64::EPSILON);
484    }
485
486    #[test]
487    fn test_draw_style_chars_default() {
488        let style = DrawStyle::chars_default();
489        assert!(style.fill.is_none());
490        assert_eq!(style.stroke.as_deref(), Some("blue"));
491    }
492
493    #[test]
494    fn test_draw_style_lines_default() {
495        let style = DrawStyle::lines_default();
496        assert_eq!(style.stroke.as_deref(), Some("red"));
497    }
498
499    #[test]
500    fn test_draw_style_rects_default() {
501        let style = DrawStyle::rects_default();
502        assert_eq!(style.stroke.as_deref(), Some("green"));
503    }
504
505    #[test]
506    fn test_draw_style_tables_default() {
507        let style = DrawStyle::tables_default();
508        assert_eq!(style.fill.as_deref(), Some("lightblue"));
509    }
510
511    #[test]
512    fn test_draw_style_to_svg_style_full() {
513        let style = DrawStyle {
514            fill: Some("red".to_string()),
515            stroke: Some("blue".to_string()),
516            stroke_width: 2.0,
517            opacity: 0.5,
518        };
519        let s = style.to_svg_style();
520        assert!(s.contains("fill:red"));
521        assert!(s.contains("stroke:blue"));
522        assert!(s.contains("stroke-width:2"));
523        assert!(s.contains("opacity:0.5"));
524    }
525
526    #[test]
527    fn test_draw_style_to_svg_style_no_fill() {
528        let style = DrawStyle {
529            fill: None,
530            stroke: Some("black".to_string()),
531            stroke_width: 1.0,
532            opacity: 1.0,
533        };
534        let s = style.to_svg_style();
535        assert!(s.contains("fill:none"));
536        assert!(s.contains("stroke:black"));
537        // opacity=1.0 should be omitted
538        assert!(!s.contains("opacity"));
539    }
540
541    // --- US-068 tests: draw_chars ---
542
543    fn make_char(text: &str, x0: f64, top: f64, x1: f64, bottom: f64) -> Char {
544        Char {
545            text: text.to_string(),
546            bbox: BBox::new(x0, top, x1, bottom),
547            fontname: "Helvetica".to_string(),
548            size: 12.0,
549            doctop: top,
550            upright: true,
551            direction: TextDirection::Ltr,
552            stroking_color: None,
553            non_stroking_color: None,
554            ctm: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
555            char_code: 0,
556            mcid: None,
557            tag: None,
558        }
559    }
560
561    #[test]
562    fn test_draw_chars_adds_rect_elements() {
563        let mut renderer = SvgRenderer::new(200.0, 200.0);
564        let chars = vec![
565            make_char("A", 10.0, 20.0, 18.0, 32.0),
566            make_char("B", 20.0, 20.0, 28.0, 32.0),
567        ];
568        renderer.draw_chars(&chars, &DrawStyle::chars_default());
569        let svg = renderer.to_svg(&SvgOptions::default());
570
571        // Should contain rect elements for each char (page boundary + 2 char rects)
572        let rect_count = svg.matches("<rect").count();
573        assert_eq!(rect_count, 3, "1 page boundary + 2 char bboxes");
574        assert!(svg.contains("stroke:blue"));
575    }
576
577    #[test]
578    fn test_draw_chars_correct_coordinates() {
579        let mut renderer = SvgRenderer::new(200.0, 200.0);
580        let chars = vec![make_char("X", 10.0, 20.0, 25.0, 35.0)];
581        renderer.draw_chars(&chars, &DrawStyle::chars_default());
582        let svg = renderer.to_svg(&SvgOptions::default());
583
584        // Check the char rect has correct position
585        assert!(svg.contains("x=\"10\""));
586        assert!(svg.contains("y=\"20\""));
587        assert!(svg.contains("width=\"15\"")); // 25 - 10
588        assert!(svg.contains("height=\"15\"")); // 35 - 20
589    }
590
591    // --- US-068 tests: draw_rects ---
592
593    fn make_rect(x0: f64, top: f64, x1: f64, bottom: f64) -> Rect {
594        Rect {
595            x0,
596            top,
597            x1,
598            bottom,
599            line_width: 1.0,
600            stroke: true,
601            fill: false,
602            stroke_color: Color::Gray(0.0),
603            fill_color: Color::Gray(1.0),
604        }
605    }
606
607    #[test]
608    fn test_draw_rects_adds_rect_elements() {
609        let mut renderer = SvgRenderer::new(200.0, 200.0);
610        let rects = vec![make_rect(50.0, 50.0, 150.0, 100.0)];
611        renderer.draw_rects(&rects, &DrawStyle::rects_default());
612        let svg = renderer.to_svg(&SvgOptions::default());
613
614        let rect_count = svg.matches("<rect").count();
615        assert_eq!(rect_count, 2, "1 page boundary + 1 rect overlay");
616        assert!(svg.contains("stroke:green"));
617    }
618
619    // --- US-068 tests: draw_lines ---
620
621    fn make_line(x0: f64, top: f64, x1: f64, bottom: f64) -> Line {
622        Line {
623            x0,
624            top,
625            x1,
626            bottom,
627            line_width: 1.0,
628            stroke_color: Color::Gray(0.0),
629            orientation: Orientation::Horizontal,
630        }
631    }
632
633    #[test]
634    fn test_draw_lines_adds_line_elements() {
635        let mut renderer = SvgRenderer::new(200.0, 200.0);
636        let lines = vec![
637            make_line(10.0, 50.0, 190.0, 50.0),
638            make_line(100.0, 10.0, 100.0, 190.0),
639        ];
640        renderer.draw_lines(&lines, &DrawStyle::lines_default());
641        let svg = renderer.to_svg(&SvgOptions::default());
642
643        let line_count = svg.matches("<line").count();
644        assert_eq!(line_count, 2);
645        assert!(svg.contains("stroke:red"));
646    }
647
648    #[test]
649    fn test_draw_lines_correct_coordinates() {
650        let mut renderer = SvgRenderer::new(200.0, 200.0);
651        let lines = vec![make_line(10.0, 50.0, 190.0, 50.0)];
652        renderer.draw_lines(&lines, &DrawStyle::lines_default());
653        let svg = renderer.to_svg(&SvgOptions::default());
654
655        assert!(svg.contains("x1=\"10\""));
656        assert!(svg.contains("y1=\"50\""));
657        assert!(svg.contains("x2=\"190\""));
658        assert!(svg.contains("y2=\"50\""));
659    }
660
661    // --- US-068 tests: draw_edges ---
662
663    fn make_edge(x0: f64, top: f64, x1: f64, bottom: f64) -> Edge {
664        Edge {
665            x0,
666            top,
667            x1,
668            bottom,
669            orientation: Orientation::Horizontal,
670            source: EdgeSource::Line,
671        }
672    }
673
674    #[test]
675    fn test_draw_edges_adds_line_elements() {
676        let mut renderer = SvgRenderer::new(200.0, 200.0);
677        let edges = vec![make_edge(0.0, 100.0, 200.0, 100.0)];
678        renderer.draw_edges(&edges, &DrawStyle::edges_default());
679        let svg = renderer.to_svg(&SvgOptions::default());
680
681        let line_count = svg.matches("<line").count();
682        assert_eq!(line_count, 1);
683        assert!(svg.contains("stroke:orange"));
684    }
685
686    // --- US-068 tests: draw_tables ---
687
688    fn make_table() -> Table {
689        Table {
690            bbox: BBox::new(10.0, 10.0, 200.0, 100.0),
691            cells: vec![
692                Cell {
693                    bbox: BBox::new(10.0, 10.0, 100.0, 50.0),
694                    text: Some("A".to_string()),
695                },
696                Cell {
697                    bbox: BBox::new(100.0, 10.0, 200.0, 50.0),
698                    text: Some("B".to_string()),
699                },
700                Cell {
701                    bbox: BBox::new(10.0, 50.0, 100.0, 100.0),
702                    text: Some("C".to_string()),
703                },
704                Cell {
705                    bbox: BBox::new(100.0, 50.0, 200.0, 100.0),
706                    text: Some("D".to_string()),
707                },
708            ],
709            rows: vec![],
710            columns: vec![],
711        }
712    }
713
714    #[test]
715    fn test_draw_tables_adds_cell_rects() {
716        let mut renderer = SvgRenderer::new(300.0, 200.0);
717        let tables = vec![make_table()];
718        renderer.draw_tables(&tables, &DrawStyle::tables_default());
719        let svg = renderer.to_svg(&SvgOptions::default());
720
721        // 1 page boundary + 4 cell rects
722        let rect_count = svg.matches("<rect").count();
723        assert_eq!(rect_count, 5);
724        assert!(svg.contains("fill:lightblue"));
725    }
726
727    // --- US-068 tests: mixed overlays ---
728
729    #[test]
730    fn test_svg_with_mixed_objects() {
731        let mut renderer = SvgRenderer::new(400.0, 400.0);
732
733        let chars = vec![make_char("H", 10.0, 10.0, 20.0, 22.0)];
734        let lines = vec![make_line(0.0, 100.0, 400.0, 100.0)];
735        let rects = vec![make_rect(50.0, 50.0, 150.0, 80.0)];
736
737        renderer.draw_chars(&chars, &DrawStyle::chars_default());
738        renderer.draw_lines(&lines, &DrawStyle::lines_default());
739        renderer.draw_rects(&rects, &DrawStyle::rects_default());
740
741        let svg = renderer.to_svg(&SvgOptions::default());
742
743        // Verify all object types present
744        assert!(svg.contains("stroke:blue"), "chars overlay");
745        assert!(svg.contains("stroke:red"), "lines overlay");
746        assert!(svg.contains("stroke:green"), "rects overlay");
747        // 1 page boundary rect + 1 char rect + 1 rect overlay = 3 rects, 1 line
748        let rect_count = svg.matches("<rect").count();
749        assert_eq!(rect_count, 3);
750        let line_count = svg.matches("<line").count();
751        assert_eq!(line_count, 1);
752    }
753
754    #[test]
755    fn test_style_customization() {
756        let mut renderer = SvgRenderer::new(100.0, 100.0);
757        let chars = vec![make_char("Z", 5.0, 5.0, 15.0, 17.0)];
758        let custom_style = DrawStyle {
759            fill: Some("yellow".to_string()),
760            stroke: Some("purple".to_string()),
761            stroke_width: 3.0,
762            opacity: 0.5,
763        };
764        renderer.draw_chars(&chars, &custom_style);
765        let svg = renderer.to_svg(&SvgOptions::default());
766
767        assert!(svg.contains("fill:yellow"));
768        assert!(svg.contains("stroke:purple"));
769        assert!(svg.contains("stroke-width:3"));
770        assert!(svg.contains("opacity:0.5"));
771    }
772
773    #[test]
774    fn test_empty_draw_no_overlays() {
775        let renderer = SvgRenderer::new(100.0, 100.0);
776        let svg = renderer.to_svg(&SvgOptions::default());
777
778        // Only the page boundary rect
779        let rect_count = svg.matches("<rect").count();
780        assert_eq!(rect_count, 1);
781        let line_count = svg.matches("<line").count();
782        assert_eq!(line_count, 0);
783    }
784
785    #[test]
786    fn test_draw_chars_empty_slice() {
787        let mut renderer = SvgRenderer::new(100.0, 100.0);
788        renderer.draw_chars(&[], &DrawStyle::chars_default());
789        let svg = renderer.to_svg(&SvgOptions::default());
790
791        // Only the page boundary rect
792        let rect_count = svg.matches("<rect").count();
793        assert_eq!(rect_count, 1);
794    }
795
796    // --- US-069 tests: SvgDebugOptions ---
797
798    #[test]
799    fn test_svg_debug_options_default() {
800        let opts = SvgDebugOptions::default();
801        assert!(opts.show_edges);
802        assert!(opts.show_intersections);
803        assert!(opts.show_cells);
804        assert!(opts.show_tables);
805    }
806
807    #[test]
808    fn test_svg_debug_options_selective() {
809        let opts = SvgDebugOptions {
810            show_edges: true,
811            show_intersections: false,
812            show_cells: false,
813            show_tables: true,
814        };
815        assert!(opts.show_edges);
816        assert!(!opts.show_intersections);
817        assert!(!opts.show_cells);
818        assert!(opts.show_tables);
819    }
820
821    // --- US-069 tests: DrawStyle defaults for debug ---
822
823    #[test]
824    fn test_draw_style_intersections_default() {
825        let style = DrawStyle::intersections_default();
826        // Intersections should be filled circles
827        assert!(style.fill.is_some());
828        assert!(style.stroke.is_some());
829    }
830
831    #[test]
832    fn test_draw_style_cells_default() {
833        let style = DrawStyle::cells_default();
834        // Cells should have dashed stroke
835        assert!(style.stroke.is_some());
836    }
837
838    // --- US-069 tests: draw_intersections ---
839
840    #[test]
841    fn test_draw_intersections_adds_circle_elements() {
842        let mut renderer = SvgRenderer::new(200.0, 200.0);
843        let intersections = vec![
844            Intersection { x: 50.0, y: 50.0 },
845            Intersection { x: 100.0, y: 50.0 },
846            Intersection { x: 50.0, y: 100.0 },
847        ];
848        renderer.draw_intersections(&intersections, &DrawStyle::intersections_default());
849        let svg = renderer.to_svg(&SvgOptions::default());
850
851        let circle_count = svg.matches("<circle").count();
852        assert_eq!(circle_count, 3, "Should have 3 intersection circles");
853    }
854
855    #[test]
856    fn test_draw_intersections_correct_coordinates() {
857        let mut renderer = SvgRenderer::new(200.0, 200.0);
858        let intersections = vec![Intersection { x: 75.0, y: 125.0 }];
859        renderer.draw_intersections(&intersections, &DrawStyle::intersections_default());
860        let svg = renderer.to_svg(&SvgOptions::default());
861
862        assert!(svg.contains("cx=\"75\""));
863        assert!(svg.contains("cy=\"125\""));
864    }
865
866    #[test]
867    fn test_draw_intersections_empty_slice() {
868        let mut renderer = SvgRenderer::new(100.0, 100.0);
869        renderer.draw_intersections(&[], &DrawStyle::intersections_default());
870        let svg = renderer.to_svg(&SvgOptions::default());
871
872        let circle_count = svg.matches("<circle").count();
873        assert_eq!(circle_count, 0);
874    }
875
876    // --- US-069 tests: draw_cells (dashed lines) ---
877
878    #[test]
879    fn test_draw_cells_adds_rect_elements_with_dashed_style() {
880        let mut renderer = SvgRenderer::new(200.0, 200.0);
881        let cells = vec![
882            Cell {
883                bbox: BBox::new(10.0, 10.0, 100.0, 50.0),
884                text: None,
885            },
886            Cell {
887                bbox: BBox::new(100.0, 10.0, 200.0, 50.0),
888                text: None,
889            },
890        ];
891        renderer.draw_cells(&cells, &DrawStyle::cells_default());
892        let svg = renderer.to_svg(&SvgOptions::default());
893
894        // 1 page boundary + 2 cell rects
895        let rect_count = svg.matches("<rect").count();
896        assert_eq!(rect_count, 3);
897        // Cell rects should have dashed stroke
898        assert!(svg.contains("stroke-dasharray"));
899    }
900
901    #[test]
902    fn test_draw_cells_correct_coordinates() {
903        let mut renderer = SvgRenderer::new(200.0, 200.0);
904        let cells = vec![Cell {
905            bbox: BBox::new(20.0, 30.0, 80.0, 90.0),
906            text: None,
907        }];
908        renderer.draw_cells(&cells, &DrawStyle::cells_default());
909        let svg = renderer.to_svg(&SvgOptions::default());
910
911        assert!(svg.contains("x=\"20\""));
912        assert!(svg.contains("y=\"30\""));
913        assert!(svg.contains("width=\"60\"")); // 80 - 20
914        assert!(svg.contains("height=\"60\"")); // 90 - 30
915    }
916
917    // --- US-069 tests: debug_tablefinder_svg via SvgRenderer ---
918
919    #[test]
920    fn test_debug_tablefinder_svg_with_table() {
921        let mut renderer = SvgRenderer::new(300.0, 200.0);
922
923        // Simulate table detection pipeline outputs
924        let edges = vec![
925            make_edge(10.0, 10.0, 200.0, 10.0),
926            make_edge(10.0, 50.0, 200.0, 50.0),
927            make_edge(10.0, 100.0, 200.0, 100.0),
928        ];
929        let intersections = vec![
930            Intersection { x: 10.0, y: 10.0 },
931            Intersection { x: 200.0, y: 10.0 },
932            Intersection { x: 10.0, y: 50.0 },
933            Intersection { x: 200.0, y: 50.0 },
934        ];
935        let cells = vec![Cell {
936            bbox: BBox::new(10.0, 10.0, 200.0, 50.0),
937            text: None,
938        }];
939        let tables = vec![Table {
940            bbox: BBox::new(10.0, 10.0, 200.0, 100.0),
941            cells: cells.clone(),
942            rows: vec![],
943            columns: vec![],
944        }];
945
946        let debug_opts = SvgDebugOptions::default();
947
948        if debug_opts.show_edges {
949            renderer.draw_edges(&edges, &DrawStyle::edges_default());
950        }
951        if debug_opts.show_intersections {
952            renderer.draw_intersections(&intersections, &DrawStyle::intersections_default());
953        }
954        if debug_opts.show_cells {
955            renderer.draw_cells(&cells, &DrawStyle::cells_default());
956        }
957        if debug_opts.show_tables {
958            renderer.draw_tables(&tables, &DrawStyle::tables_default());
959        }
960
961        let svg = renderer.to_svg(&SvgOptions::default());
962
963        // Edges rendered as lines (red)
964        assert!(svg.contains("<line"), "Should contain edge lines");
965        // Intersections rendered as circles
966        assert!(
967            svg.contains("<circle"),
968            "Should contain intersection circles"
969        );
970        // Cells rendered as dashed rects
971        assert!(
972            svg.contains("stroke-dasharray"),
973            "Should contain dashed cell boundaries"
974        );
975        // Tables rendered as filled rects
976        assert!(svg.contains("fill:lightblue"), "Should contain table fill");
977    }
978
979    #[test]
980    fn test_debug_tablefinder_svg_no_tables() {
981        let renderer = SvgRenderer::new(300.0, 200.0);
982        let svg = renderer.to_svg(&SvgOptions::default());
983
984        // No edges, intersections, cells, or tables - just the page boundary
985        assert!(svg.contains("<svg"));
986        assert!(svg.contains("</svg>"));
987        let rect_count = svg.matches("<rect").count();
988        assert_eq!(rect_count, 1, "Only page boundary rect");
989    }
990
991    #[test]
992    fn test_debug_tablefinder_svg_selective_show() {
993        let mut renderer = SvgRenderer::new(200.0, 200.0);
994
995        let edges = vec![make_edge(10.0, 10.0, 200.0, 10.0)];
996        let intersections = vec![Intersection { x: 10.0, y: 10.0 }];
997
998        let debug_opts = SvgDebugOptions {
999            show_edges: true,
1000            show_intersections: false,
1001            show_cells: false,
1002            show_tables: false,
1003        };
1004
1005        if debug_opts.show_edges {
1006            renderer.draw_edges(&edges, &DrawStyle::edges_default());
1007        }
1008        if debug_opts.show_intersections {
1009            renderer.draw_intersections(&intersections, &DrawStyle::intersections_default());
1010        }
1011
1012        let svg = renderer.to_svg(&SvgOptions::default());
1013
1014        // Edges should be present
1015        assert!(svg.contains("<line"), "Edges should be shown");
1016        // Intersections should NOT be present
1017        assert!(!svg.contains("<circle"), "Intersections should be hidden");
1018    }
1019}