Skip to main content

use_svg/
document.rs

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('&', "&amp;")
166        .replace('"', "&quot;")
167        .replace('<', "&lt;")
168}