diff --git a/Cargo.lock b/Cargo.lock index 8dbaac1..82d225f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1539,6 +1539,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "image-webp", "moxcms", "num-traits", diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 77d66b1..e450056 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -26,7 +26,7 @@ cosmic-text = { version = "=0.14.2", default-features = false, features = ["std" # and the renderer consume the same canonical variation coordinates for # shaping and rasterization. swash = { version = "=0.2.0", default-features = false, features = ["std", "scale", "render"], optional = true } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"], optional = true } +image = { version = "0.25", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"], optional = true } ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true } base64 = { workspace = true, optional = true } # SVG rasterization for , inline , and background-image diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 38e9890..5f11ad7 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -126,13 +126,13 @@ fn native_button_intrinsic_content( return; } - for child in tree.children(id) { + for child in rendered_children(tree, id) { walk(tree, child, styles, font_size, content); } } let mut content = NativeButtonIntrinsicContent::default(); - for child in tree.children(root) { + for child in rendered_children(tree, root) { walk(tree, child, styles, font_size, &mut content); } content @@ -1104,7 +1104,7 @@ fn resolve_clip_rects( } _ => inherited, }; - for cid in tree.children(id) { + for cid in rendered_children(tree, id) { resolve_clip_rects( tree, cid, @@ -1305,22 +1305,7 @@ fn apply_picture_source_hints( } } if let Some(kind) = source.get_attribute("type") { - let kind = kind.trim().to_ascii_lowercase(); - if !kind.is_empty() - && !matches!( - kind.as_str(), - "image/avif" - | "image/bmp" - | "image/gif" - | "image/jpeg" - | "image/jpg" - | "image/png" - | "image/svg+xml" - | "image/webp" - | "image/x-icon" - | "image/vnd.microsoft.icon" - ) - { + if !crate::source_type_supported(kind) { continue; } } @@ -1670,7 +1655,7 @@ fn resolve_css_counters(tree: &DomTree, styles: &mut HashMap, font_size: Option, font_weight: u16, @@ -2094,6 +2084,13 @@ fn layout_dom_once( impl Default for Inherited { fn default() -> Self { Inherited { + // CSS Display's initial outer/inner value is inline flow. + // The root is blockified after computed values settle. + display: crate::Display::Inline, + display_contents: false, + is_inline_block: false, + flow_root: false, + is_table_box: false, color: None, font_size: None, font_weight: 400, @@ -2169,6 +2166,22 @@ fn layout_dom_once( let mut child_cb_width = inh.cb_width; let mut child_cb_height_definite = false; if let Some(style) = styles.get_mut(&id) { + if style.display_inherit { + style.display = inh.display; + style.display_contents = inh.display_contents; + style.is_inline_block = inh.is_inline_block; + style.flow_root = inh.flow_root; + style.is_table_box = inh.is_table_box; + // `internal_flex_container` describes an engine-only UA + // approximation, not the computed CSS display value. + style.internal_flex_container = false; + style.display_inherit = false; + } + inh.display = style.display; + inh.display_contents = style.display_contents; + inh.is_inline_block = style.is_inline_block; + inh.flow_root = style.flow_root; + inh.is_table_box = style.is_table_box; match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color } // Resolve a relative font-size against the PARENT (em/%) or // ROOT (rem) font-size before inheriting it downward. @@ -2602,6 +2615,11 @@ fn layout_dom_once( let host_text_align = style.text_align; let host_text_indent = style.text_indent; let host_invisible = style.effectively_invisible; + let host_display = style.display; + let host_display_contents = style.display_contents; + let host_is_inline_block = style.is_inline_block; + let host_flow_root = style.flow_root; + let host_is_table_box = style.is_table_box; let host_overflow_x = if style.overflow_scroll_x { 2 } else if style.overflow_clip_x { @@ -2617,6 +2635,15 @@ fn layout_dom_once( 0 }; let settle_pseudo = |pseudo: &mut crate::LayoutStyle| { + if pseudo.display_inherit { + pseudo.display = host_display; + pseudo.display_contents = host_display_contents; + pseudo.is_inline_block = host_is_inline_block; + pseudo.flow_root = host_flow_root; + pseudo.is_table_box = host_is_table_box; + pseudo.internal_flex_container = false; + pseudo.display_inherit = false; + } if pseudo.overflow_inherit_x { pseudo.overflow_specified_x = host_overflow_x; pseudo.overflow_inherit_x = false; @@ -3185,8 +3212,8 @@ fn layout_dom_once( // Apply fetched intrinsic image sizes. A replaced element with no // explicit dimensions must size from its intrinsic pixels (else it is // 0x0 and never paints); with one dimension given, the aspect ratio - // fills the other. `max-width:100%` (a UA default for img) still caps - // it to the container, and the aspect ratio keeps it proportional. + // fills the other. An authored `max-width:100%` still caps it to the + // container, and the aspect ratio keeps it proportional. for (&nid, &(iw, ih)) in intrinsic { if iw <= 0.0 || ih <= 0.0 { continue; @@ -4126,7 +4153,7 @@ fn resolve_grid_areas( } } - for cid in tree.children(id) { + for cid in rendered_children(tree, id) { let Some(cstyle) = styles.get_mut(&cid) else { continue }; let Some(name) = cstyle.grid_area_name.clone() else { continue }; if let Some(&(r0, r1, c0, c1)) = spans.get(&name) { @@ -4146,7 +4173,7 @@ fn resolve_grid_areas( // Named grid lines: resolve children placed with `grid-column`/`grid-row` // values that reference a line name against this container's maps. if col_lines.is_some() || row_lines.is_some() { - for cid in tree.children(id) { + for cid in rendered_children(tree, id) { let Some(cstyle) = styles.get_mut(&cid) else { continue }; if let (Some(raw), Some(map)) = (cstyle.grid_column_raw.clone(), &col_lines) { if let Some(l) = resolve_named_placement(&raw, map) { @@ -4978,7 +5005,7 @@ fn apply_float_continuations( let mut current = continuation.owner; while let Some(parent) = tree.get_node(current).and_then(|node| node.parent) { - let siblings = tree.children(parent); + let siblings = rendered_children(tree, parent); let Some(index) = siblings.iter().position(|candidate| *candidate == current) else { break; @@ -5507,12 +5534,47 @@ fn resolve_static_positions_and_reparent( } } -/// Does `id` have any direct child that is inline-level (a non-whitespace -/// text node, or an element whose resolved display is `Inline`)? Used to -/// decide whether a block container needs the flex-row-wrap approximation of -/// an inline formatting context. +/// Return the DOM children that generate boxes for `id`. +pub(crate) fn rendered_children(tree: &DomTree, id: NodeId) -> Vec { + let Some(node) = tree.get_node(id) else { + return Vec::new(); + }; + let is_closed_html_details = node.as_element().is_some_and(|name| { + name.ns.as_ref() == "http://www.w3.org/1999/xhtml" + && name.local.as_ref() == "details" + && node.get_attribute("open").is_none() + }); + if !is_closed_html_details { + return tree.children(id); + } + + // HTML gives a closed
a rendered child list containing only its + // first direct element child. Source text before that summary, + // non-summary elements, later summaries, and all of their descendants + // remain in the DOM but generate no boxes until the `open` attribute is + // present. Filtering at the box-tree boundary keeps both layout + // classification and paint/text-run construction from observing the + // hidden subtree. + tree.children(id) + .into_iter() + .find(|child| { + tree.get_node(*child).is_some_and(|child| { + child.as_element().is_some_and(|name| { + name.ns.as_ref() == "http://www.w3.org/1999/xhtml" + && name.local.as_ref() == "summary" + }) + }) + }) + .into_iter() + .collect() +} + +/// Does `id` have any direct rendered child that is inline-level (a +/// non-whitespace text node, or an element whose resolved display is +/// `Inline`)? Used to decide whether a block container needs the flex-row-wrap +/// approximation of an inline formatting context. fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap) -> bool { - tree.children(id).into_iter().any(|cid| { + rendered_children(tree, id).into_iter().any(|cid| { let Some(node) = tree.get_node(cid) else { return false }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(), @@ -5562,7 +5624,7 @@ fn blockify_layout_children( parent: NodeId, styles: &mut HashMap, ) { - for child in tree.children(parent) { + for child in rendered_children(tree, parent) { let transparent = styles .get(&child) .is_some_and(|style| style.display_contents && style.display != crate::Display::None); @@ -5612,8 +5674,7 @@ fn build_any( .map(|s| s.display_contents && s.display != crate::Display::None) .unwrap_or(false); if splices_children { - return tree - .children(id) + return rendered_children(tree, id) .into_iter() .flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) .collect(); @@ -5635,8 +5696,7 @@ fn build_any( // wrapper's children into the caller's list (same trick already used // for text nodes in `build_text_words`) fixes this at the root: the // words become flat siblings that wrap only at the real block level. - return tree - .children(id) + return rendered_children(tree, id) .into_iter() .flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)) .collect(); @@ -7245,7 +7305,7 @@ fn build( // wrapping for explicitly constrained inline-blocks and for ones // with real in-flow block children; those need their inner block // formatting rather than a single max-content line. - let has_in_flow_block_child = tree.children(id).iter().any(|child| { + let has_in_flow_block_child = rendered_children(tree, id).iter().any(|child| { styles .get(child) .map_or(false, is_in_flow_block_level) @@ -7339,11 +7399,15 @@ fn build( } } - // When both preferred axes are auto and all min/max constraints - // are definite, resolve CSS2's ratio-preserving constraint table - // before handing the leaf to taffy. Otherwise taffy's block - // stretch supplies a containing-block width as a known dimension - // and independently clamps the height, distorting the ratio. + // An auto/auto replaced box starts from its intrinsic width rather + // than the fill-available width of an ordinary auto-width block. + // Seed that preferred width before handing the leaf to taffy, or + // block layout passes the containing-block width as a known + // dimension and stretches a source-less/content:url image to the + // viewport. Definite constraints additionally resolve CSS2's + // ratio-preserving constraint table up front; without constraints, + // leave height auto so flex shrink can still transfer through the + // preferred aspect ratio. let has_definite_constraint = [ style.min_width, style.min_height, @@ -7366,13 +7430,14 @@ fn build( .any(|expression| expression.contains('%')); if matches!(style.width, crate::Dimension::Auto) && matches!(style.height, crate::Dimension::Auto) - && has_definite_constraint && !has_percentage_constraint { let constrained = crate::inline::constrained_auto_replaced_size(width, height, style); taffy_style.size.width = taffy::Dimension::length(constrained.width); - taffy_style.size.height = taffy::Dimension::length(constrained.height); + if has_definite_constraint { + taffy_style.size.height = taffy::Dimension::length(constrained.height); + } } // When an auto axis has a definite min/max constraint, the @@ -7414,18 +7479,23 @@ fn build( // correct path for paragraphs/headings/labels/cells of text; the flex-wrap // approximation below only handles the leftovers (mixed inline + atomic // boxes, and layout-only builds where `try_build` always declines). - if let Some(item) = engine.try_build(tree, id, styles) { - if style.display == crate::Display::Block && style.width == crate::Dimension::Auto { - // A pure-text block is still a fill-available block. Its shaped - // inline context performs text alignment internally; retaining - // the flex alignment stand-in here shrink-wraps the leaf to its - // text, leaving no free width in which center/end can move it. - taffy_style.display = taffy::style::Display::Block; + let is_closed_html_details = _name.ns.as_ref() == "http://www.w3.org/1999/xhtml" + && _name.local.as_ref() == "details" + && node.get_attribute("open").is_none(); + if !is_closed_html_details { + if let Some(item) = engine.try_build(tree, id, styles) { + if style.display == crate::Display::Block && style.width == crate::Dimension::Auto { + // A pure-text block is still a fill-available block. Its shaped + // inline context performs text alignment internally; retaining + // the flex alignment stand-in here shrink-wraps the leaf to its + // text, leaving no free width in which center/end can move it. + taffy_style.display = taffy::style::Display::Block; + } + let leaf = taffy_tree.new_leaf_with_context(taffy_style, item).ok()?; + id_map.insert(leaf, id); + ifc_items.whole.insert(id, item); + return Some(leaf); } - let leaf = taffy_tree.new_leaf_with_context(taffy_style, item).ok()?; - id_map.insert(leaf, id); - ifc_items.whole.insert(id, item); - return Some(leaf); } // Taffy has no real inline formatting context: its native Block layout @@ -7452,7 +7522,7 @@ fn build( || has_in_flow_generated_pseudo(style.before_pseudo.as_deref()) || has_in_flow_generated_pseudo(style.after_pseudo.as_deref()); - let mut dom_children = tree.children(id); + let mut dom_children = rendered_children(tree, id); // A boxless inline wrapper is transparent to our approximated box tree. // Flatten it before deciding whether this block has mixed inline/block // content, not only later while building children. Otherwise @@ -8105,7 +8175,7 @@ fn needs_column_flex_text_fit_content_cap( return false; } - let has_direct_text = tree.children(id).into_iter().any(|child| { + let has_direct_text = rendered_children(tree, id).into_iter().any(|child| { tree.get_node(child).map_or(false, |node| { matches!( &node.data, @@ -8159,7 +8229,7 @@ fn flatten_contents_children( for &cid in children { let splices = styles.get(&cid).map(|s| s.display_contents && s.display != crate::Display::None).unwrap_or(false); if splices { - let kids = tree.children(cid); + let kids = rendered_children(tree, cid); flatten_contents_children(tree, &kids, styles, out); } else { out.push(cid); @@ -8198,7 +8268,7 @@ fn inline_wraps_only_in_flow_blocks( } let mut saw_block = false; - for cid in tree.children(id) { + for cid in rendered_children(tree, id) { let Some(node) = tree.get_node(cid) else { continue; }; @@ -8255,7 +8325,7 @@ fn flatten_boxless_inline_children( || is_flattenable_inline(tree, cid, styles) || inline_wraps_only_in_flow_blocks(tree, cid, styles) { - let kids = tree.children(cid); + let kids = rendered_children(tree, cid); flatten_boxless_inline_children(tree, &kids, styles, out); } else { out.push(cid); diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index f1c2c7f..33cc536 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -1410,7 +1410,7 @@ fn collect_spans( c: &mut Collector, loaded_families: &HashMap, ) { - for cid in tree.children(id) { + for cid in crate::dom::rendered_children(tree, id) { collect_node_spans( tree, cid, @@ -1795,7 +1795,7 @@ fn is_pure_text_ifc( return false; } let mut has_text = false; - let children = tree.children(id); + let children = crate::dom::rendered_children(tree, id); if children.is_empty() { return false; } @@ -1869,7 +1869,7 @@ fn inline_child_ok(tree: &DomTree, cid: NodeId, styles: &std::collections::HashM if !foldable_inline { return false; } - for gc in tree.children(cid) { + for gc in crate::dom::rendered_children(tree, cid) { if !inline_child_ok(tree, gc, styles, has_text) { return false; } diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e441a74..47de304 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -24,6 +24,65 @@ pub use dom::{ layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout, StickyLayout, }; +/// Whether an image MIME type names a format supported by the renderer build. +/// +/// Compare the MIME essence case-insensitively and ignore parameters. HTML +/// allows a `` value to include parameters, and HTTP MIME types +/// are ASCII case-insensitive. Keep this list aligned with the `image` crate +/// features in `Cargo.toml` plus the SVG path in `paint.rs`. +pub(crate) fn source_type_supported(value: &str) -> bool { + let essence = value.split_once(';').map_or(value, |(essence, _)| essence).trim(); + [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/vnd.microsoft.icon", + "image/webp", + "image/x-icon", + ] + .iter() + .any(|supported| essence.eq_ignore_ascii_case(supported)) +} + +#[cfg(test)] +mod image_capability_tests { + use super::source_type_supported; + + #[test] + fn image_mime_filter_uses_case_insensitive_essence_and_parameters() { + for supported in [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/jpg", + "image/png", + "image/svg+xml", + "image/vnd.microsoft.icon", + "image/webp", + "image/x-icon", + " IMAGE/WEBP ; codecs=lossless ", + "Image/Svg+Xml;charset=utf-8", + ] { + assert!(source_type_supported(supported), "{supported}"); + } + for unsupported in [ + "", + "image/avif", + "image/jxl", + "image/png,image/webp", + "text/html", + ";image/png", + ] { + assert!(!source_type_supported(unsupported), "{unsupported}"); + } + } +} + #[cfg(feature = "paint")] mod paint; #[cfg(feature = "paint")] @@ -381,6 +440,12 @@ pub enum GeneratedContentItem { #[derive(Debug, Clone, Default)] pub struct LayoutStyle { pub display: Display, + /// The specified `display` value was the CSS-wide `inherit` keyword. + /// + /// `display` is normally non-inherited, so the cascade cannot resolve this + /// until the parent's computed outer/inner display is known. The DOM + /// top-down pass copies that provenance and then clears this marker. + pub(crate) display_inherit: bool, /// Computed CSS `container-type` (not inherited). pub container_type: ContainerType, /// The specified value was the CSS-wide `inherit` keyword. Resolved diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 1e54039..f98ef74 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -27,6 +27,10 @@ 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 @@ -376,6 +380,247 @@ impl PreparedRender { }) } + /// Unscaled padding-box size used by CSSOM View's `clientWidth` and + /// `clientHeight`. Layout rects are border boxes, so remove the resolved + /// borders but retain padding. This deliberately ignores visual + /// transforms: client metrics describe layout geometry, unlike + /// `getBoundingClientRect()`. + pub fn client_size( + &self, + id: obscura_dom::tree::NodeId, + ) -> Option<(f32, f32)> { + let rect = self.layout.rects.get(&id)?; + let style = self.layout.styles.get(&id)?; + Some(( + (rect.width - style.border.left - style.border.right).max(0.0), + (rect.height - style.border.top - style.border.bottom).max(0.0), + )) + } + + /// A compact CSSOM snapshot derived from the same final cascade and + /// layout used by paint and geometry. Keeping this on `PreparedRender` + /// lets script fetch all high-traffic computed properties in one op, + /// without rebuilding layout once per property access. + pub fn computed_style( + &self, + id: obscura_dom::tree::NodeId, + ) -> Option> { + let style = self.layout.styles.get(&id)?; + let rect = self.layout.rects.get(&id); + let mut out = HashMap::new(); + + let display = if style.display_contents { + "contents" + } else if style.display == crate::Display::None { + "none" + } else if style.internal_flex_container { + "block" + } else { + match (style.display, style.is_inline_block) { + (crate::Display::Flex, true) => "inline-flex", + (crate::Display::Grid, true) => "inline-grid", + (crate::Display::Block, true) => "inline-block", + (crate::Display::Flex, false) => "flex", + (crate::Display::Grid, false) => "grid", + (crate::Display::Inline, false) => "inline", + _ => "block", + } + }; + out.insert("display", display.to_string()); + out.insert( + "position", + if style.position_fixed { + "fixed" + } else if style.position_sticky { + "sticky" + } else { + match style.position { + Some(taffy::Position::Absolute) => "absolute", + Some(taffy::Position::Relative) => "relative", + _ => "static", + } + } + .to_string(), + ); + out.insert( + "z-index", + style.z_index.map_or_else(|| "auto".to_string(), |v| v.to_string()), + ); + out.insert( + "visibility", + if style.visibility_hidden.unwrap_or(false) { + "hidden" + } else { + "visible" + } + .to_string(), + ); + out.insert("opacity", css_number(style.opacity.unwrap_or(1.0))); + out.insert( + "background-color", + css_color(style.background_color.unwrap_or([0, 0, 0, 0])), + ); + out.insert("color", css_color(style.color.unwrap_or([0, 0, 0, 255]))); + + if let Some(rect) = rect { + let horizontal_non_content = + style.border.left + style.border.right + style.padding.left + style.padding.right; + let vertical_non_content = + style.border.top + style.border.bottom + style.padding.top + style.padding.bottom; + let (width, height) = match style.box_sizing { + crate::BoxSizing::BorderBox => (rect.width, rect.height), + _ => ( + (rect.width - horizontal_non_content).max(0.0), + (rect.height - vertical_non_content).max(0.0), + ), + }; + out.insert("width", css_px(width)); + out.insert("height", css_px(height)); + } else { + out.insert("width", dimension_css(style.width, "auto")); + out.insert("height", dimension_css(style.height, "auto")); + } + out.insert("min-width", dimension_css(style.min_width, "auto")); + out.insert("min-height", dimension_css(style.min_height, "auto")); + out.insert("max-width", dimension_css(style.max_width, "none")); + out.insert("max-height", dimension_css(style.max_height, "none")); + out.insert( + "box-sizing", + if style.box_sizing == crate::BoxSizing::BorderBox { + "border-box" + } else { + "content-box" + } + .to_string(), + ); + + let overflow_axis = |specified: u8, clipped: bool, scroll: bool| { + if scroll { + // The compact layout model intentionally merges + // hidden/auto/scroll for clipping. `auto` is the least + // surprising computed scroll-container value. + "auto" + } else if specified == 1 || clipped { + "clip" + } else { + "visible" + } + }; + out.insert( + "overflow-x", + overflow_axis( + style.overflow_specified_x, + style.overflow_clip_x, + style.overflow_scroll_x, + ) + .to_string(), + ); + out.insert( + "overflow-y", + overflow_axis( + style.overflow_specified_y, + style.overflow_clip_y, + style.overflow_scroll_y, + ) + .to_string(), + ); + + for (name, value, auto) in [ + ("margin-top", style.margin.top, style.margin_auto[0]), + ("margin-right", style.margin.right, style.margin_auto[1]), + ("margin-bottom", style.margin.bottom, style.margin_auto[2]), + ("margin-left", style.margin.left, style.margin_auto[3]), + ] { + out.insert(name, if auto { "auto".to_string() } else { css_px(value) }); + } + for (name, value) in [ + ("padding-top", style.padding.top), + ("padding-right", style.padding.right), + ("padding-bottom", style.padding.bottom), + ("padding-left", style.padding.left), + ("border-top-width", style.border.top), + ("border-right-width", style.border.right), + ("border-bottom-width", style.border.bottom), + ("border-left-width", style.border.left), + ] { + out.insert(name, css_px(value)); + } + let border_color = css_color(style.border_color.or(style.color).unwrap_or([0, 0, 0, 255])); + out.insert("border-top-color", border_color.clone()); + out.insert("border-right-color", border_color.clone()); + out.insert("border-bottom-color", border_color.clone()); + out.insert("border-left-color", border_color); + for (name, width) in [ + ("border-top-style", style.border.top), + ("border-right-style", style.border.right), + ("border-bottom-style", style.border.bottom), + ("border-left-style", style.border.left), + ] { + out.insert(name, if width > 0.0 { "solid" } else { "none" }.to_string()); + } + + out.insert( + "flex-direction", + match style.flex_direction.unwrap_or(taffy::FlexDirection::Row) { + taffy::FlexDirection::Row => "row", + taffy::FlexDirection::RowReverse => "row-reverse", + taffy::FlexDirection::Column => "column", + taffy::FlexDirection::ColumnReverse => "column-reverse", + } + .to_string(), + ); + out.insert( + "flex-wrap", + match style.flex_wrap.unwrap_or(taffy::FlexWrap::NoWrap) { + taffy::FlexWrap::NoWrap => "nowrap", + taffy::FlexWrap::Wrap => "wrap", + taffy::FlexWrap::WrapReverse => "wrap-reverse", + } + .to_string(), + ); + out.insert( + "align-items", + style + .align_items + .map_or_else(|| "normal".to_string(), align_items_css), + ); + out.insert( + "justify-items", + style + .justify_items + .map_or_else(|| "normal".to_string(), align_items_css), + ); + out.insert( + "justify-content", + style + .justify_content + .map_or_else(|| "normal".to_string(), align_content_css), + ); + out.insert( + "align-content", + style + .align_content + .map_or_else(|| "normal".to_string(), align_content_css), + ); + out.insert("column-gap", style.column_gap.map_or_else(|| "normal".to_string(), css_px)); + out.insert("row-gap", style.row_gap.map_or_else(|| "normal".to_string(), css_px)); + out.insert( + "grid-auto-flow", + match style.grid_auto_flow.unwrap_or(taffy::GridAutoFlow::Row) { + taffy::GridAutoFlow::Row => "row", + taffy::GridAutoFlow::Column => "column", + taffy::GridAutoFlow::RowDense => "row dense", + taffy::GridAutoFlow::ColumnDense => "column dense", + } + .to_string(), + ); + + out.insert("transform", transform_css(style)); + out + .insert("transform-origin", transform_origin_css(style, rect)); + Some(out) + } + /// Border box in the current root viewport. This is the read-only geometry /// path used by a later CSSOM integration and shares paint's clamped scroll, /// fixed-subtree, and sticky-positioning derivatives. @@ -402,6 +647,127 @@ impl PreparedRender { } } +fn css_number(value: f32) -> String { + if value == 0.0 { + "0".to_string() + } else { + value.to_string() + } +} + +fn css_px(value: f32) -> String { + format!("{}px", css_number(if value == 0.0 { 0.0 } else { value })) +} + +fn dimension_css(value: crate::Dimension, auto: &str) -> String { + match value { + crate::Dimension::Auto => auto.to_string(), + crate::Dimension::Px(v) => css_px(v), + crate::Dimension::Percent(v) => format!("{}%", css_number(v * 100.0)), + crate::Dimension::Em(v) => format!("{}em", css_number(v)), + crate::Dimension::Ex(v) => format!("{}ex", css_number(v)), + crate::Dimension::Rem(v) => format!("{}rem", css_number(v)), + crate::Dimension::Vw(v) => format!("{}vw", css_number(v)), + crate::Dimension::Vh(v) => format!("{}vh", css_number(v)), + crate::Dimension::Vmin(v) => format!("{}vmin", css_number(v)), + crate::Dimension::Vmax(v) => format!("{}vmax", css_number(v)), + } +} + +fn css_color([r, g, b, a]: [u8; 4]) -> String { + if a == 255 { + format!("rgb({r}, {g}, {b})") + } else { + format!( + "rgba({r}, {g}, {b}, {})", + css_number(a as f32 / 255.0) + ) + } +} + +fn align_items_css(value: taffy::AlignItems) -> String { + let keyword = match value.keyword { + taffy::AlignItemsKeyword::Start => "start", + taffy::AlignItemsKeyword::End => "end", + taffy::AlignItemsKeyword::FlexStart => "flex-start", + taffy::AlignItemsKeyword::FlexEnd => "flex-end", + taffy::AlignItemsKeyword::Center => "center", + taffy::AlignItemsKeyword::Baseline => "baseline", + taffy::AlignItemsKeyword::Stretch => "stretch", + }; + if value.safety == taffy::AlignmentSafety::Safe { + format!("safe {keyword}") + } else { + keyword.to_string() + } +} + +fn align_content_css(value: taffy::AlignContent) -> String { + let keyword = match value.keyword { + taffy::AlignContentKeyword::Start => "start", + taffy::AlignContentKeyword::End => "end", + taffy::AlignContentKeyword::FlexStart => "flex-start", + taffy::AlignContentKeyword::FlexEnd => "flex-end", + taffy::AlignContentKeyword::Center => "center", + taffy::AlignContentKeyword::Stretch => "stretch", + taffy::AlignContentKeyword::SpaceBetween => "space-between", + taffy::AlignContentKeyword::SpaceEvenly => "space-evenly", + taffy::AlignContentKeyword::SpaceAround => "space-around", + }; + if value.safety == taffy::AlignmentSafety::Safe { + format!("safe {keyword}") + } else { + keyword.to_string() + } +} + +fn transform_css(style: &crate::LayoutStyle) -> String { + let mut parts = Vec::new(); + if let Some((x, y)) = style.transform_translate { + parts.push(format!( + "translate({}, {})", + dimension_css(x, "0px"), + dimension_css(y, "0px") + )); + } + if let Some((x, y)) = style.individual_translate { + parts.push(format!( + "translate({}, {})", + dimension_css(x, "0px"), + dimension_css(y, "0px") + )); + } + if let Some((x, y)) = style.transform_scale { + parts.push(format!("scale({}, {})", css_number(x), css_number(y))); + } + if let Some([a, b, c, d]) = style.transform_projection { + parts.push(format!( + "matrix({}, {}, {}, {}, 0, 0)", + css_number(a), + css_number(b), + css_number(c), + css_number(d) + )); + } + if parts.is_empty() { + "none".to_string() + } else { + parts.join(" ") + } +} + +fn transform_origin_css(style: &crate::LayoutStyle, rect: Option<&crate::Rect>) -> String { + let (x, y) = style + .transform_origin + .unwrap_or((crate::Dimension::Percent(0.5), crate::Dimension::Percent(0.5))); + let resolve = |value: crate::Dimension, axis: f32| match value { + crate::Dimension::Percent(fraction) => css_px(fraction * axis), + other => dimension_css(other, "0px"), + }; + let (width, height) = rect.map_or((0.0, 0.0), |rect| (rect.width, rect.height)); + format!("{} {}", resolve(x, width), resolve(y, height)) +} + /// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or /// None if the viewport is zero-sized. `base_url`, when given, resolves the /// relative image URLs (`
`) that make up the overwhelming @@ -525,9 +891,48 @@ pub fn paint_prepared( &mut prepared.layout, None, None, + None, ) } +/// The used `z-index` for an element which participates in stacking order. +/// +/// A non-auto z-index applies not only to positioned boxes, but also to +/// otherwise-static flex and grid items. `display:contents` boxes are skipped +/// while finding the item's formatting-context parent because they generate no +/// box of their own. +fn stacking_z_index( + tree: &DomTree, + laid: &crate::DomLayout, + id: obscura_dom::tree::NodeId, +) -> Option { + let style = laid.styles.get(&id)?; + if style.display == crate::Display::None || style.display_contents { + return None; + } + let z = style.z_index?; + if style.position.is_some() { + return Some(z); + } + + let mut parent = tree.get_node(id).and_then(|node| node.parent); + while let Some(parent_id) = parent { + let Some(parent_style) = laid.styles.get(&parent_id) else { + parent = tree.get_node(parent_id).and_then(|node| node.parent); + continue; + }; + if parent_style.display_contents { + parent = tree.get_node(parent_id).and_then(|node| node.parent); + continue; + } + let is_flex_or_grid = parent_style.display == crate::Display::Grid + || (parent_style.display == crate::Display::Flex + && !parent_style.internal_flex_container); + return is_flex_or_grid.then_some(z); + } + None +} + /// Paint an already prepared layout without changing its document-space /// geometry. Root scrolling and sticky positioning are per-shot visual state, /// so alternating captures can safely reuse the same layout. @@ -546,6 +951,7 @@ fn paint_laid_dom_scrolled( laid: &mut crate::DomLayout, paint_root: Option, suppress_opacity_for: Option, + suppress_stacking_for: Option, ) -> Option { let scroll_state = ScrollPaintState::new(viewport, scroll, content_size, viewport_fixed, sticky); @@ -582,14 +988,13 @@ fn paint_laid_dom_scrolled( // path free of any added cost. // Paint order: tree order for the normal flow (later elements paint over - // earlier ones), except that a positioned element with a non-zero - // z-index lifts its whole subtree into a separate layer: negative layers - // paint under the normal flow, positive ones above it, each sorted by - // z-index ascending (stable, so equal z keeps tree order). This is the - // pragmatic core of CSS stacking contexts: dropdowns/overlays/badges - // (z>0) stop losing to later siblings, and z:-1 decorative backdrops - // stop covering their content. Nested z roots paint inside their - // ancestor root's subtree in tree order. + // earlier ones), except that a positioned element or a flex/grid item + // with a non-auto z-index lifts its whole subtree into an atomic stacking + // unit: negative layers paint under the normal flow, non-negative ones + // above it, each sorted by z-index ascending (stable, so equal z keeps + // tree order). The unit is recursively painted at its sorted position, + // preventing its backgrounds, replaced content, and shaped text from + // leaking into different global paint phases. let mut neg_layers: Vec<(i32, Vec)> = Vec::new(); let mut pos_layers: Vec<(i32, Vec)> = Vec::new(); let mut normal: Vec = Vec::new(); @@ -606,12 +1011,9 @@ fn paint_laid_dom_scrolled( .get(&nid) .and_then(|style| style.opacity) .is_some_and(|opacity| opacity.clamp(0.0, 1.0) < 1.0); - let z = laid - .styles - .get(&nid) - .filter(|s| s.position.is_some()) - .and_then(|s| s.z_index) - .filter(|&z| z != 0); + let z = (suppress_stacking_for != Some(nid)) + .then(|| stacking_z_index(tree, laid, nid)) + .flatten(); if is_opacity_root { let mut sub = vec![nid]; sub.extend(tree.descendants(nid)); @@ -632,9 +1034,9 @@ fn paint_laid_dom_scrolled( consumed.insert(m); } if z < 0 { - neg_layers.push((z, sub)); + neg_layers.push((z, vec![nid])); } else { - pos_layers.push((z, sub)); + pos_layers.push((z, vec![nid])); } } else { normal.push(nid); @@ -707,6 +1109,36 @@ fn paint_laid_dom_scrolled( if svg_subtree_skip.contains(&nid) { continue; } + if suppress_stacking_for != Some(nid) + && stacking_z_index(tree, laid, nid).is_some() + { + opacity_subtree_skip.insert(nid); + opacity_subtree_skip.extend(tree.descendants(nid)); + // A stacking context is one structural paint item in its parent. + // Re-enter the same display-list builder with this root suppressed + // so all of its decorations, descendants, and shaped inline text + // finish before the next sibling stacking unit starts. Passing the + // existing pixmap avoids a viewport-sized intermediate surface; + // opacity still takes the isolated-surface path below when needed. + pixmap = paint_laid_dom_scrolled( + tree, + viewport, + base_url, + scroll, + pixmap, + image_cache, + selected_images, + svg_fonts, + content_size, + viewport_fixed, + sticky, + laid, + Some(nid), + suppress_opacity_for, + Some(nid), + )?; + continue; + } let node = match tree.get_node(nid) { Some(n) => n, None => continue, @@ -769,6 +1201,7 @@ fn paint_laid_dom_scrolled( laid, Some(nid), Some(nid), + suppress_stacking_for, )?; let group_paint = tiny_skia::PixmapPaint { opacity: own_opacity, @@ -2606,12 +3039,11 @@ fn split_css_top_level(value: &str, separator: char) -> Vec<&str> { fn http_get_bytes(url: &str) -> Option> { let mut backoff = std::time::Duration::from_millis(200); for attempt in 0..3 { - // A browser-like Accept advertises the modern image formats and is what - // content-negotiating CDNs expect; some UA-gated hosts also reject a - // request with no Accept header outright. + // Advertise only formats that this build can decode. Content-negotiating + // CDNs otherwise commonly choose AVIF and leave the image blank. let res = image_agent() .get(url) - .set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .set("Accept", IMAGE_ACCEPT) .call(); match res { Ok(resp) => { @@ -2689,7 +3121,7 @@ fn percent_decode(s: &str) -> Vec { out } -/// Decode raster image bytes (jpeg/png/webp) to a premultiplied-alpha pixmap +/// Decode raster image bytes (GIF/JPEG/PNG/WebP) to a premultiplied-alpha pixmap /// resized to `w`x`h`. fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option { let img = image::load_from_memory(bytes).ok()?.to_rgba8(); @@ -4147,7 +4579,7 @@ fn picture_source_url( continue; } if let Some(t) = child.get_attribute("type") { - if !source_type_supported(t) { + if !crate::source_type_supported(t) { continue; } } @@ -4166,17 +4598,6 @@ fn picture_source_url( None } -/// Whether a `` names an image format this build can decode. -/// AVIF/JPEG-XL are intentionally excluded: the `image` crate cannot decode -/// them here, so a decodable `` fallback must win over such a source. -fn source_type_supported(t: &str) -> bool { - matches!( - t.trim().to_ascii_lowercase().as_str(), - "image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp" - | "image/bmp" | "image/svg+xml" | "image/x-icon" | "image/vnd.microsoft.icon" - ) -} - /// Pick one URL from a `srcset` list, matching the WebKit/Blink selection: /// normalize each `w` descriptor to an effective density (`w / source-size`, /// with the source-size taken from `sizes` or falling back to the viewport @@ -5405,6 +5826,101 @@ mod tests { use super::*; use obscura_dom::tree_sink::parse_html; + #[test] + fn image_accept_advertises_exactly_decodable_mime_types() { + assert!(!IMAGE_ACCEPT.contains('*')); + assert!(!IMAGE_ACCEPT.to_ascii_lowercase().contains("avif")); + for mime in IMAGE_ACCEPT.split(',') { + assert!( + crate::source_type_supported(mime), + "advertised MIME type must be decodable: {mime}" + ); + } + for required in [ + "image/webp", + "image/apng", + "image/svg+xml", + "image/png", + "image/jpeg", + "image/gif", + "image/bmp", + "image/x-icon", + "image/vnd.microsoft.icon", + ] { + assert!( + IMAGE_ACCEPT.split(',').any(|mime| mime == required), + "missing supported MIME type {required}" + ); + } + } + + #[test] + fn picture_type_filter_skips_avif_and_accepts_parameterized_mime_essence() { + let tree = parse_html( + r#" + + + + "#, + ); + let hero = tree.get_element_by_id("hero").expect("hero"); + assert_eq!( + picture_source_url(&tree, hero, (800.0, 600.0)), + Some(("supported.webp".to_string(), 1.0)) + ); + } + + #[test] + fn enabled_bmp_and_ico_formats_have_metadata_and_full_raster_decode() { + use std::io::Cursor; + + let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 3, + image::Rgba([20, 40, 60, 255]), + )); + for format in [image::ImageFormat::Bmp, image::ImageFormat::Ico] { + let mut encoded = Cursor::new(Vec::new()); + source + .write_to(&mut encoded, format) + .expect("encode fixture"); + let encoded = encoded.into_inner(); + assert_eq!(image_dimensions(&encoded), Some((2, 3)), "{format:?}"); + let raster = raster_to_pixmap(&encoded, 2, 3).expect("decode raster"); + assert_eq!((raster.width(), raster.height()), (2, 3)); + } + } + + #[test] + fn gif_placeholders_have_intrinsic_pixels_and_valid_gifs_decode() { + // Apple's lazy-picture system (and many generic lazy loaders) uses a + // transparent 1x1 GIF as the selected source until the real candidate + // enters its preload range. It is still a successfully decoded image: + // Chromium reports complete=true and naturalWidth=1. + const TRANSPARENT_GIF: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x70, + 0x00, 0x00, 0x21, 0xf9, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2c, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, + 0x44, 0x01, 0x00, 0x3b, + ]; + + // Apple's deliberately palette-less placeholder is accepted by + // browsers for source selection. Header metadata is sufficient for + // complete/naturalWidth; visually it contributes no pixels. + assert_eq!(image_dimensions(TRANSPARENT_GIF), Some((1, 1))); + + const VISIBLE_GIF: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x01, 0x4c, 0x00, + 0x3b, + ]; + let decoded = raster_to_pixmap(VISIBLE_GIF, 1, 1).expect("decode GIF"); + assert_eq!(decoded.width(), 1); + assert_eq!(decoded.height(), 1); + assert_eq!(decoded.pixel(0, 0).expect("pixel").alpha(), 255); + } + #[test] fn scrolled_viewport_moves_document_content_but_not_fixed_subtrees() { let tree = parse_html( @@ -6672,6 +7188,89 @@ mod tests { ); } + #[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( diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index aa5e695..c7aed87 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -229,24 +229,16 @@ pub fn ua_style(tag: &str) -> LayoutStyle { if tag == "th" { style.font_weight = Some("bold".to_string()); } - } else if tag == "img" || tag == "figure" { - if tag == "img" { - // Images are inline-level replaced elements by default. Model the - // atomic inner box with the same inline-block marker used by - // native controls, so an icon between text fragments participates - // in their line instead of splitting the parent into block runs. - // An authored display declaration clears this marker in the - // cascade before applying its requested display. - style.display = Display::Inline; - style.is_inline_block = true; - } - // Near-universal reset: images fit their container instead of - // overflowing. Wikipedia (and most sites) set `img{max-width:100%}`; - // making it a UA default prevents a fixed-width thumbnail (e.g. a - // 250px infobox image) from spilling out of a narrower box (a 200px - // taxobox) when we have not applied the site's own rule. - style.max_width = crate::Dimension::Percent(1.0); - style.flex_shrink = Some(1.0); + } else if tag == "img" { + // Images are inline-level replaced elements by default. Model the + // atomic inner box with the same inline-block marker used by native + // controls, so an icon between text fragments participates in their + // line instead of splitting the parent into block runs. Chromium's UA + // sheet does not impose a responsive max-width: author/reset CSS must + // opt into that. A synthetic `max-width:100%` here distorts deliberately + // oversized art whose height and intrinsic ratio establish its width. + style.display = Display::Inline; + style.is_inline_block = true; } style } @@ -643,11 +635,9 @@ fn unitless_math_tokens<'i, 't>( fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { match name { "display" => { - if value != "contents" { - style.display_contents = false; - } + let value = value.trim().to_ascii_lowercase(); if matches!( - value, + value.as_str(), "none" | "flex" | "inline-flex" @@ -660,13 +650,22 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { | "table" | "inline-table" | "contents" + | "inherit" + | "initial" + | "unset" ) { + // Every valid authored display value replaces the complete + // outer/inner display pair. In particular, it must not retain + // the UA table/control approximation merely because its + // internal enum happens to equal the newly specified value. style.internal_flex_container = false; style.is_table_box = false; style.is_inline_block = false; style.flow_root = false; + style.display_contents = false; + style.display_inherit = false; } - match value { + match value.as_str() { "none" => style.display = crate::Display::None, "flex" => style.display = crate::Display::Flex, "inline-flex" => { @@ -712,6 +711,19 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.display = crate::Display::Block; style.display_contents = true; } + "inherit" => { + // Keep a valid initial-display placeholder until the + // parent computed style is available. The top-down pass + // copies the parent's outer and inner display provenance, + // but never its engine-only native-layout flags. + style.display = crate::Display::Inline; + style.display_inherit = true; + } + "initial" | "unset" => { + // `display` is not inherited and its CSS initial value is + // `inline`, independent of the element's HTML UA display. + style.display = crate::Display::Inline; + } _ => {} } } @@ -1530,14 +1542,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "grid-template" => parse_grid_template(style, value), "grid" => parse_grid_shorthand(style, value), "grid-auto-flow" => style.grid_auto_flow = parse_grid_auto_flow(value), - "grid-area" => { - // Named area (single ident) or line form `r/c/r/c`. We only resolve - // the named-area case here; line forms are handled by grid-row/column. - let v = value.trim(); - if !v.contains('/') && !v.is_empty() { - style.grid_area_name = Some(v.to_string()); - } - } + "grid-area" => set_grid_area(style, value), "grid-column" => set_grid_placement(style, value, true), "grid-row" => set_grid_placement(style, value, false), "grid-column-start" => set_grid_placement_side(style, value, true, true), @@ -1871,6 +1876,17 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { "overflow" | "overflow-x" | "overflow-y" => { parse_overflow_declaration(&name, value).is_some() } + "grid-area" => { + let parts = split_top_level(value, '/') + .into_iter() + .map(str::trim) + .collect::>(); + !parts.is_empty() + && parts.len() <= 4 + && parts.iter().all(|part| { + !part.is_empty() && parse_grid_line_kind(part).is_some() + }) + } "color" | "-webkit-text-fill-color" | "background-color" | "border-color" => { parse_color(value).is_some() } @@ -2880,6 +2896,226 @@ fn parse_grid_auto_flow(value: &str) -> Option { }) } +/// Expand the `grid-area` shorthand into its row/column placements. +/// +/// The four slash-separated components are, in order, row-start, +/// column-start, row-end, and column-end. Missing end values repeat a +/// custom-ident start (the named-area form) and otherwise become `auto`, per +/// CSS Grid. Keeping the expansion in the computed style is important for the +/// common overlay idiom `grid-area: 1 / 1 / 1 / 1`; treating every slash form +/// as unsupported makes each child auto-place into a new implicit row. +fn set_grid_area(style: &mut LayoutStyle, value: &str) { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "inherit" | "unset" | "revert" | "revert-layer" + ) { + style.grid_area_name = None; + set_grid_placement(style, "auto / auto", true); + set_grid_placement(style, "auto / auto", false); + return; + } + + let parts = split_top_level(value, '/') + .into_iter() + .map(str::trim) + .collect::>(); + if parts.is_empty() + || parts.len() > 4 + || parts.iter().any(|part| part.is_empty()) + || parts + .iter() + .any(|part| parse_grid_line_kind(part).is_none()) + { + return; + } + + let row_start = parts[0]; + let column_start = parts + .get(1) + .copied() + .unwrap_or_else(|| grid_area_omitted_side(row_start)); + let row_end = parts + .get(2) + .copied() + .unwrap_or_else(|| grid_area_omitted_side(row_start)); + let column_end = parts + .get(3) + .copied() + .unwrap_or_else(|| grid_area_omitted_side(column_start)); + + // Preserve the compact named-area representation used by + // `resolve_grid_areas`, while also retaining the generated `name-start` / + // `name-end` line form for grids that expose those named lines directly. + if parts.len() == 1 && is_grid_custom_ident(row_start) { + style.grid_area_name = Some(row_start.to_string()); + } else { + style.grid_area_name = None; + } + + set_grid_placement( + style, + &format!("{column_start} / {column_end}"), + true, + ); + set_grid_placement(style, &format!("{row_start} / {row_end}"), false); +} + +fn grid_area_omitted_side(start: &str) -> &str { + if is_grid_custom_ident(start) { + start + } else { + "auto" + } +} + +fn is_grid_custom_ident(value: &str) -> bool { + matches!( + parse_grid_line_kind(value), + Some(GridLineKind::IdentOnly) + ) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GridLineKind { + IdentOnly, + Other, +} + +/// Validate one `` without mutating the computed style. Shorthand +/// declarations are atomic: if any of their one-to-four components is invalid, +/// the entire declaration must be ignored rather than partially resetting +/// earlier winning longhands. +fn parse_grid_line_kind(value: &str) -> Option { + let mut input = cssparser::ParserInput::new(value.trim()); + let mut parser = cssparser::Parser::new(&mut input); + + // auto + { + let state = parser.state(); + if parser + .expect_ident_cloned() + .is_ok_and(|ident| ident.eq_ignore_ascii_case("auto")) + && parser.is_exhausted() + { + return Some(GridLineKind::Other); + } + parser.reset(&state); + } + + // span && [ || ] + { + let state = parser.state(); + if parser + .expect_ident_cloned() + .is_ok_and(|ident| ident.eq_ignore_ascii_case("span")) + { + let mut saw_integer = consume_grid_integer(&mut parser, true); + let mut saw_ident = consume_grid_custom_ident(&mut parser); + if !saw_integer { + saw_integer = consume_grid_integer(&mut parser, true); + } + if !saw_ident { + saw_ident = consume_grid_custom_ident(&mut parser); + } + if (saw_integer || saw_ident) && parser.is_exhausted() { + return Some(GridLineKind::Other); + } + } + parser.reset(&state); + } + + // [ && ? ], in either order. + { + let state = parser.state(); + if consume_grid_integer(&mut parser, false) { + let _ = consume_grid_custom_ident(&mut parser); + if parser.is_exhausted() { + return Some(GridLineKind::Other); + } + } + parser.reset(&state); + } + { + let state = parser.state(); + if consume_grid_custom_ident(&mut parser) { + if parser.is_exhausted() { + return Some(GridLineKind::IdentOnly); + } + if consume_grid_integer(&mut parser, false) && parser.is_exhausted() { + return Some(GridLineKind::Other); + } + } + parser.reset(&state); + } + + // Modern engines accept an integer-valued math function as the numeric + // half of a grid line. Keep it non-ident-only so omitted shorthand sides + // become auto. Resolution remains deferred in the raw placement path. + if consume_grid_integer_math(&mut parser) { + let _ = consume_grid_custom_ident(&mut parser); + if parser.is_exhausted() { + return Some(GridLineKind::Other); + } + } + None +} + +fn consume_grid_integer(parser: &mut cssparser::Parser<'_, '_>, positive_only: bool) -> bool { + let state = parser.state(); + let valid = parser.expect_integer().is_ok_and(|integer| { + if positive_only { + integer > 0 + } else { + integer != 0 + } + }); + if !valid { + parser.reset(&state); + } + valid +} + +fn consume_grid_custom_ident(parser: &mut cssparser::Parser<'_, '_>) -> bool { + let state = parser.state(); + let valid = parser.expect_ident_cloned().is_ok_and(|ident| { + !matches!( + ident.to_ascii_lowercase().as_str(), + "auto" | "span" | "initial" | "inherit" | "unset" | "revert" | "revert-layer" + ) + }); + if !valid { + parser.reset(&state); + } + valid +} + +fn consume_grid_integer_math(parser: &mut cssparser::Parser<'_, '_>) -> bool { + let state = parser.state(); + let is_math = parser.expect_function().is_ok_and(|name| { + matches!( + name.to_ascii_lowercase().as_str(), + "calc" | "min" | "max" | "clamp" + ) + }); + if !is_math { + parser.reset(&state); + return false; + } + let valid = parser + .parse_nested_block::<_, _, ()>(|nested| { + if nested.is_exhausted() { + return Err(nested.new_custom_error(())); + } + while nested.next_including_whitespace().is_ok() {} + Ok(()) + }) + .is_ok(); + if !valid { + parser.reset(&state); + } + valid +} + /// Store a `grid-column`/`grid-row` value. Numeric/`span` forms resolve to a /// `taffy::Line` now; a value that names a grid line (`content-start / /// content-end`, or the `grid-column: content` area shorthand) is kept raw and @@ -2996,7 +3232,8 @@ fn grid_line_has_name(value: &str) -> bool { if p.eq_ignore_ascii_case("auto") { return false; } - let rest = p.strip_prefix("span").map(str::trim).unwrap_or(p); + let lower = p.to_ascii_lowercase(); + let rest = lower.strip_prefix("span").map(str::trim).unwrap_or(&lower); rest.chars().any(|c| c.is_ascii_alphabetic()) }) } @@ -3016,7 +3253,8 @@ fn parse_grid_line(value: &str) -> Option> { fn parse_grid_placement(value: &str) -> taffy::GridPlacement { let value = value.trim(); - if let Some(span) = value.strip_prefix("span").map(str::trim) { + let lower = value.to_ascii_lowercase(); + if let Some(span) = lower.strip_prefix("span").map(str::trim) { if let Ok(span) = span.parse::() { return taffy::style_helpers::span(span); } @@ -3864,7 +4102,16 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> { let mut parts = Vec::new(); let mut depth = 0i32; let mut start = 0; + let mut escaped = false; for (i, c) in s.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } match c { '(' => depth += 1, ')' => depth -= 1, @@ -5624,6 +5871,32 @@ mod tests { assert!(style.display_contents); } + #[test] + fn display_css_wide_values_replace_ua_and_prior_provenance() { + let initial = compute_style("td", Some("display:initial")); + assert_eq!(initial.display, Display::Inline); + assert!(!initial.internal_flex_container); + assert!(!initial.is_inline_block); + assert!(!initial.display_contents); + + let unset = compute_style("div", Some("display:flex;display:unset")); + assert_eq!(unset.display, Display::Inline); + assert!(!unset.is_inline_block); + + let inherited = compute_style("td", Some("display:contents;display:inherit")); + assert_eq!(inherited.display, Display::Inline); + assert!(inherited.display_inherit); + assert!(!inherited.internal_flex_container); + assert!(!inherited.display_contents); + + let important = compute_style( + "div", + Some("display:block!important;display:contents"), + ); + assert_eq!(important.display, Display::Block); + assert!(!important.display_contents); + } + #[test] fn authored_display_replaces_internal_flex_provenance() { let native_cell = compute_style("td", None); @@ -5691,6 +5964,17 @@ mod tests { ); } + #[test] + fn image_ua_style_does_not_invent_a_responsive_size_cap() { + let image = ua_style("img"); + assert_eq!(image.display, crate::Display::Inline); + assert!(image.is_inline_block); + assert_eq!(image.max_width, crate::Dimension::Auto); + + let authored = compute_style("img", Some("max-width:100%")); + assert_eq!(authored.max_width, crate::Dimension::Percent(1.0)); + } + #[test] fn border_none_clears_native_and_per_side_widths() { let input = compute_style("input", Some("border:none")); @@ -6343,6 +6627,146 @@ mod tests { assert_eq!(reset.grid_column_raw, None); } + #[test] + fn grid_area_expands_slash_placements_and_named_area_defaults() { + let overlay = compute_style("div", Some("grid-area:1 / 2 / 3 / 4")); + assert_eq!( + overlay.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::line(1), + end: taffy::style_helpers::line(3), + }) + ); + assert_eq!( + overlay.grid_column, + Some(taffy::Line { + start: taffy::style_helpers::line(2), + end: taffy::style_helpers::line(4), + }) + ); + assert_eq!(overlay.grid_area_name, None); + + let named = compute_style("div", Some("grid-area:hero")); + assert_eq!(named.grid_area_name.as_deref(), Some("hero")); + assert_eq!(named.grid_row_raw.as_deref(), Some("hero / hero")); + assert_eq!(named.grid_column_raw.as_deref(), Some("hero / hero")); + + let reset = compute_style("div", Some("grid-area:hero;grid-area:initial")); + assert_eq!(reset.grid_area_name, None); + assert_eq!( + reset.grid_row, + Some(taffy::Line { + start: taffy::GridPlacement::Auto, + end: taffy::GridPlacement::Auto, + }) + ); + assert_eq!( + reset.grid_column, + Some(taffy::Line { + start: taffy::GridPlacement::Auto, + end: taffy::GridPlacement::Auto, + }) + ); + + for value in ["2 foo", "foo 2", "calc(1)"] { + let line = compute_style("div", Some(&format!("grid-area:{value}"))); + let expected_row = format!("{value} / auto"); + assert_eq!( + line.grid_area_name, None, + "{value} is a grid line, not an ident-only named area" + ); + assert_eq!( + line.grid_row_raw.as_deref(), + Some(expected_row.as_str()) + ); + assert_eq!( + line.grid_column, + Some(taffy::Line { + start: taffy::GridPlacement::Auto, + end: taffy::GridPlacement::Auto, + }) + ); + } + + let one = compute_style("div", Some("grid-area:1")); + assert_eq!( + one.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::line(1), + end: taffy::GridPlacement::Auto, + }) + ); + assert_eq!( + one.grid_column, + Some(taffy::Line { + start: taffy::GridPlacement::Auto, + end: taffy::GridPlacement::Auto, + }) + ); + + let mixed = compute_style("div", Some("grid-area:hero / 2")); + assert_eq!(mixed.grid_row_raw.as_deref(), Some("hero / hero")); + assert_eq!( + mixed.grid_column, + Some(taffy::Line { + start: taffy::style_helpers::line(2), + end: taffy::GridPlacement::Auto, + }) + ); + + let copied_column_ident = compute_style("div", Some("grid-area:1 / col / 3")); + assert_eq!( + copied_column_ident.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::line(1), + end: taffy::style_helpers::line(3), + }) + ); + assert_eq!( + copied_column_ident.grid_column_raw.as_deref(), + Some("col / col") + ); + + let uppercase_span = compute_style("div", Some("grid-area:SPAN 2")); + assert_eq!( + uppercase_span.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::span(2), + end: taffy::GridPlacement::Auto, + }) + ); + + for invalid in ["0", "span", "span 0", "span -1", "foo bar", "1 / inherit", "1.5"] { + let style = compute_style( + "div", + Some(&format!("grid-area:7 / 8 / 9 / 10;grid-area:{invalid}")), + ); + assert_eq!( + style.grid_row, + Some(taffy::Line { + start: taffy::style_helpers::line(7), + end: taffy::style_helpers::line(9), + }), + "invalid `{invalid}` must not partially mutate the earlier shorthand" + ); + assert_eq!( + style.grid_column, + Some(taffy::Line { + start: taffy::style_helpers::line(8), + end: taffy::style_helpers::line(10), + }), + "invalid `{invalid}` must not partially mutate the earlier shorthand" + ); + assert!(!supports_declaration("grid-area", invalid)); + } + for valid in ["hero", "1", "hero / 2", "1 / col / 3", "SPAN 2", "span foo"] { + assert!( + supports_declaration("grid-area", valid), + "valid grid-area `{valid}` should pass @supports parsing" + ); + } + } + #[test] fn contextual_css_math_uses_runtime_geometry() { let context = (20.0, 16.0, 9.0, 10.0, 900.0); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 6950f2d..aa99d4e 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -2345,6 +2345,49 @@ fn auto_grid_track_freezes_at_growth_limit_during_distribution() { ); } +#[test] +fn grid_area_line_shorthand_overlays_children_in_the_same_cell() { + let tree = parse_html( + r#" + +
+
+ +
+
+ "#, + ); + let layout = layout_dom(&tree, (640.0, 480.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let grid = rect("grid"); + for id in ["left", "right", "slider"] { + let child = rect(id); + assert!( + (child.x - grid.x).abs() < 0.01 && (child.y - grid.y).abs() < 0.01, + "{id} should overlay the first grid cell instead of auto-placing: \ + grid={grid:?} child={child:?}" + ); + } + assert!( + (grid.height - 180.0).abs() < 0.01, + "overlaid children must not create implicit rows: {grid:?}" + ); +} + /// Chromium 150 oracle for the full-span nested column-subgrid pattern used by /// Mozilla's springboard/data rows. The 20px child gap is centered on the /// ancestor's zero-width grid lines, so descendant contributions inflate the @@ -2655,6 +2698,13 @@ fn selected_picture_source_dimensions_override_fallback_image_ratio() { + + html, body { margin:0 } + #wrapper { + position:relative; + width:1280px; + height:692px; + overflow:hidden; + } + #image { + position:absolute; + left:50%; + bottom:0; + width:auto; + height:100%; + transform:translateX(-50%); + } + +
+ "#, + ); + let image = tree.get_element_by_id("image").unwrap(); + let intrinsic = HashMap::from([(image, (3008.0, 692.0))]); + let layout = layout_dom_with_images(&tree, (1280.0, 1400.0), &intrinsic); + let rect = layout.rects[&image]; + + // Layout stores the pre-transform border box at the authored 50% inset; + // paint/CSSOM applies translateX(-50%) and exposes the final x=-864. + assert!((rect.x - 640.0).abs() < 0.01, "{rect:?}"); + assert!((rect.width - 3008.0).abs() < 0.01, "{rect:?}"); + assert!((rect.height - 692.0).abs() < 0.01, "{rect:?}"); +} + +#[test] +fn authored_percentage_max_width_still_clamps_an_oversized_image() { + let tree = parse_html( + r#" + +
+ "#, + ); + let image = tree.get_element_by_id("image").unwrap(); + let intrinsic = HashMap::from([(image, (3008.0, 692.0))]); + let layout = layout_dom_with_images(&tree, (1280.0, 1400.0), &intrinsic); + let rect = layout.rects[&image]; + + assert!((rect.width - 1280.0).abs() < 0.01, "{rect:?}"); + assert!( + (rect.height - (1280.0 * 692.0 / 3008.0)).abs() < 1.0, + "{rect:?}" + ); +} + /// Chromium 150 applies CSS Sizing's compressible replaced-element rule when /// `max-width:100%` is cyclic during intrinsic sizing. Each image contributes /// zero to its column's automatic minimum, so the row can flex-shrink the @@ -3469,3 +3579,164 @@ fn fit_content_width_keeps_an_unbreakable_min_content_floor() { "min-content must win when an unbreakable token is wider than the available space: {rect:?}" ); } + +#[test] +fn closed_details_lays_out_only_its_first_direct_summary() { + let tree = parse_html( + r#" + +
+ source text before the summary +
+ nested summary is not the details summary +
+ visible summary +
+ hidden descendant +
+ later summary is content +
+ + "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let id = |name| tree.get_element_by_id(name).unwrap(); + + assert!(layout.rects.contains_key(&id("closed"))); + assert!(layout.rects.contains_key(&id("first"))); + for hidden in ["before", "nested", "content", "descendant", "second"] { + assert!( + !layout.rects.contains_key(&id(hidden)), + "closed details content must not generate a box: {hidden}" + ); + } + assert!( + (layout.rects[&id("closed")].height - 20.0).abs() < 0.1, + "only the first direct summary contributes to closed details geometry" + ); +} + +#[test] +fn open_details_lays_out_all_of_its_content() { + let tree = parse_html( + r#" + +
+ visible summary +
+ visible descendant +
+ later summary is ordinary content +
+ + "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let id = |name| tree.get_element_by_id(name).unwrap(); + + for visible in [ + "open-details", + "summary", + "content", + "descendant", + "second", + ] { + assert!( + layout.rects.contains_key(&id(visible)), + "open details must retain its rendered content: {visible}" + ); + } + assert!( + (layout.rects[&id("open-details")].height - 110.0).abs() < 0.1, + "all direct children contribute to open details geometry" + ); +} + +#[test] +fn display_inherit_transitively_preserves_contents_and_block_display() { + let tree = parse_html( + r#" + +
+
+
+
+ + +
+
+
+
+
+
+
+
+ "#, + ); + let layout = layout_dom(&tree, (400.0, 200.0)); + let id = |name| tree.get_element_by_id(name).unwrap(); + + for wrapper in ["contents", "inherit-one", "inherit-two"] { + let wrapper = id(wrapper); + assert!(layout.styles[&wrapper].display_contents); + assert!( + !layout.rects.contains_key(&wrapper), + "inherited display:contents wrapper must not generate a box" + ); + } + let first = layout.rects[&id("first")]; + let second = layout.rects[&id("second")]; + assert!((first.y - second.y).abs() < 0.1); + assert!((second.x - first.x - first.width).abs() < 0.1); + + let block_child = &layout.styles[&id("block-child")]; + assert_eq!(block_child.display, obscura_render::Display::Block); + assert!(!block_child.display_contents); + + let native_cell = &layout.styles[&id("native-cell")]; + let native_inherit = &layout.styles[&id("native-inherit")]; + assert!(native_cell.internal_flex_container); + assert_eq!(native_inherit.display, obscura_render::Display::Flex); + assert!( + !native_inherit.internal_flex_container, + "display inheritance must not copy the engine's UA-only table approximation" + ); +} + +#[cfg(feature = "paint")] +#[test] +fn closed_details_content_does_not_paint() { + let tree = parse_html( + r#" + +
+ +
+
+ "#, + ); + let pixmap = + obscura_render::paint_dom(&tree, (40.0, 40.0), None).expect("details fixture paints"); + let summary = pixmap.pixel(5, 5).expect("summary pixel"); + assert!( + summary.green() > 240 && summary.red() < 20, + "the first summary must paint" + ); + let hidden = pixmap.pixel(5, 25).expect("closed content pixel"); + assert!( + hidden.red() > 240 && hidden.green() > 240 && hidden.blue() > 240, + "closed details content must leave the page background untouched" + ); +}