Fix icon rendering, calc()/var() length resolution, and approximate CSS float

Icons were rendering as either overlapping solid squares or not at all,
  text ran under the infobox instead of beside it, and general.css parsing
  had two more silent-corruption bugs on top of the ones fixed earlier:

  - A CSS declaration list was split on every literal semicolon, so any
    value containing one inside a quoted string or url() (a data: URI
    with a MIME parameter, e.g. url(data:image/svg+xml;utf8,<svg...>),
    extremely common for small inline icon SVGs) got torn into two
    malformed halves and silently dropped. This is why mask-image never
    applied to the TOC toggle icons at all.
  - data: URIs were only decoded as base64. Inline SVGs are just as
    commonly written as data:image/svg+xml;utf8,<percent-escaped text>,
    which needs percent-decoding instead; added that path.
  - top/left/right/bottom/width/height only handled bare lengths and a
    flat calc(a +/- b). Real values nest var(--x, fallback), calc(), and
    min()/max() inside each other (Wikipedia's icon offset math is
    literally calc(-1 * 22px / 2), and its icon sizing is
    calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))). Replaced
    the flat evaluator with a small recursive resolver that handles all
    three, nested arbitrarily. This is what was placing the TOC toggle
    button's icon on top of the section text instead of in the left
    margin where it belongs.

  Added actual feature support once the parsing was correct:

  - mask-image/-webkit-mask-image: the pattern behind most scalable
    colored icons on the modern web (an SVG or image used purely as an
    alpha stencil, tinted by background-color/color). Previously every
    masked icon rendered as an unmasked solid-color square.
  - float: left/right, approximated without a full per-line reflow
    engine (which taffy's block/flex/grid modes don't provide): a
    floated element is grouped into a row alongside the flow siblings
    that follow it, up to whichever comes first of the next heading or
    another float, then everything from there on reverts to normal
    full-width flow. This isn't general CSS float, but it directly
    targets the overwhelmingly common real shape (an infobox or image
    floated near the top of an article, with the rest of the content
    starting at the next section heading), and is what makes article
    text actually wrap beside an infobox instead of running underneath
    it.

  Refactored paint.rs's image fetch/decode/cache logic into shared
  helpers (fetch_bytes, raster_to_pixmap) reused by plain <img> painting,
  background-image, and the new mask-image path.
This commit is contained in:
SGavrl committed 2026-07-03 12:34:27 +00:00
1 parent 920977776c
commit ca4a4ef135
4 files changed
+522 -79

No files matched your search

