parse-srcset 0.1.0

Parse an HTML img srcset attribute into image candidates (url + density/width/height). A faithful port of the WHATWG-based parse-srcset npm package. Zero dependencies, no_std.
Documentation
//! # parse-srcset — parse an HTML `srcset` attribute
//!
//! Parse the value of an `<img srcset="…">` attribute into a list of image
//! candidates, each with a URL and an optional density (`x`), width (`w`), or height
//! (`h`) descriptor. A faithful Rust port of the
//! [`parse-srcset`](https://www.npmjs.com/package/parse-srcset) npm package, which
//! follows the [WHATWG algorithm](https://html.spec.whatwg.org/multipage/images.html#parsing-a-srcset-attribute).
//! Zero dependencies and `#![no_std]`.
//!
//! ```
//! use parse_srcset::parse_srcset;
//!
//! let c = parse_srcset("small.jpg 480w, large.jpg 800w, fallback.jpg");
//! assert_eq!(c.len(), 3);
//! assert_eq!(c[0].url, "small.jpg");
//! assert_eq!(c[0].width, Some(480));
//! assert_eq!(c[2].width, None);
//!
//! let c = parse_srcset("a.png 1x, b.png 2x");
//! assert_eq!(c[1].density, Some(2.0));
//! ```
//!
//! A candidate whose descriptors are invalid (e.g. mixing `w` and `x`) is skipped,
//! matching the reference implementation.

#![no_std]
#![doc(html_root_url = "https://docs.rs/parse-srcset/0.1.0")]

extern crate alloc;

use alloc::string::{String, ToString};
use alloc::vec::Vec;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// One image candidate from a `srcset` attribute.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ImageCandidate {
    /// The image URL.
    pub url: String,
    /// The pixel density descriptor (`2x` → `2.0`), if present.
    pub density: Option<f64>,
    /// The width descriptor (`480w` → `480`), if present.
    pub width: Option<u64>,
    /// The (future-compat) height descriptor (`200h` → `200`), if present.
    pub height: Option<u64>,
}

/// Parse a `srcset` attribute value into its image candidates.
///
/// Invalid candidates are skipped (the reference implementation logs them and
/// continues); a fully invalid or empty input yields an empty list.
///
/// ```
/// use parse_srcset::parse_srcset;
/// assert_eq!(parse_srcset("img.png").len(), 1);
/// assert!(parse_srcset("img.png 1w 2x").is_empty()); // mixed descriptors
/// ```
#[must_use]
pub fn parse_srcset(input: &str) -> Vec<ImageCandidate> {
    let chars: Vec<char> = input.chars().collect();
    let len = chars.len();
    let mut pos = 0;
    let mut candidates: Vec<ImageCandidate> = Vec::new();

    loop {
        // Skip leading commas and whitespace.
        while pos < len && is_comma_or_space(chars[pos]) {
            pos += 1;
        }
        if pos >= len {
            return candidates;
        }

        // Collect a run of non-space characters as the URL.
        let url_start = pos;
        while pos < len && !is_space(chars[pos]) {
            pos += 1;
        }
        let url_token: String = chars[url_start..pos].iter().collect();

        if url_token.ends_with(',') {
            // A URL ending in commas has no descriptors; strip them and emit it.
            let url = url_token.trim_end_matches(',').to_string();
            if let Some(c) = parse_descriptors(url, &[]) {
                candidates.push(c);
            }
        } else if let Some(c) = parse_descriptors(url_token, &tokenize(&chars, &mut pos, len)) {
            candidates.push(c);
        }
    }
}

#[derive(Clone, Copy)]
enum State {
    InDescriptor,
    InParens,
    AfterDescriptor,
}

