diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 3d047e6..76ae5a8 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -450,11 +450,13 @@ fn build( taffy_style.flex_wrap = taffy::FlexWrap::Wrap; } - let child_ids: Vec = tree - .children(id) - .into_iter() - .filter_map(|cid| build(tree, cid, taffy_tree, id_map, styles)) - .collect(); + let dom_children = tree.children(id); + let has_float_child = dom_children.iter().any(|&cid| styles.get(&cid).map(|s| s.float.is_some()).unwrap_or(false)); + let child_ids: Vec = if has_float_child { + build_children_with_float_zone(tree, &dom_children, taffy_tree, id_map, styles) + } 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() { taffy_tree.new_leaf(taffy_style).ok()? @@ -465,6 +467,99 @@ fn build( 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, + styles: &HashMap, +) -> Vec { + 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 = 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 = 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 = 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)] mod tests { use super::*; diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 6fe0a03..87f6610 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -113,6 +113,11 @@ pub struct LayoutStyle { /// `background_size`: without a known image size there is no leftover /// box space to position within. 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, /// Foreground (text) color for the paint step. pub 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 /// invisible instead of painting its text wherever it lands. 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, +} + +#[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 diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 9bc4ac7..7148cff 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -72,7 +72,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - 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(); path.push_rect(box_rect); 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 // positioned within the box per background-position, instead of // 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>>, +) -> Option> { + 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 + // 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 { + // 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 { + 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( src: &str, base_url: Option<&str>, @@ -312,86 +414,23 @@ fn paint_image( if rect.width <= 0.0 || rect.height <= 0.0 { return false; } + let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false }; - let bytes = if src.starts_with("data:image/") { - if let Some(comma_idx) = src.find(',') { - use base64::Engine; - base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok() - } else { None } + let content = if is_svg(&bytes) { + render_svg(&bytes, rect.width as u32, rect.height as u32) } else { - // 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 - // 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 { - // 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, - } + raster_to_pixmap(&bytes, rect.width as u32, rect.height as u32) }; - - 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( - rect.x as i32, - rect.y as i32, - svg_pixmap.as_ref(), - &tiny_skia::PixmapPaint::default(), - Transform::identity(), - None, - ); - 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; - } - } + if let Some(content) = content { + pixmap.draw_pixmap( + rect.x as i32, + rect.y as i32, + content.as_ref(), + &tiny_skia::PixmapPaint::default(), + Transform::identity(), + None, + ); + return true; } false } @@ -423,6 +462,61 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option { 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>>, +) -> 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)] mod tests { use super::*; diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index d71bc2c..650f913 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -79,7 +79,7 @@ pub fn ua_style(tag: &str) -> LayoutStyle { } pub fn apply_inline(style: &mut LayoutStyle, css: &str) { - for raw in css.split(';') { + for raw in split_declarations(css) { let decl = raw.trim(); if decl.is_empty() { 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 = 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) { match name { "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-size" => style.background_size = parse_background_size(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), "border-color" => style.border_color = parse_color(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), "right" => style.inset[1] = px(value), "bottom" => style.inset[2] = px(value), @@ -508,12 +550,166 @@ fn set_edge(edges: &mut Edges, side: Side, v: Option) { } } -/// Parse the first length in a value as CSS pixels. `auto`, percentages, and -/// non-numeric values return None (treated as "no explicit size" in phase 1). +/// Parse the first length in a value as CSS pixels. `auto` and non-numeric +/// 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 { + let trimmed = value.trim(); + if trimmed.contains('(') { + return resolve_length(trimmed); + } 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 { + 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::().ok()) +} + +/// Find the index (relative to `s`) of the `)` matching an already-consumed +/// opening `(`, accounting for nesting. +fn find_matching_paren(s: &str) -> Option { + 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 { + 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 { + let mut result: Option = 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 { let n = tok.trim(); if n.ends_with("%") { @@ -671,4 +867,46 @@ mod tests { assert_eq!(s.width, crate::Dimension::Px(100.0)); 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)); + } }