Compress percentage-constrained images
This commit is contained in:
@@ -2027,12 +2027,6 @@ pub(crate) fn layout_dom_with_web_fonts(
|
||||
s.aspect_ratio = Some(iw / ih);
|
||||
s.aspect_ratio_is_mapped = false;
|
||||
}
|
||||
let w_auto = matches!(s.width, crate::Dimension::Auto);
|
||||
let h_auto = matches!(s.height, crate::Dimension::Auto);
|
||||
if w_auto && h_auto {
|
||||
s.width = crate::Dimension::Px(iw);
|
||||
s.height = crate::Dimension::Px(ih);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5101,6 +5095,83 @@ fn build(
|
||||
// measure callback derives the other axis through the intrinsic ratio.
|
||||
if _name.local.as_ref() == "img" {
|
||||
if let Some((width, height)) = style.intrinsic_size {
|
||||
let intrinsic_ratio =
|
||||
if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 {
|
||||
width / height
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let preferred_ratio = style
|
||||
.aspect_ratio
|
||||
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
||||
.unwrap_or(intrinsic_ratio);
|
||||
|
||||
// Taffy has one preferred-size field but CSS intrinsic sizing has
|
||||
// separate preferred and min-content contributions. For a proper
|
||||
// replaced element with a percentage maximum, encode the
|
||||
// equivalent min(preferred-width, percentage-max) function by
|
||||
// swapping the two operands: the percentage is indefinite during
|
||||
// intrinsic contribution sizing and resolves in final layout,
|
||||
// while the fixed maximum retains the replaced box's preferred
|
||||
// width. This applies to authored sizes and intrinsic auto sizes
|
||||
// alike; it is not tied to HTML width attributes.
|
||||
if let crate::Dimension::Percent(maximum) = style.max_width {
|
||||
let preferred_width = match style.width {
|
||||
crate::Dimension::Px(width) => Some(width),
|
||||
crate::Dimension::Auto => match style.height {
|
||||
crate::Dimension::Px(height) => Some(height * preferred_ratio),
|
||||
_ => Some(
|
||||
crate::inline::constrained_auto_replaced_size(
|
||||
width, height, style,
|
||||
)
|
||||
.width,
|
||||
),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(preferred_width) = preferred_width {
|
||||
taffy_style.size.width = taffy::Dimension::percent(maximum);
|
||||
taffy_style.max_size.width =
|
||||
taffy::Dimension::length(preferred_width.max(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
// When both preferred axes are auto and all min/max constraints
|
||||
// are definite, resolve CSS2's ratio-preserving constraint table
|
||||
// before handing the leaf to taffy. Otherwise taffy's block
|
||||
// stretch supplies a containing-block width as a known dimension
|
||||
// and independently clamps the height, distorting the ratio.
|
||||
let has_definite_constraint = [
|
||||
style.min_width,
|
||||
style.min_height,
|
||||
style.max_width,
|
||||
style.max_height,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|dimension| matches!(dimension, crate::Dimension::Px(_)));
|
||||
let has_percentage_constraint = [
|
||||
style.min_width,
|
||||
style.min_height,
|
||||
style.max_width,
|
||||
style.max_height,
|
||||
]
|
||||
.into_iter()
|
||||
.any(|dimension| matches!(dimension, crate::Dimension::Percent(_)))
|
||||
|| style.size_expressions[2..=5]
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|expression| expression.contains('%'));
|
||||
if matches!(style.width, crate::Dimension::Auto)
|
||||
&& matches!(style.height, crate::Dimension::Auto)
|
||||
&& has_definite_constraint
|
||||
&& !has_percentage_constraint
|
||||
{
|
||||
let constrained =
|
||||
crate::inline::constrained_auto_replaced_size(width, height, style);
|
||||
taffy_style.size.width = taffy::Dimension::length(constrained.width);
|
||||
taffy_style.size.height = taffy::Dimension::length(constrained.height);
|
||||
}
|
||||
|
||||
// When an auto axis has a definite min/max constraint, the
|
||||
// measured replaced leaf must own intrinsic-ratio transfer so it
|
||||
// can clamp the derived size. Leaving the same ratio on taffy's
|
||||
|
||||
@@ -414,11 +414,18 @@ const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1);
|
||||
#[derive(Clone, Copy)]
|
||||
struct ReplacedItem {
|
||||
intrinsic_width: f32,
|
||||
intrinsic_height: f32,
|
||||
preferred_width: Option<f32>,
|
||||
preferred_height: Option<f32>,
|
||||
preferred_ratio: f32,
|
||||
min_width: Option<f32>,
|
||||
min_height: Option<f32>,
|
||||
max_width: Option<f32>,
|
||||
max_height: Option<f32>,
|
||||
/// CSS Sizing's cyclic-percentage rule makes a proper replaced element's
|
||||
/// inline min-content contribution zero when its preferred or maximum
|
||||
/// inline size contains a percentage. The natural size still participates
|
||||
/// in max-content sizing and in the final definite layout.
|
||||
zero_inline_min_content: bool,
|
||||
}
|
||||
|
||||
impl ReplacedItem {
|
||||
@@ -427,13 +434,36 @@ impl ReplacedItem {
|
||||
Dimension::Px(value) => Some(value.max(0.0)),
|
||||
_ => None,
|
||||
};
|
||||
let expression_has_percentage = |index: usize| {
|
||||
style.size_expressions[index]
|
||||
.as_deref()
|
||||
.map_or(false, |expression| expression.contains('%'))
|
||||
};
|
||||
let intrinsic_ratio = if width.is_finite()
|
||||
&& height.is_finite()
|
||||
&& width > 0.0
|
||||
&& height > 0.0
|
||||
{
|
||||
width / height
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
ReplacedItem {
|
||||
intrinsic_width: width,
|
||||
intrinsic_height: height,
|
||||
preferred_width: px(style.width),
|
||||
preferred_height: px(style.height),
|
||||
preferred_ratio: style
|
||||
.aspect_ratio
|
||||
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
||||
.unwrap_or(intrinsic_ratio),
|
||||
min_width: px(style.min_width),
|
||||
min_height: px(style.min_height),
|
||||
max_width: px(style.max_width),
|
||||
max_height: px(style.max_height),
|
||||
zero_inline_min_content: matches!(style.width, Dimension::Percent(_))
|
||||
|| matches!(style.max_width, Dimension::Percent(_))
|
||||
|| expression_has_percentage(0)
|
||||
|| expression_has_percentage(4),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,26 +473,101 @@ impl ReplacedItem {
|
||||
min.map_or(value, |min| value.max(min))
|
||||
}
|
||||
|
||||
/// Apply the CSS 2.1 10.4/10.7 constraint table for a replaced element
|
||||
/// whose preferred width and height are both auto. Unlike independently
|
||||
/// clamping the two axes, this transfers a one-axis min/max constraint
|
||||
/// through the preferred aspect ratio whenever the constraints allow it.
|
||||
fn constrain_auto_size(self, tentative: taffy::Size<f32>) -> taffy::Size<f32> {
|
||||
let min_width = self.min_width.unwrap_or(0.0);
|
||||
let min_height = self.min_height.unwrap_or(0.0);
|
||||
let max_width = self.max_width.unwrap_or(f32::INFINITY).max(min_width);
|
||||
let max_height = self.max_height.unwrap_or(f32::INFINITY).max(min_height);
|
||||
let width = tentative.width;
|
||||
let height = tentative.height;
|
||||
|
||||
let height_at_max_width = (max_width / self.preferred_ratio).max(min_height);
|
||||
let height_at_min_width = (min_width / self.preferred_ratio).min(max_height);
|
||||
let width_at_max_height = (max_height * self.preferred_ratio).max(min_width);
|
||||
let width_at_min_height = (min_height * self.preferred_ratio).min(max_width);
|
||||
|
||||
let (width, height) = if width > max_width {
|
||||
if height > max_height {
|
||||
if max_width * height <= max_height * width {
|
||||
(max_width, height_at_max_width)
|
||||
} else {
|
||||
(width_at_max_height, max_height)
|
||||
}
|
||||
} else {
|
||||
(max_width, height_at_max_width)
|
||||
}
|
||||
} else if width < min_width {
|
||||
if height < min_height {
|
||||
if min_width * height <= min_height * width {
|
||||
(width_at_min_height, min_height)
|
||||
} else {
|
||||
(min_width, height_at_min_width)
|
||||
}
|
||||
} else {
|
||||
(min_width, height_at_min_width)
|
||||
}
|
||||
} else if height > max_height {
|
||||
(width_at_max_height, max_height)
|
||||
} else if height < min_height {
|
||||
(width_at_min_height, min_height)
|
||||
} else {
|
||||
(width, height)
|
||||
};
|
||||
|
||||
taffy::Size { width, height }
|
||||
}
|
||||
|
||||
fn size(self, known: taffy::Size<Option<f32>>) -> taffy::Size<f32> {
|
||||
let (width, height) = match (known.width, known.height) {
|
||||
(Some(width), Some(height)) => (width, height),
|
||||
(Some(width), None) => (
|
||||
width,
|
||||
width * self.intrinsic_height / self.intrinsic_width,
|
||||
width / self.preferred_ratio,
|
||||
),
|
||||
(None, Some(height)) => (
|
||||
height * self.intrinsic_width / self.intrinsic_height,
|
||||
height * self.preferred_ratio,
|
||||
height,
|
||||
),
|
||||
(None, None) => (self.intrinsic_width, self.intrinsic_height),
|
||||
(None, None) => match (self.preferred_width, self.preferred_height) {
|
||||
(Some(width), Some(height)) => (width, height),
|
||||
(Some(width), None) => (width, width / self.preferred_ratio),
|
||||
(None, Some(height)) => (height * self.preferred_ratio, height),
|
||||
(None, None) => (
|
||||
self.intrinsic_width,
|
||||
self.intrinsic_width / self.preferred_ratio,
|
||||
),
|
||||
},
|
||||
};
|
||||
taffy::Size {
|
||||
width: Self::clamp(width, self.min_width, self.max_width),
|
||||
height: Self::clamp(height, self.min_height, self.max_height),
|
||||
let tentative = taffy::Size { width, height };
|
||||
if self.preferred_width.is_none()
|
||||
&& self.preferred_height.is_none()
|
||||
&& (known.width.is_none() || known.height.is_none())
|
||||
{
|
||||
self.constrain_auto_size(tentative)
|
||||
} else {
|
||||
taffy::Size {
|
||||
width: Self::clamp(width, self.min_width, self.max_width),
|
||||
height: Self::clamp(height, self.min_height, self.max_height),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn constrained_auto_replaced_size(
|
||||
width: f32,
|
||||
height: f32,
|
||||
style: &LayoutStyle,
|
||||
) -> taffy::Size<f32> {
|
||||
ReplacedItem::from_style(width, height, style).size(taffy::Size {
|
||||
width: None,
|
||||
height: None,
|
||||
})
|
||||
}
|
||||
|
||||
impl Default for TextEngine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -822,7 +927,15 @@ impl TextEngine {
|
||||
available: taffy::Size<taffy::AvailableSpace>,
|
||||
) -> taffy::Size<f32> {
|
||||
if idx & REPLACED_CONTEXT_BIT != 0 {
|
||||
return self.replaced[idx & !REPLACED_CONTEXT_BIT].size(known);
|
||||
let replaced = self.replaced[idx & !REPLACED_CONTEXT_BIT];
|
||||
let mut size = replaced.size(known);
|
||||
if replaced.zero_inline_min_content
|
||||
&& known.width.is_none()
|
||||
&& matches!(available.width, taffy::AvailableSpace::MinContent)
|
||||
{
|
||||
size.width = 0.0;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let width = known.width.or(match available.width {
|
||||
taffy::AvailableSpace::Definite(width) => Some(width),
|
||||
@@ -1523,6 +1636,108 @@ mod tests {
|
||||
assert_eq!(normal_line_height(13.0, MONO_FAMILY), 15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaced_percentage_math_only_zeroes_inline_min_content() {
|
||||
for expression_index in [0, 4] {
|
||||
let mut engine = TextEngine::new();
|
||||
let mut style = LayoutStyle::default();
|
||||
style.size_expressions[expression_index] =
|
||||
Some("calc(100% - 1px)".to_string());
|
||||
let item = engine.register_replaced(800.0, 400.0, &style);
|
||||
let unknown = taffy::Size {
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
let min_content = engine.measure_taffy(
|
||||
item,
|
||||
unknown,
|
||||
taffy::Size {
|
||||
width: taffy::AvailableSpace::MinContent,
|
||||
height: taffy::AvailableSpace::MaxContent,
|
||||
},
|
||||
);
|
||||
let max_content = engine.measure_taffy(
|
||||
item,
|
||||
unknown,
|
||||
taffy::Size {
|
||||
width: taffy::AvailableSpace::MaxContent,
|
||||
height: taffy::AvailableSpace::MaxContent,
|
||||
},
|
||||
);
|
||||
let final_size = engine.measure_taffy(
|
||||
item,
|
||||
taffy::Size {
|
||||
width: Some(300.0),
|
||||
height: None,
|
||||
},
|
||||
taffy::Size {
|
||||
width: taffy::AvailableSpace::Definite(300.0),
|
||||
height: taffy::AvailableSpace::MaxContent,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(min_content.width, 0.0);
|
||||
assert_eq!(max_content.width, 800.0);
|
||||
assert_eq!(max_content.height, 400.0);
|
||||
assert_eq!(final_size.width, 300.0);
|
||||
assert_eq!(final_size.height, 150.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_auto_replaced_constraints_transfer_through_preferred_ratio() {
|
||||
let unknown = taffy::Size {
|
||||
width: None,
|
||||
height: None,
|
||||
};
|
||||
let measure = |style: &LayoutStyle| {
|
||||
ReplacedItem::from_style(512.0, 323.0, style).size(unknown)
|
||||
};
|
||||
|
||||
let max_height = LayoutStyle {
|
||||
max_height: Dimension::Px(128.0),
|
||||
..Default::default()
|
||||
};
|
||||
let size = measure(&max_height);
|
||||
assert!((size.width - 202.89783).abs() < 0.001, "{size:?}");
|
||||
assert!((size.height - 128.0).abs() < 0.001, "{size:?}");
|
||||
|
||||
let max_width = LayoutStyle {
|
||||
max_width: Dimension::Px(256.0),
|
||||
..Default::default()
|
||||
};
|
||||
let size = measure(&max_width);
|
||||
assert!((size.width - 256.0).abs() < 0.001, "{size:?}");
|
||||
assert!((size.height - 161.5).abs() < 0.001, "{size:?}");
|
||||
|
||||
let min_width = LayoutStyle {
|
||||
min_width: Dimension::Px(1024.0),
|
||||
..Default::default()
|
||||
};
|
||||
let size = measure(&min_width);
|
||||
assert!((size.width - 1024.0).abs() < 0.001, "{size:?}");
|
||||
assert!((size.height - 646.0).abs() < 0.001, "{size:?}");
|
||||
|
||||
let min_height = LayoutStyle {
|
||||
min_height: Dimension::Px(646.0),
|
||||
..Default::default()
|
||||
};
|
||||
let size = measure(&min_height);
|
||||
assert!((size.width - 1024.0).abs() < 0.001, "{size:?}");
|
||||
assert!((size.height - 646.0).abs() < 0.001, "{size:?}");
|
||||
|
||||
// A non-intrinsic authored ratio is the preferred ratio used for
|
||||
// transfer, rather than the decoded resource's natural ratio.
|
||||
let authored_ratio = LayoutStyle {
|
||||
max_height: Dimension::Px(128.0),
|
||||
aspect_ratio: Some(4.0),
|
||||
..Default::default()
|
||||
};
|
||||
let size = measure(&authored_ratio);
|
||||
assert!((size.width - 512.0).abs() < 0.001, "{size:?}");
|
||||
assert!((size.height - 128.0).abs() < 0.001, "{size:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_font_weights_follow_css_search_order() {
|
||||
assert_eq!(match_font_weight(500, &[400, 700]), 400);
|
||||
|
||||
@@ -2013,3 +2013,137 @@ fn authored_aspect_ratio_wins_over_decoded_image_ratio() {
|
||||
assert!((rect.height - 125.0).abs() < 0.01, "{rect:?}");
|
||||
assert_eq!(layout.styles[&image].aspect_ratio, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_auto_replaced_max_height_preserves_intrinsic_ratio() {
|
||||
let tree = parse_html(
|
||||
r#"
|
||||
<style>
|
||||
html, body { margin:0 }
|
||||
img { display:block; max-height:128px }
|
||||
</style>
|
||||
<img id="image" src="image.png">
|
||||
"#,
|
||||
);
|
||||
let image = tree.get_element_by_id("image").unwrap();
|
||||
let intrinsic = HashMap::from([(image, (512.0, 323.0))]);
|
||||
let layout = layout_dom_with_images(&tree, (1000.0, 600.0), &intrinsic);
|
||||
let rect = layout.rects[&image];
|
||||
|
||||
// The layout result is pixel-snapped; the exact pre-snap result is
|
||||
// independently covered by the inline constraint-table unit test.
|
||||
assert_eq!((rect.width, rect.height), (203.0, 128.0), "{rect:?}");
|
||||
}
|
||||
|
||||
/// Chromium 150 applies CSS Sizing's compressible replaced-element rule when
|
||||
/// `max-width:100%` is cyclic during intrinsic sizing. Each image contributes
|
||||
/// zero to its column's automatic minimum, so the row can flex-shrink the
|
||||
/// natural 303/200/500/200/293px bases into exactly 1184px including gaps.
|
||||
#[test]
|
||||
fn percentage_max_replaced_items_shrink_in_a_flex_row() {
|
||||
let tree = parse_html(
|
||||
r#"
|
||||
<style>
|
||||
html, body { margin:0 }
|
||||
#row { display:flex; gap:8px; width:1184px; overflow:hidden }
|
||||
.col { display:flex; flex-direction:column; gap:8px }
|
||||
img { display:block; max-width:100%; height:auto }
|
||||
</style>
|
||||
<div id="row">
|
||||
<div id="c1" class="col"><img id="i1" width="303" height="255"></div>
|
||||
<div id="c2" class="col"><img id="i2" width="200" height="400"></div>
|
||||
<div id="c3" class="col"><img id="i3" width="500" height="250"></div>
|
||||
<div id="c4" class="col"><img id="i4" width="200" height="400"></div>
|
||||
<div id="c5" class="col"><img id="i5" width="293" height="354"></div>
|
||||
</div>
|
||||
"#,
|
||||
);
|
||||
let ids = ["i1", "i2", "i3", "i4", "i5"]
|
||||
.map(|id| tree.get_element_by_id(id).unwrap());
|
||||
let intrinsic = HashMap::from([
|
||||
(ids[0], (303.0, 255.0)),
|
||||
(ids[1], (200.0, 400.0)),
|
||||
(ids[2], (500.0, 250.0)),
|
||||
(ids[3], (200.0, 400.0)),
|
||||
(ids[4], (293.0, 354.0)),
|
||||
]);
|
||||
let layout = layout_dom_with_images(&tree, (1280.0, 1000.0), &intrinsic);
|
||||
let row = layout.rects[&tree.get_element_by_id("row").unwrap()];
|
||||
let columns = ["c1", "c2", "c3", "c4", "c5"]
|
||||
.map(|id| layout.rects[&tree.get_element_by_id(id).unwrap()]);
|
||||
let images = ids.map(|id| layout.rects[&id]);
|
||||
let expected_widths: [f32; 5] =
|
||||
[233.328125, 154.0, 385.03125, 154.015625, 225.625];
|
||||
|
||||
assert!((row.width - 1184.0).abs() < 0.01, "{row:?}");
|
||||
assert!(
|
||||
(row.height - 308.03125).abs() < 0.05,
|
||||
"row={row:?}, columns={columns:?}, images={images:?}"
|
||||
);
|
||||
for (index, expected) in expected_widths.into_iter().enumerate() {
|
||||
// Taffy exposes device-pixel-snapped border boxes; retain Chromium's
|
||||
// fractional flex result above as the oracle, then compare its snapped
|
||||
// width at this one-CSS-pixel device scale.
|
||||
let expected = expected.round();
|
||||
assert!(
|
||||
(columns[index].width - expected).abs() < 0.01,
|
||||
"column {index}: {:?}",
|
||||
columns[index]
|
||||
);
|
||||
assert!(
|
||||
(images[index].width - expected).abs() < 0.01,
|
||||
"image {index}: {:?}",
|
||||
images[index]
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
((images[0].width / images[0].height) - (303.0 / 255.0)).abs() < 0.001,
|
||||
"final layout must preserve the first intrinsic ratio: {:?}",
|
||||
images[0]
|
||||
);
|
||||
assert!(
|
||||
((images[4].width / images[4].height) - (293.0 / 354.0)).abs() < 0.001,
|
||||
"final layout must preserve the last intrinsic ratio: {:?}",
|
||||
images[4]
|
||||
);
|
||||
}
|
||||
|
||||
/// The cyclic percentage affects only the min-content contribution. With
|
||||
/// positive free space, the same boxes retain their natural max-content bases.
|
||||
#[test]
|
||||
fn percentage_max_replaced_items_keep_natural_max_content_size() {
|
||||
let tree = parse_html(
|
||||
r#"
|
||||
<style>
|
||||
html, body { margin:0 }
|
||||
#row { display:flex; gap:8px; width:2000px }
|
||||
.col { display:flex; flex-direction:column }
|
||||
img { display:block; max-width:100%; height:auto }
|
||||
</style>
|
||||
<div id="row">
|
||||
<div id="c1" class="col"><img id="i1" width="303" height="255"></div>
|
||||
<div id="c2" class="col"><img id="i2" width="500" height="250"></div>
|
||||
</div>
|
||||
"#,
|
||||
);
|
||||
let first = tree.get_element_by_id("i1").unwrap();
|
||||
let second = tree.get_element_by_id("i2").unwrap();
|
||||
let intrinsic = HashMap::from([
|
||||
(first, (303.0, 255.0)),
|
||||
(second, (500.0, 250.0)),
|
||||
]);
|
||||
let layout = layout_dom_with_images(&tree, (2200.0, 1000.0), &intrinsic);
|
||||
let first = layout.rects[&first];
|
||||
let second = layout.rects[&second];
|
||||
|
||||
assert!(
|
||||
(first.width - 303.0).abs() < 0.01
|
||||
&& (first.height - 255.0).abs() < 0.01,
|
||||
"{first:?}"
|
||||
);
|
||||
assert!(
|
||||
(second.width - 500.0).abs() < 0.01
|
||||
&& (second.height - 250.0).abs() < 0.01,
|
||||
"{second:?}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user