/// Tokenize the descriptors following a URL, advancing `pos` past them.
fn tokenize(chars: &[char], pos: &mut usize, len: usize) -> Vec<String> {
    // Skip whitespace before the first descriptor.
    while *pos < len && is_space(chars[*pos]) {
        *pos += 1;
    }

    let mut descriptors: Vec<String> = Vec::new();
    let mut current = String::new();
    let mut state = State::InDescriptor;

    loop {
        let c = chars.get(*pos).copied(); // `None` represents EOF
        match state {
            State::InDescriptor => match c {
                Some(ch) if is_space(ch) => {
                    if !current.is_empty() {
                        descriptors.push(core::mem::take(&mut current));
                        state = State::AfterDescriptor;
                    }
                }
                Some(',') => {
                    *pos += 1;
                    if !current.is_empty() {
                        descriptors.push(current);
                    }
                    return descriptors;
                }
                Some('(') => {
                    current.push('(');
                    state = State::InParens;
                }
                None => {
                    if !current.is_empty() {
                        descriptors.push(current);
                    }
                    return descriptors;
                }
                Some(ch) => current.push(ch),
            },
            State::InParens => match c {
                Some(')') => {
                    current.push(')');
                    state = State::InDescriptor;
                }
                None => {
                    descriptors.push(current);
                    return descriptors;
                }
                Some(ch) => current.push(ch),
            },
            State::AfterDescriptor => match c {
                Some(ch) if is_space(ch) => {} // stay
                None => return descriptors,
                Some(_) => {
                    state = State::InDescriptor;
                    *pos -= 1;
                }
            },
        }
        *pos += 1;
    }
}

/// Apply the descriptors to a URL, returning a candidate or `None` on a parse error.
fn parse_descriptors(url: String, descriptors: &[String]) -> Option<ImageCandidate> {
    let mut error = false;
    let mut width: Option<u64> = None;
    let mut density: Option<f64> = None;
    let mut height: Option<u64> = None;

    // Truthiness mirrors JavaScript: `0` (and `0.0`) count as absent.
    let truthy_w = |w: Option<u64>| w.is_some();
    let truthy_h = |h: Option<u64>| h.is_some();
    let truthy_d = |d: Option<f64>| d.is_some_and(|v| v != 0.0);

    for desc in descriptors {
        let last = desc.chars().next_back().unwrap_or('\0');
        let value: String = {
            let mut it = desc.chars();
            it.next_back();
            it.collect()
        };

        if last == 'w' && is_non_negative_integer(&value) {
            if truthy_w(width) || truthy_d(density) {
                error = true;
            }
            match value.parse::<u64>() {
                Ok(0) | Err(_) => error = true,
                Ok(n) => width = Some(n),
            }
        } else if last == 'x' && is_floating_point(&value) {
            if truthy_w(width) || truthy_d(density) || truthy_h(height) {
                error = true;
            }
            match value.parse::<f64>() {
                Ok(f) if f >= 0.0 => density = Some(f),
                _ => error = true,
            }
        } else if last == 'h' && is_non_negative_integer(&value) {
            if truthy_h(height) || truthy_d(density) {
                error = true;
            }
            match value.parse::<u64>() {
                Ok(0) | Err(_) => error = true,
                Ok(n) => height = Some(n),
            }
        } else {
            error = true;
        }
    }

    if error {
        return None;
    }
    Some(ImageCandidate {
        url,
        density: if truthy_d(density) { density } else { None },
        width: if truthy_w(width) { width } else { None },
        height: if truthy_h(height) { height } else { None },
    })
}

fn is_space(c: char) -> bool {
    matches!(c, ' ' | '\t' | '\n' | '\u{000c}' | '\r')
}

fn is_comma_or_space(c: char) -> bool {
    c == ',' || is_space(c)
}

/// `/^\d+$/`
fn is_non_negative_integer(s: &str) -> bool {
    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}

/// `/^-?(?:[0-9]+|[0-9]*\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/`
fn is_floating_point(s: &str) -> bool {
    let b = s.as_bytes();
    let len = b.len();
    let mut i = 0;
    if i < len && b[i] == b'-' {
        i += 1;
    }
    let int_digits = count_digits(b, &mut i);
    if i < len && b[i] == b'.' {
        i += 1;
        if count_digits(b, &mut i) == 0 {
            return false; // a dot must be followed by a digit
        }
    } else if int_digits == 0 {
        return false; // need at least one digit
    }
    if i < len && (b[i] == b'e' || b[i] == b'E') {
        i += 1;
        if i < len && (b[i] == b'+' || b[i] == b'-') {
            i += 1;
        }
        if count_digits(b, &mut i) == 0 {
            return false; // exponent must have digits
        }
    }
    i == len
}

fn count_digits(b: &[u8], i: &mut usize) -> usize {
    let start = *i;
    while *i < b.len() && b[*i].is_ascii_digit() {
        *i += 1;
    }
    *i - start
}