//! Paint: rasterize the laid-out DOM into a [`tiny_skia::Pixmap`]. //! //! Phase 5a. Fills each element's border box with its background color over a //! white page. Text rendering arrives with the text step; borders and images //! are later enhancements. Pure Rust (tiny-skia, CPU), deterministic, no system //! dependencies, so a screenshot is reproducible across hosts. use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; use obscura_dom::tree::DomTree; use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use tiny_skia::{ Color, FillRule, FilterQuality, GradientStop, LinearGradient, Paint, PathBuilder, Pattern, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform, }; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); static SYSTEM_FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); use crate::dom::layout_dom_with_web_fonts_and_stylesheet_cache; const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512; const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024; /// CSS `content:url(...)` is only discoverable after cascade. Remember a /// bounded set of successful selections so repeated prepares can seed their /// intrinsic geometry before that cascade instead of always laying out twice. const DEFAULT_CONTENT_IMAGE_INTRINSIC_ENTRIES: usize = 256; const MISSING_RESOURCE_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(2); /// Exact formats the renderer can decode. Do not use `image/*` or `*/*` here: /// either wildcard permits a content-negotiating server to choose AVIF, /// JPEG-XL, or another format that this build cannot rasterize. const IMAGE_ACCEPT: &str = "image/webp,image/apng,image/svg+xml,image/png,image/jpeg,image/gif,image/bmp,image/x-icon,image/vnd.microsoft.icon"; /// Synchronous byte loader used by [`RenderResourceCache`]. The default /// implementation uses Obscura's pooled image agent; tests and embedding /// callers can provide a local loader without changing preparation or paint. pub trait RenderResourceLoader { fn load(&mut self, url: &str) -> Option>; } /// One script-created `FontFace` registered with the document's /// `FontFaceSet`. The JS runtime owns the registry and supplies this snapshot /// alongside the DOM when preparing a render; keeping it out of the DOM avoids /// exposing an implementation-only `
stable text
"#, ); let viewport = (100.0, 80.0); let counts = Arc::new(std::sync::Mutex::new(HashMap::::new())); let loader_counts = Arc::clone(&counts); let mut resources = RenderResourceCache::with_loader(move |url: &str| { *loader_counts .lock() .expect("loader counts") .entry(url.to_string()) .or_default() += 1; match url { "https://assets.test/font.ttf" => Some(FONT_BYTES.to_vec()), "https://assets.test/hero.svg" => Some( br##" "## .to_vec(), ), "https://assets.test/background.svg" => Some( br##" "## .to_vec(), ), _ => None, } }); let mut prepared = prepare_dom(&tree, viewport, None, &mut resources).expect("prepared render"); let hero = tree .query_selector("#hero") .expect("valid selector") .expect("hero"); assert_eq!( prepared.selected_image(hero), Some(&SelectedImage { resolved_url: "https://assets.test/hero.svg".to_string(), density: 2.0, }) ); let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect"); assert!((hero_rect.width - 100.0).abs() < 0.1); assert!((hero_rect.height - 50.0).abs() < 0.1); assert!(prepared.content_size().1 > viewport.1); assert!(!prepared.sticky_layout().is_empty()); assert_eq!( prepared.viewport_rect(hero, (0.0, 20.0)).unwrap().y, prepared.document_rect(hero).unwrap().y - 20.0 ); let fixed = tree .query_selector("#fixed") .expect("valid selector") .expect("fixed"); assert!(prepared.viewport_fixed_nodes().contains(&fixed)); assert_eq!( prepared.viewport_rect(fixed, (0.0, 100.0)), prepared.document_rect(fixed) ); let base_rects = prepared.layout().rects.clone(); let base_translates = prepared.layout().translates.clone(); let base_clips = prepared.layout().clip_rects.clone(); let near = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 20.0)) .expect("near capture"); let far = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 100.0)) .expect("far capture"); let far_repeat = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 100.0)) .expect("repeated far capture"); let near_after_far = screenshot_prepared(&tree, &mut prepared, &mut resources, (0.0, 20.0)) .expect("repeated near capture"); assert_ne!( near, far, "distinct scroll positions must paint distinct frames" ); assert_eq!(far, far_repeat, "the same scroll position must be stable"); assert_eq!( near, near_after_far, "an intervening capture must not accumulate scroll movement" ); assert_eq!(prepared.layout().rects, base_rects); assert_eq!(prepared.layout().translates, base_translates); assert_eq!(prepared.layout().clip_rects, base_clips); let counts = counts.lock().expect("final loader counts"); for url in [ "https://assets.test/font.ttf", "https://assets.test/hero.svg", "https://assets.test/background.svg", ] { assert_eq!(counts.get(url), Some(&1), "{url} must load exactly once"); } assert!(!counts.contains_key("https://assets.test/fallback.svg")); assert_eq!(resources.retained_entry_count(), 3); assert!(resources.retained_byte_len() > FONT_BYTES.len()); } /// Portable pixel counterpart to the Chromium root-sticky geometry probe /// in obscura-js. It checks that sticky backgrounds and descendants move /// as one painted subtree, bottom sticking is visible, fixed remains /// viewport-anchored, and the top sticky eventually exits at its /// containing-block boundary. #[test] fn root_scroll_sticky_paints_subtrees_and_respects_bottom_boundary() { let tree = parse_html( r#"
"#, ); let viewport = (800.0, 513.0); let top = paint_dom_scrolled(&tree, viewport, None, (0.0, 0.0)).unwrap(); let stuck = paint_dom_scrolled(&tree, viewport, None, (0.0, 100.0)).unwrap(); let bottom_normal = paint_dom_scrolled(&tree, viewport, None, (0.0, 400.0)).unwrap(); let boundary = paint_dom_scrolled(&tree, viewport, None, (0.0, 9999.0)).unwrap(); let is_color = |pixel: tiny_skia::PremultipliedColorU8, rgb: [u8; 3]| { (pixel.red() as i16 - rgb[0] as i16).abs() < 8 && (pixel.green() as i16 - rgb[1] as i16).abs() < 8 && (pixel.blue() as i16 - rgb[2] as i16).abs() < 8 }; assert!(is_color(top.pixel(100, 80).unwrap(), [255, 0, 0])); assert!(is_color(top.pixel(100, 460).unwrap(), [255, 136, 0])); assert!(is_color(stuck.pixel(100, 25).unwrap(), [0, 0, 255])); assert!(is_color(stuck.pixel(100, 50).unwrap(), [255, 0, 0])); assert!(is_color(stuck.pixel(100, 460).unwrap(), [255, 136, 0])); assert!(is_color( bottom_normal.pixel(100, 240).unwrap(), [255, 136, 0] )); assert!(is_color(boundary.pixel(100, 20).unwrap(), [34, 0, 34])); for pixmap in [&top, &stuck, &bottom_normal, &boundary] { assert!(is_color(pixmap.pixel(620, 30).unwrap(), [0, 255, 0])); } } #[test] fn object_fit_contain_and_cover_center_and_preserve_aspect() { // A 200x100 box (2:1) with a square 100x100 image, offset so centering // is checked against the box origin, not (0,0). let box_rect = crate::Rect { x: 10.0, y: 20.0, width: 200.0, height: 100.0, }; let (iw, ih) = (100.0f32, 100.0f32); // Contain: the largest square fitting inside 200x100 is 100x100, // letterboxed horizontally and centered. let c = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Contain); assert!( (c.width - 100.0).abs() < 0.01 && (c.height - 100.0).abs() < 0.01, "contain size {:?}", c ); assert!( (c.width / c.height - iw / ih).abs() < 1e-3, "contain preserves aspect: {:?}", c ); assert!( (c.x - 60.0).abs() < 0.01, "contain centered x (10 + (200-100)/2): {}", c.x ); assert!( (c.y - 20.0).abs() < 0.01, "contain centered y (20 + (100-100)/2): {}", c.y ); // Contain always fits inside the box. assert!(c.x >= box_rect.x - 0.01 && c.x + c.width <= box_rect.x + box_rect.width + 0.01); assert!(c.y >= box_rect.y - 0.01 && c.y + c.height <= box_rect.y + box_rect.height + 0.01); // Cover: the smallest square covering 200x100 is 200x200, centered so // it overflows the box vertically (the paint path clips it). let v = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Cover); assert!( (v.width - 200.0).abs() < 0.01 && (v.height - 200.0).abs() < 0.01, "cover size {:?}", v ); assert!( (v.width / v.height - iw / ih).abs() < 1e-3, "cover preserves aspect: {:?}", v ); assert!( (v.x - 10.0).abs() < 0.01, "cover centered x (10 + (200-200)/2): {}", v.x ); assert!( (v.y + 30.0).abs() < 0.01, "cover centered y (20 + (100-200)/2 = -30): {}", v.y ); // Cover fully covers the box on both axes. assert!(v.x <= box_rect.x + 0.01 && v.x + v.width >= box_rect.x + box_rect.width - 0.01); assert!(v.y <= box_rect.y + 0.01 && v.y + v.height >= box_rect.y + box_rect.height - 0.01); // scale-down never upscales: a 100x100 image in a 200x200 box stays // 100x100 (Contain would grow it to 200x200), centered. let box2 = crate::Rect { x: 0.0, y: 0.0, width: 200.0, height: 200.0, }; let sd = object_fit_dest(&box2, iw, ih, crate::ObjectFit::ScaleDown); assert!( (sd.width - 100.0).abs() < 0.01 && (sd.height - 100.0).abs() < 0.01, "scale-down no upscale: {:?}", sd ); assert!( (sd.x - 50.0).abs() < 0.01 && (sd.y - 50.0).abs() < 0.01, "scale-down centered: {:?}", sd ); let cn = object_fit_dest(&box2, iw, ih, crate::ObjectFit::Contain); assert!( (cn.width - 200.0).abs() < 0.01, "contain upscales into the box: {:?}", cn ); // None uses the intrinsic size regardless of box, centered. let n = object_fit_dest(&box2, iw, ih, crate::ObjectFit::None); assert!( (n.width - 100.0).abs() < 0.01 && (n.height - 100.0).abs() < 0.01, "none intrinsic size: {:?}", n ); assert!( (n.x - 50.0).abs() < 0.01 && (n.y - 50.0).abs() < 0.01, "none centered: {:?}", n ); // Fill stretches to exactly the box. let f = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Fill); assert!( (f.width - box_rect.width).abs() < 0.01 && (f.height - box_rect.height).abs() < 0.01, "fill: {:?}", f ); assert!( (f.x - box_rect.x).abs() < 0.01 && (f.y - box_rect.y).abs() < 0.01, "fill origin: {:?}", f ); } #[test] fn paints_background_color() { let tree = parse_html( "
", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); assert_eq!(pixmap.width(), 200); // The red div is laid out at the origin; sample inside it. let inside = pixmap.pixel(10, 10).expect("pixel"); assert!(inside.red() > 200, "expected red bg, got {:?}", inside); assert!(inside.green() < 60); assert!(inside.blue() < 60); // Outside the 100x80 div the page background is white. let outside = pixmap.pixel(150, 150).expect("pixel"); assert_eq!(outside.red(), 255); assert_eq!(outside.green(), 255); assert_eq!(outside.blue(), 255); } /// Chromium 150 oracle for CSS Backgrounds box geometry. Transparent /// borders make each clip edge directly observable, asymmetric insets /// distinguish authored gradient coordinates from the visible clip, and /// the rounded content box verifies that inner radii shrink per side. #[test] fn background_origin_clip_boxes_radii_and_sampling_match_chromium() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (500.0, 220.0), None).expect("background geometry"); let is_white = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); pixel.red() > 240 && pixel.green() > 240 && pixel.blue() > 240 }; let is_green = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); pixel.green() > 140 && pixel.red() < 30 && pixel.blue() < 30 }; let is_red = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); pixel.red() > 220 && pixel.blue() < 40 }; let is_blue = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); pixel.blue() > 220 && pixel.red() < 40 }; assert!( is_green(5, 30), "border-box clip paints beneath transparent border" ); assert!( is_white(115, 30) && is_green(125, 30), "padding-box excludes border" ); assert!(is_white(225, 30) && is_white(235, 30) && is_green(245, 30)); assert!( is_white(351, 21) && is_green(370, 40), "content radius must inset to 20px" ); assert!( is_red(60, 120) && is_blue(80, 120), "clip must not rebase gradient line" ); assert!( is_red(220, 120), "ancestor clipping must not resize gradient coordinates" ); assert!( is_white(300, 120) && !is_white(325, 110), "content origin anchors no-repeat tile" ); } #[test] fn rounded_overflow_clips_descendants_at_the_padding_edge() { let tree = parse_html( r#"
"#, ); let child = tree.get_element_by_id("child").expect("child"); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (130.0, 130.0), None, &mut resources) .expect("prepared rounded overflow"); let child_clip = prepared .layout .clip_rects .get(&child) .and_then(Option::as_ref) .expect("child clip"); let rounded = child_clip .rounded_chain() .expect("child must retain the owner's rounded clip node"); assert_eq!( rounded.clip.rect, crate::Rect { x: 20.0, y: 20.0, width: 80.0, height: 80.0 } ); assert_eq!(rounded.clip.radii.top_left, (20.0, 20.0)); let direct_mask = overflow_clip_mask(130, 130, child_clip, (130.0, 130.0)).expect("rounded mask"); assert_eq!(direct_mask.data()[(23 * 130 + 23) as usize], 0); let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) .expect("rounded overflow paint"); let rgb = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); (pixel.red(), pixel.green(), pixel.blue()) }; assert_eq!(rgb(23, 23), (0, 0, 0)); assert_eq!(rgb(40, 25), (255, 0, 0)); assert_eq!(rgb(50, 50), (255, 0, 0)); assert_eq!(rgb(15, 50), (0, 0, 0)); } #[test] fn nested_rounded_overflow_keeps_every_clip_chain_node() { let tree = parse_html( r#"
"#, ); let child = tree.get_element_by_id("child").expect("child"); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (140.0, 130.0), None, &mut resources) .expect("prepared nested clips"); let mut chain = prepared .layout .clip_rects .get(&child) .and_then(Option::as_ref) .and_then(crate::dom::OverflowClip::rounded_chain) .map(AsRef::as_ref); let mut chain_len = 0; while let Some(node) = chain { chain_len += 1; chain = node.parent.as_deref(); } assert_eq!(chain_len, 2, "both rounded owners must survive the chain"); let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)) .expect("nested clip paint"); let rgb = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); (pixel.red(), pixel.green(), pixel.blue()) }; assert_eq!(rgb(70, 30), (0, 255, 0), "inside both rounded clips"); assert_eq!( rgb(43, 13), (0, 0, 255), "inner rounded corner must reveal the outer background" ); assert_eq!( rgb(105, 15), (255, 255, 255), "outer rounded corner must still constrain an inner-visible point" ); } #[test] fn paints_border_when_var_is_adjacent_to_border_style_token() { let tree = parse_html( r##"
"##, ); let pixmap = paint_dom(&tree, (60.0, 60.0), None).expect("pixmap"); let border = pixmap.pixel(1, 1).expect("border pixel"); assert!( border.red() > 200 && border.green() < 70 && border.blue() < 100, "adjacent var() substitution must retain a painted red border: {border:?}" ); let interior = pixmap.pixel(5, 5).expect("interior pixel"); assert!( interior.red() > 245 && interior.green() > 245 && interior.blue() > 245, "the border must not consume the content box: {interior:?}" ); } #[test] fn border_outline_computed_geometry_and_pixels_share_one_used_model() { let tree = parse_html( r##"
"##, ); let none = tree.get_element_by_id("none").expect("none"); let decorated = tree.get_element_by_id("decorated").expect("decorated"); let mut resources = RenderResourceCache::default(); let mut prepared = prepare_dom(&tree, (180.0, 180.0), None, &mut resources).expect("prepare"); let none_rect = prepared.document_rect(none).expect("none rect"); assert_eq!((none_rect.width, none_rect.height), (100.0, 50.0)); let none_style = prepared.computed_style(none).expect("none style"); assert_eq!(none_style["border-top-width"], "0px"); assert_eq!(none_style["border-top-style"], "none"); let rect = prepared.document_rect(decorated).expect("decorated rect"); assert_eq!((rect.width, rect.height), (116.0, 66.0)); let computed = prepared.computed_style(decorated).expect("computed style"); assert_eq!(computed["border-top-color"], "rgb(255, 0, 0)"); assert_eq!(computed["border-right-color"], "rgb(0, 128, 0)"); assert_eq!(computed["border-bottom-color"], "rgb(0, 0, 255)"); assert_eq!(computed["border-left-color"], "rgb(128, 0, 128)"); assert_eq!(computed["border-top-left-radius"], "30px 20px"); assert_eq!(computed["border-bottom-left-radius"], "8px 6px"); assert_eq!(computed["outline-width"], "4px"); assert_eq!(computed["outline-style"], "dashed"); assert_eq!(computed["outline-offset"], "3px"); let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)).expect("paint"); let sample = |x: u32, y: u32| pixmap.pixel(x, y).expect("pixel"); let top = sample((rect.x + rect.width / 2.0) as u32, (rect.y + 2.0) as u32); let right = sample( (rect.x + rect.width - 2.0) as u32, (rect.y + rect.height / 2.0) as u32, ); let bottom = sample( (rect.x + rect.width / 2.0) as u32, (rect.y + rect.height - 2.0) as u32, ); let left = sample((rect.x + 2.0) as u32, (rect.y + rect.height / 2.0) as u32); assert!( top.red() > 220 && top.green() < 40 && top.blue() < 40, "{top:?}" ); assert!( right.green() > 90 && right.red() < 40 && right.blue() < 40, "{right:?}" ); assert!( bottom.blue() > 220 && bottom.red() < 40 && bottom.green() < 40, "{bottom:?}" ); assert!( left.red() > 90 && left.blue() > 90 && left.green() < 40, "{left:?}" ); let rounded_corner = sample((rect.x + 1.0) as u32, (rect.y + 1.0) as u32); assert!( !(rounded_corner.red() > 220 && rounded_corner.green() > 220 && rounded_corner.blue() < 40), "asymmetric elliptical corner must stay outside the yellow background: {rounded_corner:?}" ); let mut outline_pixels = 0; let outer_x = (rect.x - 8.0).max(0.0) as u32; let outer_y = (rect.y - 8.0).max(0.0) as u32; let outer_right = (rect.x + rect.width + 8.0) as u32; let outer_bottom = (rect.y + rect.height + 8.0) as u32; for y in outer_y..outer_bottom.min(pixmap.height()) { for x in outer_x..outer_right.min(pixmap.width()) { let outside = (x as f32) < rect.x || (x as f32) >= rect.x + rect.width || (y as f32) < rect.y || (y as f32) >= rect.y + rect.height; let pixel = sample(x, y); if outside && pixel.red() < 30 && pixel.green() < 30 && pixel.blue() < 30 { outline_pixels += 1; } } } assert!( outline_pixels > 40, "dashed outline should paint outside layout: {outline_pixels}" ); } #[test] fn outline_none_retains_width_but_does_not_change_geometry_or_computed_used_width() { let tree = parse_html( r#"
"#, ); let id = tree.get_element_by_id("box").unwrap(); let mut resources = RenderResourceCache::default(); let prepared = prepare_dom(&tree, (80.0, 50.0), None, &mut resources).unwrap(); let rect = prepared.document_rect(id).unwrap(); assert_eq!((rect.width, rect.height), (40.0, 20.0)); assert_eq!(prepared.layout.styles[&id].outline.specified_width, 9.0); assert_eq!(prepared.computed_style(id).unwrap()["outline-width"], "0px"); } #[test] fn direct_pure_text_webkit_clamp_matches_line_geometry_and_computed_values() { let tree = parse_html( r#"
one
two
three
four
five
one
two
one
two
three
four
five
one
two
three
"#, ); let clamped = tree.get_element_by_id("clamped").unwrap(); let exact = tree.get_element_by_id("exact").unwrap(); let inactive = tree.get_element_by_id("inactive").unwrap(); let nested = tree.get_element_by_id("nested").unwrap(); let mut resources = RenderResourceCache::default(); let prepared = prepare_dom(&tree, (300.0, 300.0), None, &mut resources).unwrap(); assert_eq!(prepared.document_rect(clamped).unwrap().height, 40.0); assert_eq!(prepared.document_rect(exact).unwrap().height, 40.0); assert_eq!(prepared.document_rect(inactive).unwrap().height, 100.0); assert_eq!( prepared.document_rect(nested).unwrap().height, 60.0, "this slice must not fake descendant block-line counting by clipping children" ); let computed = prepared.computed_style(clamped).unwrap(); assert_eq!(computed["display"], "flow-root"); assert_eq!(computed["-webkit-line-clamp"], "2"); assert_eq!(computed["-webkit-box-orient"], "vertical"); assert_eq!(computed["text-overflow"], "clip"); } #[test] fn truncation_markers_are_shaped_and_painted_without_changing_dom_text() { let ellipsis_tree = parse_html( r#"
ABCDEFGHIJKLMNO
one
two
three
one
two
ABCDEFGHIJKLMNO
"#, ); let clip_tree = parse_html( r#"
ABCDEFGHIJKLMNO
one
two
three
one
two
ABCDEFGHIJKLMNO
"#, ); let ellipsis = paint_dom(&ellipsis_tree, (180.0, 160.0), None).unwrap(); let clip = paint_dom(&clip_tree, (180.0, 160.0), None).unwrap(); let nowrap_edge_difference = (85..120) .flat_map(|x| (0..20).map(move |y| (x, y))) .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) .count(); assert!( nowrap_edge_difference > 8, "ellipsis must replace end glyph pixels: {nowrap_edge_difference}" ); let clamp_marker_difference = (20..55) .flat_map(|x| (40..60).map(move |y| (x, y))) .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) .count(); assert!( clamp_marker_difference > 4, "line clamp must paint a separately shaped marker: {clamp_marker_difference}" ); let exact_line_difference = (0..140) .flat_map(|x| (80..120).map(move |y| (x, y))) .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) .count(); assert_eq!( exact_line_difference, 0, "an exactly-full clamp must not synthesize an overflow marker" ); let visible_overflow_difference = (0..180) .flat_map(|x| (120..140).map(move |y| (x, y))) .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) .count(); assert_eq!( visible_overflow_difference, 0, "text-overflow must not synthesize a marker when overflow is visible" ); for id in ["nowrap", "clamp", "exact", "visible"] { assert_eq!( ellipsis_tree.text_content(ellipsis_tree.get_element_by_id(id).unwrap()), clip_tree.text_content(clip_tree.get_element_by_id(id).unwrap()), ); } } #[test] fn percentage_border_radius_paints_circles_ellipses_and_replaced_clips() { let tree = parse_html( r##"
"##, ); let pixmap = paint_dom(&tree, (280.0, 50.0), None).expect("pixmap"); let is_white = |x, y| { let pixel = pixmap.pixel(x, y).expect("pixel"); pixel.red() > 245 && pixel.green() > 245 && pixel.blue() > 245 }; assert!(is_white(1, 1), "a square 50% radius must clear its corner"); let circle_top = pixmap.pixel(20, 1).expect("circle top"); assert!(circle_top.red() > 200 && circle_top.green() < 40 && circle_top.blue() < 40); assert!( is_white(60, 3), "a rectangular 50% radius must use a 40x20 elliptical corner" ); let ellipse_top = pixmap.pixel(90, 1).expect("ellipse top"); let ellipse_left = pixmap.pixel(51, 20).expect("ellipse left"); for pixel in [ellipse_top, ellipse_left] { assert!(pixel.blue() > 200 && pixel.red() < 40 && pixel.green() < 40); } let pill_corner = pixmap.pixel(150, 3).expect("pixel radius pill corner"); assert!( pill_corner.green() > 100 && pill_corner.red() < 40 && pill_corner.blue() < 40, "a 20px radius must remain circular rather than resolving like 50%: {pill_corner:?}" ); assert!( is_white(231, 1), "a circular replaced image must clip its raster corner" ); let image_center = pixmap.pixel(250, 20).expect("image center"); assert!(image_center.red() > 80 && image_center.blue() > 80 && image_center.green() < 40); } #[test] fn auto_background_size_uses_intrinsic_dimensions_and_position() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (120.0, 120.0), None).expect("pixmap"); let background = pixmap.pixel(10, 10).expect("pixel"); assert!( background.red() > 200 && background.blue() < 60, "the intrinsic image must not stretch across the owner" ); let image = pixmap.pixel(90, 95).expect("pixel"); assert!( image.blue() > 200 && image.red() < 60, "the 20x10 intrinsic image must anchor at bottom right" ); } #[test] fn cover_and_contain_background_sizes_fit_the_positioning_area() { let image = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='100'%3E%3Crect%20width='200'%20height='100'%20fill='blue'/%3E%3C/svg%3E"; let tree = parse_html(&format!( r#"
"# )); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let cover_edge = pixmap.pixel(50, 10).expect("cover edge"); assert!( cover_edge.blue() > 200 && cover_edge.red() < 40, "cover must scale the 2:1 image to the owner's 200px block axis: {cover_edge:?}" ); let contain_outside = pixmap.pixel(150, 60).expect("contain letterbox"); assert!( contain_outside.red() > 200 && contain_outside.blue() < 40, "contain must leave the area outside its centered 100x50 image unpainted: {contain_outside:?}" ); let contain_center = pixmap.pixel(150, 100).expect("contain center"); assert!( contain_center.blue() > 200 && contain_center.red() < 40, "contain must paint the fitted image through the center: {contain_center:?}" ); } #[test] fn ordered_background_gradients_paint_every_translucent_layer() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (100.0, 100.0), None).expect("pixmap"); let left = pixmap.pixel(4, 4).expect("top-left gradient"); let right = pixmap.pixel(95, 4).expect("top-right gradient"); assert!( left.red() > left.blue() + 80, "the top-left radial layer must tint its own corner: {left:?}" ); assert!( right.blue() > right.red() + 80, "the later top-right radial layer must remain beneath the authored top layer: {right:?}" ); } #[test] fn repeating_linear_gradient_tiles_at_background_size_over_ordered_layer() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (40.0, 40.0), None).expect("pixmap"); let mut dark = 0usize; let mut red = 0usize; for y in 0..10 { for x in 0..10 { let first = pixmap.pixel(x, y).expect("first tile"); let repeated = pixmap.pixel(x + 20, y + 20).expect("repeated tile"); assert_eq!( first, repeated, "background-size must establish a stable 10px tile at ({x},{y})" ); if first.red() < 150 { dark += 1; } else if first.red() > 220 && first.green() < 40 && first.blue() < 40 { red += 1; } } } assert!(dark > 4, "the repeating hatch must paint dark stripes"); assert!( red > 20, "transparent hatch gaps must reveal the ordered red layer" ); } #[test] fn length_background_positions_select_sprite_frames() { let sprite = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='48'%20height='24'%3E%3Crect%20width='24'%20height='24'%20fill='%23ff0000'/%3E%3Crect%20x='24'%20width='24'%20height='24'%20fill='%230000ff'/%3E%3C/svg%3E"; let tree = parse_html(&format!( r#"
"# )); let pixmap = paint_dom(&tree, (60.0, 45.0), None).expect("pixmap"); let first = pixmap.pixel(12, 21).expect("first sprite frame"); assert!( first.red() > 200 && first.green() < 40 && first.blue() < 40, "`background-position:0` must anchor the first frame at the start edge: {first:?}" ); let second = pixmap.pixel(42, 21).expect("second sprite frame"); assert!( second.blue() > 200 && second.red() < 40 && second.green() < 40, "a negative pixel position must select the next sprite frame: {second:?}" ); let above = pixmap.pixel(12, 2).expect("vertical centering"); assert!( above.red() < 30 && above.green() < 30 && above.blue() < 30, "a one-value horizontal position must default the vertical axis to center: {above:?}" ); } #[test] fn negative_text_indent_clips_label_without_hiding_background_icon() { let tree = parse_html( r#" Bluesky (@mozilla.org) "#, ); let pixmap = paint_dom(&tree, (32.0, 32.0), None).expect("pixmap"); let corner = pixmap.pixel(1, 1).expect("anchor background"); assert!( corner.blue() > 200 && corner.red() < 40 && corner.green() < 40, "the anchor background must remain visible: {corner:?}" ); let icon = pixmap.pixel(12, 12).expect("centered SVG icon"); assert!( icon.red() > 200 && icon.green() < 40 && icon.blue() < 40, "the background SVG must paint independently of indented text: {icon:?}" ); for y in 0..24 { for x in 0..24 { let pixel = pixmap.pixel(x, y).expect("anchor pixel"); assert!( pixel.red() > 40 || pixel.green() > 40 || pixel.blue() > 40, "black label glyph leaked through the 24px overflow clip at ({x},{y})" ); } } } #[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 = RenderResourceCache::default(); let image = background_image_rect( source, None, &owner, None, Some("calc(100% - 2rem) auto"), None, crate::BackgroundPosition::new( crate::BackgroundPositionAxis::percentage(0.0), crate::BackgroundPositionAxis::percentage(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 polygon_clip_path_paints_responsive_geometry_on_elements_and_pseudos() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (300.0, 100.0), None).expect("pixmap"); for (name, left, channel) in [ ("ordinary element", 0, 0), ("in-flow pseudo", 100, 1), ("positioned pseudo", 200, 2), ] { let inside = pixmap.pixel(left + 40, 65).expect("inside pixel"); let colored = match channel { 0 => inside.red() > 180 && inside.green() < 80 && inside.blue() < 80, 1 => inside.green() > 100 && inside.red() < 80 && inside.blue() < 80, _ => inside.blue() > 180 && inside.red() < 80 && inside.green() < 80, }; assert!( colored, "{name} must paint inside its percentage polygon: {inside:?}" ); let outside = pixmap.pixel(left + 4, 65).expect("outside pixel"); assert_eq!( (outside.red(), outside.green(), outside.blue()), (255, 255, 255), "{name} must be clipped outside its triangle: {outside:?}" ); } } #[test] fn polygon_clip_path_honors_evenodd_fill_rule() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (120.0, 120.0), None).expect("pixmap"); let shell = pixmap.pixel(10, 50).expect("outer shell"); assert!( shell.red() > 180 && shell.green() < 80, "the outer contour must remain painted: {shell:?}" ); let hole = pixmap.pixel(50, 50).expect("inner hole"); assert_eq!( (hole.red(), hole.green(), hole.blue()), (255, 255, 255), "evenodd must cut the inner winding out of the clip: {hole:?}" ); } #[test] fn degenerate_polygon_clip_path_clips_the_element_away() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (100.0, 100.0), None).expect("pixmap"); for (x, y) in [(10, 10), (20, 20), (40, 40)] { let pixel = pixmap.pixel(x, y).expect("pixel"); assert_eq!( (pixel.red(), pixel.green(), pixel.blue()), (255, 255, 255), "a zero-area polygon must not fall back to an unclipped box: {pixel:?}" ); } } #[test] fn paints_generated_style_images_and_sizes_content_url_as_replaced_content() { let tree = parse_html( r#"
"#, ); // The first cascade exposes the style image. Feeding its decoded // dimensions back through the ordinary intrinsic map must give the // source-less replaced element its 30x20 CSS box. let mut intrinsic = std::collections::HashMap::new(); let first = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]); let host_id = tree .query_selector("#host") .expect("selector") .expect("host"); let host_style = &first.styles[&host_id]; assert!( host_style .before_pseudo .as_deref() .and_then(|style| style.background_image.as_deref()) .is_some(), "the positioned pseudo must retain its parsed URL background" ); assert!( host_style .after_pseudo .as_deref() .and_then(|style| style.mask_image.as_deref()) .is_some(), "the positioned pseudo must retain its parsed URL mask" ); let host_rect = first.rects[&host_id]; assert_eq!( (host_rect.x, host_rect.y, host_rect.width, host_rect.height), (0.0, 0.0, 100.0, 40.0) ); let mut cache = RenderResourceCache::default(); let mut selected = HashMap::new(); assert!(collect_content_image_intrinsics( &tree, &first.styles, None, &mut cache, &mut intrinsic, &mut selected, &HashMap::new(), &HashMap::new(), &HashSet::new(), )); let laid = layout_dom_with_web_fonts(&tree, (120.0, 80.0), &intrinsic, &[]); let image_id = tree .query_selector("#content-image") .expect("selector") .expect("content image"); let image_rect = laid.rects[&image_id]; assert_eq!( ( image_rect.x, image_rect.y, image_rect.width, image_rect.height ), (0.0, 40.0, 30.0, 20.0), "content:url must use ordinary replaced-element geometry" ); let pixmap = paint_dom(&tree, (120.0, 80.0), None).expect("pixmap"); let blue = pixmap.pixel(20, 20).expect("blue pseudo"); assert!( blue.blue() > 220 && blue.red() < 40 && blue.green() < 80, "positioned pseudo background-image must paint: {blue:?}" ); let red = pixmap.pixel(70, 20).expect("masked pseudo center"); assert!( red.red() > 220 && red.green() < 40 && red.blue() < 40, "positioned pseudo mask center must use the authored fill: {red:?}" ); let transparent_corner = pixmap.pixel(51, 1).expect("mask corner"); assert_eq!( ( transparent_corner.red(), transparent_corner.green(), transparent_corner.blue(), ), (255, 255, 255), "transparent mask corners must not paint the pseudo's solid box" ); let green = pixmap.pixel(15, 50).expect("content image"); assert!( green.green() > 220 && green.red() < 40 && green.blue() < 40, "content:url image must paint through the replaced-image path: {green:?}" ); } #[test] fn repeated_content_image_prepare_reuses_intrinsic_and_corrects_changes() { const SOURCE: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='12'%20height='8'%3E%3C/svg%3E"; const CONTENT_A: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='30'%20height='20'%3E%3C/svg%3E"; const CONTENT_B: &str = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='50'%20height='10'%3E%3C/svg%3E"; let make_tree = |content: Option<&str>| { let content = content .map(|url| format!("content:url('{url}');")) .unwrap_or_default(); parse_html(&format!( r#""# )) }; let rect_for = |tree: &DomTree, prepared: &PreparedRender| { let id = tree .query_selector("#target") .expect("selector") .expect("target"); prepared.layout.rects[&id] }; let mut resources = RenderResourceCache::default(); let first_tree = make_tree(Some(CONTENT_A)); let first = prepare_dom(&first_tree, (100.0, 80.0), None, &mut resources).expect("first prepare"); let first_rect = rect_for(&first_tree, &first); assert_eq!((first_rect.width, first_rect.height), (30.0, 20.0)); assert_eq!(resources.content_image_layout_retries, 1); // Stable node + resolved URL + dimensions seed the first layout. The // retry counter must not move on a repeated prepare. let repeated = prepare_dom(&first_tree, (100.0, 80.0), None, &mut resources) .expect("repeated prepare"); let repeated_rect = rect_for(&first_tree, &repeated); assert_eq!((repeated_rect.width, repeated_rect.height), (30.0, 20.0)); assert_eq!(resources.content_image_layout_retries, 1); // A changed computed URL initially receives the remembered geometry, // then must correct it from the newly selected resource. let changed_tree = make_tree(Some(CONTENT_B)); let changed = prepare_dom(&changed_tree, (100.0, 80.0), None, &mut resources) .expect("changed prepare"); let changed_rect = rect_for(&changed_tree, &changed); assert_eq!((changed_rect.width, changed_rect.height), (50.0, 10.0)); assert_eq!(resources.content_image_layout_retries, 2); // Removing CSS content restores the ordinary src selection and its // intrinsic geometry rather than leaking the remembered CSS image. let removed_tree = make_tree(None); let removed = prepare_dom(&removed_tree, (100.0, 80.0), None, &mut resources) .expect("removed prepare"); let removed_id = removed_tree .query_selector("#target") .expect("selector") .expect("target"); let removed_rect = removed.layout.rects[&removed_id]; assert_eq!((removed_rect.width, removed_rect.height), (12.0, 8.0)); assert_eq!(resources.content_image_layout_retries, 3); assert!(resources.content_image_intrinsics.is_empty()); assert_eq!( removed.selected_images[&removed_id].resolved_url, SOURCE, "the HTML source must regain painting ownership" ); } #[test] fn content_image_intrinsic_memory_is_bounded_and_refreshes_recency() { let mut resources = RenderResourceCache::with_loader_and_limits(|_url: &str| None, 2, 1024); let tree = parse_html(r#""#); let ids = ["#a", "#b", "#c"].map(|selector| { tree.query_selector(selector) .expect("selector") .expect("image") }); resources.remember_content_image_intrinsic(ids[0], "a".into(), (1.0, 1.0)); resources.remember_content_image_intrinsic(ids[1], "b".into(), (2.0, 2.0)); // Refreshing id 1 makes id 2 the oldest entry. resources.remember_content_image_intrinsic(ids[0], "a2".into(), (3.0, 3.0)); resources.remember_content_image_intrinsic(ids[2], "c".into(), (4.0, 4.0)); assert_eq!(resources.content_image_intrinsics.len(), 2); assert!(resources.content_image_intrinsics.contains_key(&ids[0])); assert!(!resources.content_image_intrinsics.contains_key(&ids[1])); assert!(resources.content_image_intrinsics.contains_key(&ids[2])); assert_eq!( resources.content_image_intrinsics[&ids[0]].resolved_url, "a2" ); } #[test] fn repeated_data_svg_masks_sample_radial_sources_on_every_box_path() { let tree = parse_html( r##"
"##, ); let pixmap = paint_dom(&tree, (300.0, 160.0), None).expect("pixmap"); let count_pixels = |left: u32, top: u32, predicate: fn(u8, u8, u8) -> bool| { (top..top + 66) .flat_map(|y| (left..left + 88).map(move |x| (x, y))) .filter(|&(x, y)| { let pixel = pixmap.pixel(x, y).expect("pixel"); predicate(pixel.red(), pixel.green(), pixel.blue()) }) .count() }; let is_radial_color = |red, green, blue| red > 70 && blue > 100 && blue as u16 > green as u16 * 2; for (name, left) in [ ("ordinary element", 0), ("in-flow pseudo", 100), ("positioned pseudo", 200), ] { let colored = count_pixels(left, 0, is_radial_color); assert!( colored > 20, "{name} must sample the radial source through the repeated SVG mask, found {colored} colored pixels" ); let black = count_pixels(left, 0, |red, green, blue| { red < 20 && green < 20 && blue < 20 }); assert_eq!( black, 0, "{name} must not fall back to the default black mask fill" ); } assert!( count_pixels(0, 80, |red, green, blue| green > 100 && red < 40 && blue < 40) > 20, "solid mask sources must keep painting" ); assert!( count_pixels(100, 80, |red, green, blue| (red > 100 || blue > 100) && green < 80) > 20, "linear-gradient mask sources must keep painting" ); assert!( count_pixels(200, 80, |red, green, blue| (red > 100 || blue > 100) && green < 80) > 20, "conic-gradient mask sources must keep painting" ); } #[test] fn paints_empty_in_flow_generated_block_at_its_layout_rect() { let tree = parse_html( r#"
TEXT
"#, ); let pixmap = paint_dom(&tree, (240.0, 100.0), None).expect("pixmap"); let generated = pixmap.pixel(40, 20).expect("generated block pixel"); assert!( generated.blue() > 180 && generated.green() > 70 && generated.red() < 30, "the anonymous generated box must paint its own background: {generated:?}" ); let following = pixmap.pixel(10, 75).expect("following block pixel"); assert!( following.green() > 120 && following.red() < 30 && following.blue() < 30, "the following block must paint below the generated geometry: {following:?}" ); } #[test] fn paints_positioned_attr_content_over_the_host_background() { let tree = parse_html( r#" "#, ); let pixmap = paint_dom(&tree, (140.0, 60.0), None).expect("pixmap"); let inner = pixmap.pixel(5, 5).expect("inner pixel"); assert_eq!( (inner.red(), inner.green(), inner.blue()), (255, 255, 255), "the generated box must cover the red host background" ); let dark_pixels = (35..85) .flat_map(|x| (8..32).map(move |y| (x, y))) .filter(|&(x, y)| { let pixel = pixmap.pixel(x, y).unwrap(); pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 }) .count(); assert!(dark_pixels > 10, "generated attr() text must be painted"); } #[test] fn later_positioned_pseudo_opaquely_covers_the_earlier_one() { let tree = parse_html( r#""#, ); let pixmap = paint_dom(&tree, (140.0, 60.0), None).expect("pixmap"); let inner = pixmap.pixel(5, 5).expect("inner pixel"); assert_eq!( (inner.red(), inner.green(), inner.blue()), (255, 255, 255), "::after's opaque background must cover ::before" ); let red_pixels = (1..119) .flat_map(|x| (1..39).map(move |y| (x, y))) .filter(|&(x, y)| { let pixel = pixmap.pixel(x, y).unwrap(); pixel.red() > 180 && pixel.green() < 80 && pixel.blue() < 80 }) .count(); assert_eq!(red_pixels, 0, "::before must not bleed through ::after"); } #[test] fn angular_absolute_primary_button_paints_one_generated_label() { let tree = parse_html( r#"
"#, ); let laid = crate::layout_dom(&tree, (500.0, 300.0)); let cta = tree.get_element_by_id("cta").unwrap(); let rect = laid.rects[&cta]; let style = &laid.styles[&cta]; assert_eq!(style.color, Some([0, 0, 0, 0])); let pixmap = paint_dom(&tree, (500.0, 300.0), None).expect("pixmap"); let rows = (rect.y.floor() as u32..(rect.y + rect.height).ceil() as u32) .map(|y| { (rect.x.floor() as u32..(rect.x + rect.width).ceil() as u32) .filter(|&x| { let pixel = pixmap.pixel(x, y).unwrap(); pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 }) .count() }) .collect::>(); let ink_rows = rows .iter() .enumerate() .filter_map(|(row, &ink)| (ink > 0).then_some(row)) .collect::>(); assert!( ink_rows.last().unwrap() - ink_rows.first().unwrap() <= 14, "only the generated label may paint; transparent host text leaked across rows {rows:?}" ); } #[test] fn native_select_paints_only_the_selected_label_and_arrow() { let tree = parse_html( r#" "#, ); let pixmap = paint_dom(&tree, (160.0, 60.0), None).expect("pixmap"); let dark_pixels = (0..120) .flat_map(|x| (0..30).map(move |y| (x, y))) .filter(|&(x, y)| { let pixel = pixmap.pixel(x, y).unwrap(); pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 }) .count(); assert!( dark_pixels > 20, "selected label, border, and disclosure arrow should paint" ); let select = tree.get_element_by_id("theme").unwrap(); assert_eq!( selected_option_label(&tree, select).as_deref(), Some("Dark") ); } #[test] fn authored_combobox_button_paints_css_math_border() { let tree = parse_html( r#"
"#, ); let laid = crate::layout_dom(&tree, (320.0, 80.0)); let language = tree.get_element_by_id("language").unwrap(); let rect = laid.rects[&language]; let style = &laid.styles[&language]; assert_eq!((rect.width, rect.height), (260.0, 40.0)); assert_eq!( style.border, crate::Edges { top: 1.0, right: 1.0, bottom: 1.0, left: 1.0, } ); let pixmap = paint_dom(&tree, (320.0, 80.0), None).expect("pixmap"); let painted_top_edge = (20..240) .filter(|&x| pixmap.pixel(x, 0).is_some_and(|pixel| pixel.alpha() > 0)) .count(); assert!( painted_top_edge > 200, "the rounded authored border should paint across the control: {painted_top_edge}" ); } #[test] fn fractional_opacity_composites_boxes_and_inline_svg_once() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (60.0, 30.0), None).expect("pixmap"); for (label, x) in [("box", 10), ("svg", 40)] { let pixel = pixmap.pixel(x, 10).expect("pixel"); assert!( (240..=244).contains(&pixel.red()) && pixel.red() == pixel.green() && pixel.green() == pixel.blue(), "{label} opacity:.05 should composite black once over white: {pixel:?}" ); } } #[test] fn opacity_is_applied_to_overlapping_children_as_one_group() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (40.0, 30.0), None).expect("pixmap"); let samples = [5, 15, 25].map(|x| pixmap.pixel(x, 10).unwrap().red()); assert!( samples.iter().all(|channel| (126..=129).contains(channel)), "single-child and overlap regions must receive the same group alpha: {samples:?}" ); } #[test] fn nested_opacity_groups_multiply_at_composite_boundaries() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (30.0, 30.0), None).expect("pixmap"); let pixel = pixmap.pixel(10, 10).expect("pixel"); assert!( (190..=193).contains(&pixel.red()) && pixel.red() == pixel.green() && pixel.green() == pixel.blue(), "nested .5 groups should produce .25 black over white: {pixel:?}" ); } #[test] fn opacity_group_contains_z_order_and_preserves_clip_transforms() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (60.0, 30.0), None).expect("pixmap"); let clipped_out = pixmap.pixel(5, 10).unwrap(); let clipped_in = pixmap.pixel(15, 10).unwrap(); let outside = pixmap.pixel(25, 10).unwrap(); assert_eq!( (clipped_out.red(), clipped_out.green(), clipped_out.blue()), (255, 255, 255) ); assert!( clipped_in.red() > 240 && (126..=129).contains(&clipped_in.green()), "translated child should be clipped then group-composited: {clipped_in:?}" ); assert_eq!( (outside.red(), outside.green(), outside.blue()), (255, 255, 255) ); let covered = pixmap.pixel(40, 10).unwrap(); assert!( covered.blue() > 240 && covered.red() < 20, "high-z descendant must stay inside the earlier opacity group: {covered:?}" ); } #[test] fn static_flex_and_grid_item_z_index_paints_each_subtree_atomically() { let blue_image = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='60'%3E%3Crect%20width='100'%20height='60'%20fill='blue'/%3E%3C/svg%3E"; let tree = parse_html(&format!( r#"
FLEX
GRID
LOW
"# )); let pixmap = paint_dom(&tree, (140.0, 210.0), None).expect("pixmap"); let is_green = |x, y| { let pixel = pixmap.pixel(x, y).expect("border pixel"); pixel.green() > 230 && pixel.red() < 30 && pixel.blue() < 30 }; let is_red = |x, y| { let pixel = pixmap.pixel(x, y).expect("background pixel"); pixel.red() > 230 && pixel.green() < 30 && pixel.blue() < 30 }; assert!( is_green(2, 30) && is_red(80, 50), "the static flex item's border/background must paint above its later absolute sibling" ); assert!( is_green(2, 100) && is_red(80, 120), "the static grid item's border/background must paint above its later image sibling" ); for (label, y0) in [("flex", 0), ("grid", 70)] { let dark_ink = (8..92) .flat_map(|x| (y0 + 8..y0 + 42).map(move |y| (x, y))) .filter(|&(x, y)| { let pixel = pixmap.pixel(x, y).expect("text pixel"); pixel.red() < 40 && pixel.green() < 40 && pixel.blue() < 40 }) .count(); assert!( dark_ink > 20, "{label} item text must remain inside the raised atomic subtree, found {dark_ink} dark pixels" ); } assert!( (0..60).all(|dy| (0..100).all(|x| { let pixel = pixmap.pixel(x, 140 + dy).expect("covered pixel"); pixel.blue() > 230 && pixel.red() < 30 && pixel.green() < 30 })), "a higher-z sibling must cover the lower item's background, border, and shaped text as one unit" ); } #[test] fn wrapped_inline_background_and_slice_borders_paint_per_continuation() { let tree = parse_html( r#"

aaaa aaaa aaaa

"#, ); let token = tree.get_element_by_id("token").unwrap(); let mut resources = RenderResourceCache::default(); let mut prepared = prepare_dom(&tree, (120.0, 100.0), None, &mut resources).unwrap(); let fragments = prepared.layout.inline_fragments[&token].clone(); assert_eq!(fragments.len(), 3, "{fragments:?}"); let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0)).unwrap(); let rgb = |x: f32, y: f32| { let pixel = pixmap .pixel(x.floor().max(0.0) as u32, y.floor().max(0.0) as u32) .unwrap(); (pixel.red(), pixel.green(), pixel.blue()) }; let first = fragments[0]; let middle = fragments[1]; let last = fragments[2]; assert_eq!(rgb(first.x + 0.5, first.y + 1.0), (0, 0, 255)); assert_eq!(rgb(middle.x + 0.5, middle.y + 1.0), (255, 0, 0)); assert_eq!(rgb(last.x + last.width - 0.5, last.y + 1.0), (0, 0, 255)); let gap_y = (first.y + first.height + middle.y) * 0.5; assert_eq!( rgb(5.0, gap_y), (255, 255, 255), "the multiline bounding union must not fill the inter-line gap" ); } #[test] fn overflow_clipping_keeps_physical_axes_independent() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (90.0, 50.0), None).expect("pixmap"); let x_inside_y_outside = pixmap.pixel(15, 35).unwrap(); assert!( x_inside_y_outside.red() > 240 && x_inside_y_outside.blue() < 20, "overflow-x:clip must not clip a Brave-shaped absolute child on Y: \ {x_inside_y_outside:?}" ); assert_eq!( pixmap.pixel(25, 35).map(|p| (p.red(), p.green(), p.blue())), Some((255, 255, 255)), "overflow-x:clip must still clip X" ); let y_inside_x_outside = pixmap.pixel(75, 15).unwrap(); assert!( y_inside_x_outside.blue() > 240 && y_inside_x_outside.red() < 20, "overflow-y:clip must not clip the reciprocal X overflow: \ {y_inside_x_outside:?}" ); assert_eq!( pixmap.pixel(75, 25).map(|p| (p.red(), p.green(), p.blue())), Some((255, 255, 255)), "overflow-y:clip must still clip Y" ); } #[test] fn generated_boxes_use_the_hosts_overflow_on_only_the_authored_axis() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (90.0, 50.0), None).expect("pixmap"); let positioned_y_overflow = pixmap.pixel(15, 35).unwrap(); assert!( positioned_y_overflow.red() > 240 && positioned_y_overflow.blue() < 20, "positioned pseudo must remain visible outside Y for overflow-x:clip: \ {positioned_y_overflow:?}" ); assert_eq!( pixmap.pixel(25, 35).map(|p| (p.red(), p.green(), p.blue())), Some((255, 255, 255)), "positioned pseudo must be clipped on X" ); let in_flow_x_overflow = pixmap.pixel(75, 10).unwrap(); assert!( in_flow_x_overflow.blue() > 240 && in_flow_x_overflow.red() < 20, "in-flow pseudo must remain visible outside X for overflow-y:clip: \ {in_flow_x_overflow:?}" ); assert_eq!( pixmap.pixel(75, 25).map(|p| (p.red(), p.green(), p.blue())), Some((255, 255, 255)), "in-flow pseudo must be clipped on Y" ); } #[test] fn later_element_paints_over_earlier() { // A blue div nested inside a red one: both cover the origin, and blue // (a descendant, later in tree order) paints over red. let tree = parse_html( "\
\
\
\ ", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let p = pixmap.pixel(5, 5).expect("pixel"); assert!( p.blue() > 200, "expected blue to paint over red, got {:?}", p ); } #[test] fn nested_translate_accumulates_through_subtree() { // Parent red box (position:absolute at 0,0, 20x20) translated by // (50,60). Child blue box (10x10, in-flow at the red box's origin) // translated by an additional (30,0). The child's painted position must // be the SUM of both translates, (50+30, 60+0) = (80,60), proving an // ancestor's translate offsets the whole subtree on top of the node's // own translate. let tree = parse_html( "\
\
\
\
\
\ ", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); // Child blue lands at (80..90, 60..70). let blue = pixmap.pixel(85, 65).expect("pixel"); assert!( blue.blue() > 200 && blue.red() < 60, "expected blue child at accumulated offset (80,60), got {:?}", blue ); // Parent red lands at (50..70, 60..80); sample where the blue child does // not cover. let red = pixmap.pixel(55, 75).expect("pixel"); assert!( red.red() > 200 && red.blue() < 60, "expected red parent at its own translate (50,60), got {:?}", red ); // Nothing painted at the pre-transform origin: both boxes moved away. let origin = pixmap.pixel(5, 5).expect("pixel"); assert_eq!( (origin.red(), origin.green(), origin.blue()), (255, 255, 255) ); } #[test] fn rotate_and_scale_paint_complete_mixed_subtrees() { let tree = parse_html( r#"
MMMM
M
"#, ); let pixmap = paint_dom(&tree, (240.0, 120.0), None).expect("pixmap"); let scaled_box = pixmap.pixel(30, 70).expect("scaled box"); assert!(scaled_box.red() > 220 && scaled_box.green() < 40); let scaled_image = pixmap.pixel(110, 70).expect("scaled image"); assert!(scaled_image.green() > 220 && scaled_image.red() < 40); assert!( (20..100).any(|x| (20..60).any(|y| { let pixel = pixmap.pixel(x, y).expect("scaled text region"); pixel.red() < 80 && pixel.green() < 80 && pixel.blue() < 80 })), "text must be rasterized inside the scaled atomic subtree" ); let rotated_box = pixmap.pixel(165, 25).expect("rotated box"); assert!(rotated_box.red() > 220 && rotated_box.blue() > 220); let rotated_image = pixmap.pixel(175, 45).expect("rotated image"); assert!(rotated_image.green() > 220 && rotated_image.blue() > 220); } #[test] fn transform_function_order_nested_world_matrices_and_cssom_aabbs() { let tree = parse_html( r#"
"#, ); let mut resources = RenderResourceCache::default(); let prepared = prepare_dom(&tree, (500.0, 600.0), None, &mut resources) .expect("prepared transform geometry"); let assert_rect = |actual: crate::Rect, expected: crate::Rect| { assert!((actual.x - expected.x).abs() < 0.02, "x: {actual:?}"); assert!((actual.y - expected.y).abs() < 0.02, "y: {actual:?}"); assert!( (actual.width - expected.width).abs() < 0.02, "width: {actual:?}" ); assert!( (actual.height - expected.height).abs() < 0.02, "height: {actual:?}" ); }; let first = tree.get_element_by_id("translate-rotate").unwrap(); let second = tree.get_element_by_id("rotate-translate").unwrap(); assert_rect( prepared.document_rect(first).unwrap(), crate::Rect { x: 140.0, y: 50.0, width: 10.0, height: 20.0, }, ); assert_rect( prepared.document_rect(second).unwrap(), crate::Rect { x: 40.0, y: 150.0, width: 10.0, height: 20.0, }, ); let child = tree.get_element_by_id("child").unwrap(); let child_rect = crate::Rect { x: 30.0, y: 240.0, width: 40.0, height: 20.0, }; assert_rect(prepared.document_rect(child).unwrap(), child_rect); assert_rect( prepared.viewport_client_rects(child, (0.0, 0.0)).unwrap()[0], child_rect, ); let fixed = tree.get_element_by_id("captured-fixed").unwrap(); assert_rect( prepared.document_rect(fixed).unwrap(), crate::Rect { x: 50.0, y: 200.0, width: 20.0, height: 20.0, }, ); let centered = tree.get_element_by_id("center-origin").unwrap(); assert_rect( prepared.document_rect(centered).unwrap(), crate::Rect { x: 105.0, y: 395.0, width: 10.0, height: 20.0, }, ); let cssom = tree.get_element_by_id("cssom-transform").unwrap(); let computed = prepared.computed_style(cssom).unwrap(); assert_eq!(computed["transform"], "matrix(1, 0, 0, 1, 10, 0)"); assert_eq!(computed["translate"], "7px 0px"); assert_eq!(computed["rotate"], "30deg"); assert_eq!(computed["scale"], "2 2"); } #[test] fn transformed_subtree_is_clipped_by_outside_ancestor() { let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (100.0, 80.0), None).expect("pixmap"); let inside = pixmap.pixel(55, 40).expect("inside ancestor clip"); assert!(inside.green() > 120 && inside.red() < 40); let outside = pixmap.pixel(65, 40).expect("outside ancestor clip"); assert_eq!( (outside.red(), outside.green(), outside.blue()), (255, 255, 255) ); } #[test] fn no_transform_keeps_identity_geometry_and_baseline_paint() { let tree = parse_html( r#"
"#, ); let mut resources = RenderResourceCache::default(); let prepared = prepare_dom(&tree, (80.0, 60.0), None, &mut resources).unwrap(); let plain = tree.get_element_by_id("plain").unwrap(); assert!(prepared.layout().transforms.is_empty()); assert_eq!( prepared.document_rect(plain), Some(crate::Rect { x: 10.0, y: 12.0, width: 20.0, height: 15.0 }) ); let pixmap = paint_dom(&tree, (80.0, 60.0), None).unwrap(); let painted = pixmap.pixel(15, 15).unwrap(); assert!(painted.red() > 220 && painted.green() < 40); let empty = pixmap.pixel(5, 5).unwrap(); assert_eq!((empty.red(), empty.green(), empty.blue()), (255, 255, 255)); } #[test] fn transformed_image_is_clipped_inside_overflow_border() { // CSS overflow clipping belongs to the owner's padding box. The // translated images paint after the viewport's border in tree order, // so a border-box clip would let them overwrite the border. let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (140.0, 90.0), None).expect("pixmap"); for &(x, y) in &[(0, 30), (3, 30), (104, 30), (107, 30), (50, 0), (50, 67)] { let pixel = pixmap.pixel(x, y).expect("border pixel"); assert!( pixel.red() > 220 && pixel.green() < 40 && pixel.blue() < 40, "translated image must not overwrite border pixel ({x},{y}): {pixel:?}" ); } let content = pixmap.pixel(50, 30).expect("content pixel"); assert!( content.blue() > 220 && content.red() < 40, "translated cover image must remain visible inside padding box: {content:?}" ); } #[test] fn projected_image_transform_enters_overflow_clip() { // Chromium reduction: without the projected rotate/scale, this image is // wholly left of the clip. Its transformed right edge reaches x=53.14. let tree = parse_html( r#"
"#, ); let pixmap = paint_dom(&tree, (160.0, 120.0), None).expect("pixmap"); assert!( (0..100).any(|y| (0..120).any(|x| { let pixel = pixmap.pixel(x, y).expect("inside viewport"); pixel.red() > 220 && pixel.green() < 40 && pixel.blue() < 40 })), "projected image should enter the overflow clip" ); assert!( (0..120).all(|y| (120..160).all(|x| { let pixel = pixmap.pixel(x, y).expect("outside clip"); pixel.red() > 240 && pixel.green() > 240 && pixel.blue() > 240 })), "projected image must remain clipped to its overflow ancestor" ); } #[test] fn translate_offscreen_box_is_not_painted() { // translate(-10000px,0) shoves the box far off the left edge (the old // hidden skip-link idiom); it must not paint anywhere on the canvas. let tree = parse_html( "\
\ ", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let mut any_red = false; 'scan: for y in 0..200 { for x in 0..200 { let p = pixmap.pixel(x, y).expect("pixel"); if p.red() > 200 && p.green() < 60 && p.blue() < 60 { any_red = true; break 'scan; } } } assert!( !any_red, "translate(-10000px,0) box should be off-screen and unpainted" ); } #[test] fn translate_percent_centers_absolute_box() { // The canonical centering idiom: an absolutely-positioned box at // top:50%/left:50% of its containing block pulled back by // translate(-50%,-50%) of its own size centers within it. In a 200x200 // container a 40x40 box centers at (100,100), so its border box (with // top-left at 100,100 before the transform) becomes (80..120, 80..120). let tree = parse_html( "\
\
\
\ ", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let center = pixmap.pixel(100, 100).expect("pixel"); assert!( center.red() > 200 && center.blue() < 60, "expected centered red box, got {:?}", center ); // Just outside the centered box stays white. let outside = pixmap.pixel(70, 70).expect("pixel"); assert_eq!( (outside.red(), outside.green(), outside.blue()), (255, 255, 255) ); } #[test] fn paints_text_color() { let tree = parse_html( "
Hello
", ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let mut found_green = false; for y in 0..200 { for x in 0..200 { let p = pixmap.pixel(x, y).expect("pixel"); if p.green() > 200 && p.red() < 50 && p.blue() < 50 { found_green = true; break; } } if found_green { break; } } assert!(found_green, "expected green text to be painted"); } #[test] fn word_measurement_honors_generic_font_family() { let sans = measure_text("iiiiiiii", 16.0, false, Some("sans-serif")); let mono = measure_text("iiiiiiii", 16.0, false, Some("monospace")); assert!( mono > sans * 1.5, "monospace advances must be used for code text: sans={sans}, mono={mono}" ); let sample = "Build fast, responsive sites with Bootstrap"; let system = measure_text(sample, 64.0, false, Some("system-ui, sans-serif")); let arial = measure_text(sample, 64.0, false, Some("Arial, sans-serif")); assert!( system > arial * 1.08, "Chromium's Linux system-ui face is DejaVu Sans, not the narrower \ Liberation Sans used for Arial: system={system}, arial={arial}" ); } #[test] fn paints_vendor_gradient_on_inline_text_span() { // Vue and many other framework sites put the gradient on an inline // accent span, not on the whole heading. The surrounding text must // keep its normal color while this span samples both gradient ends. let tree = parse_html( r#"

The Progressive

"#, ); let pixmap = paint_dom(&tree, (500.0, 100.0), None).expect("pixmap"); let mut green = false; let mut blue = false; let mut normal = false; for pixel in pixmap.pixels() { let (r, g, b) = (pixel.red(), pixel.green(), pixel.blue()); green |= g > r.saturating_add(20) && g > b.saturating_add(10); blue |= b > r.saturating_add(20) && b > g.saturating_add(5); normal |= b > g.saturating_add(10) && r < 80 && g < 100; } assert!( normal, "surrounding heading text should retain its normal color" ); assert!( green && blue, "inline accent should contain both gradient colors" ); } #[test] fn serializes_inline_svg_subtree() { // A sprite-style svg: a that references a in the same // document must survive serialization so resvg can resolve it. let tree = parse_html( r##""##, ); let svg = tree.query_selector("svg").unwrap().unwrap(); let out = serialize_svg(&tree, svg); assert!(out.starts_with(""), "path opened + closed: {out}" ); assert!(out.trim_end().ends_with(""), "root closed: {out}"); // The serialized string parses as a standalone SVG document. let opts = usvg::Options::default(); assert!( usvg::Tree::from_data(out.as_bytes(), &opts).is_ok(), "usvg should parse serialized svg: {out}", ); } #[test] fn inline_svg_keeps_author_css_and_embedded_text_fonts() { // Inline SVG is parsed in the HTML document and styled by the page's // author sheet, then serialized into a standalone document for resvg. // The standalone boundary must not erase a CSS fill/font and inherit // the root's `fill:none`, which made text-only illustrations blank. let tree = parse_html( r##" SVG text "##, ); let svg = tree.query_selector("svg").unwrap().unwrap(); let layout = crate::dom::layout_dom(&tree, (160.0, 80.0)); let markup = serialize_svg_styled(&tree, svg, &layout.styles, None); assert!( markup.contains("fill:#00cc55!important"), "computed author fill must cross the standalone boundary: {markup}" ); assert!( markup.contains("font-size:24px!important"), "computed SVG font size must cross the standalone boundary: {markup}" ); let pixmap = paint_dom(&tree, (160.0, 80.0), None).expect("pixmap"); let painted_green = pixmap .pixels() .iter() .any(|pixel| pixel.green() > 150 && pixel.red() < 80 && pixel.blue() < 120); assert!( painted_green, "author-styled SVG text should rasterize with embedded fonts" ); } #[test] fn injects_xmlns_only_when_absent() { let tree = parse_html( r#""#, ); let svg = tree.query_selector("svg").unwrap().unwrap(); let out = serialize_svg(&tree, svg); assert_eq!( out.matches("xmlns=").count(), 1, "no duplicate xmlns: {out}" ); } #[test] fn paints_inline_svg() { // The inside an inline svg must rasterize (it is not an ). let tree = parse_html( r##""##, ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let mut found_red = false; 'outer: for y in 0..80 { for x in 0..80 { let p = pixmap.pixel(x, y).expect("pixel"); if p.red() > 200 && p.green() < 60 && p.blue() < 60 { found_red = true; break 'outer; } } } assert!(found_red, "expected inline svg to paint red"); } #[test] fn svg_missing_root_height_uses_the_final_css_viewport() { let svg = br##" "##; let pixmap = render_svg(svg, 32, 34).expect("svg raster"); let mut min_y = 34u32; let mut max_y = 0u32; for y in 0..34 { for x in 0..32 { let pixel = pixmap.pixel(x, y).unwrap(); if pixel.alpha() > 0 { min_y = min_y.min(y); max_y = max_y.max(y); } } } assert!( max_y.saturating_sub(min_y) >= 30, "viewBox artwork should fill the resolved 32x34 viewport, got rows {min_y}..{max_y}" ); } #[test] fn paints_inline_svg_current_color_from_computed_style() { let tree = parse_html( r##""##, ); let pixmap = paint_dom(&tree, (80.0, 80.0), None).expect("pixmap"); let mut found = false; for pixel in pixmap.pixels() { found |= pixel.blue() > 120 && pixel.green() > 80 && pixel.red() < 40; } assert!( found, "computed color should resolve currentColor in inline svg" ); } #[test] fn computed_style_exposes_text_break_longhands_and_alias() { let tree = parse_html( r#"

copy

"#, ); let copy = tree.get_element_by_id("copy").unwrap(); let mut resources = RenderResourceCache::default(); let prepared = prepare_dom(&tree, (320.0, 200.0), None, &mut resources).expect("prepared render"); let computed = prepared.computed_style(copy).expect("computed style"); assert_eq!( computed.get("overflow-wrap").map(String::as_str), Some("anywhere") ); assert_eq!( computed.get("word-wrap").map(String::as_str), Some("anywhere") ); assert_eq!( computed.get("word-break").map(String::as_str), Some("keep-all") ); } #[test] fn paints_inline_svg_with_framework_colon_attribute() { let tree = obscura_dom::parse_html( r##""##, ); let output = paint_dom(&tree, (80.0, 80.0), None).expect("pixmap"); let found_blue = (0..80).any(|y| { (0..80).any(|x| { let pixel = output.pixel(x, y).expect("pixel"); pixel.blue() > 200 && pixel.green() > 120 && pixel.red() < 80 }) }); assert!( found_blue, "framework hydration attributes must not invalidate inline SVG XML" ); } #[test] fn paints_inline_svg_use_reference() { // The icon-sprite pattern: resolves against a // element in the same svg only because the whole subtree is serialized // and handed to resvg as one document. let tree = parse_html( r##""##, ); let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap"); let mut found_blue = false; 'outer: for y in 0..80 { for x in 0..80 { let p = pixmap.pixel(x, y).expect("pixel"); if p.blue() > 200 && p.red() < 60 && p.green() < 60 { found_blue = true; break 'outer; } } } assert!( found_blue, "expected to instantiate the referenced " ); } #[test] fn extracts_symbol_by_id_from_sprite() { // The external-sprite core: given a fetched sprite, pull out just the // referenced verbatim so it can be spliced into the local svg. let sprite = r##""##; let out = extract_svg_element_by_id(sprite, "a").expect("symbol a found"); assert!( out.starts_with(""), "closed at matching end: {out}" ); assert!( !out.contains(r#"id="b""#), "stops before the sibling symbol: {out}" ); assert!(!out.contains(""#; assert_eq!( extract_svg_element_by_id(s1, "x").as_deref(), Some(r#""#), ); // Same-name nesting: the matching close is the outer one, not the inner. let s2 = r#""#; assert_eq!( extract_svg_element_by_id(s2, "grp").as_deref(), Some(r#""#), ); // `data-id` / a missing id must not be mistaken for `id`. let s3 = r#""#; assert!( extract_svg_element_by_id(s3, "a").is_none(), "data-id is not id" ); assert!(extract_svg_element_by_id(s2, "nope").is_none(), "absent id"); } #[test] fn same_document_use_left_unchanged_by_inject() { // A same-document symbol already inside the target SVG needs no // injection and leaves the serialized markup byte-for-byte unchanged. let tree = parse_html( r##""##, ); let svg = tree.query_selector("svg").unwrap().unwrap(); let mut markup = serialize_svg(&tree, svg); let before = markup.clone(); let mut cache = RenderResourceCache::default(); let mut sprite_cache = std::collections::HashMap::new(); inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache); assert_eq!(markup, before, "same-document use must be untouched"); } #[test] fn injects_document_level_symbol_into_target_svg() { // Frameworks commonly keep one hidden sprite beside the application // root and reference it from otherwise independent inline SVGs. let tree = parse_html( r##" "##, ); let svg = tree.query_selector("#icon").unwrap().unwrap(); let mut markup = serialize_svg(&tree, svg); let mut cache = RenderResourceCache::default(); let mut sprite_cache = std::collections::HashMap::new(); inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache); assert!( markup.contains(r#" "##, ); let svg = tree.query_selector("#icon").unwrap().unwrap(); let mut markup = serialize_svg(&tree, svg); let mut cache = RenderResourceCache::default(); 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 svg_light_dark_presentation_is_resolved_before_usvg() { let tree = parse_html( r#"
"#, ); let laid = crate::dom::layout_dom(&tree, (100.0, 100.0)); let svg = tree.query_selector("#icon").unwrap().unwrap(); let markup = serialize_svg_styled(&tree, svg, &laid.styles, None); assert!( !markup.to_ascii_lowercase().contains("light-dark("), "unsupported CSS Color 5 syntax must not reach usvg: {markup}" ); assert!( markup.contains("fill:#51565dff!important") && markup.contains("stroke:#000000ff!important"), "serialized presentation colors must use the dark subtree scheme: {markup}" ); let pixmap = render_svg(markup.as_bytes(), 10, 10).expect("resolved SVG renders"); let center = pixmap.pixel(5, 5).expect("center pixel"); assert!( center.red() > 60 && center.red() < 110 && center.green() > 60 && center.green() < 120 && center.blue() > 70 && center.blue() < 130, "resolved dark fill must survive usvg rasterization: {center:?}" ); } #[test] fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() { let css = r#" @font-face { font-family: "Example"; src: local("Example"), url("./example-cyrillic.woff2") format("woff2"); unicode-range: U+0400-04FF; } @font-face { font-family: "Example"; font-style: italic; font-weight: 350 650; src: url(data:font/woff2;base64,d09GMg==) format("woff2"), url("./example-latin.woff") format("woff"); unicode-range: U+??, U+2000-206F; } "#; let faces = font_face_blocks(css); assert_eq!(faces.len(), 2); assert!(!font_face_covers_ascii(faces[0])); assert!(font_face_covers_ascii(faces[1])); assert_eq!(font_face_family(faces[1]).as_deref(), Some("Example")); assert_eq!(font_face_weight(faces[1]), Some((350, 650))); assert_eq!(font_face_italic(faces[1]), Some(true)); assert_eq!( font_face_urls(faces[1]), vec![ "data:font/woff2;base64,d09GMg==".to_string(), "./example-latin.woff".to_string(), ] ); } #[test] fn font_face_without_unicode_range_is_general_purpose() { let css = r#"@font-face{font-family:Example;src:url(example.otf)}"#; let face = font_face_blocks(css)[0]; assert!(font_face_covers_ascii(face)); assert_eq!(font_face_urls(face), vec!["example.otf"]); } #[test] fn dynamic_font_face_uses_shared_fetch_decode_and_layout_path() { let tree = parse_html( r#"WWWWiiii"#, ); let sample = tree .query_selector("#sample") .expect("valid selector") .expect("sample"); let fallback = prepare_dom( &tree, (400.0, 100.0), Some("https://example.test/page/index.html"), &mut RenderResourceCache::default(), ) .expect("fallback render") .document_rect(sample) .expect("fallback geometry") .width; let loads = Arc::new(std::sync::Mutex::new(Vec::new())); let loader_loads = Arc::clone(&loads); let mut resources = RenderResourceCache::with_loader(move |url: &str| { loader_loads .lock() .expect("font loads") .push(url.to_string()); (url == "https://example.test/fonts/fixture.ttf").then(|| SERIF_FONT_BYTES.to_vec()) }); let prepared = prepare_dom_with_dynamic_fonts( &tree, (400.0, 100.0), Some("https://example.test/page/index.html"), &mut resources, &[DynamicFontFace { family: "DynamicFixture".to_string(), source: "url('../../fonts/fixture.ttf') format('truetype')".to_string(), style: "normal".to_string(), weight: "400".to_string(), unicode_range: "U+20-7E".to_string(), }], ) .expect("dynamic font render"); let dynamic = prepared .document_rect(sample) .expect("dynamic geometry") .width; assert_ne!( fallback, dynamic, "dynamic face must participate in shaping" ); assert_eq!( *loads.lock().expect("dynamic font loads"), vec!["https://example.test/fonts/fixture.ttf".to_string()] ); } #[test] fn resolved_nested_scroll_keeps_owner_and_clip_stationary_while_pixels_move() { let tree = parse_html( r#"
"#, ); let outer = tree.get_element_by_id("outer").expect("outer"); let inner = tree.get_element_by_id("inner").expect("inner"); let target = tree.get_element_by_id("target").expect("target"); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (360.0, 240.0), None, &mut resources).expect("prepared render"); let top_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); let top_outer = prepared .viewport_rect_with_scroll(outer, &top_state) .expect("top outer"); let top_target = prepared .viewport_rect_with_scroll(target, &top_state) .expect("top target"); let top = paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &top_state) .expect("top paint"); let offsets = HashMap::from([(outer, (9999.0, 9999.0)), (inner, (9999.0, 9999.0))]); let scrolled_state = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &offsets); let outer_metrics = prepared .element_scroll_metrics(outer, &scrolled_state) .expect("outer metrics"); let inner_metrics = prepared .element_scroll_metrics(inner, &scrolled_state) .expect("inner metrics"); assert_eq!(outer_metrics.client_size, (112.0, 92.0)); assert_eq!(outer_metrics.content_size, (220.0, 200.0)); assert_eq!(outer_metrics.offset, (108.0, 108.0)); assert_eq!(inner_metrics.content_size, (330.0, 300.0)); assert_eq!(inner_metrics.offset, (110.0, 100.0)); let scrolled_outer = prepared .viewport_rect_with_scroll(outer, &scrolled_state) .expect("scrolled outer"); let scrolled_target = prepared .viewport_rect_with_scroll(target, &scrolled_state) .expect("scrolled target"); assert_eq!( scrolled_outer, top_outer, "scroller chrome must not move with its content" ); assert_eq!(scrolled_target.x, top_target.x - 218.0); assert_eq!(scrolled_target.y, top_target.y - 208.0); let scrolled = paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &scrolled_state) .expect("scrolled paint"); let repeated = paint_prepared_with_scroll(&tree, &mut prepared, &mut resources, &scrolled_state) .expect("repeated paint"); assert_ne!(top.data(), scrolled.data()); assert_eq!( scrolled.data(), repeated.data(), "scroll deltas must not accumulate" ); let pixel = |pixmap: &Pixmap, x: u32, y: u32| pixmap.pixels()[(y * pixmap.width() + x) as usize]; assert_eq!( pixel(&top, 2, 2), pixel(&scrolled, 2, 2), "outer border moved" ); assert_eq!( pixel(&top, 125, 50), pixel(&scrolled, 125, 50), "content escaped the stationary outer clip" ); assert_ne!( pixel(&top, 95, 85), pixel(&scrolled, 95, 85), "newly visible nested content did not move into the scrollport" ); } #[test] fn document_region_capture_reuses_layout_scroll_and_resources() { let tree = parse_html( r#"
"#, ); let loads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let loader_loads = loads.clone(); let mut resources = RenderResourceCache::with_loader(move |url: &str| { assert_eq!(url, "https://example.test/fixture.svg"); loader_loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Some( br##" "## .to_vec(), ) }); let mut prepared = prepare_dom( &tree, (80.0, 60.0), Some("https://example.test/page"), &mut resources, ) .expect("prepared render"); assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 100.0), &HashMap::new()); let fixed = tree.get_element_by_id("fixed").expect("fixed"); let before_fixed = prepared .viewport_rect_with_scroll(fixed, &scroll) .expect("fixed geometry"); let offscreen = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 100.0, 80.0, 80.0, 1.0), ) .expect("offscreen region"); assert_eq!((offscreen.width(), offscreen.height()), (80, 80)); let fixed_pixel = offscreen.pixel(5, 5).expect("fixed pixel"); assert!(fixed_pixel.blue() > 240 && fixed_pixel.red() < 20); let sticky_pixel = offscreen.pixel(25, 5).expect("sticky pixel"); assert!(sticky_pixel.red() > 240 && sticky_pixel.green() > 240); let target_pixel = offscreen.pixel(45, 55).expect("offscreen target"); assert!(target_pixel.red() > 240 && target_pixel.blue() > 240); let full_height = prepared.content_size().1; let full = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 80.0, full_height, 1.0), ) .expect("full-content region"); assert_eq!(full.height(), full_height.ceil() as u32); let fixed_at_live_viewport = full.pixel(5, 105).expect("full fixed pixel"); assert!(fixed_at_live_viewport.blue() > 240 && fixed_at_live_viewport.red() < 20); let fixed_not_duplicated = full.pixel(5, 5).expect("document top pixel"); assert!(fixed_not_duplicated.red() > 240 && fixed_not_duplicated.blue() < 20); let scaled = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(40.0, 150.0, 10.0, 10.0, 2.0), ) .expect("scaled region"); assert_eq!((scaled.width(), scaled.height()), (20, 20)); let scaled_center = scaled.pixel(10, 10).expect("scaled center"); assert!(scaled_center.red() > 240 && scaled_center.blue() > 240); let protocol_sized = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::with_output_size(40.0, 150.0, 10.0, 9.0, 1.1, 11, 10), ) .expect("protocol-sized region"); assert_eq!((protocol_sized.width(), protocol_sized.height()), (11, 10)); assert_eq!(scroll.root_offset(), (0.0, 100.0)); assert_eq!( prepared .viewport_rect_with_scroll(fixed, &scroll) .expect("unchanged fixed geometry"), before_fixed ); assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1); } #[test] fn document_region_capture_rejects_invalid_and_oversized_surfaces_before_paint() { let tree = parse_html("
"); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (20.0, 20.0), None, &mut resources).expect("prepared render"); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); for invalid in [ CaptureRegion::new(0.0, 0.0, 0.0, 10.0, 1.0), CaptureRegion::new(f32::NAN, 0.0, 10.0, 10.0, 1.0), CaptureRegion::new(0.0, 0.0, 10.0, 10.0, f32::INFINITY), ] { assert_eq!( paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, invalid, ) .unwrap_err(), CaptureError::InvalidRegion ); } assert_eq!( paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, MAX_CAPTURE_DIMENSION as f32, 10.0, 2.0), ) .unwrap_err(), CaptureError::AllocationLimitExceeded ); assert_eq!( paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 10.0, 10.0, MAX_CAPTURE_SCALE + 1.0), ) .unwrap_err(), CaptureError::AllocationLimitExceeded ); assert_eq!( paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, // Each surface is below the legacy 64M-pixel per-surface // bound, but their simultaneous RGBA peak exceeds 256 MiB. CaptureRegion::new(0.0, 0.0, 6000.0, 6000.0, 1.2), ) .unwrap_err(), CaptureError::AllocationLimitExceeded ); assert_eq!( paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 9000.0, 9000.0, 1.0), ) .unwrap_err(), CaptureError::AllocationLimitExceeded ); } #[test] fn simple_region_capture_rasterizes_geometry_and_text_natively_at_two_x() { let tree = parse_html( r#"

