From 37f6c93af98c79248100a28247a636eb53fb513d Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:48:59 +0000 Subject: [PATCH] render: paint generated boxes with contextual backgrounds --- crates/obscura-render/src/css.rs | 118 ++++++--- crates/obscura-render/src/dom.rs | 40 +++- crates/obscura-render/src/lib.rs | 13 + crates/obscura-render/src/paint.rs | 371 +++++++++++++++++++++++++++-- crates/obscura-render/src/style.rs | 77 ++++++ 5 files changed, 556 insertions(+), 63 deletions(-) diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index cbac674..fe15c66 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -21,6 +21,13 @@ struct Rule { order: usize, } +struct PseudoRule { + sel: CompiledSelector, + normal_decls: String, + important_decls: String, + order: usize, +} + /// An indexed set of author rules ready for fast per-element matching. pub struct Stylesheet { rules: Vec, @@ -32,17 +39,11 @@ pub struct Stylesheet { by_class: HashMap>, by_local: HashMap>, universal: Vec, - /// `sel::before{content:"..."}` / `sel::after{...}` rules with a plain - /// string-literal `content`, matched separately from the main cascade: - /// our selector engine has no pseudo-element matching machinery, but a - /// `::before`/`::after` selector's *base* (everything before the - /// pseudo-element) is an ordinary selector we can already compile and - /// match. Extremely common on Wikipedia (`.hlist`/`.cslist` render as - /// comma-separated lists purely via `li::before{content:", "}`); without - /// this, adjacent list items with no real whitespace between them in the - /// DOM run together with no separator at all. - before_content: Vec<(CompiledSelector, String)>, - after_content: Vec<(CompiledSelector, String)>, + /// `sel::before` / `sel::after` rules matched against their ordinary base + /// selector. Keeping their full declaration cascade supports both literal + /// generated text and positioned decorative boxes. + before_rules: Vec, + after_rules: Vec, } impl Stylesheet { @@ -68,8 +69,8 @@ impl Stylesheet { by_class: HashMap::new(), by_local: HashMap::new(), universal: Vec::new(), - before_content: Vec::new(), - after_content: Vec::new(), + before_rules: Vec::new(), + after_rules: Vec::new(), }; let mut order = 0usize; for src in sources { @@ -79,15 +80,31 @@ impl Stylesheet { for (selector, decls) in parse_stylesheet_for_viewport(src, viewport) { let sel_trim = selector.trim(); if let Some(base) = strip_pseudo_element(sel_trim, "before") { - if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) { - sheet.before_content.push((sel, content)); + if let Some(sel) = tree.compile_rule_selector(base) { + let (normal_decls, important_decls) = + crate::style::partition_declarations(&decls); + sheet.before_rules.push(PseudoRule { + sel, + normal_decls, + important_decls, + order, + }); } + order += 1; continue; } if let Some(base) = strip_pseudo_element(sel_trim, "after") { - if let (Some(content), Some(sel)) = (extract_string_content(&decls), tree.compile_rule_selector(base)) { - sheet.after_content.push((sel, content)); + if let Some(sel) = tree.compile_rule_selector(base) { + let (normal_decls, important_decls) = + crate::style::partition_declarations(&decls); + sheet.after_rules.push(PseudoRule { + sel, + normal_decls, + important_decls, + order, + }); } + order += 1; continue; } let Some(sel) = tree.compile_rule_selector(&selector) else { continue }; @@ -106,26 +123,57 @@ impl Stylesheet { sheet } - /// The literal text (if any) that `::before`/`::after` rules inject for - /// `nid`, as `(before, after)`. Last matching rule wins, approximating - /// cascade order without tracking specificity for this small side table. - pub fn pseudo_content(&self, tree: &DomTree, matcher: &mut Matcher, nid: NodeId) -> (Option, Option) { - if self.before_content.is_empty() && self.after_content.is_empty() { - return (None, None); - } - let mut before = None; - for (sel, content) in &self.before_content { - if matcher.matches(tree, nid, sel) { - before = Some(content.clone()); + pub fn pseudo_styles( + &self, + tree: &DomTree, + matcher: &mut Matcher, + nid: NodeId, + host: &LayoutStyle, + props: &HashMap, + ) -> (Option, Option) { + let build = |rules: &[PseudoRule], matcher: &mut Matcher| { + let mut matched: Vec<(u32, usize, &PseudoRule)> = rules + .iter() + .filter(|rule| matcher.matches(tree, nid, &rule.sel)) + .map(|rule| (rule.sel.specificity(), rule.order, rule)) + .collect(); + if matched.is_empty() { + return None; } - } - let mut after = None; - for (sel, content) in &self.after_content { - if matcher.matches(tree, nid, sel) { - after = Some(content.clone()); + matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); + let mut style = LayoutStyle::default(); + style.color = host.color; + style.font_size = host.font_size; + style.font_weight = host.font_weight.clone(); + style.font_family = host.font_family.clone(); + style.line_height = host.line_height; + style.text_transform = host.text_transform; + let mut content = None; + for &(_, _, rule) in &matched { + let expanded = substitute_vars(&rule.normal_decls, props, 0); + crate::style::apply_declarations(&mut style, &expanded); + if let Some(value) = extract_string_content(&expanded) { + content = Some(value); + } } - } - (before, after) + for &(_, _, rule) in &matched { + let expanded = substitute_vars(&rule.important_decls, props, 0); + crate::style::apply_declarations(&mut style, &expanded); + if let Some(value) = extract_string_content(&expanded) { + content = Some(value); + } + } + style.before_content = content; + if style.before_content.is_some() { + Some(style) + } else { + None + } + }; + ( + build(&self.before_rules, matcher), + build(&self.after_rules, matcher), + ) } pub fn is_empty(&self) -> bool { diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index c06541b..934799a 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -352,9 +352,16 @@ fn cascade_walk( ) { this_props = std::rc::Rc::new(m); } - let (before, after) = sheet.pseudo_content(tree, matcher, id); - style.before_content = before; - style.after_content = after; + let (before_pseudo, after_pseudo) = + sheet.pseudo_styles(tree, matcher, id, &style, &this_props); + style.before_content = before_pseudo + .as_ref() + .and_then(|pseudo| pseudo.before_content.clone()); + style.after_content = after_pseudo + .as_ref() + .and_then(|pseudo| pseudo.before_content.clone()); + style.before_pseudo = before_pseudo.map(Box::new); + style.after_pseudo = after_pseudo.map(Box::new); styles.insert(id, style); } @@ -4882,6 +4889,33 @@ mod tests { assert_eq!(runs[0].0.height, 32.0); } + #[test] + fn light_root_rejects_dark_pseudo_gradient_variants() { + let tree = parse_html( + r#"
"#, + ); + let laid = layout_dom(&tree, (1280.0, 720.0)); + let hero = tree.query_selector(".hero").unwrap().unwrap(); + let pseudo = laid.styles[&hero].before_pseudo.as_ref().unwrap(); + let (_, stops) = pseudo.background_radial_gradient.as_ref().unwrap(); + assert_eq!(stops[0].0, [0xeb, 0xf3, 0xf9, 255]); + assert_eq!(stops[1].0, [0xd6, 0xde, 0xe4, 255]); + } + #[test] fn table_cell_content_box_width_includes_padding_and_border_in_track() { let tree = parse_html( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 800a5ae..e315670 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -251,6 +251,11 @@ pub struct LayoutStyle { /// o'clock per CSS, list of (rgba, optional 0..1 stop position)). Modern /// hero sections use gradients heavily; without this they paint white. pub background_gradient: Option<(f32, Vec<([u8; 4], Option)>)>, + /// First `radial-gradient(...)` layer: center in box-relative fractions + /// and color stops. It is painted below the first linear layer, matching + /// the common `linear-gradient(...), radial-gradient(...)` hero pattern. + pub background_radial_gradient: + Option<((f32, f32), Vec<([u8; 4], Option)>)>, /// `conic-gradient(...)` background. The angle is the CSS `from` angle, /// the center is a fraction of the border box, and stops are normalized /// during paint. Conic gradients commonly provide the color source for a @@ -265,6 +270,9 @@ pub struct LayoutStyle { /// `10px` applies to both axes, matching how small square icons are /// almost always sized). pub background_size: Option<(f32, f32)>, + /// Raw one/two-axis `background-size` expression, retained for paint-time + /// resolution against the final owner box (`calc(100% - 2rem) auto`). + pub background_size_expression: Option, /// Keyword `background-size` behavior. `None` is CSS `auto`, which uses /// the image's intrinsic dimensions rather than stretching it to the box. pub background_size_fit: Option, @@ -440,6 +448,11 @@ pub struct LayoutStyle { /// children, same as if it were real text content. pub before_content: Option, pub after_content: Option, + /// Computed boxes generated by `::before`/`::after`. Text-only pseudos + /// continue through `before_content`/`after_content`; these styles retain + /// positioned decorative boxes for layout-independent painting. + pub before_pseudo: Option>, + pub after_pseudo: Option>, /// True for `inline-block`/`inline-flex`/`inline-grid`: participates in /// the surrounding inline flow from the outside, like plain `inline` diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index e86d7fe..4a17ecc 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -6,7 +6,7 @@ //! dependencies, so a screenshot is reproducible across hosts. use obscura_dom::tree::DomTree; -use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform}; +use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform}; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); @@ -37,6 +37,13 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); let fonts = collect_web_fonts(tree, base_url); let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts); + let root_font_size = tree + .query_selector("html") + .ok() + .flatten() + .and_then(|root| laid.styles.get(&root)) + .and_then(|style| style.font_size) + .unwrap_or(16.0); // Nodes that live inside an inline `` we rasterized as one document; // their painting is owned by that raster, so they are skipped in both the // box/text loop below and the inline-formatting loop after it (an svg @@ -180,29 +187,37 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // `background-clip: text` clips the background to the glyphs, so it must // not paint as a box here; the text paint path fills the glyphs instead. if style.mask_image.is_none() && !style.background_clip_text { - if let Some((angle, stops)) = &style.background_gradient { - if let Some(path) = bg_path() { - paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); - } - } else if let Some((angle, center, stops)) = - &style.background_conic_gradient - { - paint_conic_gradient( - &mut pixmap, - &visible_rect, - *angle, - *center, - stops, - ); - } else if let Some(bg) = style.background_color { - // A masked element's background-color is the mask's fill color, - // not an ordinary box background (handled below), so this only - // runs for unmasked boxes. + if let Some(bg) = style.background_color { if let Some(path) = bg_path() { let mut paint = Paint::default(); paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); paint.anti_alias = r > 0.5; - pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); + } + } + if let Some((center, stops)) = &style.background_radial_gradient { + if let Some(path) = bg_path() { + paint_radial_gradient( + &mut pixmap, + &path, + &visible_rect, + *center, + stops, + ); + } + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient(&mut pixmap, &visible_rect, *angle, *center, stops); + } + if let Some((angle, stops)) = &style.background_gradient { + if let Some(path) = bg_path() { + paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); } } } @@ -227,8 +242,12 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - base_url, &rect, style.background_size, + style.background_size_expression.as_deref(), style.background_size_fit, style.background_position, + style.font_size.unwrap_or(16.0), + root_font_size, + viewport, &mut image_cache, ) { // A background layer is always clipped to its owner's border @@ -252,6 +271,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - } } } + if let Some(pseudo) = style.before_pseudo.as_deref() { + paint_positioned_pseudo( + &mut pixmap, + pseudo, + &rect, + viewport, + root_font_size, + clip, + ); + } // Rounded, uniform border: stroke the rounded-rect outline instead of // four sharp edge rects. @@ -355,6 +384,11 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // overflow-visible region. if name.local.as_ref() == "svg" { let mut markup = serialize_svg(tree, nid); + // Resolve referenced symbols before carrying the host color into + // the standalone document. A document-level/external symbol may + // itself contain `currentColor`, and therefore has to be present + // when the root color is established. + inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache); // resvg parses the serialized subtree as a standalone SVG // document, outside the page's author stylesheet. Preserve the // host element's computed `color` so paths using `currentColor` @@ -368,7 +402,6 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - // the sprite, splice the referenced `` into a local ``, // and rewrite the href to a same-document `#id`. Same-document // `` (empty url) is untouched. - inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache); if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) { let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect)); pixmap.draw_pixmap( @@ -1296,7 +1329,8 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra let n = stops.len(); let mut gs: Vec = Vec::with_capacity(n); let mut last = 0.0f32; - for (i, (c, pos)) in stops.iter().enumerate() { + for (i, (_, pos)) in stops.iter().enumerate() { + let c = gradient_stop_color(stops, i); let p = pos.unwrap_or(i as f32 / (n - 1) as f32).clamp(0.0, 1.0).max(last); last = p; gs.push(GradientStop::new(p, Color::from_rgba8(c[0], c[1], c[2], c[3]))); @@ -1309,6 +1343,54 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra } } +fn paint_radial_gradient( + pixmap: &mut Pixmap, + path: &tiny_skia::Path, + rect: &crate::Rect, + center: (f32, f32), + stops: &[([u8; 4], Option)], +) { + if stops.len() < 2 { + return; + } + let center = Point::from_xy( + rect.x + rect.width * center.0, + rect.y + rect.height * center.1, + ); + let radius = [ + (rect.x - center.x).hypot(rect.y - center.y), + (rect.x + rect.width - center.x).hypot(rect.y - center.y), + (rect.x - center.x).hypot(rect.y + rect.height - center.y), + (rect.x + rect.width - center.x).hypot(rect.y + rect.height - center.y), + ] + .into_iter() + .fold(0.0, f32::max); + let normalized = normalized_stops(stops); + let gradient_stops = normalized + .into_iter() + .map(|(position, color)| { + GradientStop::new( + position, + Color::from_rgba8(color[0], color[1], color[2], color[3]), + ) + }) + .collect(); + if let Some(shader) = RadialGradient::new( + center, + 0.0, + center, + radius, + gradient_stops, + SpreadMode::Pad, + Transform::identity(), + ) { + let mut paint = Paint::default(); + paint.shader = shader; + paint.anti_alias = true; + pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); + } +} + fn paint_conic_gradient( pixmap: &mut Pixmap, rect: &crate::Rect, @@ -1354,7 +1436,8 @@ fn normalized_stops( let count = stops.len(); let mut normalized = Vec::with_capacity(count); let mut last = 0.0f32; - for (index, (color, position)) in stops.iter().enumerate() { + for (index, (_, position)) in stops.iter().enumerate() { + let color = gradient_stop_color(stops, index); let position = position .unwrap_or_else(|| { if count <= 1 { @@ -1366,11 +1449,28 @@ fn normalized_stops( .clamp(0.0, 1.0) .max(last); last = position; - normalized.push((position, *color)); + normalized.push((position, color)); } normalized } +fn gradient_stop_color( + stops: &[([u8; 4], Option)], + index: usize, +) -> [u8; 4] { + let color = stops[index].0; + if color[3] != 0 { + return color; + } + let neighbor = stops[index + 1..] + .iter() + .find(|(candidate, _)| candidate[3] != 0) + .or_else(|| stops[..index].iter().rev().find(|(candidate, _)| candidate[3] != 0)); + neighbor + .map(|(neighbor, _)| [neighbor[0], neighbor[1], neighbor[2], 0]) + .unwrap_or(color) +} + fn sample_normalized_stops(stops: &[(f32, [u8; 4])], t: f32) -> [u8; 4] { let t = t.clamp(0.0, 1.0); let Some(&(first_position, first_color)) = stops.first() else { @@ -1472,8 +1572,12 @@ fn background_image_rect( base_url: Option<&str>, box_rect: &crate::Rect, explicit_size: Option<(f32, f32)>, + size_expression: Option<&str>, fit: Option, position: (f32, f32), + em: f32, + rem: f32, + viewport: (f32, f32), cache: &mut std::collections::HashMap>>, ) -> Option { let bytes = fetch_bytes(src, base_url, cache)?; @@ -1482,7 +1586,43 @@ fn background_image_rect( } else { image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) }; - let (width, height) = if let Some(size) = explicit_size { + let expression_size = size_expression.and_then(|expression| { + let components = split_background_size_components(expression); + let width = components.first().and_then(|value| { + (!value.eq_ignore_ascii_case("auto")).then(|| { + crate::style::resolve_contextual_length( + value, + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + box_rect.width, + ) + })? + }); + let height = components.get(1).and_then(|value| { + (!value.eq_ignore_ascii_case("auto")).then(|| { + crate::style::resolve_contextual_length( + value, + em, + rem, + viewport.0 / 100.0, + viewport.1 / 100.0, + box_rect.height, + ) + })? + }); + match (width, height, intrinsic) { + (Some(width), Some(height), _) => Some((width, height)), + (Some(width), None, Some((iw, ih))) => Some((width, width * ih / iw)), + (None, Some(height), Some((iw, ih))) => Some((height * iw / ih, height)), + (None, None, Some(intrinsic)) => Some(intrinsic), + _ => None, + } + }); + let (width, height) = if let Some(size) = expression_size { + size + } else if let Some(size) = explicit_size { size } else if let Some(fit) = fit { let (iw, ih) = intrinsic?; @@ -1506,6 +1646,102 @@ fn background_image_rect( }) } +fn split_background_size_components(value: &str) -> Vec<&str> { + let mut components = Vec::new(); + let mut depth = 0i32; + let mut start = None; + for (index, ch) in value.char_indices() { + match ch { + '(' => { + depth += 1; + start.get_or_insert(index); + } + ')' => depth = (depth - 1).max(0), + ch if ch.is_whitespace() && depth == 0 => { + if let Some(start) = start.take() { + components.push(value[start..index].trim()); + } + } + _ => { + start.get_or_insert(index); + } + } + } + if let Some(start) = start { + components.push(value[start..].trim()); + } + components +} + +fn paint_positioned_pseudo( + pixmap: &mut Pixmap, + style: &crate::LayoutStyle, + containing_block: &crate::Rect, + viewport: (f32, f32), + root_font_size: f32, + ancestor_clip: Option, +) { + if style.position != Some(taffy::Position::Absolute) { + return; + } + let em = style.font_size.unwrap_or(16.0); + let resolve = |dimension: crate::Dimension, basis: f32| { + match dimension.resolve(em, root_font_size, viewport.0 / 100.0, viewport.1 / 100.0) { + crate::Dimension::Px(value) => Some(value), + crate::Dimension::Percent(value) => Some(value * basis), + _ => None, + } + }; + let top = style.inset[0].and_then(|value| resolve(value, containing_block.height)); + let right = style.inset[1].and_then(|value| resolve(value, containing_block.width)); + let bottom = style.inset[2].and_then(|value| resolve(value, containing_block.height)); + let left = style.inset[3].and_then(|value| resolve(value, containing_block.width)); + let width = resolve(style.width, containing_block.width).or_else(|| { + Some(containing_block.width - left? - right?) + }); + let height = resolve(style.height, containing_block.height).or_else(|| { + Some(containing_block.height - top? - bottom?) + }); + let (Some(width), Some(height)) = (width, height) else { return }; + if width <= 0.0 || height <= 0.0 { + return; + } + let x = left + .map(|value| containing_block.x + value) + .or_else(|| right.map(|value| containing_block.x + containing_block.width - value - width)) + .unwrap_or(containing_block.x); + let y = top + .map(|value| containing_block.y + value) + .or_else(|| bottom.map(|value| containing_block.y + containing_block.height - value - height)) + .unwrap_or(containing_block.y); + let rect = crate::Rect { x, y, width, height }; + let visible = match ancestor_clip { + Some(clip) => rect.intersect(&clip), + None => Some(rect), + }; + let Some(visible) = visible else { return }; + let Some(sk_rect) = Rect::from_xywh(visible.x, visible.y, visible.width, visible.height) else { + return; + }; + let mut builder = PathBuilder::new(); + builder.push_rect(sk_rect); + let Some(path) = builder.finish() else { return }; + if let Some(color) = style.background_color { + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3])); + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + if let Some((center, stops)) = &style.background_radial_gradient { + paint_radial_gradient(pixmap, &path, &rect, *center, stops); + } + if let Some((angle, center, stops)) = &style.background_conic_gradient { + paint_conic_gradient(pixmap, &rect, *angle, *center, stops); + } + if let Some((angle, stops)) = &style.background_gradient { + paint_linear_gradient(pixmap, &path, &rect, *angle, stops); + } +} + /// Fetch every `` once (seeding `cache` for the paint pass) and record its /// intrinsic (width, height) so layout can size replaced elements that have no /// explicit dimensions. Keyed by the ``'s NodeId. @@ -2509,6 +2745,67 @@ mod tests { ); } + #[test] + fn contextual_background_size_preserves_auto_axis_ratio() { + let source = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='50'%3E%3C/svg%3E"; + let owner = crate::Rect { + x: 0.0, + y: 0.0, + width: 132.0, + height: 60.0, + }; + let mut cache = std::collections::HashMap::new(); + let image = background_image_rect( + source, + None, + &owner, + None, + Some("calc(100% - 2rem) auto"), + None, + (0.0, 0.5), + 10.0, + 10.0, + (1280.0, 720.0), + &mut cache, + ) + .unwrap(); + assert_eq!(image.width, 112.0); + assert_eq!(image.height, 28.0); + assert_eq!(image.y, 16.0); + } + + #[test] + fn paints_positioned_empty_pseudo_background_box() { + let tree = parse_html( + r#"
"#, + ); + let pixmap = paint_dom(&tree, (120.0, 80.0), None).expect("pixmap"); + let center = pixmap.pixel(40, 25).expect("pixel"); + assert!( + center.red() >= 214 && center.green() >= 222 && center.blue() >= 228, + "transparent-to-white over a light radial layer must not darken it: {center:?}" + ); + let outside = pixmap.pixel(5, 5).expect("pixel"); + assert_eq!( + (outside.red(), outside.green(), outside.blue()), + (255, 255, 255) + ); + } + #[test] fn later_element_paints_over_earlier() { // A blue div nested inside a red one: both cover the origin, and blue @@ -2856,6 +3153,30 @@ mod tests { ); } + #[test] + fn injected_document_symbol_inherits_target_current_color() { + let tree = parse_html( + r##" + + + "##, + ); + let svg = tree.query_selector("#icon").unwrap().unwrap(); + let mut markup = serialize_svg(&tree, svg); + let mut cache = std::collections::HashMap::new(); + let mut sprite_cache = std::collections::HashMap::new(); + inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache); + inject_svg_current_color(&mut markup, [220, 20, 60, 255]); + let pixmap = render_svg(markup.as_bytes(), 20, 20).expect("injected svg renders"); + assert!( + pixmap + .pixels() + .iter() + .any(|pixel| pixel.red() > 180 && pixel.green() < 60 && pixel.blue() < 100), + "injected currentColor symbol should inherit target SVG color: {markup}", + ); + } + #[test] fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() { let css = r#" diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 00eb58a..a2905f3 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -434,14 +434,17 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { if !value.trim().is_empty() { style.background_color = None; style.background_gradient = parse_linear_gradient(value); + style.background_radial_gradient = parse_radial_gradient(value); style.background_conic_gradient = parse_conic_gradient(value); if style.background_gradient.is_none() + && style.background_radial_gradient.is_none() && style.background_conic_gradient.is_none() { style.background_color = parse_color(value); } style.background_image = parse_url(value); style.background_size = None; + style.background_size_expression = background_size_expression(value); style.background_size_fit = parse_background_size_fit(value); style.background_position = (0.0, 0.0); style.background_clip_text = false; @@ -449,11 +452,14 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "background-image" => { style.background_gradient = parse_linear_gradient(value); + style.background_radial_gradient = parse_radial_gradient(value); style.background_conic_gradient = parse_conic_gradient(value); style.background_image = parse_url(value); } "background-size" => { style.background_size = parse_background_size(value); + style.background_size_expression = + (!value.trim().is_empty()).then(|| value.trim().to_string()); style.background_size_fit = parse_background_size_fit(value); } "background-position" => style.background_position = parse_background_position(value), @@ -2897,6 +2903,47 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) Some((angle, stops)) } +fn parse_radial_gradient( + value: &str, +) -> Option<((f32, f32), Vec<([u8; 4], Option)>)> { + let lower = value.to_ascii_lowercase(); + let start = lower.find("radial-gradient(")?; + let open = start + "radial-gradient(".len(); + let end = find_matching_paren(&value[open..])? + open; + let parts = split_top_level(&value[open..end], ','); + if parts.is_empty() { + return None; + } + let mut center = (0.5, 0.5); + let mut stop_start = 0; + let prelude = parts[0].trim().to_ascii_lowercase(); + if prelude.contains(" at ") || prelude.starts_with("at ") { + let coords = prelude + .split_once(" at ") + .map(|(_, coords)| coords) + .or_else(|| prelude.strip_prefix("at ")) + .unwrap_or_default(); + let mut coords = coords.split_whitespace(); + if let Some(x) = coords.next().and_then(percent_fraction) { + center.0 = x; + } + if let Some(y) = coords.next().and_then(percent_fraction) { + center.1 = y; + } + stop_start = 1; + } else if parse_color(parts[0].trim()).is_none() { + stop_start = 1; + } + let mut stops = Vec::new(); + for part in &parts[stop_start..] { + let (color, position) = split_color_stop(part.trim()); + if let Some(color) = parse_color(color) { + stops.push((color, position)); + } + } + (stops.len() >= 2).then_some((center, stops)) +} + /// Parse the common `conic-gradient(from A at X Y, color P%, ...)` form. /// Angles follow CSS convention (0deg at 12 o'clock, clockwise); the center /// is retained as box-relative fractions for paint-time resolution. @@ -3173,6 +3220,25 @@ fn parse_background_size_fit(value: &str) -> Option { } } +fn background_size_expression(value: &str) -> Option { + let (_, size) = value.rsplit_once('/')?; + let mut depth = 0i32; + let mut end = size.len(); + for (index, ch) in size.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = (depth - 1).max(0), + _ if depth == 0 && ch == ',' => { + end = index; + break; + } + _ => {} + } + } + let size = size[..end].trim(); + (!size.is_empty()).then(|| size.to_string()) +} + /// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the /// fraction of the box's leftover space, after the image's own size, to /// offset by: 0 = start edge, 1 = end edge, 0.5 = centered). @@ -3684,6 +3750,7 @@ mod tests { assert_eq!(s.background_conic_gradient, None); assert_eq!(s.background_image, None); assert_eq!(s.background_size, None); + assert_eq!(s.background_size_expression, None); assert_eq!(s.background_size_fit, None); assert_eq!(s.background_position, (0.0, 0.0)); assert!(!s.background_clip_text); @@ -3698,6 +3765,16 @@ mod tests { contain.background_size_fit, Some(crate::ObjectFit::Contain) ); + let contextual = compute_style( + "a", + Some( + "background:url(icon.svg) no-repeat 0 50% / calc(100% - 2rem) auto", + ), + ); + assert_eq!( + contextual.background_size_expression.as_deref(), + Some("calc(100% - 2rem) auto") + ); } #[test]