Support responsive images and atomic multicol
This commit is contained in:
1 parent
917da17410
commit
277753d31d
6 files changed
+557
-31
No files matched your search
@@ -932,17 +932,13 @@ fn split_supports_operator<'a>(condition: &'a str, operator: &str) -> Option<Vec
|
||||
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
|
||||
/// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this
|
||||
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
|
||||
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
||||
/// 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(
|
||||
query: &str,
|
||||
viewport: (f32, f32),
|
||||
|
||||
@@ -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<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)]
|
||||
@@ -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
|
||||
/// `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<NodeId, crate::LayoutStyle>,
|
||||
parent_props: &std::rc::Rc<HashMap<String, String>>,
|
||||
quirks_mode: bool,
|
||||
viewport: (f32, f32),
|
||||
inherited_cell_padding: Option<f32>,
|
||||
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<String> = 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<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
|
||||
/// `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(
|
||||
"<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]
|
||||
fn empty_document_is_safe() {
|
||||
// html5ever always synthesizes html/head/body, so an empty document
|
||||
|
||||
@@ -388,6 +388,19 @@ pub struct LayoutStyle {
|
||||
pub column_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`
|
||||
/// attribute). Only meaningful on a `<table>`; taffy has no native table
|
||||
/// display mode, so `dom::propagate_border_spacing` distributes this down
|
||||
|
||||
@@ -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 `<img>`'s NodeId.
|
||||
fn collect_image_intrinsics(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> 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) {
|
||||
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 `<picture>`
|
||||
/// 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 <picture>'s preceding, type/media-matching <source> wins over the
|
||||
// <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);
|
||||
}
|
||||
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 `<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 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>, 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<f32> {
|
||||
fn length_to_px(len: &str, viewport_width: f32) -> Option<f32> {
|
||||
let t = len.trim().to_ascii_lowercase();
|
||||
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('%').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); }
|
||||
|
||||
@@ -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<f32> = 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<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 {
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user