Skip to main content

graphitepdf_svg/
lib.rs

1pub mod error;
2
3pub use error::*;
4
5use std::collections::BTreeMap;
6use std::fmt;
7use std::str::from_utf8;
8
9use graphitepdf_primitives as P;
10use quick_xml::Reader;
11use quick_xml::XmlVersion;
12use quick_xml::escape::unescape;
13use quick_xml::events::{BytesCData, BytesRef, BytesStart, BytesText, Event};
14
15pub type SvgProps = BTreeMap<String, String>;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum SvgNodeKind {
19    Svg,
20    G,
21    Path,
22    Rect,
23    Circle,
24    Ellipse,
25    Line,
26    Polyline,
27    Polygon,
28    Text,
29    Tspan,
30    Defs,
31    ClipPath,
32    LinearGradient,
33    RadialGradient,
34    Marker,
35    Stop,
36    Image,
37    Use,
38    TextInstance,
39}
40
41impl SvgNodeKind {
42    pub const fn primitive_name(self) -> &'static str {
43        match self {
44            Self::Svg => P::Svg,
45            Self::G => P::G,
46            Self::Path => P::Path,
47            Self::Rect => P::Rect,
48            Self::Circle => P::Circle,
49            Self::Ellipse => P::Ellipse,
50            Self::Line => P::Line,
51            Self::Polyline => P::Polyline,
52            Self::Polygon => P::Polygon,
53            Self::Text => P::Text,
54            Self::Tspan => P::Tspan,
55            Self::Defs => P::Defs,
56            Self::ClipPath => P::ClipPath,
57            Self::LinearGradient => P::LinearGradient,
58            Self::RadialGradient => P::RadialGradient,
59            Self::Marker => P::Marker,
60            Self::Stop => P::Stop,
61            Self::Image => P::Image,
62            Self::Use => P::Use,
63            Self::TextInstance => P::TextInstance,
64        }
65    }
66
67    fn from_tag_name(tag_name: &str) -> Option<Self> {
68        match tag_name {
69            "svg" => Some(Self::Svg),
70            "g" => Some(Self::G),
71            "path" => Some(Self::Path),
72            "rect" => Some(Self::Rect),
73            "circle" => Some(Self::Circle),
74            "ellipse" => Some(Self::Ellipse),
75            "line" => Some(Self::Line),
76            "polyline" => Some(Self::Polyline),
77            "polygon" => Some(Self::Polygon),
78            "text" => Some(Self::Text),
79            "tspan" => Some(Self::Tspan),
80            "defs" => Some(Self::Defs),
81            "clippath" => Some(Self::ClipPath),
82            "lineargradient" => Some(Self::LinearGradient),
83            "radialgradient" => Some(Self::RadialGradient),
84            "marker" => Some(Self::Marker),
85            "stop" => Some(Self::Stop),
86            "image" => Some(Self::Image),
87            "use" => Some(Self::Use),
88            _ => None,
89        }
90    }
91
92    const fn is_text_container(self) -> bool {
93        matches!(self, Self::Text | Self::Tspan)
94    }
95}
96
97impl fmt::Display for SvgNodeKind {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.primitive_name())
100    }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct SvgNode {
105    pub kind: SvgNodeKind,
106    pub r#type: &'static str,
107    pub props: SvgProps,
108    pub children: Vec<SvgNode>,
109    pub value: Option<String>,
110}
111
112impl SvgNode {
113    pub fn new(kind: SvgNodeKind) -> Self {
114        Self {
115            kind,
116            r#type: kind.primitive_name(),
117            props: SvgProps::new(),
118            children: Vec::new(),
119            value: None,
120        }
121    }
122
123    pub fn empty_svg() -> Self {
124        Self::new(SvgNodeKind::Svg)
125    }
126
127    pub fn type_name(&self) -> &'static str {
128        self.r#type
129    }
130
131    fn text_instance(value: String) -> Self {
132        let mut node = Self::new(SvgNodeKind::TextInstance);
133        node.value = Some(value);
134        node
135    }
136}
137
138impl Default for SvgNode {
139    fn default() -> Self {
140        Self::empty_svg()
141    }
142}
143
144#[derive(Debug)]
145struct OpenNode {
146    tag_name: String,
147    node: SvgNode,
148}
149
150fn is_skipped_element(tag_name: &str) -> bool {
151    matches!(
152        tag_name,
153        "script"
154            | "foreignobject"
155            | "filter"
156            | "mask"
157            | "pattern"
158            | "symbol"
159            | "animate"
160            | "animatetransform"
161            | "animatemotion"
162            | "set"
163    )
164}
165
166fn to_camel_case(input: &str) -> String {
167    let mut result = String::with_capacity(input.len());
168    let mut capitalize_next = false;
169
170    for character in input.chars() {
171        if character == '-' || character == ':' {
172            capitalize_next = true;
173        } else if capitalize_next {
174            result.push(character.to_ascii_uppercase());
175            capitalize_next = false;
176        } else {
177            result.push(character);
178        }
179    }
180
181    result
182}
183
184fn parse_style_attribute(style_string: &str) -> SvgProps {
185    let mut props = SvgProps::new();
186
187    for declaration in style_string.split(';') {
188        let declaration = declaration.trim();
189        if declaration.is_empty() {
190            continue;
191        }
192
193        let Some(colon_index) = declaration.find(':') else {
194            continue;
195        };
196
197        let property = declaration[..colon_index].trim();
198        let value = declaration[colon_index + 1..].trim();
199
200        if !property.is_empty() && !value.is_empty() {
201            props.insert(to_camel_case(property), value.to_string());
202        }
203    }
204
205    props
206}
207
208fn convert_attributes(attributes: impl IntoIterator<Item = (String, String)>) -> SvgProps {
209    let mut props = SvgProps::new();
210
211    for (name, value) in attributes {
212        if name == "style" {
213            props.extend(parse_style_attribute(&value));
214        } else {
215            props.insert(to_camel_case(&name), value);
216        }
217    }
218
219    props
220}
221
222fn get_attributes_from_start(event: &BytesStart<'_>) -> Result<Vec<(String, String)>> {
223    let mut attributes = Vec::new();
224
225    for attribute in event.attributes() {
226        let attribute = attribute?;
227        let key = from_utf8(attribute.key.as_ref())?.to_string();
228        let value = attribute
229            .normalized_value(XmlVersion::Implicit1_0)?
230            .into_owned();
231        attributes.push((key, value));
232    }
233
234    Ok(attributes)
235}
236
237fn decode_tag_name(bytes: &[u8]) -> Result<String> {
238    Ok(from_utf8(bytes)?.to_ascii_lowercase())
239}
240
241fn attach_node(root: &mut Option<SvgNode>, stack: &mut [OpenNode], node: SvgNode) {
242    if let Some(parent) = stack.last_mut() {
243        parent.node.children.push(node);
244    } else if root.is_none() {
245        *root = Some(node);
246    }
247}
248
249fn push_text_if_relevant(stack: &mut [OpenNode], text: String) {
250    let Some(parent) = stack.last_mut() else {
251        return;
252    };
253
254    if !parent.node.kind.is_text_container() {
255        return;
256    }
257
258    if text.trim().is_empty() {
259        if let Some(last_child) = parent.node.children.last_mut()
260            && last_child.kind == SvgNodeKind::TextInstance
261            && let Some(existing_value) = last_child.value.as_mut()
262        {
263            existing_value.push_str(&text);
264        }
265        return;
266    }
267
268    if let Some(last_child) = parent.node.children.last_mut()
269        && last_child.kind == SvgNodeKind::TextInstance
270        && let Some(existing_value) = last_child.value.as_mut()
271    {
272        existing_value.push_str(&text);
273        return;
274    }
275
276    parent
277        .node
278        .children
279        .push(SvgNode::text_instance(text.trim_start().to_string()));
280}
281
282fn trim_last_text_instance(node: &mut SvgNode) {
283    if !node.kind.is_text_container() {
284        return;
285    }
286
287    let Some(last_child) = node.children.last_mut() else {
288        return;
289    };
290
291    if last_child.kind != SvgNodeKind::TextInstance {
292        return;
293    }
294
295    let Some(value) = last_child.value.as_mut() else {
296        return;
297    };
298
299    let trimmed = value.trim_end();
300    if trimmed.len() != value.len() {
301        *value = trimmed.to_string();
302    }
303}
304
305fn decode_cdata_text(event: &BytesCData<'_>) -> Result<String> {
306    Ok(event.decode()?.into_owned())
307}
308
309fn decode_text(event: &BytesText<'_>) -> Result<String> {
310    Ok(event.decode()?.into_owned())
311}
312
313fn decode_general_reference(reference: &BytesRef<'_>) -> Result<String> {
314    let decoded = reference.decode()?.into_owned();
315    let escaped = if decoded.starts_with('&') {
316        decoded
317    } else {
318        format!("&{decoded};")
319    };
320
321    Ok(unescape(&escaped)?.into_owned())
322}
323
324fn collapse_stack(mut stack: Vec<OpenNode>, root: Option<SvgNode>) -> SvgNode {
325    if let Some(root) = root {
326        return root;
327    }
328
329    while stack.len() > 1 {
330        let child = stack.pop().expect("stack length checked").node;
331        if let Some(parent) = stack.last_mut() {
332            parent.node.children.push(child);
333        }
334    }
335
336    stack.pop().map(|entry| entry.node).unwrap_or_default()
337}
338
339pub fn try_parse_svg(svg_string: &str) -> Result<SvgNode> {
340    let mut reader = Reader::from_str(svg_string);
341    let mut buffer = Vec::new();
342    let mut stack: Vec<OpenNode> = Vec::new();
343    let mut root: Option<SvgNode> = None;
344    let mut skip_depth = 0usize;
345
346    loop {
347        match reader.read_event_into(&mut buffer)? {
348            Event::Start(event) => {
349                let tag_name = decode_tag_name(event.name().as_ref())?;
350
351                if skip_depth > 0 {
352                    skip_depth += 1;
353                    buffer.clear();
354                    continue;
355                }
356
357                if is_skipped_element(&tag_name) {
358                    skip_depth = 1;
359                    buffer.clear();
360                    continue;
361                }
362
363                let Some(kind) = SvgNodeKind::from_tag_name(&tag_name) else {
364                    skip_depth = 1;
365                    buffer.clear();
366                    continue;
367                };
368
369                if let Some(parent) = stack.last_mut() {
370                    trim_last_text_instance(&mut parent.node);
371                }
372
373                let mut node = SvgNode::new(kind);
374                node.props = convert_attributes(get_attributes_from_start(&event)?);
375
376                stack.push(OpenNode { tag_name, node });
377            }
378            Event::Empty(event) => {
379                let tag_name = decode_tag_name(event.name().as_ref())?;
380
381                if skip_depth > 0 || is_skipped_element(&tag_name) {
382                    buffer.clear();
383                    continue;
384                }
385
386                let Some(kind) = SvgNodeKind::from_tag_name(&tag_name) else {
387                    buffer.clear();
388                    continue;
389                };
390
391                if let Some(parent) = stack.last_mut() {
392                    trim_last_text_instance(&mut parent.node);
393                }
394
395                let mut node = SvgNode::new(kind);
396                node.props = convert_attributes(get_attributes_from_start(&event)?);
397                attach_node(&mut root, &mut stack, node);
398            }
399            Event::End(event) => {
400                let tag_name = decode_tag_name(event.name().as_ref())?;
401
402                if skip_depth > 0 {
403                    skip_depth -= 1;
404                    buffer.clear();
405                    continue;
406                }
407
408                let Some(last_tag_name) = stack.last().map(|entry| entry.tag_name.as_str()) else {
409                    buffer.clear();
410                    continue;
411                };
412
413                if last_tag_name != tag_name {
414                    buffer.clear();
415                    continue;
416                }
417
418                if let Some(last) = stack.last_mut() {
419                    trim_last_text_instance(&mut last.node);
420                }
421
422                let node = stack.pop().expect("stack is not empty").node;
423                attach_node(&mut root, &mut stack, node);
424            }
425            Event::Text(event) => {
426                if skip_depth == 0 {
427                    push_text_if_relevant(&mut stack, decode_text(&event)?);
428                }
429            }
430            Event::CData(event) => {
431                if skip_depth == 0 {
432                    push_text_if_relevant(&mut stack, decode_cdata_text(&event)?);
433                }
434            }
435            Event::GeneralRef(reference) => {
436                if skip_depth == 0 {
437                    push_text_if_relevant(&mut stack, decode_general_reference(&reference)?);
438                }
439            }
440            Event::Comment(_) | Event::Decl(_) | Event::DocType(_) | Event::PI(_) => {}
441            Event::Eof => break,
442        }
443
444        buffer.clear();
445    }
446
447    Ok(collapse_stack(stack, root))
448}
449
450pub fn parse_svg(svg_string: &str) -> SvgNode {
451    try_parse_svg(svg_string).unwrap_or_default()
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn props(entries: &[(&str, &str)]) -> SvgProps {
459        entries
460            .iter()
461            .map(|(name, value)| (String::from(*name), String::from(*value)))
462            .collect()
463    }
464
465    fn node(kind: SvgNodeKind, props_entries: &[(&str, &str)], children: Vec<SvgNode>) -> SvgNode {
466        SvgNode {
467            kind,
468            r#type: kind.primitive_name(),
469            props: props(props_entries),
470            children,
471            value: None,
472        }
473    }
474
475    fn text(value: &str) -> SvgNode {
476        SvgNode {
477            kind: SvgNodeKind::TextInstance,
478            r#type: SvgNodeKind::TextInstance.primitive_name(),
479            props: SvgProps::new(),
480            children: Vec::new(),
481            value: Some(value.to_string()),
482        }
483    }
484
485    #[test]
486    fn parses_dimensions_variants() {
487        let unitless =
488            parse_svg(r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="150"></svg>"#);
489        assert_eq!(
490            unitless,
491            node(
492                SvgNodeKind::Svg,
493                &[
494                    ("height", "150"),
495                    ("width", "200"),
496                    ("xmlns", "http://www.w3.org/2000/svg")
497                ],
498                vec![],
499            )
500        );
501
502        let px = parse_svg(
503            r#"<svg xmlns="http://www.w3.org/2000/svg" width="96px" height="48px"></svg>"#,
504        );
505        assert_eq!(px.props.get("width"), Some(&"96px".to_string()));
506        assert_eq!(px.props.get("height"), Some(&"48px".to_string()));
507
508        let pt = parse_svg(
509            r#"<svg xmlns="http://www.w3.org/2000/svg" width="72pt" height="36pt"></svg>"#,
510        );
511        assert_eq!(pt.props.get("width"), Some(&"72pt".to_string()));
512        assert_eq!(pt.props.get("height"), Some(&"36pt".to_string()));
513
514        let inches =
515            parse_svg(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1in" height="2in"></svg>"#);
516        assert_eq!(inches.props.get("width"), Some(&"1in".to_string()));
517        assert_eq!(inches.props.get("height"), Some(&"2in".to_string()));
518
519        let cm = parse_svg(
520            r#"<svg xmlns="http://www.w3.org/2000/svg" width="2.54cm" height="5.08cm"></svg>"#,
521        );
522        assert_eq!(cm.props.get("width"), Some(&"2.54cm".to_string()));
523        assert_eq!(cm.props.get("height"), Some(&"5.08cm".to_string()));
524
525        let mm = parse_svg(
526            r#"<svg xmlns="http://www.w3.org/2000/svg" width="25.4mm" height="50.8mm"></svg>"#,
527        );
528        assert_eq!(mm.props.get("width"), Some(&"25.4mm".to_string()));
529        assert_eq!(mm.props.get("height"), Some(&"50.8mm".to_string()));
530    }
531
532    #[test]
533    fn parses_viewbox_and_missing_dimensions() {
534        let view_box =
535            parse_svg(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 20 300 200"></svg>"#);
536        assert_eq!(
537            view_box.props.get("viewBox"),
538            Some(&"10 20 300 200".to_string())
539        );
540
541        let no_dimensions = parse_svg(r#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#);
542        assert_eq!(
543            no_dimensions,
544            node(
545                SvgNodeKind::Svg,
546                &[("xmlns", "http://www.w3.org/2000/svg")],
547                vec![],
548            )
549        );
550    }
551
552    #[test]
553    fn maps_basic_shapes() {
554        let svg = parse_svg(
555            r#"
556            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
557              <rect x="10" y="10" width="80" height="80" fill="red"/>
558              <circle cx="50" cy="50" r="25" fill="blue"/>
559              <ellipse cx="50" cy="50" rx="40" ry="20"/>
560              <line x1="0" y1="0" x2="100" y2="100" stroke="black"/>
561              <polyline points="0,0 50,50 100,0"/>
562              <polygon points="50,0 100,100 0,100"/>
563            </svg>"#,
564        );
565
566        assert_eq!(
567            svg,
568            node(
569                SvgNodeKind::Svg,
570                &[
571                    ("height", "100"),
572                    ("width", "100"),
573                    ("xmlns", "http://www.w3.org/2000/svg")
574                ],
575                vec![
576                    node(
577                        SvgNodeKind::Rect,
578                        &[
579                            ("fill", "red"),
580                            ("height", "80"),
581                            ("width", "80"),
582                            ("x", "10"),
583                            ("y", "10")
584                        ],
585                        vec![],
586                    ),
587                    node(
588                        SvgNodeKind::Circle,
589                        &[("cx", "50"), ("cy", "50"), ("fill", "blue"), ("r", "25")],
590                        vec![],
591                    ),
592                    node(
593                        SvgNodeKind::Ellipse,
594                        &[("cx", "50"), ("cy", "50"), ("rx", "40"), ("ry", "20")],
595                        vec![],
596                    ),
597                    node(
598                        SvgNodeKind::Line,
599                        &[
600                            ("stroke", "black"),
601                            ("x1", "0"),
602                            ("x2", "100"),
603                            ("y1", "0"),
604                            ("y2", "100")
605                        ],
606                        vec![],
607                    ),
608                    node(
609                        SvgNodeKind::Polyline,
610                        &[("points", "0,0 50,50 100,0")],
611                        vec![]
612                    ),
613                    node(
614                        SvgNodeKind::Polygon,
615                        &[("points", "50,0 100,100 0,100")],
616                        vec![]
617                    ),
618                ],
619            )
620        );
621    }
622
623    #[test]
624    fn maps_path_gradients_groups_clip_path_and_image() {
625        let path = parse_svg(
626            r#"
627            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
628              <path d="M10 10 H 90 V 90 H 10 Z" fill="none" stroke="black"/>
629            </svg>"#,
630        );
631        assert_eq!(path.children[0].kind, SvgNodeKind::Path);
632
633        let gradients = parse_svg(
634            r#"
635            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
636              <defs>
637                <linearGradient id="lg1" x1="0%" y1="0%" x2="100%" y2="0%">
638                  <stop offset="0%" stop-color="red"/>
639                  <stop offset="100%" stop-color="blue"/>
640                </linearGradient>
641                <radialGradient id="rg1" cx="50%" cy="50%" r="50%">
642                  <stop offset="0%" stop-color="white"/>
643                  <stop offset="100%" stop-color="black"/>
644                </radialGradient>
645              </defs>
646            </svg>"#,
647        );
648        assert_eq!(gradients.children[0].kind, SvgNodeKind::Defs);
649        assert_eq!(
650            gradients.children[0].children[0].kind,
651            SvgNodeKind::LinearGradient
652        );
653        assert_eq!(
654            gradients.children[0].children[1].kind,
655            SvgNodeKind::RadialGradient
656        );
657        assert_eq!(
658            gradients.children[0].children[0].children[0]
659                .props
660                .get("stopColor"),
661            Some(&"red".to_string())
662        );
663
664        let groups = parse_svg(
665            r#"
666            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
667              <g transform="translate(10,10)">
668                <g opacity="0.5">
669                  <rect width="50" height="50"/>
670                </g>
671                <circle cx="75" cy="75" r="10"/>
672              </g>
673            </svg>"#,
674        );
675        assert_eq!(groups.children[0].kind, SvgNodeKind::G);
676        assert_eq!(groups.children[0].children[0].kind, SvgNodeKind::G);
677        assert_eq!(groups.children[0].children[1].kind, SvgNodeKind::Circle);
678
679        let clip_path = parse_svg(
680            r#"
681            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
682              <defs>
683                <clipPath id="clip1">
684                  <rect width="50" height="50"/>
685                </clipPath>
686              </defs>
687            </svg>"#,
688        );
689        assert_eq!(
690            clip_path.children[0].children[0].kind,
691            SvgNodeKind::ClipPath
692        );
693
694        let image = parse_svg(
695            r#"
696            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
697              <image href="photo.png" x="0" y="0" width="100" height="100"/>
698            </svg>"#,
699        );
700        assert_eq!(
701            image.children[0],
702            node(
703                SvgNodeKind::Image,
704                &[
705                    ("height", "100"),
706                    ("href", "photo.png"),
707                    ("width", "100"),
708                    ("x", "0"),
709                    ("y", "0")
710                ],
711                vec![],
712            )
713        );
714    }
715
716    #[test]
717    fn handles_text_and_tspan_content() {
718        let text_svg = parse_svg(
719            r#"
720            <svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
721              <text x="10" y="30" font-size="20">Hello World</text>
722            </svg>"#,
723        );
724        assert_eq!(
725            text_svg.children[0],
726            node(
727                SvgNodeKind::Text,
728                &[("fontSize", "20"), ("x", "10"), ("y", "30")],
729                vec![text("Hello World")],
730            )
731        );
732
733        let tspan_svg = parse_svg(
734            r#"
735            <svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
736              <text x="10" y="30">
737                <tspan fill="red">Red</tspan>
738                <tspan fill="blue">Blue</tspan>
739              </text>
740            </svg>"#,
741        );
742        assert_eq!(
743            tspan_svg.children[0],
744            node(
745                SvgNodeKind::Text,
746                &[("x", "10"), ("y", "30")],
747                vec![
748                    node(SvgNodeKind::Tspan, &[("fill", "red")], vec![text("Red")]),
749                    node(SvgNodeKind::Tspan, &[("fill", "blue")], vec![text("Blue")]),
750                ],
751            )
752        );
753
754        let non_text = parse_svg(
755            r#"
756            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
757              <rect>some text</rect>
758            </svg>"#,
759        );
760        assert_eq!(non_text.children[0], node(SvgNodeKind::Rect, &[], vec![]));
761    }
762
763    #[test]
764    fn converts_attributes_and_styles() {
765        let camel_case = parse_svg(
766            r#"
767            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
768              <rect stroke-width="2" fill-opacity="0.5" stroke-dasharray="5,3" stroke-linecap="round"/>
769            </svg>"#,
770        );
771        assert_eq!(
772            camel_case.children[0],
773            node(
774                SvgNodeKind::Rect,
775                &[
776                    ("fillOpacity", "0.5"),
777                    ("strokeDasharray", "5,3"),
778                    ("strokeLinecap", "round"),
779                    ("strokeWidth", "2"),
780                ],
781                vec![],
782            )
783        );
784
785        let inline_style = parse_svg(
786            r#"
787            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
788              <rect style="fill:red;stroke:blue;stroke-width:2px;opacity:0.8"/>
789            </svg>"#,
790        );
791        assert_eq!(
792            inline_style.children[0],
793            node(
794                SvgNodeKind::Rect,
795                &[
796                    ("fill", "red"),
797                    ("opacity", "0.8"),
798                    ("stroke", "blue"),
799                    ("strokeWidth", "2px")
800                ],
801                vec![],
802            )
803        );
804
805        let merged = parse_svg(
806            r#"
807            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
808              <rect fill="green" width="50" height="50" style="stroke:blue;stroke-width:3"/>
809            </svg>"#,
810        );
811        assert_eq!(
812            merged.children[0],
813            node(
814                SvgNodeKind::Rect,
815                &[
816                    ("fill", "green"),
817                    ("height", "50"),
818                    ("stroke", "blue"),
819                    ("strokeWidth", "3"),
820                    ("width", "50"),
821                ],
822                vec![],
823            )
824        );
825
826        let single_quoted = parse_svg(
827            r#"
828            <svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>
829              <rect fill='red' width='50' height='50'/>
830            </svg>"#,
831        );
832        assert_eq!(
833            single_quoted.children[0],
834            node(
835                SvgNodeKind::Rect,
836                &[("fill", "red"), ("height", "50"), ("width", "50")],
837                vec![],
838            )
839        );
840    }
841
842    #[test]
843    fn decodes_entities_and_cdata_in_text() {
844        let entities = parse_svg(
845            r#"
846            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
847              <text x="10" y="30">&lt;hello&gt; &amp; &quot;world&quot;</text>
848            </svg>"#,
849        );
850        assert_eq!(
851            entities.children[0].children,
852            vec![text("<hello> & \"world\"")]
853        );
854
855        let cdata = parse_svg(
856            r#"
857            <svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">
858              <text x="10" y="30"><![CDATA[Some <special> text]]></text>
859            </svg>"#,
860        );
861        assert_eq!(
862            cdata.children[0].children,
863            vec![text("Some <special> text")]
864        );
865    }
866
867    #[test]
868    fn skips_unsupported_and_unknown_elements_like_ts_parser() {
869        let skipped = parse_svg(
870            r#"
871            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
872              <script>alert(1)</script>
873              <rect width="50" height="50"/>
874              <foreignObject><div>hi</div></foreignObject>
875              <circle cx="50" cy="50" r="10"/>
876              <filter id="f1"><feGaussianBlur/></filter>
877              <mask id="m1"><rect/></mask>
878            </svg>"#,
879        );
880        assert_eq!(
881            skipped,
882            node(
883                SvgNodeKind::Svg,
884                &[
885                    ("height", "100"),
886                    ("width", "100"),
887                    ("xmlns", "http://www.w3.org/2000/svg")
888                ],
889                vec![
890                    node(
891                        SvgNodeKind::Rect,
892                        &[("height", "50"), ("width", "50")],
893                        vec![]
894                    ),
895                    node(
896                        SvgNodeKind::Circle,
897                        &[("cx", "50"), ("cy", "50"), ("r", "10")],
898                        vec![]
899                    ),
900                ],
901            )
902        );
903
904        let unknown = parse_svg(
905            r#"
906            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
907              <custom-element foo="bar"/>
908              <rect width="50" height="50"/>
909            </svg>"#,
910        );
911        assert_eq!(unknown.children.len(), 1);
912        assert_eq!(unknown.children[0].kind, SvgNodeKind::Rect);
913    }
914
915    #[test]
916    fn parses_use_nodes_and_xlink_references() {
917        let svg = parse_svg(
918            r##"
919            <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
920              <defs>
921                <path id="shape" d="M0 0 L10 0 L10 10 Z"/>
922              </defs>
923              <use href="#shape" x="20" y="30"/>
924              <use xlink:href="#shape" transform="translate(40,0)"/>
925            </svg>"##,
926        );
927
928        assert_eq!(svg.children[0].kind, SvgNodeKind::Defs);
929        assert_eq!(svg.children[1].kind, SvgNodeKind::Use);
930        assert_eq!(
931            svg.children[1].props.get("href"),
932            Some(&"#shape".to_string())
933        );
934        assert_eq!(svg.children[1].props.get("x"), Some(&"20".to_string()));
935        assert_eq!(svg.children[2].kind, SvgNodeKind::Use);
936        assert_eq!(
937            svg.children[2].props.get("xlinkHref"),
938            Some(&"#shape".to_string())
939        );
940    }
941
942    #[test]
943    fn ignores_xml_preamble_doctype_and_comments() {
944        let xml_decl = parse_svg(
945            r#"<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="50" height="50"/></svg>"#,
946        );
947        assert_eq!(xml_decl.children[0].kind, SvgNodeKind::Rect);
948
949        let doctype = parse_svg(
950            r#"<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect/></svg>"#,
951        );
952        assert_eq!(doctype.children[0].kind, SvgNodeKind::Rect);
953
954        let comments = parse_svg(
955            r#"
956            <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
957              <!-- this is a comment -->
958              <rect width="50" height="50"/>
959            </svg>"#,
960        );
961        assert_eq!(comments.children[0].kind, SvgNodeKind::Rect);
962    }
963
964    #[test]
965    fn handles_invalid_input_like_ts_parser() {
966        let non_svg_root = parse_svg("<div>not svg</div>");
967        assert_eq!(non_svg_root, SvgNode::empty_svg());
968
969        let empty = parse_svg("");
970        assert_eq!(empty, SvgNode::empty_svg());
971
972        let invalid_view_box = parse_svg(
973            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="bad"></svg>"#,
974        );
975        assert_eq!(
976            invalid_view_box.props.get("viewBox"),
977            Some(&"bad".to_string())
978        );
979
980        let short_view_box = parse_svg(
981            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100"></svg>"#,
982        );
983        assert_eq!(
984            short_view_box.props.get("viewBox"),
985            Some(&"0 0 100".to_string())
986        );
987    }
988
989    #[test]
990    fn parses_real_world_like_examples() {
991        let icon = parse_svg(
992            r##"
993            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
994              <defs>
995                <linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
996                  <stop offset="0%" stop-color="#ff6b6b"/>
997                  <stop offset="100%" stop-color="#4ecdc4"/>
998                </linearGradient>
999              </defs>
1000              <g fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1001                <path d="M12 2L2 7l10 5 10-5-10-5z"/>
1002                <path d="M2 17l10 5 10-5"/>
1003                <path d="M2 12l10 5 10-5"/>
1004              </g>
1005            </svg>"##,
1006        );
1007        assert_eq!(icon.children[0].kind, SvgNodeKind::Defs);
1008        assert_eq!(icon.children[1].kind, SvgNodeKind::G);
1009        assert_eq!(icon.children[1].children.len(), 3);
1010        assert_eq!(
1011            icon.children[1].props,
1012            props(&[
1013                ("fill", "none"),
1014                ("strokeLinecap", "round"),
1015                ("strokeLinejoin", "round"),
1016                ("strokeWidth", "2"),
1017            ])
1018        );
1019
1020        let chart = parse_svg(
1021            r##"
1022            <svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
1023              <rect width="200" height="100" fill="#f0f0f0"/>
1024              <g transform="translate(20,80)">
1025                <line x1="0" y1="0" x2="160" y2="0" stroke="#ccc"/>
1026                <rect x="0" y="-60" width="30" height="60" style="fill:#4ecdc4;opacity:0.9"/>
1027                <rect x="40" y="-40" width="30" height="40" style="fill:#ff6b6b;opacity:0.9"/>
1028                <rect x="80" y="-75" width="30" height="75" style="fill:#45b7d1;opacity:0.9"/>
1029              </g>
1030            </svg>"##,
1031        );
1032        assert_eq!(chart.children.len(), 2);
1033        assert_eq!(chart.children[0].kind, SvgNodeKind::Rect);
1034        assert_eq!(chart.children[1].kind, SvgNodeKind::G);
1035        assert_eq!(chart.children[1].children.len(), 4);
1036        assert_eq!(
1037            chart.children[1].children[1].props,
1038            props(&[
1039                ("fill", "#4ecdc4"),
1040                ("height", "60"),
1041                ("opacity", "0.9"),
1042                ("width", "30"),
1043                ("x", "0"),
1044                ("y", "-60"),
1045            ])
1046        );
1047    }
1048
1049    #[test]
1050    fn exposes_typed_and_fallible_api() {
1051        let parsed =
1052            try_parse_svg(r#"<svg xmlns="http://www.w3.org/2000/svg"><text>Hello</text></svg>"#)
1053                .expect("valid SVG should parse");
1054
1055        assert_eq!(parsed.kind, SvgNodeKind::Svg);
1056        assert_eq!(parsed.type_name(), P::Svg);
1057        assert_eq!(parsed.children[0].kind, SvgNodeKind::Text);
1058    }
1059}