diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 9b92d46..af0923f 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -932,17 +932,13 @@ fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option bool { - media_query_applies_for_viewport(query, (1280.0, 720.0)) -} - pub(crate) fn media_query_applies_for_viewport( query: &str, viewport: (f32, f32), diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index a276055..129bdf2 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -489,6 +489,17 @@ struct IfcRegistry { /// nodes real geometry; `apply_float_continuations` uses it to narrow the /// later intersecting bands before the final layout. float_continuations: Vec, + /// CSS multi-column containers built as a row of anonymous block + /// fragmentainers. All in-flow boxes start in the first fragmentainer so + /// the preliminary layout measures them at the final column width; the + /// post-pass then balances those already-built boxes without rebuilding + /// or reshaping their subtrees. + multicol: Vec, +} + +struct MulticolBuild { + columns: Vec, + children: Vec, } #[derive(Clone, Copy)] @@ -650,6 +661,109 @@ fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate: } } +/// The selected `` contributes presentation hints to its +/// associated ``. In particular, source width/height replace the fallback +/// image attributes and provide the pre-load aspect ratio. Gecko exposes this +/// as an extra mapped declaration block on HTMLImageElement; doing the same +/// before the author cascade preserves the correct origin and precedence. +fn apply_picture_source_hints( + tree: &DomTree, + img_id: NodeId, + viewport: (f32, f32), + style: &mut crate::LayoutStyle, +) { + let Some(img) = tree.get_node(img_id) else { return }; + if img + .as_element() + .map_or(true, |element| element.local.as_ref() != "img") + { + return; + } + let Some(parent_id) = img.parent else { return }; + let is_picture = tree + .get_node(parent_id) + .is_some_and(|parent| { + parent + .as_element() + .is_some_and(|element| element.local.as_ref() == "picture") + }); + if !is_picture { + return; + } + + let mut selected = None; + for child_id in tree.children(parent_id) { + if child_id == img_id { + break; + } + let Some(source) = tree.get_node(child_id) else { continue }; + if source + .as_element() + .map_or(true, |element| element.local.as_ref() != "source") + { + continue; + } + if source + .get_attribute("srcset") + .is_none_or(|srcset| srcset.trim().is_empty()) + { + continue; + } + if let Some(media) = source.get_attribute("media") { + if !media.trim().is_empty() + && !crate::css::media_query_applies_for_viewport(media, viewport) + { + continue; + } + } + 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" + ) + { + continue; + } + } + selected = Some(source); + break; + } + let Some(source) = selected else { return }; + let parse_dimension = |name| { + source + .get_attribute(name) + .and_then(|value| value.trim().parse::().ok()) + .filter(|value| value.is_finite() && *value > 0.0) + }; + let width = parse_dimension("width"); + let height = parse_dimension("height"); + if width.is_none() && height.is_none() { + return; + } + + // A missing source dimension explicitly maps to auto so it replaces the + // corresponding fallback presentation hint. + style.width = width.map_or(crate::Dimension::Auto, crate::Dimension::Px); + style.height = height.map_or(crate::Dimension::Auto, crate::Dimension::Px); + style.width_set = true; + style.height_set = true; + style.aspect_ratio = match (width, height) { + (Some(width), Some(height)) => Some(width / height), + _ => None, + }; +} + /// Compute the UA + author style for every element in preorder, maintaining /// `matcher`'s ancestor filter as we descend so descendant-combinator rules /// fast-reject correctly. Non-element nodes (text, comments) are skipped but @@ -662,6 +776,7 @@ fn cascade_walk( styles: &mut HashMap, parent_props: &std::rc::Rc>, quirks_mode: bool, + viewport: (f32, f32), inherited_cell_padding: Option, inherited_color_scheme_dark: bool, ) { @@ -746,6 +861,7 @@ fn cascade_walk( } } apply_presentational_hints(&node, &mut style); + apply_picture_source_hints(tree, id, viewport, &mut style); let node_id = node.get_attribute("id"); let classes: Vec = node .get_attribute("class") @@ -792,6 +908,7 @@ fn cascade_walk( styles, &this_props, quirks_mode, + viewport, descendant_cell_padding, descendant_color_scheme_dark, ); @@ -888,6 +1005,7 @@ pub(crate) fn layout_dom_with_web_fonts( &mut styles, &root_props, quirks_mode, + viewport, None, false, ); @@ -2225,6 +2343,13 @@ pub(crate) fn layout_dom_with_web_fonts( ); } let _ = taffy_tree.compute_layout_with_measure(taffy_root, available, &mut measure); + if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) { + let _ = taffy_tree.compute_layout_with_measure( + taffy_root, + available, + &mut measure, + ); + } if apply_float_continuations( tree, &mut taffy_tree, @@ -2273,6 +2398,9 @@ pub(crate) fn layout_dom_with_web_fonts( ); } let _ = taffy_tree.compute_layout(taffy_root, available); + if apply_multicol_balance(&mut taffy_tree, &ifc_items.multicol) { + let _ = taffy_tree.compute_layout(taffy_root, available); + } if apply_float_continuations( tree, &mut taffy_tree, @@ -3268,6 +3396,96 @@ fn apply_table_row_geometry( changed } +/// Balance the atomic child boxes of each CSS multi-column container. +/// +/// Gecko's multicol reflow brackets a known-infeasible and known-feasible +/// column block-size, then repeatedly reflows at a tighter candidate until it +/// finds the smallest feasible height. We use the same invariant over the +/// already measured direct child boxes: a candidate is feasible when a +/// source-ordered greedy fill needs no more than the requested column count. +/// This is O(24 * children) per multicol container and does not reshape or +/// rebuild descendants during the search. +fn apply_multicol_balance( + taffy_tree: &mut TaffyTree, + multicol: &[MulticolBuild], +) -> bool { + let mut changed = false; + for set in multicol { + let column_count = set.columns.len(); + if column_count < 2 || set.children.is_empty() { + continue; + } + let weights: Vec = set + .children + .iter() + .map(|child| { + taffy_tree.layout(*child).map_or(0.0, |layout| { + (layout.size.height + layout.margin.top + layout.margin.bottom) + .max(0.0) + }) + }) + .collect(); + let groups = column_count.min(weights.len()); + let max_weight = weights.iter().copied().fold(0.0f32, f32::max); + let total: f32 = weights.iter().sum(); + let mut low = max_weight; + let mut high = total.max(low); + if high > 0.0 { + for _ in 0..24 { + let candidate = (low + high) * 0.5; + let mut used = 1usize; + let mut current = 0.0f32; + for weight in &weights { + if current > 0.0 && current + *weight > candidate { + used += 1; + current = *weight; + } else { + current += *weight; + } + } + if used <= groups { + high = candidate; + } else { + low = candidate; + } + } + } + + // Pack from the end at the smallest feasible height. This leaves the + // first columns filled first (column-major source order) while still + // guaranteeing one item for every non-empty column. + let mut ranges = Vec::with_capacity(groups); + let mut end = weights.len(); + for remaining_groups in (1..=groups).rev() { + let earliest = remaining_groups - 1; + let mut start = end; + let mut used = 0.0f32; + while start > earliest { + let next = weights[start - 1]; + if start < end && used + next > high + 0.001 { + break; + } + start -= 1; + used += next; + } + ranges.push(start..end); + end = start; + } + ranges.reverse(); + + for (index, column) in set.columns.iter().enumerate() { + let children = ranges + .get(index) + .map(|range| &set.children[range.clone()]) + .unwrap_or(&[]); + if taffy_tree.set_children(*column, children).is_ok() { + changed = true; + } + } + } + changed +} + /// Table-cell block alignment is a post-row-sizing operation. Feeding /// `justify-content:center/end` into a cell while grid is still computing its /// auto row tracks lets alignment offsets contaminate the cell's intrinsic @@ -4345,6 +4563,31 @@ fn build( } } + // An inline SVG is an atomic replaced box in the surrounding formatting + // context. Its descendants paint inside its SVG viewport; they must not + // become CSS layout children. In particular, a viewBox supplies an + // intrinsic ratio whose auto axis is transferred from a percentage-sized + // definite axis during the grid's final pass. Treating the SVG as a normal + // empty container makes its intrinsic contribution zero during grid track + // sizing, collapsing responsive logo walls to zero-height rows. + if _name.local.as_ref() == "svg" { + if let Some(ratio) = style.aspect_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.0) + { + // A viewBox supplies a ratio but no intrinsic dimensions. The + // 300px default inline size is CSS Images' default object size; + // the ratio supplies the corresponding block size. Definite CSS + // axes still arrive through `known` and override this fallback. + let width = 300.0; + let height = width / ratio; + let context = engine.register_replaced(width, height, style); + let leaf = taffy_tree + .new_leaf_with_context(taffy_style, context) + .ok()?; + id_map.insert(leaf, id); + return Some(leaf); + } + } + // A replaced image is a measured leaf, even when CSS gives it a percentage // width. Its intrinsic dimensions participate in an auto-sized ancestor's // max-content measurement; once the percentage axis becomes definite, the @@ -4662,7 +4905,76 @@ fn build( } } - let taffy_id = if child_ids.is_empty() { + let multicol_count = style + .column_count + .filter(|count| *count > 1) + .map(usize::from); + // Box-level balancing is sound only when every generated direct box is an + // authored atomic fragment. Ordinary prose needs line-level fragmentation; + // pretending its paragraph boxes are columns would be worse than the + // current single-column fallback. This gate intentionally makes + // `break-inside:avoid` the capability boundary until inline fragment + // continuations exist. + let atomic_multicol_children = !child_ids.is_empty() + && child_ids.iter().all(|child| { + id_map + .get(child) + .and_then(|dom_id| styles.get(dom_id)) + .map_or(false, |child_style| { + child_style.break_inside_avoid + && child_style.float.is_none() + && !matches!( + child_style.position, + Some(taffy::Position::Absolute) + ) + }) + }); + let taffy_id = if let Some(column_count) = + multicol_count.filter(|_| atomic_multicol_children) + { + // A multicol container keeps its ordinary outer/block box, but its + // inner formatting context is a horizontal sequence of equal-width + // fragmentainers. Taffy does not expose CSS fragmentation, so build + // those fragmentainers explicitly. The first preliminary layout puts + // every child in column one, which measures each subtree at the exact + // final column width. `apply_multicol_balance` then performs the + // bounded Gecko-style feasible-height search and reparents the + // already measured boxes before the final layout. + taffy_style.display = taffy::style::Display::Flex; + taffy_style.flex_direction = taffy::FlexDirection::Row; + taffy_style.flex_wrap = taffy::FlexWrap::NoWrap; + if style.column_gap.is_none() { + taffy_style.gap.width = + taffy::style::LengthPercentage::length(style.font_size.unwrap_or(16.0)); + } + let column_style = taffy::Style { + display: taffy::style::Display::Block, + flex_grow: 1.0, + flex_shrink: 1.0, + flex_basis: taffy::style::Dimension::length(0.0), + min_size: taffy::Size { + width: taffy::style::Dimension::length(0.0), + height: taffy::style::Dimension::auto(), + }, + ..Default::default() + }; + let mut columns = Vec::with_capacity(column_count); + columns.push( + taffy_tree + .new_with_children(column_style.clone(), &child_ids) + .ok()?, + ); + for _ in 1..column_count { + columns.push(taffy_tree.new_leaf(column_style.clone()).ok()?); + } + let multicol = + taffy_tree.new_with_children(taffy_style, &columns).ok()?; + ifc_items.multicol.push(MulticolBuild { + columns, + children: child_ids, + }); + multicol + } else if child_ids.is_empty() { taffy_tree.new_leaf(taffy_style).ok()? } else { taffy_tree.new_with_children(taffy_style, &child_ids).ok()? @@ -5831,6 +6143,83 @@ mod tests { ); } + #[test] + fn multicol_balances_atomic_blocks_like_chromium() { + // Chromium 140 geometry for this reduction is a 630x300 container, + // 190px columns at x=0/220/440, and a 2/3/3 source-order partition. + // Explicit heights keep this a layout-algorithm regression rather + // than a font/raster comparison. + let tree = parse_html( + "\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
\ +
", + ); + let laid = layout_dom(&tree, (800.0, 600.0)); + let rect = |id: &str| laid.rects[&tree.get_element_by_id(id).unwrap()]; + let columns = rect("columns"); + assert!((columns.width - 630.0).abs() < 0.1, "{columns:?}"); + assert!((columns.height - 300.0).abs() < 0.1, "{columns:?}"); + + let expected = [ + ("a", 0.0, 0.0, 190.0, 180.0), + ("b", 0.0, 180.0, 190.0, 120.0), + ("c", 220.0, 0.0, 190.0, 60.0), + ("d", 220.0, 60.0, 190.0, 150.0), + ("e", 220.0, 210.0, 190.0, 90.0), + ("f", 440.0, 0.0, 190.0, 120.0), + ("g", 440.0, 120.0, 190.0, 80.0), + ("h", 440.0, 200.0, 190.0, 100.0), + ]; + for (id, x, y, width, height) in expected { + let child = rect(id); + assert!((child.x - columns.x - x).abs() < 0.1, "{id}: {child:?}"); + assert!((child.y - columns.y - y).abs() < 0.1, "{id}: {child:?}"); + assert!((child.width - width).abs() < 0.1, "{id}: {child:?}"); + assert!((child.height - height).abs() < 0.1, "{id}: {child:?}"); + } + + let narrow = layout_dom(&tree, (600.0, 1000.0)); + let columns_id = tree.get_element_by_id("columns").unwrap(); + let c_id = tree.get_element_by_id("c").unwrap(); + assert_eq!(narrow.styles[&columns_id].column_count, Some(1)); + assert!( + (narrow.rects[&columns_id].height - 900.0).abs() < 0.1, + "{:?}", + narrow.rects[&columns_id] + ); + assert!( + (narrow.rects[&c_id].y - narrow.rects[&columns_id].y - 300.0).abs() < 0.1, + "{:?}", + narrow.rects[&c_id] + ); + } + + #[test] + fn multicol_does_not_atomize_breakable_prose_boxes() { + let tree = parse_html( + "
\ +

\ +

\ +
", + ); + let laid = layout_dom(&tree, (400.0, 400.0)); + let columns = laid.rects[&tree.get_element_by_id("columns").unwrap()]; + let first = laid.rects[&tree.get_element_by_id("first").unwrap()]; + let second = laid.rects[&tree.get_element_by_id("second").unwrap()]; + assert!((columns.height - 200.0).abs() < 0.1, "{columns:?}"); + assert!((first.width - 200.0).abs() < 0.1, "{first:?}"); + assert!((second.x - first.x).abs() < 0.1, "{second:?}"); + assert!((second.y - first.y - 100.0).abs() < 0.1, "{second:?}"); + } + #[test] fn empty_document_is_safe() { // html5ever always synthesizes html/head/body, so an empty document diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index cdb06fe..0cd3551 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -388,6 +388,19 @@ pub struct LayoutStyle { pub column_gap_expression: Option, pub row_gap_expression: Option, + // CSS Multi-column Layout. A count greater than one creates that many + // equal-width fragmentainer columns in `dom::build`; the first layout + // pass measures the in-flow child boxes at their real column width and a + // bounded balancing pass then distributes them in column-major order. + // `None` is the initial `auto` column count. + pub column_count: Option, + /// `break-inside: avoid` makes this box an atomic balancing unit. The + /// current box-level multicol implementation cannot fragment the inside + /// of a child yet, but retaining the computed value makes that limitation + /// explicit and lets the balancing path distinguish authored break + /// avoidance as finer-grained fragmentation is added. + pub break_inside_avoid: bool, + /// `border-spacing: ?` (or the `cellspacing` /// attribute). Only meaningful on a ``; taffy has no native table /// display mode, so `dom::propagate_border_spacing` distributes this down diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 29861e5..553b022 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -49,7 +49,7 @@ pub fn paint_dom_scrolled( // CSS-sized image with no width/height attribute would otherwise be 0x0 // and never paint). This seeds the same cache the paint pass reads, so // each URL is still fetched at most once. - let mut intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); + let mut intrinsic = collect_image_intrinsics(tree, viewport, base_url, &mut image_cache); let fonts = collect_web_fonts(tree, base_url); // Most framework pages use web fonts and many decorative SVG icons, but // only SVG text needs the page font faces. Avoid cloning/loading the page @@ -440,7 +440,7 @@ pub fn paint_dom_scrolled( .content_image .as_ref() .map(|url| (url.clone(), 1.0)) - .or_else(|| resolve_img_url(tree, nid)); + .or_else(|| resolve_img_url(tree, nid, viewport)); if let Some((src, _density)) = source { // `visible_rect` is the border box already intersected with the // ancestor overflow clip: the raster must not paint past it (a @@ -2373,6 +2373,7 @@ fn paint_positioned_pseudo( /// explicit dimensions. Keyed by the ``'s NodeId. fn collect_image_intrinsics( tree: &DomTree, + viewport: (f32, f32), base_url: Option<&str>, cache: &mut std::collections::HashMap>>, ) -> std::collections::HashMap { @@ -2382,7 +2383,7 @@ fn collect_image_intrinsics( if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) { continue; } - let Some((url, density)) = resolve_img_url(tree, nid) else { continue }; + let Some((url, density)) = resolve_img_url(tree, nid, viewport) else { continue }; let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue }; let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) .or_else(|| svg_intrinsic(&bytes)); @@ -2447,17 +2448,21 @@ fn collect_content_image_intrinsics( /// resolve the same URL the browser would end up with: a matching `` /// source first, then a real candidate from `srcset`/`data-srcset`, then a /// non-inline `src`/`data-*` URL, then any `src` (an inlined data: image). -fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<(String, f32)> { +fn resolve_img_url( + tree: &DomTree, + nid: obscura_dom::tree::NodeId, + viewport: (f32, f32), +) -> Option<(String, f32)> { let node = tree.get_node(nid)?; // A 's preceding, type/media-matching wins over the // 's own attributes (HTML "update the source set"). - if let Some(pick) = picture_source_url(tree, nid) { + if let Some(pick) = picture_source_url(tree, nid, viewport) { return Some(pick); } let sizes = node.get_attribute("sizes"); for a in ["srcset", "data-srcset"] { if let Some(v) = node.get_attribute(a) { - if let Some(pick) = best_srcset_candidate(v, sizes) { + if let Some(pick) = best_srcset_candidate(v, sizes, viewport) { return Some(pick); } } @@ -2489,7 +2494,11 @@ fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<(St /// first supported one (matching `type` and `media`), per WebKit's /// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source /// applied and the caller should fall back to the ``'s own attributes. -fn picture_source_url(tree: &DomTree, img_nid: obscura_dom::tree::NodeId) -> Option<(String, f32)> { +fn picture_source_url( + tree: &DomTree, + img_nid: obscura_dom::tree::NodeId, + viewport: (f32, f32), +) -> Option<(String, f32)> { let img = tree.get_node(img_nid)?; let parent = img.parent?; let is_picture = tree @@ -2518,12 +2527,14 @@ fn picture_source_url(tree: &DomTree, img_nid: obscura_dom::tree::NodeId) -> Opt } } if let Some(m) = child.get_attribute("media") { - if !m.trim().is_empty() && !crate::css::media_query_applies(m) { + if !m.trim().is_empty() + && !crate::css::media_query_applies_for_viewport(m, viewport) + { continue; } } let sizes = child.get_attribute("sizes"); - if let Some(u) = best_srcset_candidate(srcset, sizes) { + if let Some(u) = best_srcset_candidate(srcset, sizes, viewport) { return Some(u); } } @@ -2541,11 +2552,6 @@ fn source_type_supported(t: &str) -> bool { ) } -/// Assumed layout viewport width (matches the desktop width the `@media` -/// cascade evaluates against in `css.rs`). Used to turn `w` descriptors and -/// `vw`/`%` source sizes into effective pixel densities. -const SRCSET_VIEWPORT_W: f32 = 1280.0; - /// 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 @@ -2556,9 +2562,13 @@ const SRCSET_VIEWPORT_W: f32 = 1280.0; /// x-descriptor (or, for w-descriptors, width / source-size): the factor the /// file's raw pixels must be divided by to get CSS px. Laying out with raw /// pixels made every 2x responsive image occupy twice its design size. -fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<(String, f32)> { +fn best_srcset_candidate( + srcset: &str, + sizes: Option<&str>, + viewport: (f32, f32), +) -> Option<(String, f32)> { const DPR: f32 = 1.0; - let source_size = source_size_px(sizes); + let source_size = source_size_px(sizes, viewport); let mut cands: Vec<(f32, String)> = Vec::new(); // Parse candidates WHATWG-style: a URL is a run of non-whitespace (so a // data: URI's internal commas stay part of it, unlike a naive split on @@ -2616,8 +2626,8 @@ fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<(String, f /// attribute: the first entry whose media condition holds at our assumed /// desktop viewport (a bare entry always holds), else the viewport width. Used /// only to convert `w` descriptors to densities, so a coarse value is fine. -fn source_size_px(sizes: Option<&str>) -> f32 { - let Some(sizes) = sizes else { return SRCSET_VIEWPORT_W }; +fn source_size_px(sizes: Option<&str>, viewport: (f32, f32)) -> f32 { + let Some(sizes) = sizes else { return viewport.0 }; for entry in sizes.split(',') { let entry = entry.trim(); if entry.is_empty() { @@ -2625,15 +2635,15 @@ fn source_size_px(sizes: Option<&str>) -> f32 { } let (cond, len) = split_size_entry(entry); if let Some(cond) = cond { - if !crate::css::media_query_applies(&cond) { + if !crate::css::media_query_applies_for_viewport(&cond, viewport) { continue; } } - if let Some(px) = length_to_px(&len) { + if let Some(px) = length_to_px(&len, viewport.0) { return px; } } - SRCSET_VIEWPORT_W + viewport.0 } /// Split one `sizes` entry into its optional leading media condition and its @@ -2665,11 +2675,11 @@ fn split_size_entry(entry: &str) -> (Option, String) { /// Resolve a `sizes` length to px against the assumed viewport. `vw`/`%` scale /// by the viewport width; `px` is literal; `em`/`rem` use the 16px root. /// `calc()` and other forms return `None` (the caller tries the next entry). -fn length_to_px(len: &str) -> Option { +fn length_to_px(len: &str, viewport_width: f32) -> Option { let t = len.trim().to_ascii_lowercase(); let num = |s: &str| s.trim().parse::().ok(); - if let Some(v) = t.strip_suffix("vw").and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); } - if let Some(v) = t.strip_suffix('%').and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); } + if let Some(v) = t.strip_suffix("vw").and_then(num) { return Some(v / 100.0 * viewport_width); } + if let Some(v) = t.strip_suffix('%').and_then(num) { return Some(v / 100.0 * viewport_width); } if let Some(v) = t.strip_suffix("px").and_then(num) { return Some(v); } if let Some(v) = t.strip_suffix("rem").and_then(num) { return Some(v * 16.0); } if let Some(v) = t.strip_suffix("em").and_then(num) { return Some(v * 16.0); } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 688631a..c6a35f1 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -951,7 +951,27 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } } "row-gap" | "grid-row-gap" => apply_gap_value(style, true, value), - "column-gap" | "grid-column-gap" => apply_gap_value(style, false, value), + "column-gap" | "grid-column-gap" | "-webkit-column-gap" => { + apply_gap_value(style, false, value) + } + "column-count" | "-webkit-column-count" => { + style.column_count = parse_column_count(value); + } + "columns" | "-webkit-columns" => { + // `columns` is `column-width || column-count`; width remains auto + // in our box-fragmentation subset, but the shorthand still resets + // an earlier count when it contains no positive integer. + style.column_count = split_ws_paren(value) + .into_iter() + .find_map(parse_column_count); + } + "break-inside" => { + style.break_inside_avoid = + matches!(value.trim().to_ascii_lowercase().as_str(), "avoid" | "avoid-column"); + } + "-webkit-column-break-inside" => { + style.break_inside_avoid = value.trim().eq_ignore_ascii_case("avoid"); + } "border-spacing" => { let dims: Vec = value.split_whitespace().filter_map(px_value).collect(); if let Some(&h) = dims.first() { @@ -1179,6 +1199,13 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "grid-row-gap" | "column-gap" | "grid-column-gap" + | "-webkit-column-gap" + | "column-count" + | "-webkit-column-count" + | "columns" + | "-webkit-columns" + | "break-inside" + | "-webkit-column-break-inside" | "border-spacing" | "border-collapse" | "grid-template-columns" @@ -2999,6 +3026,14 @@ fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) { } } +fn parse_column_count(value: &str) -> Option { + let count = value.trim().parse::().ok()?; + // Keep pathological author input from allocating thousands of anonymous + // fragmentainers. Real page layouts use single-digit counts; 64 retains + // ample useful range while keeping construction and balancing bounded. + (count > 0).then(|| count.min(64) as u16) +} + pub(crate) fn line_height_expression_is_length(value: &str) -> bool { let lower = value.to_ascii_lowercase(); lower.contains('%') @@ -3974,6 +4009,19 @@ mod tests { assert!(flow_root.flow_root); } + #[test] + fn parses_multicol_count_shorthand_and_break_avoidance() { + let shorthand = compute_style( + "div", + Some("columns: 240px 3; break-inside: avoid-column"), + ); + assert_eq!(shorthand.column_count, Some(3)); + assert!(shorthand.break_inside_avoid); + + let reset = compute_style("div", Some("column-count: 4; columns: auto")); + assert_eq!(reset.column_count, None); + } + #[test] fn animation_shorthand_preserves_settled_forward_fill_contract() { let finite = compute_style( diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index b826f87..0684323 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -744,6 +744,39 @@ fn inline_svg_derives_auto_height_from_view_box() { ); } +/// Chromium 150 resolves a ratio-only inline SVG as an atomic replaced item +/// during both intrinsic grid track sizing and the final percentage-width +/// pass. The 270px track leaves 254px after the grid item's inline padding; +/// the 320:96 viewBox then contributes 76.1875px of height. +#[test] +fn ratio_only_inline_svg_sizes_inside_an_auto_grid_row() { + let tree = parse_html( + r#" +
+ +
+ "#, + ); + let layout = layout_dom(&tree, (800.0, 600.0)); + let rect = |id| layout.rects[&tree.get_element_by_id(id).unwrap()]; + let cell = rect("cell"); + let logo = rect("logo"); + assert!((logo.width - 254.0).abs() < 0.01, "SVG width: {logo:?}"); + assert!( + (logo.height - 76.1875).abs() < 0.01, + "viewBox ratio must transfer the final grid width: {logo:?}" + ); + assert!( + (cell.height - 108.1875).abs() < 0.01, + "SVG contribution plus block padding must size the auto row: {cell:?}" + ); +} + #[test] fn inline_block_flex_items_keep_block_inner_flow() { let tree = parse_html(include_str!("../../../render-repros/inline-block-flex-items.html")); @@ -1627,3 +1660,40 @@ fn light_dark_uses_the_final_inherited_color_scheme_across_the_cascade() { ); assert_eq!(mixed_style.background_color, Some([0xab, 0xcd, 0xef, 0xff])); } + +/// Chromium 150 maps width/height from the selected onto +/// the associated image as presentation hints. The selected 800/400 source +/// therefore reserves a 500x250 box before it loads; below the media +/// breakpoint, the fallback img's 600/600 ratio reserves 500x500. +#[test] +fn selected_picture_source_dimensions_override_fallback_image_ratio() { + let tree = parse_html( + r#" + + + + + + "#, + ); + let image = tree.get_element_by_id("image").unwrap(); + + let wide = layout_dom(&tree, (1000.0, 600.0)); + let wide_rect = wide.rects[&image]; + assert!((wide_rect.width - 500.0).abs() < 0.01, "{wide_rect:?}"); + assert!((wide_rect.height - 250.0).abs() < 0.01, "{wide_rect:?}"); + assert_eq!(wide.styles[&image].aspect_ratio, Some(2.0)); + + let narrow = layout_dom(&tree, (700.0, 600.0)); + let narrow_rect = narrow.rects[&image]; + assert!((narrow_rect.width - 500.0).abs() < 0.01, "{narrow_rect:?}"); + assert!((narrow_rect.height - 500.0).abs() < 0.01, "{narrow_rect:?}"); + assert_eq!(narrow.styles[&image].aspect_ratio, Some(1.0)); +}