Hi

"#, ); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (40.0, 30.0), None, &mut resources).expect("prepared render"); assert!( native_raster_scale_supported(&tree, &prepared.layout), "solid boxes and shaped text should use direct device-scale paint" ); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); let one_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 40.0, 30.0, 1.0), ) .unwrap(); let two_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 40.0, 30.0, 2.0), ) .unwrap(); assert_eq!((two_x.width(), two_x.height()), (80, 60)); let red = |x, y| { let pixel = two_x.pixel(x, y).unwrap(); pixel.red() > 245 && pixel.green() < 10 && pixel.blue() < 10 }; assert!(red(15, 7), "the 8 CSS-px box must cover 16 device pixels"); assert!( !red(16, 7), "the adjacent CSS pixel must remain outside the box" ); let source = image::RgbaImage::from_raw(one_x.width(), one_x.height(), one_x.data().to_vec()) .unwrap(); let post_scaled = image::imageops::resize( &source, two_x.width(), two_x.height(), image::imageops::FilterType::Lanczos3, ); let differing = two_x .data() .iter() .zip(post_scaled.as_raw()) .filter(|(native, post)| native != post) .count(); assert!( differing > 100, "2x glyph outlines and box edges must be rerasterized, not resize-equivalent ({differing} differing channels)" ); let dark_text_pixels = (16..60) .flat_map(|y| (0..50).map(move |x| (x, y))) .filter(|&(x, y)| { let pixel = two_x.pixel(x, y).unwrap(); pixel.red() < 100 && pixel.green() < 100 && pixel.blue() < 100 }) .count(); assert!(dark_text_pixels > 10, "native 2x text must remain painted"); } #[test] fn direct_vector_gradients_rasterize_natively_at_two_x() { let tree = parse_html( r#"
"#, ); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (30.0, 40.0), None, &mut resources).expect("prepared render"); assert!( native_raster_scale_supported(&tree, &prepared.layout), "direct non-repeating linear/radial gradients should use device-scale paint" ); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); let one_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 30.0, 40.0, 1.0), ) .unwrap(); let two_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 30.0, 40.0, 2.0), ) .unwrap(); assert_eq!((one_x.width(), one_x.height()), (30, 40)); assert_eq!((two_x.width(), two_x.height()), (60, 80)); let linear_left = two_x.pixel(1, 8).unwrap(); let linear_right = two_x.pixel(38, 8).unwrap(); assert!( linear_left.red() > 220 && linear_left.blue() < 40, "linear gradient start must remain red at 2x: {linear_left:?}" ); assert!( linear_right.blue() > 220 && linear_right.red() < 40, "linear gradient end must remain blue at 2x: {linear_right:?}" ); let radial_center = two_x.pixel(20, 48).unwrap(); let radial_edge = two_x.pixel(1, 48).unwrap(); assert!( radial_center.red() > 230, "radial center must remain light at 2x: {radial_center:?}" ); assert!( radial_edge.red() < 100, "radial edge must remain dark at 2x: {radial_edge:?}" ); let source = image::RgbaImage::from_raw(one_x.width(), one_x.height(), one_x.data().to_vec()) .unwrap(); let post_scaled = image::imageops::resize( &source, two_x.width(), two_x.height(), image::imageops::FilterType::Lanczos3, ); let differing = two_x .data() .iter() .zip(post_scaled.as_raw()) .filter(|(native, post)| native != post) .count(); assert!( differing > 200, "2x vector gradient samples must be rerasterized, not resize-equivalent ({differing} differing channels)" ); } #[test] fn effectful_region_capture_keeps_the_proven_bounded_resample_fallback() { let tree = parse_html( r#"
"#, ); let mut resources = RenderResourceCache::with_loader(|_url: &str| None); let mut prepared = prepare_dom(&tree, (30.0, 30.0), None, &mut resources).expect("prepared render"); assert!( !native_raster_scale_supported(&tree, &prepared.layout), "sampled conic gradients must retain the logical-pixel fallback" ); let scroll = prepared.resolve_scroll_state(&tree, (0.0, 0.0), &HashMap::new()); let one_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 30.0, 30.0, 1.0), ) .unwrap(); let two_x = paint_prepared_region_with_scroll( &tree, &mut prepared, &mut resources, &scroll, CaptureRegion::new(0.0, 0.0, 30.0, 30.0, 2.0), ) .unwrap(); let source = image::RgbaImage::from_raw(30, 30, one_x.data().to_vec()).unwrap(); let expected = image::imageops::resize(&source, 60, 60, image::imageops::FilterType::Lanczos3); assert_eq!(two_x.data(), expected.as_raw()); } }