diff --git a/.isu/issues.json b/.isu/issues.json index b410b62..475945e 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 305, + "next_id": 306, "issues": [ { "id": 1, @@ -3693,6 +3693,19 @@ "author": "piefev", "state": "open", "created_at": "2026-05-28T18:03:52Z" + }, + { + "id": 305, + "repo": "we", + "title": "Bing real-web snapshot and Chromium golden are out of sync", + "body": "The current bing.com offline fixture and Chromium golden are not comparing the same page state, which blocks closing isu issue 280 on engine changes alone.\n\nRepro:\ncargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/bing.com.we --out-dir crates/e2e/artifacts\n\nObserved on 2026-05-28 after the Bing rendering fixes in branch bing-parity-280:\n- desktop: 2.65% match (1195912/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile: 5.66% match (310529/329160 px differ, tol=4, max_diff=0.1000%)\n\nThe offline scenario now loads crates/e2e/real-web/snapshots/bing.com/index.html plus a seeded Sichuan Tea background image:\n- crates/e2e/real-web/snapshots/bing.com/www.bing.com/th-OHR.SichuanTea_ROW1006834324_1920x1080.jpg\n\nThe committed Chromium desktop golden shows a different Bing daily background/page state (Jaipur/news-card page), so most pixels differ before remaining engine gaps are considered:\n- crates/e2e/scenarios/real-web/bing.com.desktop.chromium.expected.png\n- crates/e2e/scenarios/real-web/bing.com.mobile.chromium.expected.png\n\nCurrent artifacts:\n- crates/e2e/artifacts/real-web/bing.com/desktop.png\n- crates/e2e/artifacts/real-web/bing.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png\n- crates/e2e/artifacts/real-web/bing.com/mobile.png.diff.png\n\nExpected: rebaseline the Bing snapshot and Chromium goldens from the same page state, or adjust the golden capture policy for this scenario so the offline fixture and asserted Chromium reference are comparable. After that, remaining JS/layout differences can be tracked separately and issue 280 can be closed only when the xfail is removed.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-05-28T23:03:46Z" } ] } diff --git a/crates/browser/src/img_loader.rs b/crates/browser/src/img_loader.rs index f62d0a2..e228f6b 100644 --- a/crates/browser/src/img_loader.rs +++ b/crates/browser/src/img_loader.rs @@ -12,6 +12,7 @@ use we_image::jpeg::decode_jpeg; use we_image::pixel::{Image, ImageError}; use we_image::png::decode_png; use we_net::referrer::ReferrerPolicy; +use we_style::computed::StyledNode; use we_url::Url; use crate::loader::{LoadError, Resource, ResourceLoader}; @@ -170,6 +171,56 @@ pub fn collect_images(doc: &Document, loader: &mut ResourceLoader, base_url: &Ur store } +/// Fetch and decode CSS background images referenced by the computed style tree. +pub fn collect_background_images( + doc: &Document, + styled: &StyledNode, + loader: &mut ResourceLoader, + base_url: &Url, + store: &mut ImageStore, +) { + collect_background_images_for_node(doc, styled, loader, base_url, store); +} + +fn collect_background_images_for_node( + doc: &Document, + styled: &StyledNode, + loader: &mut ResourceLoader, + base_url: &Url, + store: &mut ImageStore, +) { + if let Some(url) = styled.style.background_image.as_ref() { + let src = url.to_string(); + match fetch_and_decode(loader, &src, base_url, None) { + Ok(image) => { + store.insert( + styled.node, + ImageResource { + display_width: 0.0, + display_height: 0.0, + alt: String::new(), + image: Some(image), + }, + ); + } + Err(_) => { + store.entry(styled.node).or_insert_with(|| ImageResource { + image: None, + display_width: 0.0, + display_height: 0.0, + alt: String::new(), + }); + } + } + } + + for child in &styled.children { + if matches!(doc.node_data(child.node), NodeData::Element { .. }) { + collect_background_images_for_node(doc, child, loader, base_url, store); + } + } +} + /// Walk the DOM in document order and collect `` element nodes. fn collect_img_nodes(doc: &Document, node: NodeId, result: &mut Vec) { if let NodeData::Element { tag_name, .. } = doc.node_data(node) { diff --git a/crates/browser/src/main.rs b/crates/browser/src/main.rs index 9536416..1b67cd3 100644 --- a/crates/browser/src/main.rs +++ b/crates/browser/src/main.rs @@ -11,7 +11,7 @@ use we_browser::form_submission::{ use we_browser::img_loader::{collect_images, ImageStore}; use we_browser::loader::{LoadError, Resource, ResourceLoader}; use we_browser::navigation_history::NavigationHistory; -use we_browser::script_loader::execute_scripts_into_vm; +use we_browser::script_loader::{drain_event_loop_and_dynamic_scripts, execute_scripts_into_vm}; use we_css::parser::Stylesheet; use we_css::values::Color; use we_dom::{Document, NodeData, NodeId}; @@ -3915,8 +3915,9 @@ fn load_page(loaded: LoadedHtml) -> PageState { let document_origin = loaded.base_url.origin(); let mut vm = execute_scripts_into_vm(doc, &mut loader, &loaded.base_url, &document_origin); - // Borrow the (possibly script-modified) document to load subresources. - let (stylesheet, font_registry, images) = { + // Borrow the (possibly script-modified) document to load subresources + // before the page load event fires. + let initial_images = { let doc_ref = vm.borrow_document().expect("document still attached"); // Fetch external stylesheets and merge with inline +
Box
+"# + ); + let doc = we_html::parse_html(&html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + body_box.children[0].rect.width + } + + assert_eq!( + layout_single_div_width("div { width: 80%; max-width: 300px; }"), + 300.0 + ); + assert_eq!( + layout_single_div_width("div { width: 10%; min-width: 120px; }"), + 120.0 + ); + } + + #[test] + fn max_height_clamps_percentage_height() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let div = doc.create_element("div"); + doc.append_child(body, div); + doc.set_attribute(div, "style", "height: 100%; max-height: 52px;"); + + let tree = layout_doc(&doc); + + let mut divs = Vec::new(); + collect_boxes_with_tag(&doc, &tree.root, "div", &mut divs); + assert_eq!(divs[0].rect.height, 52.0); + } + #[test] fn border_box_height() { // box-sizing: border-box also applies to height. @@ -7767,6 +8012,32 @@ body { margin: 0; } ); } + #[test] + fn flex_align_items_uses_max_height_clamped_cross_size() { + let html_str = r#" + +
A
"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + + let body = &tree.root.children[0]; + let container = &body.children[0]; + let a = &container.children[0]; + + assert_eq!(container.rect.height, 52.0); + let expected_y = container.rect.y + 21.0; + assert!( + (a.rect.y - expected_y).abs() < 1.0, + "item y should be centered within max-height, expected {}, got {}", + expected_y, + a.rect.y + ); + } + #[test] fn flex_column_auto_width_centers_item_in_viewport() { let html_str = r#" @@ -8228,6 +8499,79 @@ body { margin: 0; } ); } + #[test] + fn absolute_left_right_auto_margins_center_fixed_width() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let container = doc.create_element("div"); + let abs_child = doc.create_element("div"); + doc.append_child(body, container); + doc.append_child(container, abs_child); + doc.set_attribute( + container, + "style", + "position: relative; width: 400px; height: 120px;", + ); + doc.set_attribute( + abs_child, + "style", + "position: absolute; left: 0; right: 0; margin-left: auto; margin-right: auto; width: 100px; height: 50px;", + ); + + let tree = layout_doc(&doc); + let body_box = &tree.root.children[0]; + let container_box = &body_box.children[0]; + let abs_box = &container_box.children[0]; + + let cb_x = container_box.rect.x - container_box.padding.left; + assert!( + (abs_box.rect.x - (cb_x + 150.0)).abs() < 0.01, + "auto margins should center abspos box at x=150, got {}", + abs_box.rect.x - cb_x, + ); + } + + #[test] + fn absolute_percentage_width_respects_max_width_before_auto_margins() { + let mut doc = Document::new(); + let (_, _, body) = make_html_body(&mut doc); + let container = doc.create_element("div"); + let abs_child = doc.create_element("div"); + doc.append_child(body, container); + doc.append_child(container, abs_child); + doc.set_attribute( + container, + "style", + "position: relative; width: 1000px; height: 120px;", + ); + doc.set_attribute( + abs_child, + "style", + "position: absolute; left: 0; right: 0; margin-left: auto; margin-right: auto; width: 80%; max-width: 300px; height: 100%; max-height: 52px;", + ); + + let tree = layout_doc(&doc); + fn find_box_for_node(b: &LayoutBox, node: NodeId) -> Option<&LayoutBox> { + if box_node_id(b) == Some(node) { + return Some(b); + } + b.children + .iter() + .find_map(|child| find_box_for_node(child, node)) + } + let container_box = find_box_for_node(&tree.root, container).unwrap(); + let abs_box = find_box_for_node(&tree.root, abs_child).unwrap(); + let cb_x = container_box.rect.x - container_box.padding.left; + + assert_eq!(abs_box.rect.width, 300.0); + assert_eq!(abs_box.rect.height, 52.0); + assert!( + (abs_box.rect.x - (cb_x + 350.0)).abs() < 0.01, + "auto margins should center max-width-constrained abspos box, got {}", + abs_box.rect.x - cb_x, + ); + } + #[test] fn absolute_bottom_right_positioning() { let mut doc = Document::new(); diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index 94e964d..7b1eacf 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -499,6 +499,65 @@ impl GpuRenderer { } } + PaintCommand::DrawBackgroundImage { + x, + y, + width, + height, + node_id, + size, + position_x, + position_y, + .. + } => { + let nid = node_id.index(); + if !self.image_cache.contains(nid) { + if let Some(img) = images.get(node_id) { + if let Some(tex) = upload_image(self.renderer.device(), img) { + self.image_cache.insert(nid, tex); + } + } + } + + if self.image_cache.contains(nid) { + let Some(img) = images.get(node_id) else { + continue; + }; + let (draw_w, draw_h) = crate::resolve_background_image_size( + *size, + *width, + *height, + img.width as f32, + img.height as f32, + ); + let draw_x = *x + + crate::background_position_offset( + *position_x, + *width - draw_w, + *width, + ); + let draw_y = *y + + crate::background_position_offset( + *position_y, + *height - draw_h, + *height, + ); + let tex_key = TextureKey::Image(nid); + if tex_key != batch_texture && !batch_vertices.is_empty() { + self.flush_batch(&batch_vertices, batch_texture, &encoder); + batch_vertices.clear(); + } + batch_texture = tex_key; + push_image_quad( + &mut batch_vertices, + draw_x + coord_offset.0, + draw_y + coord_offset.1, + draw_w, + draw_h, + ); + } + } + PaintCommand::PushClip { x, y, diff --git a/crates/render/src/layer.rs b/crates/render/src/layer.rs index 8539b12..bc8c854 100644 --- a/crates/render/src/layer.rs +++ b/crates/render/src/layer.rs @@ -597,7 +597,6 @@ mod tests { // -- LayerTree tests (using real HTML parsing) -------------------------- - use we_dom::Document; use we_style::computed::{extract_stylesheets, resolve_styles}; use we_text::font::Font; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1d091ab..4dc66d7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,7 +21,8 @@ use we_layout::{ SCROLLBAR_WIDTH, }; use we_style::computed::{ - BorderStyle, Display, LengthOrAuto, Overflow, Position, TextDecoration, Visibility, WillChange, + BackgroundRepeat, BackgroundSize, BorderStyle, Display, LengthOrAuto, Overflow, Position, + TextDecoration, Visibility, WillChange, }; use we_text::font::Font; @@ -69,6 +70,18 @@ pub enum PaintCommand { height: f32, node_id: NodeId, }, + /// Draw a CSS background image inside an element background painting area. + DrawBackgroundImage { + x: f32, + y: f32, + width: f32, + height: f32, + node_id: NodeId, + size: BackgroundSize, + position_x: LengthOrAuto, + position_y: LengthOrAuto, + repeat: BackgroundRepeat, + }, /// Push a clip rectangle onto the clip stack. All subsequent paint /// commands are clipped to the intersection of all active clip rects. PushClip { @@ -564,15 +577,15 @@ pub(crate) fn node_id_from_box_type(box_type: &BoxType) -> Option { fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { let bg = layout_box.background_color; - if bg.a == 0 { - return; - } // CSS default `background-clip: border-box`: the background fills the // border box. Borders paint on top, so for solid borders the result is // identical to padding-box clipping, but transparent/gapped border // styles (dotted, dashed) show the background behind them. let bb = border_box(layout_box); - if bb.width > 0.0 && bb.height > 0.0 { + if bb.width <= 0.0 || bb.height <= 0.0 { + return; + } + if bg.a > 0 { list.push(PaintCommand::FillRect { x: bb.x + tx, y: bb.y + ty, @@ -581,6 +594,21 @@ fn paint_background(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: color: bg, }); } + if layout_box.background_image.is_some() { + if let Some(node_id) = node_id_from_box_type(&layout_box.box_type) { + list.push(PaintCommand::DrawBackgroundImage { + x: bb.x + tx, + y: bb.y + ty, + width: bb.width, + height: bb.height, + node_id, + size: layout_box.background_size, + position_x: layout_box.background_position_x, + position_y: layout_box.background_position_y, + repeat: layout_box.background_repeat, + }); + } + } } fn paint_borders(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty: f32) { @@ -1069,6 +1097,11 @@ fn paint_form_control( tx: f32, ty: f32, ) { + let bb = border_box(layout_box); + if bb.width <= 0.0 || bb.height <= 0.0 { + return; + } + match fc.control_type { FormControlType::TextInput | FormControlType::Password => { paint_text_input(layout_box, fc, list, tx, ty); @@ -2029,6 +2062,18 @@ pub struct Renderer { layer_stack: Vec, } +struct BackgroundImagePaint<'a> { + x: f32, + y: f32, + width: f32, + height: f32, + image: &'a Image, + size: BackgroundSize, + position_x: LengthOrAuto, + position_y: LengthOrAuto, + repeat: BackgroundRepeat, +} + impl Renderer { /// Create a new renderer with the given dimensions. /// The buffer is initialized to white. @@ -2152,6 +2197,31 @@ impl Renderer { self.draw_image(*x, *y, *width, *height, image); } } + PaintCommand::DrawBackgroundImage { + x, + y, + width, + height, + node_id, + size, + position_x, + position_y, + repeat, + } => { + if let Some(image) = images.get(node_id) { + self.draw_background_image(BackgroundImagePaint { + x: *x, + y: *y, + width: *width, + height: *height, + image, + size: *size, + position_x: *position_x, + position_y: *position_y, + repeat: *repeat, + }); + } + } PaintCommand::PushClip { x, y, @@ -2468,6 +2538,64 @@ impl Renderer { } } + fn draw_background_image(&mut self, paint: BackgroundImagePaint<'_>) { + let BackgroundImagePaint { + x, + y, + width, + height, + image, + size, + position_x, + position_y, + repeat, + } = paint; + if image.width == 0 || image.height == 0 || width <= 0.0 || height <= 0.0 { + return; + } + + let iw = image.width as f32; + let ih = image.height as f32; + let (draw_w, draw_h) = resolve_background_image_size(size, width, height, iw, ih); + if draw_w <= 0.0 || draw_h <= 0.0 { + return; + } + + let free_x = width - draw_w; + let free_y = height - draw_h; + let base_x = x + background_position_offset(position_x, free_x, width); + let base_y = y + background_position_offset(position_y, free_y, height); + + self.clip_stack.push(ClipRect { + x0: x, + y0: y, + x1: x + width, + y1: y + height, + }); + + if repeat == BackgroundRepeat::Repeat { + let mut tile_y = base_y; + while tile_y > y { + tile_y -= draw_h; + } + while tile_y < y + height { + let mut tile_x = base_x; + while tile_x > x { + tile_x -= draw_w; + } + while tile_x < x + width { + self.draw_image(tile_x, tile_y, draw_w, draw_h, image); + tile_x += draw_w; + } + tile_y += draw_h; + } + } else { + self.draw_image(base_x, base_y, draw_w, draw_h, image); + } + + self.clip_stack.pop(); + } + /// Set a single pixel to the given color (no blending). fn set_pixel(&mut self, x: u32, y: u32, color: Color) { if x >= self.width || y >= self.height { @@ -2481,6 +2609,76 @@ impl Renderer { } } +pub(crate) fn resolve_background_image_size( + size: BackgroundSize, + box_width: f32, + box_height: f32, + intrinsic_width: f32, + intrinsic_height: f32, +) -> (f32, f32) { + match size { + BackgroundSize::Cover => { + let scale = (box_width / intrinsic_width).max(box_height / intrinsic_height); + (intrinsic_width * scale, intrinsic_height * scale) + } + BackgroundSize::Contain => { + let scale = (box_width / intrinsic_width).min(box_height / intrinsic_height); + (intrinsic_width * scale, intrinsic_height * scale) + } + BackgroundSize::Explicit(w, h) => { + let resolved_w = resolve_background_length(w, box_width); + let resolved_h = resolve_background_length(h, box_height); + match (resolved_w, resolved_h) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, w * intrinsic_height / intrinsic_width), + (None, Some(h)) => (h * intrinsic_width / intrinsic_height, h), + (None, None) => (intrinsic_width, intrinsic_height), + } + } + BackgroundSize::Auto => (intrinsic_width, intrinsic_height), + } +} + +fn resolve_background_length(value: LengthOrAuto, reference: f32) -> Option { + match value { + LengthOrAuto::Length(px) => Some(px), + LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), + LengthOrAuto::Calc(px, pct) => Some(px + pct / 100.0 * reference), + LengthOrAuto::ClampCalc { + min_px, + px_offset, + pct, + max_px, + } => Some( + (px_offset + pct / 100.0 * reference) + .max(min_px) + .min(max_px), + ), + LengthOrAuto::Auto => None, + } +} + +pub(crate) fn background_position_offset( + value: LengthOrAuto, + free_space: f32, + reference: f32, +) -> f32 { + match value { + LengthOrAuto::Percentage(p) => p / 100.0 * free_space, + LengthOrAuto::Length(px) => px, + LengthOrAuto::Calc(px, pct) => px + pct / 100.0 * reference, + LengthOrAuto::ClampCalc { + min_px, + px_offset, + pct, + max_px, + } => (px_offset + pct / 100.0 * reference) + .max(min_px) + .min(max_px), + LengthOrAuto::Auto => 0.0, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2798,6 +2996,34 @@ mod tests { assert!(fill_colors.iter().any(|c| **c == Color::rgb(255, 255, 0))); } + #[test] + fn css_background_image_emits_background_paint_command() { + let html_str = r#" + + +
+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = we_layout::layout( + &styled, + &doc, + 800.0, + 600.0, + &font, + &std::collections::HashMap::new(), + ); + + let list = build_display_list(&tree); + assert!( + list.iter() + .any(|c| matches!(c, PaintCommand::DrawBackgroundImage { .. })), + "background-image should produce a background image paint command" + ); + } + #[test] fn hsl_flex_descendant_backgrounds_render_at_flex_positions() { let html_str = r#" @@ -4227,6 +4453,21 @@ body { margin: 0; } ); } + #[test] + fn zero_sized_submit_button_does_not_render_label() { + let html_str = r#" + +"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let has_label = list + .iter() + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Submit")); + assert!(!has_label, "zero-sized submit button should not paint text"); + } + #[test] fn button_element_renders_label() { let html_str = r#" diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index dfe471e..ba0d621 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -491,6 +491,22 @@ pub enum LengthOrAuto { Auto, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BackgroundRepeat { + #[default] + Repeat, + NoRepeat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum BackgroundSize { + #[default] + Auto, + Cover, + Contain, + Explicit(LengthOrAuto, LengthOrAuto), +} + // --------------------------------------------------------------------------- // ComputedStyle // --------------------------------------------------------------------------- @@ -534,6 +550,10 @@ pub struct ComputedStyle { // Box model: dimensions pub width: LengthOrAuto, pub height: LengthOrAuto, + pub min_width: LengthOrAuto, + pub max_width: LengthOrAuto, + pub min_height: LengthOrAuto, + pub max_height: LengthOrAuto, // Box model: sizing pub box_sizing: BoxSizing, @@ -556,6 +576,11 @@ pub struct ComputedStyle { // Background pub background_color: Color, + pub background_image: Option, + pub background_size: BackgroundSize, + pub background_position_x: LengthOrAuto, + pub background_position_y: LengthOrAuto, + pub background_repeat: BackgroundRepeat, // Position pub position: Position, @@ -655,6 +680,10 @@ impl Default for ComputedStyle { width: LengthOrAuto::Auto, height: LengthOrAuto::Auto, + min_width: LengthOrAuto::Auto, + max_width: LengthOrAuto::Auto, + min_height: LengthOrAuto::Auto, + max_height: LengthOrAuto::Auto, box_sizing: BoxSizing::ContentBox, @@ -673,6 +702,11 @@ impl Default for ComputedStyle { white_space: WhiteSpace::Normal, background_color: Color::new(0, 0, 0, 0), // transparent + background_image: None, + background_size: BackgroundSize::Auto, + background_position_x: LengthOrAuto::Percentage(0.0), + background_position_y: LengthOrAuto::Percentage(0.0), + background_repeat: BackgroundRepeat::Repeat, position: Position::Static, top: LengthOrAuto::Auto, @@ -1015,6 +1049,105 @@ fn resolve_layout_length_or_auto( } } +fn background_size_from_value( + value: &CssValue, + current_font_size: f32, + viewport: (f32, f32), +) -> BackgroundSize { + match value { + CssValue::Keyword(k) if k == "cover" => BackgroundSize::Cover, + CssValue::Keyword(k) if k == "contain" => BackgroundSize::Contain, + CssValue::Auto => BackgroundSize::Auto, + CssValue::List(values) => { + if values.len() == 1 { + match &values[0] { + CssValue::Keyword(k) if k == "cover" => BackgroundSize::Cover, + CssValue::Keyword(k) if k == "contain" => BackgroundSize::Contain, + CssValue::Auto => BackgroundSize::Auto, + first => BackgroundSize::Explicit( + resolve_layout_length_or_auto(first, current_font_size, viewport), + LengthOrAuto::Auto, + ), + } + } else if values.len() >= 2 { + BackgroundSize::Explicit( + resolve_layout_length_or_auto(&values[0], current_font_size, viewport), + resolve_layout_length_or_auto(&values[1], current_font_size, viewport), + ) + } else { + BackgroundSize::Auto + } + } + other => BackgroundSize::Explicit( + resolve_layout_length_or_auto(other, current_font_size, viewport), + LengthOrAuto::Auto, + ), + } +} + +fn background_position_from_value( + value: &CssValue, + current_font_size: f32, + viewport: (f32, f32), +) -> (LengthOrAuto, LengthOrAuto) { + let values: Vec<&CssValue> = match value { + CssValue::List(values) => values.iter().collect(), + other => vec![other], + }; + let mut x = None; + let mut y = None; + for part in values { + match part { + CssValue::Keyword(k) => match k.as_str() { + "left" => x = Some(LengthOrAuto::Percentage(0.0)), + "right" => x = Some(LengthOrAuto::Percentage(100.0)), + "top" => y = Some(LengthOrAuto::Percentage(0.0)), + "bottom" => y = Some(LengthOrAuto::Percentage(100.0)), + "center" => { + if x.is_none() { + x = Some(LengthOrAuto::Percentage(50.0)); + } else if y.is_none() { + y = Some(LengthOrAuto::Percentage(50.0)); + } + } + _ => {} + }, + CssValue::Percentage(_) | CssValue::Length(..) | CssValue::Zero | CssValue::Math(_) => { + let resolved = resolve_layout_length_or_auto(part, current_font_size, viewport); + if x.is_none() { + x = Some(resolved); + } else if y.is_none() { + y = Some(resolved); + } + } + _ => {} + } + } + let x = x.unwrap_or(LengthOrAuto::Percentage(0.0)); + let y = y.unwrap_or({ + if matches!(value, CssValue::List(_)) { + LengthOrAuto::Percentage(0.0) + } else { + LengthOrAuto::Percentage(50.0) + } + }); + (x, y) +} + +fn background_repeat_from_value(value: &CssValue) -> BackgroundRepeat { + match value { + CssValue::Keyword(k) if k == "no-repeat" => BackgroundRepeat::NoRepeat, + CssValue::List(values) + if values + .iter() + .any(|v| matches!(v, CssValue::Keyword(k) if k == "no-repeat")) => + { + BackgroundRepeat::NoRepeat + } + _ => BackgroundRepeat::Repeat, + } +} + // --------------------------------------------------------------------------- // CSS math expression evaluation // --------------------------------------------------------------------------- @@ -1469,6 +1602,18 @@ fn apply_property( "height" => { style.height = resolve_layout_length_or_auto(value, current_fs, viewport); } + "min-width" => { + style.min_width = resolve_layout_length_or_auto(value, current_fs, viewport); + } + "max-width" => { + style.max_width = resolve_layout_length_or_auto(value, current_fs, viewport); + } + "min-height" => { + style.min_height = resolve_layout_length_or_auto(value, current_fs, viewport); + } + "max-height" => { + style.max_height = resolve_layout_length_or_auto(value, current_fs, viewport); + } // Box sizing "box-sizing" => { @@ -1681,6 +1826,22 @@ fn apply_property( style.background_color = c; } } + "background-image" => match value { + CssValue::Url(url) => style.background_image = Some(Atom::new(url)), + CssValue::None => style.background_image = None, + _ => {} + }, + "background-size" => { + style.background_size = background_size_from_value(value, current_fs, viewport); + } + "background-position" => { + let (x, y) = background_position_from_value(value, current_fs, viewport); + style.background_position_x = x; + style.background_position_y = y; + } + "background-repeat" => { + style.background_repeat = background_repeat_from_value(value); + } // Position "position" => { @@ -2511,8 +2672,19 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "padding-left" => style.padding_left = parent.padding_left, "width" => style.width = parent.width, "height" => style.height = parent.height, + "min-width" => style.min_width = parent.min_width, + "max-width" => style.max_width = parent.max_width, + "min-height" => style.min_height = parent.min_height, + "max-height" => style.max_height = parent.max_height, "box-sizing" => style.box_sizing = parent.box_sizing, "background-color" => style.background_color = parent.background_color, + "background-image" => style.background_image = parent.background_image.clone(), + "background-size" => style.background_size = parent.background_size, + "background-position" => { + style.background_position_x = parent.background_position_x; + style.background_position_y = parent.background_position_y; + } + "background-repeat" => style.background_repeat = parent.background_repeat, "position" => style.position = parent.position, "float" => style.float = parent.float, "clear" => style.clear = parent.clear, @@ -2569,6 +2741,10 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "border-left-width" => style.border_left_width = initial.border_left_width, "width" => style.width = initial.width, "height" => style.height = initial.height, + "min-width" => style.min_width = initial.min_width, + "max-width" => style.max_width = initial.max_width, + "min-height" => style.min_height = initial.min_height, + "max-height" => style.max_height = initial.max_height, "box-sizing" => style.box_sizing = initial.box_sizing, "color" => style.color = initial.color, "font-size" => { @@ -2583,6 +2759,13 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "line-height" => style.line_height = initial.line_height, "white-space" => style.white_space = initial.white_space, "background-color" => style.background_color = initial.background_color, + "background-image" => style.background_image = initial.background_image.clone(), + "background-size" => style.background_size = initial.background_size, + "background-position" => { + style.background_position_x = initial.background_position_x; + style.background_position_y = initial.background_position_y; + } + "background-repeat" => style.background_repeat = initial.background_repeat, "position" => style.position = initial.position, "top" => style.top = initial.top, "right" => style.right = initial.right, @@ -2886,8 +3069,8 @@ fn resolve_node( cache: &mut StyleCache, ) -> Option { match doc.node_data(node) { - NodeData::Document => { - // Document node: resolve children, return first element child or wrapper. + NodeData::Document | NodeData::DocumentFragment => { + // Container nodes: resolve children, return first element child or wrapper. let mut children = Vec::new(); for child in doc.children(node) { if let Some(styled) = resolve_node( @@ -3405,6 +3588,52 @@ mod tests { assert_eq!(div_node.style.background_color, Color::rgb(0, 0, 255)); } + #[test] + fn author_min_max_dimensions() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + + let ss = Parser::parse( + "div { min-width: 20%; max-width: 600px; min-height: 40px; max-height: 52px; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + assert_eq!(div_node.style.min_width, LengthOrAuto::Percentage(20.0)); + assert_eq!(div_node.style.max_width, LengthOrAuto::Length(600.0)); + assert_eq!(div_node.style.min_height, LengthOrAuto::Length(40.0)); + assert_eq!(div_node.style.max_height, LengthOrAuto::Length(52.0)); + } + + #[test] + fn author_background_image_size_and_position() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + doc.append_child(body, div); + + let ss = Parser::parse( + "div { background-image: url(hero.jpg); background-size: cover; background-position: center; background-repeat: no-repeat; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + assert_eq!( + div_node.style.background_image.as_ref().map(|s| s.as_str()), + Some("hero.jpg") + ); + assert_eq!(div_node.style.background_size, BackgroundSize::Cover); + assert_eq!( + div_node.style.background_position_x, + LengthOrAuto::Percentage(50.0) + ); + assert_eq!( + div_node.style.background_position_y, + LengthOrAuto::Percentage(50.0) + ); + assert_eq!(div_node.style.background_repeat, BackgroundRepeat::NoRepeat); + } + #[test] fn author_font_size_px() { let (mut doc, _, _, body) = make_doc_with_body(); diff --git a/crates/style/src/matching.rs b/crates/style/src/matching.rs index 943be70..7cb231d 100644 --- a/crates/style/src/matching.rs +++ b/crates/style/src/matching.rs @@ -203,7 +203,15 @@ pub fn matches_simple(doc: &Document, node: NodeId, selector: &SimpleSelector) - /// Check if a pseudo-class matches an element. fn matches_pseudo_class(doc: &Document, node: NodeId, name: &str) -> bool { + if let Some(inner) = name.strip_prefix("not(").and_then(|s| s.strip_suffix(')')) { + let selector_list = we_css::parser::Parser::parse_selectors(inner); + return !selector_list.selectors.is_empty() + && !matches_selector_list(doc, node, &selector_list); + } + match name { + "first-child" => prev_element_sibling(doc, node).is_none(), + "last-child" => next_element_sibling(doc, node).is_none(), "focus" => doc.active_element() == Some(node), "focus-visible" => doc.active_element() == Some(node) && doc.is_focus_visible(), "focus-within" => doc.active_element() == Some(node) || doc.is_focus_within(node), @@ -422,6 +430,18 @@ fn prev_element_sibling(doc: &Document, node: NodeId) -> Option { None } +/// Find the next element sibling (skipping text/comment nodes). +fn next_element_sibling(doc: &Document, node: NodeId) -> Option { + let mut next = doc.next_sibling(node); + while let Some(n) = next { + if matches!(doc.node_data(n), NodeData::Element { .. }) { + return Some(n); + } + next = doc.next_sibling(n); + } + None +} + /// Get all declarations that apply to a node, in cascade order. /// Returns declarations from matching rules, sorted by specificity and source order. /// Important declarations are placed after normal declarations. @@ -682,6 +702,27 @@ mod tests { assert!(matches_selector(&t.doc, t.div_main, &sel)); } + #[test] + fn not_pseudo_class_matches_negated_selector_list() { + let t = make_test_dom(); + let sel = parse_first_selector("p:not(.intro) {}"); + assert!(!matches_selector(&t.doc, t.p_intro, &sel)); + assert!(matches_selector(&t.doc, t.p2, &sel)); + + let sel = parse_first_selector("div:not(.narrow, #missing) {}"); + assert!(matches_selector(&t.doc, t.div_main, &sel)); + } + + #[test] + fn first_and_last_child_pseudo_classes_match_element_siblings() { + let t = make_test_dom(); + let first = parse_first_selector("p:first-child {}"); + let last = parse_first_selector("a:last-child {}"); + assert!(matches_selector(&t.doc, t.p_intro, &first)); + assert!(!matches_selector(&t.doc, t.p2, &first)); + assert!(matches_selector(&t.doc, t.a_link, &last)); + } + // ----------------------------------------------------------------------- // Descendant combinator // -----------------------------------------------------------------------