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)
|
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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user