1use crate::attribute::{find_opening_tag, find_tag_end, get_attribute, tag_name_matches};
2use crate::normalize::strip_comments;
3use crate::path::SvgPath;
4use crate::view_box::{SvgViewBox, format_view_box, parse_view_box};
5
6const SVG_XMLNS: &str = "http://www.w3.org/2000/svg";
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct SvgDocument {
10 pub source: String,
11}
12
13impl SvgDocument {
14 #[must_use]
15 pub fn new(source: impl Into<String>) -> Self {
16 Self {
17 source: source.into(),
18 }
19 }
20
21 #[must_use]
22 pub fn as_str(&self) -> &str {
23 &self.source
24 }
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct SvgMetadata {
29 pub title: Option<String>,
30 pub description: Option<String>,
31}
32
33#[must_use]
34pub fn is_svg(input: &str) -> bool {
35 has_svg_root(input)
36}
37
38#[must_use]
39pub fn has_svg_root(input: &str) -> bool {
40 extract_svg_root(input).is_some()
41}
42
43pub fn extract_svg_root(input: &str) -> Option<&str> {
44 let remaining = skip_leading_comments(strip_xml_declaration(input))?;
45
46 if !tag_name_matches(remaining, 0, "svg") {
47 return None;
48 }
49
50 let end = find_tag_end(remaining, 0)?;
51 Some(&remaining[..end])
52}
53
54pub fn strip_xml_declaration(input: &str) -> &str {
55 let trimmed = input.strip_prefix('\u{feff}').unwrap_or(input).trim_start();
56
57 if !starts_with_ascii_case_insensitive(trimmed, "<?xml") {
58 return trimmed;
59 }
60
61 match find_ascii_case_insensitive(trimmed, "?>") {
62 Some(end) => trimmed[end + 2..].trim_start(),
63 None => trimmed,
64 }
65}
66
67#[must_use]
68pub fn extract_width(input: &str) -> Option<String> {
69 extract_svg_root(input).and_then(|root| get_attribute(root, "width"))
70}
71
72#[must_use]
73pub fn extract_height(input: &str) -> Option<String> {
74 extract_svg_root(input).and_then(|root| get_attribute(root, "height"))
75}
76
77#[must_use]
78pub fn extract_view_box(input: &str) -> Option<SvgViewBox> {
79 extract_svg_root(input)
80 .and_then(|root| get_attribute(root, "viewBox"))
81 .and_then(|value| parse_view_box(&value))
82}
83
84#[must_use]
85pub fn extract_title(input: &str) -> Option<String> {
86 extract_text_element(input, "title")
87}
88
89#[must_use]
90pub fn extract_description(input: &str) -> Option<String> {
91 extract_text_element(input, "desc")
92}
93
94#[must_use]
95pub fn extract_metadata(input: &str) -> SvgMetadata {
96 SvgMetadata {
97 title: extract_title(input),
98 description: extract_description(input),
99 }
100}
101
102#[must_use]
103pub fn build_svg_document(view_box: SvgViewBox, body: &str) -> String {
104 let body = body.trim();
105
106 format!(
107 r#"<svg xmlns="{SVG_XMLNS}" viewBox="{}">{body}</svg>"#,
108 format_view_box(view_box)
109 )
110}
111
112#[must_use]
113pub fn build_svg_icon(view_box: SvgViewBox, paths: &[SvgPath]) -> String {
114 let body = paths
115 .iter()
116 .map(|path| format!(r#"<path d="{}"/>"#, escape_attribute_value(&path.data)))
117 .collect::<String>();
118
119 build_svg_document(view_box, &body)
120}
121
122fn extract_text_element(input: &str, tag_name: &str) -> Option<String> {
123 let cleaned = strip_comments(strip_xml_declaration(input));
124 let (_, end) = find_opening_tag(cleaned.as_str(), tag_name, 0)?;
125 let remainder = &cleaned[end..];
126 let close_tag = format!("</{tag_name}>");
127 let close_start = find_ascii_case_insensitive(remainder, &close_tag)?;
128 let value = remainder[..close_start].trim();
129
130 (!value.is_empty()).then(|| value.to_string())
131}
132
133fn skip_leading_comments(mut input: &str) -> Option<&str> {
134 loop {
135 input = input.trim_start();
136
137 if !input.starts_with("<!--") {
138 return Some(input);
139 }
140
141 let end = input.find("-->")?;
142 input = &input[end + 3..];
143 }
144}
145
146fn starts_with_ascii_case_insensitive(input: &str, prefix: &str) -> bool {
147 input
148 .get(..prefix.len())
149 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
150}
151
152fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
153 if needle.is_empty() {
154 return Some(0);
155 }
156
157 haystack.char_indices().find_map(|(index, _)| {
158 let candidate = haystack.get(index..index + needle.len())?;
159 candidate.eq_ignore_ascii_case(needle).then_some(index)
160 })
161}
162
163fn escape_attribute_value(value: &str) -> String {
164 value
165 .replace('&', "&")
166 .replace('"', """)
167 .replace('<', "<")
168}