+100 -5
View File
@@ -450,11 +450,13 @@ fn build(
taffy_style.flex_wrap = taffy::FlexWrap::Wrap; taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
} }
let child_ids: Vec<taffy::NodeId> = tree let dom_children = tree.children(id);
.children(id) let has_float_child = dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false));
.into_iter() let child_ids: Vec<taffy::NodeId> = if has_float_child {
.filter_map(|cid| build(tree, cid, taffy_tree, id_map, styles)) build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, styles)
.collect(); } else {
dom_children.into_iter().filter_map(|cid| build(tree, cid, taffy_tree, id_map, styles)).collect()
};
let taffy_id = if child_ids.is_empty() { let taffy_id = if child_ids.is_empty() {
taffy_tree.new_leaf(taffy_style).ok()? taffy_tree.new_leaf(taffy_style).ok()?
@@ -465,6 +467,99 @@ fn build(
Some(taffy_id) Some(taffy_id)
} }
/// Approximate `float: left|right` without real per-line reflow (which
/// taffy's block/flex/grid modes do not provide): place the float alongside
/// the flow siblings that follow it, up to whichever comes first of the next
/// heading or another floated element, then let everything from there on
/// revert to normal full-width flow.
///
/// This is not a general CSS float implementation (a float taller than its
/// flow zone, or one that should keep affecting content past a heading,
/// won't reflow correctly), but it directly targets the overwhelmingly
/// common real-world shape: a floated image or infobox near the top of an
/// article, sitting beside the intro text, with the rest of the content
/// (starting at the next section heading) running full width beneath it.
fn build_children_with_float_zone(
tree: &DomTree,
dom_children: &[NodeId],
taffy_tree: &mut TaffyTree,
id_map: &mut HashMap<taffy::NodeId, NodeId>,
styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Vec<taffy::NodeId> {
let is_float = |cid: NodeId| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false);
let is_heading = |cid: NodeId| {
tree.get_node(cid)
.and_then(|n| n.as_element().map(|e| e.local.to_string()))
.map(|local| matches!(local.as_str(), "h1" | "h2" | "h3" | "h4" | "h5" | "h6"))
.unwrap_or(false)
};
let Some(float_idx) = dom_children.iter().position(|&cid| is_float(cid)) else {
return dom_children.iter().filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)).collect();
};
let mut result: Vec<taffy::NodeId> = dom_children[..float_idx]
.iter()
.filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles))
.collect();
let float_side = styles.get(&dom_children[float_idx]).and_then(|s| s.float);
let mut zone_end = float_idx + 1;
while zone_end < dom_children.len() && !is_heading(dom_children[zone_end]) && !is_float(dom_children[zone_end]) {
zone_end += 1;
}
let float_taffy = build(tree, dom_children[float_idx], taffy_tree, id_map, styles);
let flow_taffy: Vec<taffy::NodeId> = dom_children[float_idx + 1..zone_end]
.iter()
.filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles))
.collect();
match float_taffy {
Some(float_id) => {
let flow_column_style = taffy::Style {
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Column,
flex_grow: 1.0,
flex_shrink: 1.0,
flex_basis: taffy::Dimension::Length(0.0),
min_size: taffy::Size { width: taffy::Dimension::Length(0.0), height: taffy::Dimension::Auto },
..Default::default()
};
let flow_column = if flow_taffy.is_empty() {
taffy_tree.new_leaf(flow_column_style).ok()
} else {
taffy_tree.new_with_children(flow_column_style, &flow_taffy).ok()
};
let row_style = taffy::Style {
display: taffy::style::Display::Flex,
flex_direction: taffy::FlexDirection::Row,
align_items: Some(taffy::AlignItems::FlexStart),
..Default::default()
};
let row_children: Vec<taffy::NodeId> = match float_side {
Some(crate::Float::Left) => [Some(float_id), flow_column].into_iter().flatten().collect(),
_ => [flow_column, Some(float_id)].into_iter().flatten().collect(),
};
if let Ok(row) = taffy_tree.new_with_children(row_style, &row_children) {
result.push(row);
}
}
// The float itself failed to build (e.g. display:none resolved for
// it specifically); still build its flow siblings so their content
// is not silently lost.
None => result.extend(flow_taffy),
}
result.extend(
dom_children[zone_end..]
.iter()
.filter_map(|&cid| build(tree, cid, taffy_tree, id_map, styles)),
);
result
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+16
View File
@@ -113,6 +113,11 @@ pub struct LayoutStyle {
/// `background_size`: without a known image size there is no leftover /// `background_size`: without a known image size there is no leftover
/// box space to position within. /// box space to position within.
pub background_position: (f32, f32), pub background_position: (f32, f32),
/// `mask-image`/`-webkit-mask-image: url(...)`: the ubiquitous "colored,
/// scalable icon" pattern (an SVG shape used as a stencil, tinted by
/// `background-color`/`color` instead of carrying its own colors). Without
/// this, every such icon paints as a solid filled square.
pub mask_image: Option<String>,
/// Foreground (text) color for the paint step. /// Foreground (text) color for the paint step.
pub color: Option<[u8; 4]>, pub color: Option<[u8; 4]>,
pub border_color: Option<[u8; 4]>, pub border_color: Option<[u8; 4]>,
@@ -147,6 +152,17 @@ pub struct LayoutStyle {
/// clipped box used for skip-links and screen-reader-only labels) actually /// clipped box used for skip-links and screen-reader-only labels) actually
/// invisible instead of painting its text wherever it lands. /// invisible instead of painting its text wherever it lands.
pub overflow_hidden: bool, pub overflow_hidden: bool,
/// `float: left|right`. True CSS float needs per-line reflow around the
/// float's shape, which taffy's block/flex/grid modes do not do; see
/// `dom::group_float_zone` for the bounded approximation this drives.
pub float: Option<Float>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Float {
Left,
Right,
} }
/// A node in the input layout tree. `text` is carried for the paint phase; it /// A node in the input layout tree. `text` is carried for the paint phase; it
+165 -71
View File
@@ -72,7 +72,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
None => continue, None => continue,
}; };
if let Some(bg) = style.background_color { // A masked element's background-color is the mask's fill color, not
// an ordinary box background: it must only show through the mask
// shape (painted below), not as a solid rect behind it.
if let (Some(bg), None) = (style.background_color, &style.mask_image) {
let mut path = PathBuilder::new(); let mut path = PathBuilder::new();
path.push_rect(box_rect); path.push_rect(box_rect);
if let Some(path) = path.finish() { if let Some(path) = path.finish() {
@@ -83,7 +86,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
} }
} }
if let Some(bg_url) = &style.background_image { if let Some(mask_url) = &style.mask_image {
let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]);
paint_mask(mask_url, base_url, &visible_rect, fill, &mut pixmap, &mut image_cache);
} else if let Some(bg_url) = &style.background_image {
// With an explicit background-size, paint the image at that size // With an explicit background-size, paint the image at that size
// positioned within the box per background-position, instead of // positioned within the box per background-position, instead of
// stretching it to fill the whole element: a small icon // stretching it to fill the whole element: a small icon
@@ -302,6 +308,102 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
} }
} }
/// Resolve `src` (a `data:` URI, or an absolute/relative URL against
/// `base_url`) to raw bytes, fetching over the network at most once per
/// distinct URL per screenshot via `cache`.
fn fetch_bytes(
src: &str,
base_url: Option<&str>,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> Option<Vec<u8>> {
if let Some(rest) = src.strip_prefix("data:image/") {
let comma_idx = rest.find(',')?;
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
// Inline SVGs are very commonly authored as data:image/svg+xml;utf8,
// (or with no encoding label at all, which is equivalent): plain,
// percent-escaped text, not base64. Only decode as base64 when the
// URI actually says so.
return if meta.contains("base64") {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(data).ok()
} else {
Some(percent_decode(data))
};
}
// Resolve relative to the document's base URL: the overwhelming majority
// of real markup uses relative image paths ("logo.svg", not
// "https://example.com/logo.svg"), so without this every relative <img>
// or mask/background reference silently fails to fetch.
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
Some(src.to_string())
} else {
base_url
.and_then(|b| url::Url::parse(b).ok())
.and_then(|base| base.join(src).ok())
.map(|u| u.to_string())
};
// The same icon/sprite/background is routinely referenced by dozens of
// elements on one page (every story's vote arrow, every repeated logo);
// fetch each distinct URL over the network once per screenshot rather
// than once per element.
let url = resolved?;
cache
.entry(url.clone())
.or_insert_with(|| {
ureq::get(&url).call().ok().and_then(|resp| {
let mut buf = Vec::new();
use std::io::Read;
resp.into_reader().read_to_end(&mut buf).ok()?;
Some(buf)
})
})
.clone()
}
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
/// are not part of a `%XX` escape pass through unchanged, which is exactly
/// right for the inline-SVG case: only the characters that would otherwise be
/// ambiguous in a URI (`#`, `"`, ...) get escaped, everything else is literal
/// UTF-8 text.
fn percent_decode(s: &str) -> Vec<u8> {
// Operates on raw bytes throughout (never slices `s` as a string): a
// stray '%' followed by non-hex bytes could otherwise land a string
// slice in the middle of a multi-byte UTF-8 character and panic.
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
/// Decode raster image bytes (jpeg/png/webp) to a premultiplied-alpha pixmap
/// resized to `w`x`h`.
fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let resized = image::imageops::resize(&img, w, h, image::imageops::FilterType::Triangle);
let mut raw = resized.into_raw();
for pixel in raw.chunks_exact_mut(4) {
let a = pixel[3] as u32;
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
}
let size = tiny_skia::IntSize::from_wh(w, h)?;
Pixmap::from_vec(raw, size)
}
fn paint_image( fn paint_image(
src: &str, src: &str,
base_url: Option<&str>, base_url: Option<&str>,
@@ -312,87 +414,24 @@ fn paint_image(
if rect.width <= 0.0 || rect.height <= 0.0 { if rect.width <= 0.0 || rect.height <= 0.0 {
return false; return false;
} }
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
let bytes = if src.starts_with("data:image/") { let content = if is_svg(&bytes) {
if let Some(comma_idx) = src.find(',') { render_svg(&bytes, rect.width as u32, rect.height as u32)
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok()
} else { None }
} else { } else {
// Resolve relative to the document's base URL: the overwhelming raster_to_pixmap(&bytes, rect.width as u32, rect.height as u32)
// majority of real markup uses relative image paths ("logo.svg", not
// "https://example.com/logo.svg"), so without this every relative
// <img> silently fails to fetch, not just SVGs.
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
Some(src.to_string())
} else {
base_url
.and_then(|b| url::Url::parse(b).ok())
.and_then(|base| base.join(src).ok())
.map(|u| u.to_string())
}; };
match resolved { if let Some(content) = content {
// The same icon/sprite/background is routinely referenced by
// dozens of elements on one page (every story's vote arrow, every
// repeated logo); fetch each distinct URL over the network once
// per screenshot rather than once per element.
Some(url) => cache
.entry(url.clone())
.or_insert_with(|| {
ureq::get(&url).call().ok().and_then(|resp| {
let mut buf = Vec::new();
use std::io::Read;
resp.into_reader().read_to_end(&mut buf).ok()?;
Some(buf)
})
})
.clone(),
None => None,
}
};
let Some(bytes) = bytes else { return false };
if is_svg(&bytes) {
if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) {
pixmap.draw_pixmap( pixmap.draw_pixmap(
rect.x as i32, rect.x as i32,
rect.y as i32, rect.y as i32,
svg_pixmap.as_ref(), content.as_ref(),
&tiny_skia::PixmapPaint::default(), &tiny_skia::PixmapPaint::default(),
Transform::identity(), Transform::identity(),
None, None,
); );
return true; return true;
} }
return false;
}
if let Ok(img) = image::load_from_memory(&bytes) {
let img = img.to_rgba8();
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
let mut raw = resized.into_raw();
for pixel in raw.chunks_exact_mut(4) {
let a = pixel[3] as u32;
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
}
if let Some(size) = tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32) {
if let Some(img_pixmap) = Pixmap::from_vec(raw, size) {
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
img_pixmap.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
None,
);
return true;
}
}
}
false false
} }
@@ -423,6 +462,61 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
Some(svg_pixmap) Some(svg_pixmap)
} }
/// Paint a `mask-image`: the ubiquitous "colored, scalable icon" pattern,
/// where an SVG shape is used purely as a stencil and tinted by
/// `background-color`/`color` rather than carrying its own colors. Fetches
/// and rasterizes the mask the same way as an ordinary image, then repaints
/// every pixel it covers as `fill`, weighted by the mask's own alpha there
/// (its "coverage"), instead of drawing the mask's own pixel colors.
fn paint_mask(
src: &str,
base_url: Option<&str>,
rect: &crate::Rect,
fill: [u8; 4],
pixmap: &mut Pixmap,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> bool {
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
let (w, h) = (rect.width as u32, rect.height as u32);
let mask = if is_svg(&bytes) { render_svg(&bytes, w, h) } else { raster_to_pixmap(&bytes, w, h) };
let Some(mask) = mask else { return false };
let recolored = recolor_by_alpha(&mask, fill);
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
recolored.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
None,
);
true
}
/// Replace every pixel's color with `fill`, scaling `fill`'s alpha by the
/// source pixel's own alpha (its mask coverage at that point).
fn recolor_by_alpha(src: &Pixmap, fill: [u8; 4]) -> Pixmap {
let (w, h) = (src.width(), src.height());
let mut out = Pixmap::new(w, h).expect("non-zero size, already validated by caller");
let dst = out.pixels_mut();
for (i, p) in src.pixels().iter().enumerate() {
let coverage = p.alpha() as u32;
if coverage == 0 {
continue;
}
let a = (fill[3] as u32 * coverage) / 255;
let r = (fill[0] as u32 * a) / 255;
let g = (fill[1] as u32 * a) / 255;
let b = (fill[2] as u32 * a) / 255;
dst[i] = tiny_skia::PremultipliedColorU8::from_rgba(r as u8, g as u8, b as u8, a as u8)
.unwrap_or_else(|| tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap());
}
out
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+241 -3
View File
@@ -79,7 +79,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
} }
pub fn apply_inline(style: &mut LayoutStyle, css: &str) { pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
for raw in css.split(';') { for raw in split_declarations(css) {
let decl = raw.trim(); let decl = raw.trim();
if decl.is_empty() { if decl.is_empty() {
continue; continue;
@@ -97,6 +97,39 @@ pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
} }
} }
/// Split a declaration list on top-level semicolons, respecting `url(...)`
/// and quoted strings. A data: URI (`url(data:image/svg+xml;utf8,...)`, an
/// extremely common way to inline small icon SVGs) or a quoted string
/// (`content: "a; b"`) routinely contains a literal semicolon that is not a
/// declaration separator; splitting on every `;` blindly corrupts the
/// declaration into two malformed halves and silently drops it.
fn split_declarations(css: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut in_quote: Option<char> = None;
let mut start = 0;
for (i, c) in css.char_indices() {
if let Some(q) = in_quote {
if c == q {
in_quote = None;
}
continue;
}
match c {
'\'' | '"' => in_quote = Some(c),
'(' => depth += 1,
')' => depth -= 1,
';' if depth == 0 => {
parts.push(&css[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&css[start..]);
parts
}
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
match name { match name {
"display" => match value { "display" => match value {
@@ -148,6 +181,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
"background-image" => style.background_image = parse_url(value), "background-image" => style.background_image = parse_url(value),
"background-size" => style.background_size = parse_background_size(value), "background-size" => style.background_size = parse_background_size(value),
"background-position" => style.background_position = parse_background_position(value), "background-position" => style.background_position = parse_background_position(value),
"mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value),
"color" => style.color = parse_color(value), "color" => style.color = parse_color(value),
"border-color" => style.border_color = parse_color(value), "border-color" => style.border_color = parse_color(value),
"font-size" => style.font_size = px(value), "font-size" => style.font_size = px(value),
@@ -210,6 +244,14 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
_ => {} _ => {}
} }
}, },
"float" => {
match value {
"left" => style.float = Some(crate::Float::Left),
"right" => style.float = Some(crate::Float::Right),
"none" => style.float = None,
_ => {}
}
},
"top" => style.inset[0] = px(value), "top" => style.inset[0] = px(value),
"right" => style.inset[1] = px(value), "right" => style.inset[1] = px(value),
"bottom" => style.inset[2] = px(value), "bottom" => style.inset[2] = px(value),
@@ -508,12 +550,166 @@ fn set_edge(edges: &mut Edges, side: Side, v: Option<f32>) {
} }
} }
/// Parse the first length in a value as CSS pixels. `auto`, percentages, and /// Parse the first length in a value as CSS pixels. `auto` and non-numeric
/// non-numeric values return None (treated as "no explicit size" in phase 1). /// values return None (treated as "no explicit size" in phase 1). Delegates
/// to `resolve_length` for anything beyond a bare token, since `calc()`,
/// `var()`, and `min()`/`max()` all contain spaces that would otherwise break
/// the single-token fast path.
fn px(value: &str) -> Option<f32> { fn px(value: &str) -> Option<f32> {
let trimmed = value.trim();
if trimmed.contains('(') {
return resolve_length(trimmed);
}
token(value).and_then(px_value) token(value).and_then(px_value)
} }
/// Resolve a CSS length expression to px, recursively handling the small set
/// of functional forms real stylesheets actually nest in practice:
/// `var(--x, fallback)` (substitute the fallback; we track no custom
/// property values), `calc(...)`, and `min()`/`max()`. These commonly nest
/// inside each other (`calc(max(calc(var(--x,1rem) + 4px),10px))` is a real
/// example from Wikipedia's icon sizing), so each case recurses back into
/// this function rather than assuming a flat expression.
fn resolve_length(value: &str) -> Option<f32> {
let v = value.trim();
if let Some(rest) = v.strip_prefix("var(") {
let end = find_matching_paren(rest)?;
let inner = &rest[..end];
let (_, fallback) = inner.split_once(',')?;
return resolve_length(fallback.trim());
}
if let Some(rest) = v.strip_prefix("calc(") {
let end = find_matching_paren(rest)?;
return eval_calc(&rest[..end]);
}
if let Some(rest) = v.strip_prefix("max(").or_else(|| v.strip_prefix("min(")) {
let is_max = v.starts_with("max(");
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let mut values = args.iter().filter_map(|a| resolve_length(a));
let mut best = values.next()?;
for val in values {
if (is_max && val > best) || (!is_max && val < best) {
best = val;
}
}
return Some(best);
}
if v.contains('(') {
return None; // an unhandled function (clamp(), env(), ...): no safe fallback
}
px_value(v).or_else(|| v.parse::<f32>().ok())
}
/// Find the index (relative to `s`) of the `)` matching an already-consumed
/// opening `(`, accounting for nesting.
fn find_matching_paren(s: &str) -> Option<usize> {
let mut depth = 1i32;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
/// Split on `sep` at paren-depth 0 only, so `max(a,b)` inside an argument
/// list is not itself split on its internal comma.
fn split_top_level(s: &str, sep: char) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0i32;
let mut start = 0;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth -= 1,
c if c == sep && depth == 0 => {
parts.push(&s[start..i]);
start = i + 1;
}
_ => {}
}
}
parts.push(&s[start..]);
parts
}
/// Evaluate a `calc()` body (already stripped of `calc(` / the matching `)`):
/// a left-to-right sum of terms, each itself a left-to-right `*`/`/` chain.
/// Terms may themselves be nested `max()`/`min()`/`var()`/`calc()` calls.
fn eval_calc(expr: &str) -> Option<f32> {
let mut terms: Vec<(f32, String)> = Vec::new();
let mut sign = 1.0;
let mut cur = String::new();
let mut depth = 0i32;
for c in expr.chars() {
match c {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
'+' | '-' if depth == 0 => {
if !cur.trim().is_empty() {
terms.push((sign, std::mem::take(&mut cur)));
}
sign = if c == '-' { -1.0 } else { 1.0 };
}
_ => cur.push(c),
}
}
if !cur.trim().is_empty() {
terms.push((sign, cur));
}
if terms.is_empty() {
return None;
}
let mut total = 0.0;
for (term_sign, term) in terms {
total += term_sign * eval_product(term.trim())?;
}
Some(total)
}
/// Evaluate a `*`/`/` chain within one additive term of a calc() expression,
/// e.g. `-1 * 22px / 2`, where a factor may itself be a nested function call.
fn eval_product(term: &str) -> Option<f32> {
let mut result: Option<f32> = None;
let mut op = '*';
let mut depth = 0i32;
let mut cur = String::new();
let mut factors = Vec::new();
for c in term.chars() {
match c {
'(' => { depth += 1; cur.push(c); }
')' => { depth -= 1; cur.push(c); }
' ' if depth == 0 => {
if !cur.is_empty() { factors.push(std::mem::take(&mut cur)); }
}
_ => cur.push(c),
}
}
if !cur.is_empty() { factors.push(cur); }
for tok in &factors {
if tok == "*" || tok == "/" {
op = tok.chars().next()?;
continue;
}
let v = resolve_length(tok)?;
result = Some(match result {
None => v,
Some(r) if op == '/' => r / v,
Some(r) => r * v,
});
}
result
}
fn dimension_value(tok: &str) -> crate::Dimension { fn dimension_value(tok: &str) -> crate::Dimension {
let n = tok.trim(); let n = tok.trim();
if n.ends_with("%") { if n.ends_with("%") {
@@ -671,4 +867,46 @@ mod tests {
assert_eq!(s.width, crate::Dimension::Px(100.0)); assert_eq!(s.width, crate::Dimension::Px(100.0));
assert_eq!(s.height, crate::Dimension::Auto); assert_eq!(s.height, crate::Dimension::Auto);
} }
#[test]
fn calc_with_multiply_and_divide() {
// The exact shape MediaWiki uses to offset a TOC toggle button into
// the left margin: a negative product divided by a constant.
assert_eq!(resolve_length("calc(-1 * 22px / 2)"), Some(-11.0));
}
#[test]
fn calc_add_and_subtract() {
assert_eq!(resolve_length("calc(750px - 1px)"), Some(749.0));
assert_eq!(resolve_length("calc(10px + 5px)"), Some(15.0));
}
#[test]
fn var_with_fallback_resolves_to_fallback() {
assert_eq!(resolve_length("var(--font-size-medium, 1rem)"), Some(16.0));
}
#[test]
fn var_without_fallback_is_unresolvable() {
assert_eq!(resolve_length("var(--unknown-token)"), None);
}
#[test]
fn min_and_max_functions() {
assert_eq!(resolve_length("max(5px, 10px)"), Some(10.0));
assert_eq!(resolve_length("min(5px, 10px)"), Some(5.0));
}
#[test]
fn nested_var_calc_max_like_wikipedia_icon_sizing() {
// calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))
let expr = "calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))";
assert_eq!(resolve_length(expr), Some(20.0));
}
#[test]
fn width_property_resolves_calc_with_var() {
let s = compute_style("div", Some("width: calc(var(--x, 10px) + 5px)"));
assert_eq!(s.width, crate::Dimension::Px(15.0));
}
} }