merman_render/svg/pipeline/builtin/
attr_sanitize.rs1use crate::Result;
2use std::borrow::Cow;
3
4use super::css_sanitize::strip_css_deg_units;
5use super::util::find_tag_end;
6use crate::svg::pipeline::{SvgPostprocessContext, SvgPostprocessor};
7
8#[derive(Debug, Clone, Copy, Default)]
9pub struct SanitizeSvgAttributesPostprocessor;
10
11impl SvgPostprocessor for SanitizeSvgAttributesPostprocessor {
12 fn name(&self) -> &'static str {
13 "sanitize-svg-attributes"
14 }
15
16 fn process<'a>(
17 &self,
18 svg: Cow<'a, str>,
19 _ctx: &SvgPostprocessContext<'_>,
20 ) -> Result<Cow<'a, str>> {
21 Ok(Cow::Owned(sanitize_element_attributes(&svg)))
22 }
23}
24
25pub(crate) fn sanitize_element_attributes(svg: &str) -> String {
26 let mut out = String::with_capacity(svg.len());
27 let mut cursor = 0;
28
29 while let Some(rel_start) = svg[cursor..].find('<') {
30 let start = cursor + rel_start;
31 out.push_str(&svg[cursor..start]);
32
33 let Some(end) = find_tag_end(svg, start) else {
34 out.push_str(&svg[start..]);
35 return out;
36 };
37
38 let tag = &svg[start..=end];
39 if is_bad_rect_tag(tag) {
40 cursor = if tag.trim_end().ends_with("/>") {
41 end + 1
42 } else {
43 svg[end + 1..]
44 .find("</rect>")
45 .map(|rel_close| end + 1 + rel_close + "</rect>".len())
46 .unwrap_or(end + 1)
47 };
48 continue;
49 }
50 out.push_str(&sanitize_tag_attributes(tag));
51 cursor = end + 1;
52 }
53
54 out.push_str(&svg[cursor..]);
55 out
56}
57
58fn sanitize_tag_attributes(tag: &str) -> Cow<'_, str> {
59 if tag.starts_with("</")
60 || tag.starts_with("<!--")
61 || tag.starts_with("<!")
62 || tag.starts_with("<?")
63 {
64 return Cow::Borrowed(tag);
65 }
66
67 let mut changed = false;
68 let mut out = String::new();
69 let mut copied_until = 0usize;
70 let mut cursor = 0usize;
71
72 while let Some(attr) = next_svg_double_quoted_attr(tag, cursor) {
73 let name = &tag[attr.name_start..attr.name_end];
74 let value = &tag[attr.value_start..attr.value_end];
75
76 let replacement = sanitized_attr_replacement(name, value);
77 if let AttrReplacement::Unchanged = replacement {
78 cursor = attr.full_end;
79 continue;
80 }
81
82 if !changed {
83 out = String::with_capacity(tag.len());
84 changed = true;
85 }
86 out.push_str(&tag[copied_until..attr.full_start]);
87 match replacement {
88 AttrReplacement::Unchanged => {}
89 AttrReplacement::Drop => {}
90 AttrReplacement::Replace(replacement) => out.push_str(&replacement),
91 }
92 copied_until = attr.full_end;
93 cursor = attr.full_end;
94 }
95
96 if changed {
97 out.push_str(&tag[copied_until..]);
98 Cow::Owned(out)
99 } else {
100 Cow::Borrowed(tag)
101 }
102}
103
104enum AttrReplacement {
105 Unchanged,
106 Drop,
107 Replace(String),
108}
109
110fn sanitized_attr_replacement(name: &str, value: &str) -> AttrReplacement {
111 if should_drop_attribute(name, value) {
112 return AttrReplacement::Drop;
113 }
114
115 if let Some(value) = normalize_px_attribute(name, value) {
116 return AttrReplacement::Replace(format!(r#" {name}="{value}""#));
117 }
118
119 if name.eq_ignore_ascii_case("style") {
120 let sanitized = sanitize_style_attribute(value);
121 if sanitized.trim().is_empty() {
122 return AttrReplacement::Drop;
123 }
124 if sanitized != value {
125 return AttrReplacement::Replace(format!(r#" style="{sanitized}""#));
126 }
127 }
128
129 AttrReplacement::Unchanged
130}
131
132fn should_drop_attribute(name: &str, value: &str) -> bool {
133 if name.eq_ignore_ascii_case("style") {
134 return false;
135 }
136
137 let normalized = name.to_ascii_lowercase();
138 let guarded = matches!(
139 normalized.as_str(),
140 "fill"
141 | "stroke"
142 | "width"
143 | "height"
144 | "x"
145 | "y"
146 | "x1"
147 | "x2"
148 | "y1"
149 | "y2"
150 | "r"
151 | "cx"
152 | "cy"
153 | "rx"
154 | "ry"
155 | "stroke-width"
156 | "transform"
157 | "d"
158 | "points"
159 );
160
161 guarded && is_invalid_svg_value(value)
162}
163
164fn normalize_px_attribute(name: &str, value: &str) -> Option<String> {
165 let normalized = name.to_ascii_lowercase();
166 let guarded = matches!(
167 normalized.as_str(),
168 "width"
169 | "height"
170 | "x"
171 | "y"
172 | "x1"
173 | "x2"
174 | "y1"
175 | "y2"
176 | "r"
177 | "cx"
178 | "cy"
179 | "rx"
180 | "ry"
181 | "stroke-width"
182 );
183 if !guarded {
184 return None;
185 }
186
187 let trimmed = value.trim();
188 let number = trimmed.strip_suffix("px")?.trim();
189 if number.parse::<f64>().is_ok_and(f64::is_finite) {
190 Some(number.to_string())
191 } else {
192 None
193 }
194}
195
196fn is_start_or_empty_tag(tag: &str, expected: &str) -> bool {
197 let tag = tag.trim_start();
198 if !tag.starts_with('<') || tag.starts_with("</") || tag.starts_with("<!--") {
199 return false;
200 }
201
202 let name = tag[1..]
203 .chars()
204 .take_while(|ch| !ch.is_whitespace() && *ch != '/' && *ch != '>')
205 .collect::<String>();
206 name.eq_ignore_ascii_case(expected)
207}
208
209fn attr_value(tag: &str, name: &str) -> Option<String> {
210 let mut cursor = 0usize;
211 while let Some(attr) = next_svg_double_quoted_attr(tag, cursor) {
212 if tag[attr.name_start..attr.name_end].eq_ignore_ascii_case(name) {
213 return Some(tag[attr.value_start..attr.value_end].to_string());
214 }
215 cursor = attr.full_end;
216 }
217 None
218}
219
220#[derive(Debug, Clone, Copy)]
221struct SvgAttrMatch {
222 full_start: usize,
223 full_end: usize,
224 name_start: usize,
225 name_end: usize,
226 value_start: usize,
227 value_end: usize,
228}
229
230fn next_svg_double_quoted_attr(tag: &str, from: usize) -> Option<SvgAttrMatch> {
231 let mut cursor = from;
232 while cursor < tag.len() {
233 let ch = tag.get(cursor..)?.chars().next()?;
234 if ch.is_whitespace() {
235 let full_start = cursor;
236 let name_start = skip_svg_attr_regex_whitespace(tag, cursor);
237 if let Some(attr_match) = svg_double_quoted_attr_at(tag, full_start, name_start) {
238 return Some(attr_match);
239 }
240 cursor = name_start;
241 } else {
242 cursor += ch.len_utf8();
243 }
244 }
245 None
246}
247
248fn svg_double_quoted_attr_at(
249 tag: &str,
250 full_start: usize,
251 name_start: usize,
252) -> Option<SvgAttrMatch> {
253 let first = *tag.as_bytes().get(name_start)?;
254 if !is_svg_attr_name_start_byte(first) {
255 return None;
256 }
257
258 let name_end = consume_svg_attr_name(tag, name_start);
259 let mut cursor = skip_svg_attr_regex_whitespace(tag, name_end);
260 if !tag.get(cursor..)?.starts_with('=') {
261 return None;
262 }
263 cursor += 1;
264 cursor = skip_svg_attr_regex_whitespace(tag, cursor);
265 if !tag.get(cursor..)?.starts_with('"') {
266 return None;
267 }
268
269 let value_start = cursor + 1;
270 let value_end = value_start + tag.get(value_start..)?.find('"')?;
271 Some(SvgAttrMatch {
272 full_start,
273 full_end: value_end + 1,
274 name_start,
275 name_end,
276 value_start,
277 value_end,
278 })
279}
280
281fn skip_svg_attr_regex_whitespace(tag: &str, mut cursor: usize) -> usize {
282 while let Some(ch) = tag.get(cursor..).and_then(|tail| tail.chars().next()) {
283 if !ch.is_whitespace() {
284 break;
285 }
286 cursor += ch.len_utf8();
287 }
288 cursor
289}
290
291fn consume_svg_attr_name(tag: &str, mut cursor: usize) -> usize {
292 while let Some(b) = tag.as_bytes().get(cursor) {
293 if !is_svg_attr_name_continue_byte(*b) {
294 break;
295 }
296 cursor += 1;
297 }
298 cursor
299}
300
301fn is_svg_attr_name_start_byte(b: u8) -> bool {
302 b.is_ascii_alphabetic() || matches!(b, b'_' | b':')
303}
304
305fn is_svg_attr_name_continue_byte(b: u8) -> bool {
306 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b':' | b'.')
307}
308
309fn is_missing_or_invalid_rect_dimension(value: Option<&str>) -> bool {
310 let Some(value) = value.map(str::trim) else {
311 return true;
312 };
313 if value.is_empty() {
314 return true;
315 }
316 if let Ok(n) = value.parse::<f64>() {
317 return !n.is_finite() || n <= 0.0;
318 }
319 false
320}
321
322fn is_bad_rect_tag(tag: &str) -> bool {
323 if !is_start_or_empty_tag(tag, "rect") {
324 return false;
325 }
326
327 let width = attr_value(tag, "width");
328 let height = attr_value(tag, "height");
329 is_missing_or_invalid_rect_dimension(width.as_deref())
330 || is_missing_or_invalid_rect_dimension(height.as_deref())
331}
332
333fn sanitize_style_attribute(value: &str) -> String {
334 let mut out = Vec::new();
335
336 for decl in value.split(';') {
337 let trimmed = decl.trim();
338 if trimmed.is_empty() {
339 continue;
340 }
341
342 let Some((property, raw_value)) = trimmed.split_once(':') else {
343 if is_invalid_svg_value(trimmed) {
344 continue;
345 }
346 out.push(strip_css_deg_units(trimmed));
347 continue;
348 };
349
350 let property = property.trim();
351 let value = raw_value.trim();
352 if value.is_empty() || is_invalid_svg_value(value) {
353 continue;
354 }
355 if property
356 .trim()
357 .to_ascii_lowercase()
358 .starts_with("animation")
359 {
360 continue;
361 }
362
363 out.push(format!("{property}:{}", strip_css_deg_units(value)));
364 }
365
366 out.join(";")
367}
368
369fn is_invalid_svg_value(value: &str) -> bool {
370 let value = value.trim();
371 if value.is_empty() {
372 return true;
373 }
374
375 let lower = value.to_ascii_lowercase();
376 lower.contains("nan") || lower.contains("undefined") || lower.contains("infinity")
377}
378
379#[cfg(test)]
380mod tests {
381 use super::sanitize_element_attributes;
382
383 #[test]
384 fn sanitize_style_attribute_drops_invalid_bare_declarations() {
385 let svg = r#"<svg><path style="undefined; stroke: #333; undefined"/></svg>"#;
386 let out = sanitize_element_attributes(svg);
387
388 assert!(!out.contains("undefined"), "got: {out}");
389 assert!(out.contains(r#"style="stroke:#333""#), "got: {out}");
390 }
391
392 #[test]
393 fn sanitize_element_attributes_drops_rects_without_positive_dimensions() {
394 let svg = r#"<svg><rect/><rect width="0" height="10"/><rect width="12" height="8"/><g><rect width="NaN" height="10"><title>bad</title></rect></g></svg>"#;
395 let out = sanitize_element_attributes(svg);
396
397 assert!(!out.contains("<rect/>"), "got: {out}");
398 assert!(!out.contains(r#"width="0""#), "got: {out}");
399 assert!(!out.contains("NaN"), "got: {out}");
400 assert!(!out.contains("<title>bad</title>"), "got: {out}");
401 assert!(
402 out.contains(r#"<rect width="12" height="8"/>"#),
403 "got: {out}"
404 );
405 }
406
407 #[test]
408 fn sanitize_element_attributes_scans_double_quoted_attrs_without_regex() {
409 let svg = r#"<svg><path data-keep = "ok" x = "10px" stroke="" style="transform: rotate(45deg); animation: dash 1s; stroke: #333;"/></svg>"#;
410 let out = sanitize_element_attributes(svg);
411
412 assert!(out.contains(r#"data-keep = "ok""#), "got: {out}");
413 assert!(out.contains(r#" x="10""#), "got: {out}");
414 assert!(!out.contains(r#"stroke="""#), "got: {out}");
415 assert!(
416 out.contains(r#"style="transform:rotate(45);stroke:#333""#),
417 "got: {out}"
418 );
419 assert!(!out.contains("animation"), "got: {out}");
420 }
421
422 #[test]
423 fn sanitize_element_attributes_uses_scanned_attrs_for_bad_rect_detection() {
424 let svg = r#"<svg><rect WIDTH = "12" HEIGHT = "8"/><rect width = "NaN" height = "8"><title>bad</title></rect></svg>"#;
425 let out = sanitize_element_attributes(svg);
426
427 assert!(
428 out.contains(r#"<rect WIDTH = "12" HEIGHT = "8"/>"#),
429 "got: {out}"
430 );
431 assert!(!out.contains("NaN"), "got: {out}");
432 assert!(!out.contains("<title>bad</title>"), "got: {out}");
433 }
434}