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
7const 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 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 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 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 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 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
178fn 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
306fn 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 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
338fn 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
363fn 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('&', "&")
383 .replace('<', "<")
384 .replace('>', ">")
385 .replace('"', """)
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")); }
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("<world>"));
480 assert!(svg.contains("&"));
481 assert!(svg.contains(""friends""));
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 assert!(svg.starts_with("<svg"));
565 assert!(svg.ends_with("</svg>"));
566
567 assert!(svg.contains("<rect"));
569 assert!(svg.contains("<ellipse"));
570 assert!(svg.contains("<text"));
571 assert!(svg.matches("<path").count() >= 2);
573
574 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 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 #[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 assert!(svg.contains("<defs>"));
618 assert!(svg.contains("<clipPath"));
619 assert!(svg.contains("clip-0"));
620 assert!(svg.contains(r#"clip-path="url(#clip-0)""#));
622 assert!(svg.contains("<line"));
623 assert!(svg.contains("stroke=\"#ff0000\""));
624 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 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 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 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}