Skip to main content

srcset_parse/
lib.rs

1use regex::Regex;
2use std::sync::OnceLock;
3
4/// A single candidate in a `srcset`: a URL plus optional "width" or "density".
5#[derive(Debug, Clone, PartialEq)]
6pub struct ImageCandidate {
7    pub url: String,
8    pub width: Option<f64>,
9    pub density: Option<f64>,
10}
11
12impl PartialOrd for ImageCandidate {
13    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
14        match (self.width, self.density, other.width, other.density) {
15            (Some(a), None, Some(b), None) => Some(a.partial_cmp(&b).unwrap()),
16            (None, Some(a), None, Some(b)) => Some(a.partial_cmp(&b).unwrap()),
17            _ => None,
18        }
19    }
20}
21
22/// Regex for matching srcset segments.
23///
24/// Explanation:
25/// 1. `(\S*[^,\s])` captures a run of non-whitespace, stopping before `,` or space at the end,
26///    which we treat as the `url`.
27/// 2. `(\s+([\d.]+)(x|w))?` is optional (`?`) and captures:
28///    - `([\d.]+)` which is the numeric part (value),
29///    - `(x|w)` which indicates the descriptor (density or width).
30///
31/// The entire pattern is repeated globally on the input text.
32static SRCSEG_PATTERN: &str = r"(\S*[^,\s])(\s+([\d.]+)(x|w))?";
33static SRCSEG_REGEX: OnceLock<Regex> = OnceLock::new();
34
35/// Parses an `srcset` string and returns a vector of `ImageCandidate`s.
36///
37/// # Examples
38/// ```
39/// let srcset = "image1.png 1x, image2.png 2x, image3.png 100w";
40/// let candidates = srcset_parse::parse(srcset);
41/// assert_eq!(candidates.len(), 3);
42/// assert_eq!(candidates[0].density, Some(1.0));
43/// assert_eq!(candidates[1].density, Some(2.0));
44/// assert_eq!(candidates[2].width, Some(100.0));
45/// ```
46pub fn parse(srcset: &str) -> Vec<ImageCandidate> {
47    let re = SRCSEG_REGEX.get_or_init(|| Regex::new(SRCSEG_PATTERN).expect("Invalid regex"));
48    let mut results = Vec::new();
49
50    for caps in re.captures_iter(srcset) {
51        // Group 1: the `url`
52        let url = caps
53            .get(1)
54            .map(|m| m.as_str().to_string())
55            .unwrap_or_default();
56
57        // Group 3: the numeric value (e.g. "1", "2", "100")
58        let value = caps.get(3).map(|m| m.as_str());
59        // Group 4: the descriptor (e.g. "x" or "w")
60        let descriptor = caps.get(4).map(|m| m.as_str());
61
62        // Convert the captured numeric value to f64 if present
63        let parsed_value = value.map(|v| v.parse::<f64>().unwrap_or_default());
64
65        // Fill in the struct's fields based on the descriptor
66        let (width, density) = match descriptor {
67            Some("w") => (parsed_value, None),
68            Some("x") => (None, parsed_value),
69            _ => (None, None),
70        };
71
72        results.push(ImageCandidate {
73            url,
74            width,
75            density,
76        });
77    }
78
79    results
80}
81
82#[cfg(test)]
83mod tests {
84
85    use super::{parse, ImageCandidate};
86
87    #[test]
88    fn parses_srcset_strings() {
89        let srcset = "cat-@2x.jpeg 2x, dog.jpeg 100w";
90        let result = parse(srcset);
91        assert_eq!(
92            result,
93            vec![
94                ImageCandidate {
95                    url: "cat-@2x.jpeg".to_string(),
96                    width: None,
97                    density: Some(2.0),
98                },
99                ImageCandidate {
100                    url: "dog.jpeg".to_string(),
101                    width: Some(100.0),
102                    density: None,
103                },
104            ]
105        );
106    }
107
108    #[test]
109    fn ignores_extra_whitespaces() {
110        let srcset = r#"
111            foo-bar.png     2x ,
112            bar-baz.png  100w
113        "#;
114
115        let result = parse(srcset);
116        assert_eq!(
117            result,
118            vec![
119                ImageCandidate {
120                    url: "foo-bar.png".to_string(),
121                    width: None,
122                    density: Some(2.0),
123                },
124                ImageCandidate {
125                    url: "bar-baz.png".to_string(),
126                    width: Some(100.0),
127                    density: None,
128                },
129            ]
130        );
131    }
132
133    #[test]
134    fn properly_parses_float_descriptors() {
135        let srcset = "cat.jpeg 2.4x, dog.jpeg 1.5x";
136        let result = parse(srcset);
137        assert_eq!(
138            result,
139            vec![
140                ImageCandidate {
141                    url: "cat.jpeg".to_string(),
142                    width: None,
143                    density: Some(2.4),
144                },
145                ImageCandidate {
146                    url: "dog.jpeg".to_string(),
147                    width: None,
148                    density: Some(1.5),
149                },
150            ]
151        );
152    }
153
154    #[test]
155    fn supports_urls_that_contain_comma() {
156        let srcset = r#"
157          https://foo.bar/w=100,h=200/dog.png  100w,
158          https://baz.bar/cat.png?meow=yes     1024w
159        "#;
160
161        let result = parse(srcset);
162        assert_eq!(
163            result,
164            vec![
165                ImageCandidate {
166                    url: "https://foo.bar/w=100,h=200/dog.png".to_string(),
167                    width: Some(100.0),
168                    density: None,
169                },
170                ImageCandidate {
171                    url: "https://baz.bar/cat.png?meow=yes".to_string(),
172                    width: Some(1024.0),
173                    density: None,
174                },
175            ]
176        );
177    }
178
179    #[test]
180    fn supports_single_urls() {
181        let srcset = "/cat.jpg";
182        let result = parse(srcset);
183        assert_eq!(
184            result,
185            vec![ImageCandidate {
186                url: "/cat.jpg".to_string(),
187                width: None,
188                density: None,
189            }]
190        );
191    }
192
193    #[test]
194    fn supports_optional_descriptors() {
195        let srcset = "/cat.jpg, /dog.png 3x , /lol ";
196        let result = parse(srcset);
197        assert_eq!(
198            result,
199            vec![
200                ImageCandidate {
201                    url: "/cat.jpg".to_string(),
202                    width: None,
203                    density: None,
204                },
205                ImageCandidate {
206                    url: "/dog.png".to_string(),
207                    width: None,
208                    density: Some(3.0),
209                },
210                ImageCandidate {
211                    url: "/lol".to_string(),
212                    width: None,
213                    density: None,
214                },
215            ]
216        );
217    }
218}