Support responsive images and atomic multicol

This commit is contained in:
SGavrl committed 2026-07-30 20:38:33 +00:00
1 parent 917da17410
commit 277753d31d
6 files changed
+557 -31

No files matched your search

+1 -5
View File
@@ -932,17 +932,13 @@ fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option<Vec
Some(parts) Some(parts)
} }
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport. /// Coarse `@media` evaluation against the live layout viewport.
/// ///
/// Real stylesheets format media features inconsistently /// Real stylesheets format media features inconsistently
/// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this /// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces /// strips whitespace before scanning: CSS gives no semantic meaning to spaces
/// inside `(feature: value)`, so it's safe to discard them wholesale rather /// inside `(feature: value)`, so it's safe to discard them wholesale rather
/// than special-case every formatting variant a site might use. /// than special-case every formatting variant a site might use.
pub(crate) fn media_query_applies(query: &str) -> bool {
media_query_applies_for_viewport(query, (1280.0, 720.0))
}
pub(crate) fn media_query_applies_for_viewport( pub(crate) fn media_query_applies_for_viewport(
query: &str, query: &str,
viewport: (f32, f32), viewport: (f32, f32),
+390 -1
View File
@@ -489,6 +489,17 @@ struct IfcRegistry {
/// nodes real geometry; `apply_float_continuations` uses it to narrow the /// nodes real geometry; `apply_float_continuations` uses it to narrow the
/// later intersecting bands before the final layout. /// later intersecting bands before the final layout.
float_continuations: Vec<FloatContinuation>, float_continuations: Vec<FloatContinuation>,
/// 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<MulticolBuild>,
}
struct MulticolBuild {
columns: Vec<taffy::NodeId>,
children: Vec<taffy::NodeId>,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -650,6 +661,109 @@ fn apply_presentational_hints(node: &obscura_dom::tree::Node, style: &mut crate:
} }
} }
/// The selected `<picture><source>` contributes presentation hints to its
/// associated `<img>`. 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::<f32>().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 <img> 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 /// Compute the UA + author style for every element in preorder, maintaining
/// `matcher`'s ancestor filter as we descend so descendant-combinator rules /// `matcher`'s ancestor filter as we descend so descendant-combinator rules
/// fast-reject correctly. Non-element nodes (text, comments) are skipped but /// fast-reject correctly. Non-element nodes (text, comments) are skipped but
@@ -662,6 +776,7 @@ fn cascade_walk(
styles: &mut HashMap<NodeId, crate::LayoutStyle>, styles: &mut HashMap<NodeId, crate::LayoutStyle>,
parent_props: &std::rc::Rc<HashMap<String, String>>, parent_props: &std::rc::Rc<HashMap<String, String>>,
quirks_mode: bool, quirks_mode: bool,
viewport: (f32, f32),
inherited_cell_padding: Option<f32>, inherited_cell_padding: Option<f32>,
inherited_color_scheme_dark: bool, inherited_color_scheme_dark: bool,
) { ) {
@@ -746,6 +861,7 @@ fn cascade_walk(
} }
} }
apply_presentational_hints(&node, &mut style); apply_presentational_hints(&node, &mut style);
apply_picture_source_hints(tree, id, viewport, &mut style);
let node_id = node.get_attribute("id"); let node_id = node.get_attribute("id");
let classes: Vec<String> = node let classes: Vec<String> = node
.get_attribute("class") .get_attribute("class")
@@ -792,6 +908,7 @@ fn cascade_walk(
styles, styles,
&this_props, &this_props,
quirks_mode, quirks_mode,
viewport,
descendant_cell_padding, descendant_cell_padding,
descendant_color_scheme_dark, descendant_color_scheme_dark,
); );
@@ -888,6 +1005,7 @@ pub(crate) fn layout_dom_with_web_fonts(
&mut styles, &mut styles,
&root_props, &root_props,
quirks_mode, quirks_mode,
viewport,
None, None,
false, 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); 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( if apply_float_continuations(
tree, tree,
&mut taffy_tree, &mut taffy_tree,
@@ -2273,6 +2398,9 @@ pub(crate) fn layout_dom_with_web_fonts(
); );
} }
let _ = taffy_tree.compute_layout(taffy_root, available); 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( if apply_float_continuations(
tree, tree,
&mut taffy_tree, &mut taffy_tree,
@@ -3268,6 +3396,96 @@ fn apply_table_row_geometry(
changed 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<usize>,
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<f32> = 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 /// Table-cell block alignment is a post-row-sizing operation. Feeding
/// `justify-content:center/end` into a cell while grid is still computing its /// `justify-content:center/end` into a cell while grid is still computing its
/// auto row tracks lets alignment offsets contaminate the cell's intrinsic /// 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 // 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 // width. Its intrinsic dimensions participate in an auto-sized ancestor's
// max-content measurement; once the percentage axis becomes definite, the // 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()? taffy_tree.new_leaf(taffy_style).ok()?
} else { } else {
taffy_tree.new_with_children(taffy_style, &child_ids).ok()? 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(
"<html><head><style>#columns{columns:1}@media (width >= 700px){#columns{columns:3}}</style></head>\
<body style=\"margin:0\"><div id=\"columns\" style=\"column-gap:30px;width:630px\">\
<div id=\"a\" style=\"height:180px;break-inside:avoid\"></div>\
<div id=\"b\" style=\"height:120px;break-inside:avoid\"></div>\
<div id=\"c\" style=\"height:60px;break-inside:avoid\"></div>\
<div id=\"d\" style=\"height:150px;break-inside:avoid\"></div>\
<div id=\"e\" style=\"height:90px;break-inside:avoid\"></div>\
<div id=\"f\" style=\"height:120px;break-inside:avoid\"></div>\
<div id=\"g\" style=\"height:80px;break-inside:avoid\"></div>\
<div id=\"h\" style=\"height:100px;break-inside:avoid\"></div>\
</div></body></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(
"<html><body style=\"margin:0\"><main id=\"columns\" style=\"columns:2;width:200px\">\
<p id=\"first\" style=\"height:100px;margin:0\"></p>\
<p id=\"second\" style=\"height:100px;margin:0\"></p>\
</main></body></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] #[test]
fn empty_document_is_safe() { fn empty_document_is_safe() {
// html5ever always synthesizes html/head/body, so an empty document // html5ever always synthesizes html/head/body, so an empty document
+13
View File
@@ -388,6 +388,19 @@ pub struct LayoutStyle {
pub column_gap_expression: Option<String>, pub column_gap_expression: Option<String>,
pub row_gap_expression: Option<String>, pub row_gap_expression: Option<String>,
// 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<u16>,
/// `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: <horizontal> <vertical>?` (or the `cellspacing` /// `border-spacing: <horizontal> <vertical>?` (or the `cellspacing`
/// attribute). Only meaningful on a `<table>`; taffy has no native table /// attribute). Only meaningful on a `<table>`; taffy has no native table
/// display mode, so `dom::propagate_border_spacing` distributes this down /// display mode, so `dom::propagate_border_spacing` distributes this down
+34 -24
View File
@@ -49,7 +49,7 @@ pub fn paint_dom_scrolled(
// CSS-sized image with no width/height attribute would otherwise be 0x0 // 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 // and never paint). This seeds the same cache the paint pass reads, so
// each URL is still fetched at most once. // 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); let fonts = collect_web_fonts(tree, base_url);
// Most framework pages use web fonts and many decorative SVG icons, but // 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 // only SVG text needs the page font faces. Avoid cloning/loading the page
@@ -440,7 +440,7 @@ pub fn paint_dom_scrolled(
.content_image .content_image
.as_ref() .as_ref()
.map(|url| (url.clone(), 1.0)) .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 { if let Some((src, _density)) = source {
// `visible_rect` is the border box already intersected with the // `visible_rect` is the border box already intersected with the
// ancestor overflow clip: the raster must not paint past it (a // ancestor overflow clip: the raster must not paint past it (a
@@ -2373,6 +2373,7 @@ fn paint_positioned_pseudo(
/// explicit dimensions. Keyed by the `<img>`'s NodeId. /// explicit dimensions. Keyed by the `<img>`'s NodeId.
fn collect_image_intrinsics( fn collect_image_intrinsics(
tree: &DomTree, tree: &DomTree,
viewport: (f32, f32),
base_url: Option<&str>, base_url: Option<&str>,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>, cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)> { ) -> std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)> {
@@ -2382,7 +2383,7 @@ fn collect_image_intrinsics(
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) { if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
continue; 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 Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue };
let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32))
.or_else(|| svg_intrinsic(&bytes)); .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 `<picture>` /// resolve the same URL the browser would end up with: a matching `<picture>`
/// source first, then a real candidate from `srcset`/`data-srcset`, then a /// source first, then a real candidate from `srcset`/`data-srcset`, then a
/// non-inline `src`/`data-*` URL, then any `src` (an inlined data: image). /// 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)?; let node = tree.get_node(nid)?;
// A <picture>'s preceding, type/media-matching <source> wins over the // A <picture>'s preceding, type/media-matching <source> wins over the
// <img>'s own attributes (HTML "update the source set"). // <img>'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); return Some(pick);
} }
let sizes = node.get_attribute("sizes"); let sizes = node.get_attribute("sizes");
for a in ["srcset", "data-srcset"] { for a in ["srcset", "data-srcset"] {
if let Some(v) = node.get_attribute(a) { 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); 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 /// first supported one (matching `type` and `media`), per WebKit's
/// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source /// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source
/// applied and the caller should fall back to the `<img>`'s own attributes. /// applied and the caller should fall back to the `<img>`'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 img = tree.get_node(img_nid)?;
let parent = img.parent?; let parent = img.parent?;
let is_picture = tree 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 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; continue;
} }
} }
let sizes = child.get_attribute("sizes"); 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); 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: /// Pick one URL from a `srcset` list, matching the WebKit/Blink selection:
/// normalize each `w` descriptor to an effective density (`w / source-size`, /// normalize each `w` descriptor to an effective density (`w / source-size`,
/// with the source-size taken from `sizes` or falling back to the viewport /// 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 /// 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 /// 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. /// 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; 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(); let mut cands: Vec<(f32, String)> = Vec::new();
// Parse candidates WHATWG-style: a URL is a run of non-whitespace (so a // 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 // 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 /// attribute: the first entry whose media condition holds at our assumed
/// desktop viewport (a bare entry always holds), else the viewport width. Used /// 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. /// only to convert `w` descriptors to densities, so a coarse value is fine.
fn source_size_px(sizes: Option<&str>) -> f32 { fn source_size_px(sizes: Option<&str>, viewport: (f32, f32)) -> f32 {
let Some(sizes) = sizes else { return SRCSET_VIEWPORT_W }; let Some(sizes) = sizes else { return viewport.0 };
for entry in sizes.split(',') { for entry in sizes.split(',') {
let entry = entry.trim(); let entry = entry.trim();
if entry.is_empty() { if entry.is_empty() {
@@ -2625,15 +2635,15 @@ fn source_size_px(sizes: Option<&str>) -> f32 {
} }
let (cond, len) = split_size_entry(entry); let (cond, len) = split_size_entry(entry);
if let Some(cond) = cond { if let Some(cond) = cond {
if !crate::css::media_query_applies(&cond) { if !crate::css::media_query_applies_for_viewport(&cond, viewport) {
continue; continue;
} }
} }
if let Some(px) = length_to_px(&len) { if let Some(px) = length_to_px(&len, viewport.0) {
return px; return px;
} }
} }
SRCSET_VIEWPORT_W viewport.0
} }
/// Split one `sizes` entry into its optional leading media condition and its /// Split one `sizes` entry into its optional leading media condition and its
@@ -2665,11 +2675,11 @@ fn split_size_entry(entry: &str) -> (Option<String>, String) {
/// Resolve a `sizes` length to px against the assumed viewport. `vw`/`%` scale /// 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. /// 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). /// `calc()` and other forms return `None` (the caller tries the next entry).
fn length_to_px(len: &str) -> Option<f32> { fn length_to_px(len: &str, viewport_width: f32) -> Option<f32> {
let t = len.trim().to_ascii_lowercase(); let t = len.trim().to_ascii_lowercase();
let num = |s: &str| s.trim().parse::<f32>().ok(); let num = |s: &str| s.trim().parse::<f32>().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("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 * SRCSET_VIEWPORT_W); } 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("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("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); } if let Some(v) = t.strip_suffix("em").and_then(num) { return Some(v * 16.0); }
+49 -1
View File
@@ -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), "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" => { "border-spacing" => {
let dims: Vec<f32> = value.split_whitespace().filter_map(px_value).collect(); let dims: Vec<f32> = value.split_whitespace().filter_map(px_value).collect();
if let Some(&h) = dims.first() { if let Some(&h) = dims.first() {
@@ -1179,6 +1199,13 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "grid-row-gap" | "grid-row-gap"
| "column-gap" | "column-gap"
| "grid-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-spacing"
| "border-collapse" | "border-collapse"
| "grid-template-columns" | "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<u16> {
let count = value.trim().parse::<u32>().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 { pub(crate) fn line_height_expression_is_length(value: &str) -> bool {
let lower = value.to_ascii_lowercase(); let lower = value.to_ascii_lowercase();
lower.contains('%') lower.contains('%')
@@ -3974,6 +4009,19 @@ mod tests {
assert!(flow_root.flow_root); 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] #[test]
fn animation_shorthand_preserves_settled_forward_fill_contract() { fn animation_shorthand_preserves_settled_forward_fill_contract() {
let finite = compute_style( let finite = compute_style(
@@ -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#"<html><head><style>
html,body{margin:0}
#grid{display:grid;grid-template-columns:270px}
#cell{display:grid;place-content:center;padding:16px 8px}
#logo{display:block;width:100%;max-width:320px}
</style></head><body>
<div id="grid"><div id="cell">
<svg id="logo" viewBox="0 0 320 96"><path d="M0 0h320v96z"/></svg>
</div></div>
</body></html>"#,
);
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] #[test]
fn inline_block_flex_items_keep_block_inner_flow() { fn inline_block_flex_items_keep_block_inner_flow() {
let tree = parse_html(include_str!("../../../render-repros/inline-block-flex-items.html")); 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])); assert_eq!(mixed_style.background_color, Some([0xab, 0xcd, 0xef, 0xff]));
} }
/// Chromium 150 maps width/height from the selected <picture><source> 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#"
<style>
html, body { margin:0 }
img { display:block; width:500px; height:auto }
</style>
<picture>
<source
media="(min-width:800px)"
srcset="chosen.png"
width="800"
height="400">
<img id="image" src="fallback.png" width="600" height="600">
</picture>
"#,
);
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));
}