//! 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 obscura_dom::tree::DomTree; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; use std::collections::{HashMap, 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; const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512; const DEFAULT_RESOURCE_CACHE_BYTES: usize = 64 * 1024 * 1024; 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); } #[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 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 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, )); 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_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 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 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 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()] ); } }