Skip to main content

draw_core/
export_svg.rs

1use crate::document::Document;
2use crate::element::{Element, ShapeElement};
3use crate::point::Bounds;
4use crate::render::{ARROWHEAD_ANGLE, ARROWHEAD_LENGTH};
5use crate::style::{FillStyle, FillType};
6
7// ── Hachure rendering constants ─────────────────────────────────────
8const HACHURE_LINE_WIDTH: f64 = 1.5;
9const HACHURE_OPACITY: f64 = 0.5;
10const PERPENDICULAR_OFFSET: f64 = std::f64::consts::FRAC_PI_2;
11
12pub fn export_svg(doc: &Document) -> String {
13    if doc.elements.is_empty() {
14        return r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"></svg>"#
15            .to_string();
16    }
17
18    // Compute bounding box of all elements
19    let bounds = compute_bounds(&doc.elements);
20    let padding = 20.0;
21    let x = bounds.x - padding;
22    let y = bounds.y - padding;
23    let w = bounds.width + padding * 2.0;
24    let h = bounds.height + padding * 2.0;
25
26    let mut svg = format!(
27        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x} {y} {w} {h}" width="{w}" height="{h}">"#
28    );
29    svg.push('\n');
30
31    // Collect defs (clipPaths for hachure fills)
32    let mut defs = String::new();
33    let mut clip_id: usize = 0;
34
35    for element in &doc.elements {
36        let (element_svg, element_defs) = render_element(element, &mut clip_id);
37        defs.push_str(&element_defs);
38        svg.push_str(&element_svg);
39        svg.push('\n');
40    }
41
42    // Insert defs block before elements if needed
43    if !defs.is_empty() {
44        let defs_block = format!("  <defs>\n{defs}  </defs>\n");
45        let insert_pos = svg.find('\n').unwrap() + 1;
46        svg.insert_str(insert_pos, &defs_block);
47    }
48
49    svg.push_str("</svg>");
50    svg
51}
52
53fn compute_bounds(elements: &[Element]) -> Bounds {
54    let mut min_x = f64::INFINITY;
55    let mut min_y = f64::INFINITY;
56    let mut max_x = f64::NEG_INFINITY;
57    let mut max_y = f64::NEG_INFINITY;
58
59    for element in elements {
60        let b = element.bounds();
61        min_x = min_x.min(b.x);
62        min_y = min_y.min(b.y);
63        max_x = max_x.max(b.x + b.width);
64        max_y = max_y.max(b.y + b.height);
65    }
66
67    Bounds::new(min_x, min_y, max_x - min_x, max_y - min_y)
68}
69
70fn render_element(element: &Element, clip_id: &mut usize) -> (String, String) {
71    match element {
72        Element::Rectangle(e) => render_rectangle(e, clip_id),
73        Element::Ellipse(e) => render_ellipse(e, clip_id),
74        Element::Diamond(e) => render_diamond(e, clip_id),
75        Element::Line(e) | Element::Arrow(e) => {
76            if e.points.len() < 2 {
77                return (String::new(), String::new());
78            }
79            let mut d = format!("M {} {}", e.points[0].x + e.x, e.points[0].y + e.y);
80            for p in &e.points[1..] {
81                d.push_str(&format!(" L {} {}", p.x + e.x, p.y + e.y));
82            }
83            let stroke = stroke_attrs(&e.stroke.color, e.stroke.width, &e.stroke.dash);
84            let marker = if matches!(element, Element::Arrow(_)) {
85                let arrow_len = ARROWHEAD_LENGTH as f64;
86                let arrow_angle = ARROWHEAD_ANGLE as f64;
87                let mut markers = String::new();
88
89                // End arrowhead (tip at last point, pointing away from second-to-last)
90                let last = e.points.last().unwrap();
91                let prev = if e.points.len() >= 2 {
92                    &e.points[e.points.len() - 2]
93                } else {
94                    &e.points[0]
95                };
96                let angle = (last.y - prev.y).atan2(last.x - prev.x);
97                let tip_x = last.x + e.x;
98                let tip_y = last.y + e.y;
99                let left_x = tip_x - arrow_len * (angle - arrow_angle).cos();
100                let left_y = tip_y - arrow_len * (angle - arrow_angle).sin();
101                let right_x = tip_x - arrow_len * (angle + arrow_angle).cos();
102                let right_y = tip_y - arrow_len * (angle + arrow_angle).sin();
103                markers.push_str(&format!(
104                    r#"  <polygon points="{tip_x},{tip_y} {left_x},{left_y} {right_x},{right_y}" fill="{}" stroke="none"/>"#,
105                    e.stroke.color
106                ));
107
108                // Start arrowhead (tip at first point, pointing away from second point)
109                if e.start_arrowhead.is_some() {
110                    let first = &e.points[0];
111                    let next = &e.points[1];
112                    let start_angle = (first.y - next.y).atan2(first.x - next.x);
113                    let start_tip_x = first.x + e.x;
114                    let start_tip_y = first.y + e.y;
115                    let start_left_x = start_tip_x - arrow_len * (start_angle - arrow_angle).cos();
116                    let start_left_y = start_tip_y - arrow_len * (start_angle - arrow_angle).sin();
117                    let start_right_x = start_tip_x - arrow_len * (start_angle + arrow_angle).cos();
118                    let start_right_y = start_tip_y - arrow_len * (start_angle + arrow_angle).sin();
119                    markers.push_str(&format!(
120                        r#"  <polygon points="{start_tip_x},{start_tip_y} {start_left_x},{start_left_y} {start_right_x},{start_right_y}" fill="{}" stroke="none"/>"#,
121                        e.stroke.color
122                    ));
123                }
124
125                markers
126            } else {
127                String::new()
128            };
129            (
130                format!(
131                    r#"  <path d="{d}" fill="none" {stroke} opacity="{}"/>{marker}"#,
132                    e.opacity
133                ),
134                String::new(),
135            )
136        }
137        Element::FreeDraw(e) => {
138            if e.points.is_empty() {
139                return (String::new(), String::new());
140            }
141            let mut d = format!("M {} {}", e.points[0].x + e.x, e.points[0].y + e.y);
142            for p in &e.points[1..] {
143                d.push_str(&format!(" L {} {}", p.x + e.x, p.y + e.y));
144            }
145            let stroke = stroke_attrs(&e.stroke.color, e.stroke.width, &e.stroke.dash);
146            (
147                format!(
148                    r#"  <path d="{d}" fill="none" {stroke} opacity="{}" stroke-linecap="round" stroke-linejoin="round"/>"#,
149                    e.opacity
150                ),
151                String::new(),
152            )
153        }
154        Element::Text(e) => {
155            let anchor = match e.font.align {
156                crate::style::TextAlign::Left => "start",
157                crate::style::TextAlign::Center => "middle",
158                crate::style::TextAlign::Right => "end",
159            };
160            let text_color = &e.stroke.color;
161            (
162                format!(
163                    r#"  <text x="{}" y="{}" font-family="{}" font-size="{}" text-anchor="{anchor}" fill="{}" opacity="{}">{}</text>"#,
164                    e.x,
165                    e.y + e.font.size,
166                    xml_escape(&e.font.family),
167                    e.font.size,
168                    xml_escape(text_color),
169                    e.opacity,
170                    xml_escape(&e.text)
171                ),
172                String::new(),
173            )
174        }
175    }
176}
177
178// ── Shape renderers ─────────────────────────────────────────────────
179
180fn render_rectangle(e: &ShapeElement, clip_id: &mut usize) -> (String, String) {
181    let stroke = stroke_attrs(&e.stroke.color, e.stroke.width, &e.stroke.dash);
182
183    match e.fill.style {
184        FillType::Hachure | FillType::CrossHatch => {
185            let id = *clip_id;
186            *clip_id += 1;
187            let clip_name = format!("clip-{id}");
188
189            let clip_def = format!(
190                "    <clipPath id=\"{clip_name}\">\n      <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>\n    </clipPath>\n",
191                e.x, e.y, e.width, e.height
192            );
193
194            let bounds = Bounds::new(e.x, e.y, e.width, e.height);
195            let hachure_lines = render_hachure_group(&clip_name, &bounds, &e.fill, e.opacity);
196
197            let shape = format!(
198                r#"  <rect x="{}" y="{}" width="{}" height="{}" fill="none" {stroke} opacity="{}"/>"#,
199                e.x, e.y, e.width, e.height, e.opacity
200            );
201
202            (format!("{hachure_lines}\n{shape}"), clip_def)
203        }
204        _ => {
205            let fill = fill_attr(&e.fill.color, &e.fill.style);
206            (
207                format!(
208                    r#"  <rect x="{}" y="{}" width="{}" height="{}" {fill} {stroke} opacity="{}"/>"#,
209                    e.x, e.y, e.width, e.height, e.opacity
210                ),
211                String::new(),
212            )
213        }
214    }
215}
216
217fn render_ellipse(e: &ShapeElement, clip_id: &mut usize) -> (String, String) {
218    let cx = e.x + e.width / 2.0;
219    let cy = e.y + e.height / 2.0;
220    let rx = e.width / 2.0;
221    let ry = e.height / 2.0;
222    let stroke = stroke_attrs(&e.stroke.color, e.stroke.width, &e.stroke.dash);
223
224    match e.fill.style {
225        FillType::Hachure | FillType::CrossHatch => {
226            let id = *clip_id;
227            *clip_id += 1;
228            let clip_name = format!("clip-{id}");
229
230            let clip_def = format!(
231                "    <clipPath id=\"{clip_name}\">\n      <ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\"/>\n    </clipPath>\n",
232            );
233
234            let bounds = Bounds::new(e.x, e.y, e.width, e.height);
235            let hachure_lines = render_hachure_group(&clip_name, &bounds, &e.fill, e.opacity);
236
237            let shape = format!(
238                r#"  <ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" fill="none" {stroke} opacity="{}"/>"#,
239                e.opacity
240            );
241
242            (format!("{hachure_lines}\n{shape}"), clip_def)
243        }
244        _ => {
245            let fill = fill_attr(&e.fill.color, &e.fill.style);
246            (
247                format!(
248                    r#"  <ellipse cx="{cx}" cy="{cy}" rx="{rx}" ry="{ry}" {fill} {stroke} opacity="{}"/>"#,
249                    e.opacity
250                ),
251                String::new(),
252            )
253        }
254    }
255}
256
257fn render_diamond(e: &ShapeElement, clip_id: &mut usize) -> (String, String) {
258    let cx = e.x + e.width / 2.0;
259    let cy = e.y + e.height / 2.0;
260    let points = format!(
261        "{},{} {},{} {},{} {},{}",
262        cx,
263        e.y,
264        e.x + e.width,
265        cy,
266        cx,
267        e.y + e.height,
268        e.x,
269        cy
270    );
271    let stroke = stroke_attrs(&e.stroke.color, e.stroke.width, &e.stroke.dash);
272
273    match e.fill.style {
274        FillType::Hachure | FillType::CrossHatch => {
275            let id = *clip_id;
276            *clip_id += 1;
277            let clip_name = format!("clip-{id}");
278
279            let clip_def = format!(
280                "    <clipPath id=\"{clip_name}\">\n      <polygon points=\"{points}\"/>\n    </clipPath>\n",
281            );
282
283            let bounds = Bounds::new(e.x, e.y, e.width, e.height);
284            let hachure_lines = render_hachure_group(&clip_name, &bounds, &e.fill, e.opacity);
285
286            let shape = format!(
287                r#"  <polygon points="{points}" fill="none" {stroke} opacity="{}"/>"#,
288                e.opacity
289            );
290
291            (format!("{hachure_lines}\n{shape}"), clip_def)
292        }
293        _ => {
294            let fill = fill_attr(&e.fill.color, &e.fill.style);
295            (
296                format!(
297                    r#"  <polygon points="{points}" {fill} {stroke} opacity="{}"/>"#,
298                    e.opacity
299                ),
300                String::new(),
301            )
302        }
303    }
304}
305
306// ── Hachure line generation ─────────────────────────────────────────
307
308/// Generate parallel hachure lines across a bounding box at the given angle.
309/// Returns SVG `<line>` elements as a string. Lines span from -diag to +diag
310/// (rotated around center) so they fully cover the shape before clipping.
311fn generate_hachure_lines(bounds: &Bounds, color: &str, gap: f64, angle: f64) -> String {
312    let cx = bounds.x + bounds.width / 2.0;
313    let cy = bounds.y + bounds.height / 2.0;
314    let diag = (bounds.width * bounds.width + bounds.height * bounds.height).sqrt();
315
316    let cos_a = angle.cos();
317    let sin_a = angle.sin();
318
319    let mut lines = String::new();
320    let mut d = -diag;
321    while d < diag {
322        // Line endpoints before rotation: (d, -diag) to (d, diag)
323        // After rotation around center:
324        let x1 = cx + d * cos_a - (-diag) * sin_a;
325        let y1 = cy + d * sin_a + (-diag) * cos_a;
326        let x2 = cx + d * cos_a - diag * sin_a;
327        let y2 = cy + d * sin_a + diag * cos_a;
328
329        lines.push_str(&format!(
330            r#"    <line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{HACHURE_LINE_WIDTH}" stroke-linecap="round"/>"#
331        ));
332        lines.push('\n');
333        d += gap;
334    }
335    lines
336}
337
338/// Render a `<g>` group with clip-path containing hachure lines.
339/// Supports both single-pass Hachure and double-pass CrossHatch.
340fn render_hachure_group(clip_id: &str, bounds: &Bounds, fill: &FillStyle, opacity: f64) -> String {
341    let color = &fill.color;
342    let gap = fill.gap;
343    let angle = fill.angle;
344
345    let mut lines = generate_hachure_lines(bounds, color, gap, angle);
346
347    if fill.style == FillType::CrossHatch {
348        lines.push_str(&generate_hachure_lines(
349            bounds,
350            color,
351            gap,
352            angle + PERPENDICULAR_OFFSET,
353        ));
354    }
355
356    format!(
357        r#"  <g clip-path="url(#{clip_id})" opacity="{}" style="opacity:{HACHURE_OPACITY}">
358{lines}  </g>"#,
359        opacity
360    )
361}
362
363// ── Utility functions ───────────────────────────────────────────────
364
365fn fill_attr(color: &str, style: &FillType) -> String {
366    match style {
367        FillType::None => r#"fill="none""#.to_string(),
368        _ => format!(r#"fill="{color}""#),
369    }
370}
371
372fn stroke_attrs(color: &str, width: f64, dash: &[f64]) -> String {
373    let mut s = format!(r#"stroke="{color}" stroke-width="{width}""#);
374    if !dash.is_empty() {
375        let dash_str: Vec<String> = dash.iter().map(|d| d.to_string()).collect();
376        s.push_str(&format!(r#" stroke-dasharray="{}""#, dash_str.join(",")));
377    }
378    s
379}
380
381fn xml_escape(s: &str) -> String {
382    s.replace('&', "&amp;")
383        .replace('<', "&lt;")
384        .replace('>', "&gt;")
385        .replace('"', "&quot;")
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::document::Document;
392    use crate::element::{Element, ShapeElement};
393    use crate::style::FillType;
394
395    #[test]
396    fn test_empty_document() {
397        let doc = Document::new("test".to_string());
398        let svg = export_svg(&doc);
399        assert!(svg.contains("<svg"));
400        assert!(svg.contains("</svg>"));
401    }
402
403    #[test]
404    fn test_rectangle() {
405        let mut doc = Document::new("test".to_string());
406        doc.add_element(Element::Rectangle(ShapeElement::new(
407            "r1".to_string(),
408            10.0,
409            20.0,
410            100.0,
411            50.0,
412        )));
413        let svg = export_svg(&doc);
414        assert!(svg.contains("<rect"));
415        assert!(svg.contains(r#"x="10""#));
416        assert!(svg.contains(r#"width="100""#));
417    }
418
419    #[test]
420    fn test_ellipse() {
421        let mut doc = Document::new("test".to_string());
422        doc.add_element(Element::Ellipse(ShapeElement::new(
423            "e1".to_string(),
424            0.0,
425            0.0,
426            100.0,
427            60.0,
428        )));
429        let svg = export_svg(&doc);
430        assert!(svg.contains("<ellipse"));
431        assert!(svg.contains(r#"rx="50""#));
432        assert!(svg.contains(r#"ry="30""#));
433    }
434
435    #[test]
436    fn test_line() {
437        use crate::element::LineElement;
438        use crate::point::Point;
439        let mut doc = Document::new("test".to_string());
440        doc.add_element(Element::Line(LineElement::new(
441            "l1".to_string(),
442            0.0,
443            0.0,
444            vec![Point::new(0.0, 0.0), Point::new(50.0, 50.0)],
445        )));
446        let svg = export_svg(&doc);
447        assert!(svg.contains("<path"));
448        assert!(svg.contains(r#"fill="none""#));
449    }
450
451    #[test]
452    fn test_arrow() {
453        use crate::element::LineElement;
454        use crate::point::Point;
455        let mut doc = Document::new("test".to_string());
456        doc.add_element(Element::Arrow(LineElement::new(
457            "a1".to_string(),
458            0.0,
459            0.0,
460            vec![Point::new(0.0, 0.0), Point::new(50.0, 50.0)],
461        )));
462        let svg = export_svg(&doc);
463        assert!(svg.contains("<path"));
464        assert!(svg.contains("<polygon")); // arrowhead
465    }
466
467    #[test]
468    fn test_text() {
469        use crate::element::TextElement;
470        let mut doc = Document::new("test".to_string());
471        doc.add_element(Element::Text(TextElement::new(
472            "t1".to_string(),
473            10.0,
474            20.0,
475            "Hello <world> & \"friends\"".to_string(),
476        )));
477        let svg = export_svg(&doc);
478        assert!(svg.contains("<text"));
479        assert!(svg.contains("&lt;world&gt;"));
480        assert!(svg.contains("&amp;"));
481        assert!(svg.contains("&quot;friends&quot;"));
482    }
483
484    #[test]
485    fn test_freedraw() {
486        use crate::element::FreeDrawElement;
487        use crate::point::Point;
488        let mut doc = Document::new("test".to_string());
489        doc.add_element(Element::FreeDraw(FreeDrawElement::new(
490            "fd1".to_string(),
491            0.0,
492            0.0,
493            vec![
494                Point::new(0.0, 0.0),
495                Point::new(5.0, 5.0),
496                Point::new(10.0, 0.0),
497            ],
498        )));
499        let svg = export_svg(&doc);
500        assert!(svg.contains("<path"));
501        assert!(svg.contains("stroke-linecap"));
502    }
503
504    #[test]
505    fn test_diamond() {
506        let mut doc = Document::new("test".to_string());
507        doc.add_element(Element::Diamond(ShapeElement::new(
508            "d1".to_string(),
509            0.0,
510            0.0,
511            100.0,
512            100.0,
513        )));
514        let svg = export_svg(&doc);
515        assert!(svg.contains("<polygon"));
516    }
517
518    #[test]
519    fn test_multi_element_svg() {
520        use crate::element::{FreeDrawElement, LineElement, TextElement};
521        use crate::point::Point;
522
523        let mut doc = Document::new("multi".to_string());
524        doc.add_element(Element::Rectangle(ShapeElement::new(
525            "r1".to_string(),
526            0.0,
527            0.0,
528            100.0,
529            50.0,
530        )));
531        doc.add_element(Element::Ellipse(ShapeElement::new(
532            "e1".to_string(),
533            120.0,
534            0.0,
535            80.0,
536            60.0,
537        )));
538        doc.add_element(Element::Line(LineElement::new(
539            "l1".to_string(),
540            0.0,
541            70.0,
542            vec![Point::new(0.0, 0.0), Point::new(50.0, 50.0)],
543        )));
544        doc.add_element(Element::FreeDraw(FreeDrawElement::new(
545            "fd1".to_string(),
546            60.0,
547            70.0,
548            vec![
549                Point::new(0.0, 0.0),
550                Point::new(5.0, 10.0),
551                Point::new(10.0, 0.0),
552            ],
553        )));
554        doc.add_element(Element::Text(TextElement::new(
555            "t1".to_string(),
556            0.0,
557            150.0,
558            "hello".to_string(),
559        )));
560
561        let svg = export_svg(&doc);
562
563        // Valid SVG structure
564        assert!(svg.starts_with("<svg"));
565        assert!(svg.ends_with("</svg>"));
566
567        // All element types present
568        assert!(svg.contains("<rect"));
569        assert!(svg.contains("<ellipse"));
570        assert!(svg.contains("<text"));
571        // Line and freedraw both produce <path>
572        assert!(svg.matches("<path").count() >= 2);
573
574        // viewBox is set (non-empty doc)
575        assert!(svg.contains("viewBox="));
576    }
577
578    #[test]
579    fn test_arrow_start_and_end_arrowheads() {
580        use crate::element::LineElement;
581        use crate::point::Point;
582        use crate::style::Arrowhead;
583
584        let mut doc = Document::new("arrows".to_string());
585        let mut arrow = LineElement::new(
586            "a1".to_string(),
587            0.0,
588            0.0,
589            vec![Point::new(0.0, 0.0), Point::new(100.0, 0.0)],
590        );
591        arrow.start_arrowhead = Some(Arrowhead::Arrow);
592        arrow.end_arrowhead = Some(Arrowhead::Arrow);
593        doc.add_element(Element::Arrow(arrow));
594
595        let svg = export_svg(&doc);
596
597        // Should have two polygon elements (one for each arrowhead)
598        let polygon_count = svg.matches("<polygon").count();
599        assert_eq!(
600            polygon_count, 2,
601            "Expected 2 arrowhead polygons (start + end), got {polygon_count}"
602        );
603    }
604
605    // ── Hachure fill tests ──────────────────────────────────────────
606
607    #[test]
608    fn test_rectangle_hachure_fill() {
609        let mut doc = Document::new("test".to_string());
610        let mut shape = ShapeElement::new("r1".to_string(), 10.0, 20.0, 100.0, 50.0);
611        shape.fill.style = FillType::Hachure;
612        shape.fill.color = "#ff0000".to_string();
613        doc.add_element(Element::Rectangle(shape));
614        let svg = export_svg(&doc);
615
616        // Should have a clipPath in defs
617        assert!(svg.contains("<defs>"));
618        assert!(svg.contains("<clipPath"));
619        assert!(svg.contains("clip-0"));
620        // Should have hachure lines clipped to shape
621        assert!(svg.contains(r#"clip-path="url(#clip-0)""#));
622        assert!(svg.contains("<line"));
623        assert!(svg.contains("stroke=\"#ff0000\""));
624        // Shape stroke should still render with fill="none"
625        assert!(svg.contains(r#"<rect x="10" y="20" width="100" height="50" fill="none""#));
626    }
627
628    #[test]
629    fn test_ellipse_hachure_fill() {
630        let mut doc = Document::new("test".to_string());
631        let mut shape = ShapeElement::new("e1".to_string(), 0.0, 0.0, 80.0, 60.0);
632        shape.fill.style = FillType::Hachure;
633        doc.add_element(Element::Ellipse(shape));
634        let svg = export_svg(&doc);
635
636        assert!(svg.contains("<clipPath"));
637        assert!(svg.contains("<ellipse cx="));
638        assert!(svg.contains(r#"clip-path="url(#clip-0)""#));
639        assert!(svg.contains("<line"));
640    }
641
642    #[test]
643    fn test_diamond_hachure_fill() {
644        let mut doc = Document::new("test".to_string());
645        let mut shape = ShapeElement::new("d1".to_string(), 0.0, 0.0, 100.0, 100.0);
646        shape.fill.style = FillType::Hachure;
647        doc.add_element(Element::Diamond(shape));
648        let svg = export_svg(&doc);
649
650        assert!(svg.contains("<clipPath"));
651        assert!(svg.contains("<polygon points="));
652        assert!(svg.contains(r#"clip-path="url(#clip-0)""#));
653        assert!(svg.contains("<line"));
654    }
655
656    #[test]
657    fn test_crosshatch_has_more_lines_than_hachure() {
658        // CrossHatch should produce roughly 2x lines (two passes)
659        let mut doc_hachure = Document::new("test".to_string());
660        let mut shape_h = ShapeElement::new("r1".to_string(), 0.0, 0.0, 100.0, 100.0);
661        shape_h.fill.style = FillType::Hachure;
662        doc_hachure.add_element(Element::Rectangle(shape_h));
663        let svg_hachure = export_svg(&doc_hachure);
664
665        let mut doc_cross = Document::new("test".to_string());
666        let mut shape_c = ShapeElement::new("r2".to_string(), 0.0, 0.0, 100.0, 100.0);
667        shape_c.fill.style = FillType::CrossHatch;
668        doc_cross.add_element(Element::Rectangle(shape_c));
669        let svg_cross = export_svg(&doc_cross);
670
671        let hachure_lines = svg_hachure.matches("<line").count();
672        let cross_lines = svg_cross.matches("<line").count();
673        assert!(
674            cross_lines > hachure_lines,
675            "CrossHatch ({cross_lines} lines) should have more lines than Hachure ({hachure_lines} lines)"
676        );
677    }
678
679    #[test]
680    fn test_solid_fill_no_clippath() {
681        let mut doc = Document::new("test".to_string());
682        let mut shape = ShapeElement::new("r1".to_string(), 0.0, 0.0, 100.0, 50.0);
683        shape.fill.style = FillType::Solid;
684        shape.fill.color = "#00ff00".to_string();
685        doc.add_element(Element::Rectangle(shape));
686        let svg = export_svg(&doc);
687
688        // Solid fill should NOT produce clipPaths or hachure lines
689        assert!(!svg.contains("<defs>"));
690        assert!(!svg.contains("<clipPath"));
691        assert!(!svg.contains("<line"));
692        assert!(svg.contains("fill=\"#00ff00\""));
693    }
694
695    #[test]
696    fn test_none_fill_no_clippath() {
697        let mut doc = Document::new("test".to_string());
698        let mut shape = ShapeElement::new("r1".to_string(), 0.0, 0.0, 100.0, 50.0);
699        shape.fill.style = FillType::None;
700        doc.add_element(Element::Rectangle(shape));
701        let svg = export_svg(&doc);
702
703        assert!(!svg.contains("<defs>"));
704        assert!(!svg.contains("<line"));
705        assert!(svg.contains(r#"fill="none""#));
706    }
707
708    #[test]
709    fn test_multiple_hachure_shapes_unique_clip_ids() {
710        let mut doc = Document::new("test".to_string());
711        let mut r1 = ShapeElement::new("r1".to_string(), 0.0, 0.0, 50.0, 50.0);
712        r1.fill.style = FillType::Hachure;
713        let mut r2 = ShapeElement::new("r2".to_string(), 100.0, 0.0, 50.0, 50.0);
714        r2.fill.style = FillType::Hachure;
715        doc.add_element(Element::Rectangle(r1));
716        doc.add_element(Element::Rectangle(r2));
717        let svg = export_svg(&doc);
718
719        // Should have two distinct clip IDs
720        assert!(svg.contains("clip-0"));
721        assert!(svg.contains("clip-1"));
722    }
723
724    #[test]
725    fn test_hachure_respects_gap() {
726        let mut doc_small = Document::new("test".to_string());
727        let mut shape_small = ShapeElement::new("r1".to_string(), 0.0, 0.0, 100.0, 100.0);
728        shape_small.fill.style = FillType::Hachure;
729        shape_small.fill.gap = 5.0;
730        doc_small.add_element(Element::Rectangle(shape_small));
731        let svg_small = export_svg(&doc_small);
732
733        let mut doc_large = Document::new("test".to_string());
734        let mut shape_large = ShapeElement::new("r2".to_string(), 0.0, 0.0, 100.0, 100.0);
735        shape_large.fill.style = FillType::Hachure;
736        shape_large.fill.gap = 20.0;
737        doc_large.add_element(Element::Rectangle(shape_large));
738        let svg_large = export_svg(&doc_large);
739
740        let small_lines = svg_small.matches("<line").count();
741        let large_lines = svg_large.matches("<line").count();
742        assert!(
743            small_lines > large_lines,
744            "Smaller gap ({small_lines} lines) should produce more lines than larger gap ({large_lines} lines)"
745        );
746    }
747}