1#[derive(Debug, Clone, PartialEq, Eq)]
2pub struct SvgAttribute {
3 pub name: String,
4 pub value: String,
5}
6
7impl SvgAttribute {
8 #[must_use]
9 pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
10 Self {
11 name: name.into(),
12 value: value.into(),
13 }
14 }
15}
16
17#[must_use]
18pub fn extract_attributes(element: &str) -> Vec<SvgAttribute> {
19 let tag = match opening_tag_slice(element) {
20 Some(tag) => tag,
21 None => return Vec::new(),
22 };
23 let bytes = tag.as_bytes();
24 let mut index = 1;
25
26 if bytes.get(index) == Some(&b'/') {
27 return Vec::new();
28 }
29
30 while index < bytes.len()
31 && !bytes[index].is_ascii_whitespace()
32 && bytes[index] != b'>'
33 && bytes[index] != b'/'
34 {
35 index += 1;
36 }
37
38 let mut attributes = Vec::new();
39
40 while index < bytes.len() {
41 while index < bytes.len() && (bytes[index].is_ascii_whitespace() || bytes[index] == b'/') {
42 index += 1;
43 }
44
45 if index >= bytes.len() || bytes[index] == b'>' {
46 break;
47 }
48
49 let name_start = index;
50 while index < bytes.len()
51 && !bytes[index].is_ascii_whitespace()
52 && bytes[index] != b'='
53 && bytes[index] != b'>'
54 && bytes[index] != b'/'
55 {
56 index += 1;
57 }
58
59 let name = tag[name_start..index].trim();
60 if name.is_empty() {
61 if index < bytes.len() {
62 index += 1;
63 }
64 continue;
65 }
66
67 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
68 index += 1;
69 }
70
71 let value = if index < bytes.len() && bytes[index] == b'=' {
72 index += 1;
73 while index < bytes.len() && bytes[index].is_ascii_whitespace() {
74 index += 1;
75 }
76 parse_attribute_value(tag, &mut index)
77 } else {
78 String::new()
79 };
80
81 attributes.push(SvgAttribute::new(name, value));
82 }
83
84 attributes
85}
86
87#[must_use]
88pub fn get_attribute(element: &str, name: &str) -> Option<String> {
89 extract_attributes(element)
90 .into_iter()
91 .find(|attribute| attribute.name == name)
92 .map(|attribute| attribute.value)
93}
94
95#[must_use]
96pub fn has_attribute(element: &str, name: &str) -> bool {
97 get_attribute(element, name).is_some()
98}
99
100pub(crate) fn extract_attribute_values(input: &str, name: &str) -> Vec<String> {
101 let cleaned = crate::normalize::strip_comments(input);
102 let mut values = Vec::new();
103 let mut index = 0;
104
105 while let Some(relative) = cleaned[index..].find('<') {
106 let start = index + relative;
107 let Some(end) = find_tag_end(cleaned.as_str(), start) else {
108 break;
109 };
110 let next = cleaned[start + 1..].chars().next().unwrap_or('\0');
111
112 if !matches!(next, '/' | '!' | '?') {
113 let tag = &cleaned[start..end];
114 if let Some(value) = get_attribute(tag, name) {
115 values.push(value);
116 }
117 }
118
119 index = end;
120 }
121
122 values
123}
124
125pub(crate) fn find_opening_tag(input: &str, tag_name: &str, from: usize) -> Option<(usize, usize)> {
126 let mut index = from;
127
128 while let Some(relative) = input[index..].find('<') {
129 let start = index + relative;
130 let next = input[start + 1..].chars().next().unwrap_or('\0');
131
132 if matches!(next, '/' | '!' | '?') {
133 index = start + 1;
134 continue;
135 }
136
137 if tag_name_matches(input, start, tag_name) {
138 let end = find_tag_end(input, start)?;
139 return Some((start, end));
140 }
141
142 index = start + 1;
143 }
144
145 None
146}
147
148pub(crate) fn find_tag_end(input: &str, start: usize) -> Option<usize> {
149 if !input[start..].starts_with('<') {
150 return None;
151 }
152
153 let mut active_quote = None;
154
155 for (offset, ch) in input[start + 1..].char_indices() {
156 if let Some(quote) = active_quote {
157 if ch == quote {
158 active_quote = None;
159 }
160 continue;
161 }
162
163 match ch {
164 '"' | '\'' => active_quote = Some(ch),
165 '>' => return Some(start + offset + 2),
166 _ => {}
167 }
168 }
169
170 None
171}
172
173pub(crate) fn opening_tag_name(element: &str) -> Option<String> {
174 let tag = opening_tag_slice(element)?;
175 let bytes = tag.as_bytes();
176 let mut index = 1;
177
178 if bytes.get(index) == Some(&b'/') {
179 return None;
180 }
181
182 let start = index;
183 while index < bytes.len()
184 && !bytes[index].is_ascii_whitespace()
185 && bytes[index] != b'>'
186 && bytes[index] != b'/'
187 {
188 index += 1;
189 }
190
191 (start < index).then(|| tag[start..index].to_string())
192}
193
194pub(crate) fn tag_name_matches(input: &str, start: usize, tag_name: &str) -> bool {
195 if !input[start..].starts_with('<') {
196 return false;
197 }
198
199 let name_start = start + 1;
200 let Some(candidate) = input[name_start..].get(..tag_name.len()) else {
201 return false;
202 };
203
204 if !candidate.eq_ignore_ascii_case(tag_name) {
205 return false;
206 }
207
208 match input[name_start + tag_name.len()..].chars().next() {
209 Some(ch) => ch.is_ascii_whitespace() || matches!(ch, '>' | '/'),
210 None => true,
211 }
212}
213
214fn opening_tag_slice(element: &str) -> Option<&str> {
215 let start = element.find('<')?;
216 let end = find_tag_end(element, start)?;
217 Some(&element[start..end])
218}
219
220fn parse_attribute_value(tag: &str, index: &mut usize) -> String {
221 let bytes = tag.as_bytes();
222
223 if *index >= bytes.len() {
224 return String::new();
225 }
226
227 match bytes[*index] {
228 b'"' | b'\'' => {
229 let quote = bytes[*index];
230 *index += 1;
231 let start = *index;
232
233 while *index < bytes.len() && bytes[*index] != quote {
234 *index += 1;
235 }
236
237 let value = tag[start..*index].to_string();
238 if *index < bytes.len() {
239 *index += 1;
240 }
241 value
242 }
243 _ => {
244 let start = *index;
245 while *index < bytes.len()
246 && !bytes[*index].is_ascii_whitespace()
247 && bytes[*index] != b'>'
248 && bytes[*index] != b'/'
249 {
250 *index += 1;
251 }
252
253 tag[start..*index].to_string()
254 }
255 }
256}