Skip to main content

parse_srcset/
lib.rs

1//! # parse-srcset — parse an HTML `srcset` attribute
2//!
3//! Parse the value of an `<img srcset="…">` attribute into a list of image
4//! candidates, each with a URL and an optional density (`x`), width (`w`), or height
5//! (`h`) descriptor. A faithful Rust port of the
6//! [`parse-srcset`](https://www.npmjs.com/package/parse-srcset) npm package, which
7//! follows the [WHATWG algorithm](https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute).
8//! Zero dependencies and `#![no_std]`.
9//!
10//! ```
11//! use parse_srcset::parse_srcset;
12//!
13//! let c = parse_srcset("small.jpg 480w, large.jpg 800w, fallback.jpg");
14//! assert_eq!(c.len(), 3);
15//! assert_eq!(c[0].url, "small.jpg");
16//! assert_eq!(c[0].width, Some(480));
17//! assert_eq!(c[2].width, None);
18//!
19//! let c = parse_srcset("a.png 1x, b.png 2x");
20//! assert_eq!(c[1].density, Some(2.0));
21//! ```
22//!
23//! A candidate whose descriptors are invalid (e.g. mixing `w` and `x`) is skipped,
24//! matching the reference implementation.
25
26#![no_std]
27#![doc(html_root_url = "https://docs.rs/parse-srcset/0.1.0")]
28
29extern crate alloc;
30
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33
34// Compile-test the README's examples as part of `cargo test`.
35#[cfg(doctest)]
36#[doc = include_str!("../README.md")]
37struct ReadmeDoctests;
38
39/// One image candidate from a `srcset` attribute.
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct ImageCandidate {
42    /// The image URL.
43    pub url: String,
44    /// The pixel density descriptor (`2x` → `2.0`), if present.
45    pub density: Option<f64>,
46    /// The width descriptor (`480w` → `480`), if present.
47    pub width: Option<u64>,
48    /// The (future-compat) height descriptor (`200h` → `200`), if present.
49    pub height: Option<u64>,
50}
51
52/// Parse a `srcset` attribute value into its image candidates.
53///
54/// Invalid candidates are skipped (the reference implementation logs them and
55/// continues); a fully invalid or empty input yields an empty list.
56///
57/// ```
58/// use parse_srcset::parse_srcset;
59/// assert_eq!(parse_srcset("img.png").len(), 1);
60/// assert!(parse_srcset("img.png 1w 2x").is_empty()); // mixed descriptors
61/// ```
62#[must_use]
63pub fn parse_srcset(input: &str) -> Vec<ImageCandidate> {
64    let chars: Vec<char> = input.chars().collect();
65    let len = chars.len();
66    let mut pos = 0;
67    let mut candidates: Vec<ImageCandidate> = Vec::new();
68
69    loop {
70        // Skip leading commas and whitespace.
71        while pos < len && is_comma_or_space(chars[pos]) {
72            pos += 1;
73        }
74        if pos >= len {
75            return candidates;
76        }
77
78        // Collect a run of non-space characters as the URL.
79        let url_start = pos;
80        while pos < len && !is_space(chars[pos]) {
81            pos += 1;
82        }
83        let url_token: String = chars[url_start..pos].iter().collect();
84
85        if url_token.ends_with(',') {
86            // A URL ending in commas has no descriptors; strip them and emit it.
87            let url = url_token.trim_end_matches(',').to_string();
88            if let Some(c) = parse_descriptors(url, &[]) {
89                candidates.push(c);
90            }
91        } else if let Some(c) = parse_descriptors(url_token, &tokenize(&chars, &mut pos, len)) {
92            candidates.push(c);
93        }
94    }
95}
96
97#[derive(Clone, Copy)]
98enum State {
99    InDescriptor,
100    InParens,
101    AfterDescriptor,
102}
103
104/// Tokenize the descriptors following a URL, advancing `pos` past them.
105fn tokenize(chars: &[char], pos: &mut usize, len: usize) -> Vec<String> {
106    // Skip whitespace before the first descriptor.
107    while *pos < len && is_space(chars[*pos]) {
108        *pos += 1;
109    }
110
111    let mut descriptors: Vec<String> = Vec::new();
112    let mut current = String::new();
113    let mut state = State::InDescriptor;
114
115    loop {
116        let c = chars.get(*pos).copied(); // `None` represents EOF
117        match state {
118            State::InDescriptor => match c {
119                Some(ch) if is_space(ch) => {
120                    if !current.is_empty() {
121                        descriptors.push(core::mem::take(&mut current));
122                        state = State::AfterDescriptor;
123                    }
124                }
125                Some(',') => {
126                    *pos += 1;
127                    if !current.is_empty() {
128                        descriptors.push(current);
129                    }
130                    return descriptors;
131                }
132                Some('(') => {
133                    current.push('(');
134                    state = State::InParens;
135                }
136                None => {
137                    if !current.is_empty() {
138                        descriptors.push(current);
139                    }
140                    return descriptors;
141                }
142                Some(ch) => current.push(ch),
143            },
144            State::InParens => match c {
145                Some(')') => {
146                    current.push(')');
147                    state = State::InDescriptor;
148                }
149                None => {
150                    descriptors.push(current);
151                    return descriptors;
152                }
153                Some(ch) => current.push(ch),
154            },
155            State::AfterDescriptor => match c {
156                Some(ch) if is_space(ch) => {} // stay
157                None => return descriptors,
158                Some(_) => {
159                    state = State::InDescriptor;
160                    *pos -= 1;
161                }
162            },
163        }
164        *pos += 1;
165    }
166}
167
168/// Apply the descriptors to a URL, returning a candidate or `None` on a parse error.
169fn parse_descriptors(url: String, descriptors: &[String]) -> Option<ImageCandidate> {
170    let mut error = false;
171    let mut width: Option<u64> = None;
172    let mut density: Option<f64> = None;
173    let mut height: Option<u64> = None;
174
175    // Truthiness mirrors JavaScript: `0` (and `0.0`) count as absent.
176    let truthy_w = |w: Option<u64>| w.is_some();
177    let truthy_h = |h: Option<u64>| h.is_some();
178    let truthy_d = |d: Option<f64>| d.is_some_and(|v| v != 0.0);
179
180    for desc in descriptors {
181        let last = desc.chars().next_back().unwrap_or('\0');
182        let value: String = {
183            let mut it = desc.chars();
184            it.next_back();
185            it.collect()
186        };
187
188        if last == 'w' && is_non_negative_integer(&value) {
189            if truthy_w(width) || truthy_d(density) {
190                error = true;
191            }
192            match value.parse::<u64>() {
193                Ok(0) | Err(_) => error = true,
194                Ok(n) => width = Some(n),
195            }
196        } else if last == 'x' && is_floating_point(&value) {
197            if truthy_w(width) || truthy_d(density) || truthy_h(height) {
198                error = true;
199            }
200            match value.parse::<f64>() {
201                Ok(f) if f >= 0.0 => density = Some(f),
202                _ => error = true,
203            }
204        } else if last == 'h' && is_non_negative_integer(&value) {
205            if truthy_h(height) || truthy_d(density) {
206                error = true;
207            }
208            match value.parse::<u64>() {
209                Ok(0) | Err(_) => error = true,
210                Ok(n) => height = Some(n),
211            }
212        } else {
213            error = true;
214        }
215    }
216
217    if error {
218        return None;
219    }
220    Some(ImageCandidate {
221        url,
222        density: if truthy_d(density) { density } else { None },
223        width: if truthy_w(width) { width } else { None },
224        height: if truthy_h(height) { height } else { None },
225    })
226}
227
228fn is_space(c: char) -> bool {
229    matches!(c, ' ' | '\t' | '\n' | '\u{000c}' | '\r')
230}
231
232fn is_comma_or_space(c: char) -> bool {
233    c == ',' || is_space(c)
234}
235
236/// `/^\d+$/`
237fn is_non_negative_integer(s: &str) -> bool {
238    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
239}
240
241/// `/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/`
242fn is_floating_point(s: &str) -> bool {
243    let b = s.as_bytes();
244    let len = b.len();
245    let mut i = 0;
246    if i < len && b[i] == b'-' {
247        i += 1;
248    }
249    let int_digits = count_digits(b, &mut i);
250    if i < len && b[i] == b'.' {
251        i += 1;
252        if count_digits(b, &mut i) == 0 {
253            return false; // a dot must be followed by a digit
254        }
255    } else if int_digits == 0 {
256        return false; // need at least one digit
257    }
258    if i < len && (b[i] == b'e' || b[i] == b'E') {
259        i += 1;
260        if i < len && (b[i] == b'+' || b[i] == b'-') {
261            i += 1;
262        }
263        if count_digits(b, &mut i) == 0 {
264            return false; // exponent must have digits
265        }
266    }
267    i == len
268}
269
270fn count_digits(b: &[u8], i: &mut usize) -> usize {
271    let start = *i;
272    while *i < b.len() && b[*i].is_ascii_digit() {
273        *i += 1;
274    }
275    *i - start
276}