diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 705cb61..0caca20 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -2064,7 +2064,34 @@ 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 context = engine.register_replaced(width, 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 + // style lets taffy synthesize that axis before measurement and + // bypass the constraint (width:100%; height:auto; + // max-height:200px became the uncapped intrinsic height). + // + // Keep taffy's ratio in the unconstrained case: percentage-sized + // images in auto wrappers can be measured at intrinsic size before + // their percentage width becomes definite, and taffy then needs + // the ratio to transfer that final width to height. + let measured_axis_constraint = + (!matches!(style.height, crate::Dimension::Auto) + && matches!(style.width, crate::Dimension::Auto) + && matches!( + (style.min_width, style.max_width), + (crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_)) + )) + || (!matches!(style.width, crate::Dimension::Auto) + && matches!(style.height, crate::Dimension::Auto) + && matches!( + (style.min_height, style.max_height), + (crate::Dimension::Px(_), _) | (_, crate::Dimension::Px(_)) + )); + if measured_axis_constraint { + taffy_style.aspect_ratio = None; + } + 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); diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index e7c7ac0..99df7fd 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -18,7 +18,7 @@ use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shap use obscura_dom::tree::{DomTree, NodeId}; -use crate::{Display, LayoutStyle, Rect, TextTransform}; +use crate::{Dimension, Display, LayoutStyle, Rect, TextTransform}; // Bundled faces. Chrome on this class of host renders `sans-serif` and the // ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as @@ -105,11 +105,63 @@ pub struct TextEngine { font_system: FontSystem, swash: SwashCache, items: Vec, - replaced: Vec<(f32, f32)>, + replaced: Vec, } const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1); +#[derive(Clone, Copy)] +struct ReplacedItem { + intrinsic_width: f32, + intrinsic_height: f32, + min_width: Option, + min_height: Option, + max_width: Option, + max_height: Option, +} + +impl ReplacedItem { + fn from_style(width: f32, height: f32, style: &LayoutStyle) -> Self { + let px = |dimension| match dimension { + Dimension::Px(value) => Some(value.max(0.0)), + _ => None, + }; + ReplacedItem { + intrinsic_width: width, + intrinsic_height: height, + min_width: px(style.min_width), + min_height: px(style.min_height), + max_width: px(style.max_width), + max_height: px(style.max_height), + } + } + + fn clamp(value: f32, min: Option, max: Option) -> f32 { + // CSS sizing gives the minimum precedence when min > max. + let value = max.map_or(value, |max| value.min(max)); + min.map_or(value, |min| value.max(min)) + } + + fn size(self, known: taffy::Size>) -> taffy::Size { + 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, + ), + (None, Some(height)) => ( + height * self.intrinsic_width / self.intrinsic_height, + height, + ), + (None, None) => (self.intrinsic_width, self.intrinsic_height), + }; + taffy::Size { + width: Self::clamp(width, self.min_width, self.max_width), + height: Self::clamp(height, self.min_height, self.max_height), + } + } +} + impl Default for TextEngine { fn default() -> Self { Self::new() @@ -267,10 +319,9 @@ impl TextEngine { /// taffy's measure function during layout. pub fn measure(&mut self, idx: usize, width: Option) -> (f32, f32) { if idx & REPLACED_CONTEXT_BIT != 0 { - let (iw, ih) = self.replaced[idx & !REPLACED_CONTEXT_BIT]; - return width - .map(|width| (width, width * ih / iw)) - .unwrap_or((iw, ih)); + let size = self.replaced[idx & !REPLACED_CONTEXT_BIT] + .size(taffy::Size { width, height: None }); + return (size.width, size.height); } let TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; @@ -282,9 +333,15 @@ impl TextEngine { /// Register a replaced element's intrinsic size as a taffy measure /// context. Percentage-sized image leaves still need their intrinsic /// max-content contribution while an auto-sized ancestor is measured. - pub fn register_replaced(&mut self, width: f32, height: f32) -> usize { + pub fn register_replaced( + &mut self, + width: f32, + height: f32, + style: &LayoutStyle, + ) -> usize { let index = self.replaced.len(); - self.replaced.push((width, height)); + self.replaced + .push(ReplacedItem::from_style(width, height, style)); REPLACED_CONTEXT_BIT | index } @@ -298,22 +355,7 @@ impl TextEngine { available: taffy::Size, ) -> taffy::Size { if idx & REPLACED_CONTEXT_BIT != 0 { - let (iw, ih) = self.replaced[idx & !REPLACED_CONTEXT_BIT]; - return match (known.width, known.height) { - (Some(width), Some(height)) => taffy::Size { width, height }, - (Some(width), None) => taffy::Size { - width, - height: width * ih / iw, - }, - (None, Some(height)) => taffy::Size { - width: height * iw / ih, - height, - }, - (None, None) => taffy::Size { - width: iw, - height: ih, - }, - }; + return self.replaced[idx & !REPLACED_CONTEXT_BIT].size(known); } let width = known.width.or(match available.width { taffy::AvailableSpace::Definite(width) => Some(width),