diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 8951598..c00117d 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -650,6 +650,7 @@ pub fn layout_dom_with_images( continue; } if let Some(s) = styles.get_mut(&nid) { + s.intrinsic_size = Some((iw, ih)); if s.aspect_ratio.is_none() { s.aspect_ratio = Some(iw / ih); } @@ -675,16 +676,7 @@ pub fn layout_dom_with_images( |known: taffy::Size>, avail: taffy::Size, _node, ctx: Option<&mut usize>, _style: &taffy::Style| { match ctx { Some(&mut idx) => { - // Width to line-break at: a known definite width, - // else the available width; MinContent -> narrowest - // (longest word), MaxContent -> unbounded (one line). - let width = known.width.or(match avail.width { - taffy::AvailableSpace::Definite(w) => Some(w), - taffy::AvailableSpace::MinContent => Some(0.0), - taffy::AvailableSpace::MaxContent => None, - }); - let (w, h) = engine.measure(idx, width); - taffy::Size { width: w, height: h } + engine.measure_taffy(idx, known, avail) } None => taffy::Size::ZERO, } @@ -1917,6 +1909,19 @@ fn build( let mut taffy_style = to_taffy_style(style); + // 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 + // 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); + let leaf = taffy_tree.new_leaf_with_context(taffy_style, context).ok()?; + id_map.insert(leaf, id); + return Some(leaf); + } + } + // If this container is a pure-text inline formatting context, collapse its // whole subtree to one leaf shaped and line-broken by cosmic-text (real // text layout), sized on demand by the measure function. This is the fast, @@ -1969,6 +1974,11 @@ fn build( dom_children.retain(|&cid| { tree.get_node(cid).map_or(false, |n| n.is_element()) || !tree.text_content(cid).trim().is_empty() }); + // Flex and grid placement consume the order-modified document order. + // Rust's stable sort preserves source order for equal values, exactly + // the CSS tie-break. Taffy has no CSS `order` style field, so feeding + // it the correctly ordered item sequence is the missing translation. + dom_children.sort_by_key(|cid| styles.get(cid).map(|style| style.order).unwrap_or(0)); } // `float` has no effect on a flex or grid item. Legacy stylesheets often // leave floats on children after a newer rule turns their parent into a diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 62311b4..24faa87 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -105,8 +105,11 @@ pub struct TextEngine { font_system: FontSystem, swash: SwashCache, items: Vec, + replaced: Vec<(f32, f32)>, } +const REPLACED_CONTEXT_BIT: usize = 1usize << (usize::BITS - 1); + impl Default for TextEngine { fn default() -> Self { Self::new() @@ -129,7 +132,12 @@ impl TextEngine { } db.set_sans_serif_family(FAMILY); let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db); - TextEngine { font_system, swash: SwashCache::new(), items: Vec::new() } + TextEngine { + font_system, + swash: SwashCache::new(), + items: Vec::new(), + replaced: Vec::new(), + } } /// Number of inline formatting contexts collected (for debug/stats). @@ -258,6 +266,12 @@ impl TextEngine { /// None for max-content), returning its shaped (width, height). Called by /// 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 TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; item.buffer.set_size(font_system, width.map(|w| w.max(0.0)), None); @@ -265,6 +279,51 @@ impl TextEngine { buffer_size(&item.buffer) } + /// 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 { + let index = self.replaced.len(); + self.replaced.push((width, height)); + REPLACED_CONTEXT_BIT | index + } + + /// Measure either a shaped text context or an intrinsic replaced element. + /// Replaced boxes transfer a definite axis through their intrinsic ratio; + /// with neither axis definite they contribute their natural size. + pub fn measure_taffy( + &mut self, + idx: usize, + known: taffy::Size>, + 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, + }, + }; + } + let width = known.width.or(match available.width { + taffy::AvailableSpace::Definite(width) => Some(width), + taffy::AvailableSpace::MinContent => Some(0.0), + taffy::AvailableSpace::MaxContent => None, + }); + let (width, height) = self.measure(idx, width); + taffy::Size { width, height } + } + /// After layout, pin each context to its final content-box origin and /// clip, reshaping once at the resolved width so paint draws the same /// line breaks the box was sized for. @@ -628,7 +687,12 @@ impl TextEngine { /// translate()` of the container's ancestors, shifting both the glyph /// origin and the clip so shaped text moves with a transformed box. pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) { - let TextEngine { font_system, swash, items } = self; + let TextEngine { + font_system, + swash, + items, + .. + } = self; let Some(item) = items.get_mut(idx) else { return }; let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1); // The glyph origin shifts by the container's accumulated translate, diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index abb8a5e..5a5a3e3 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -191,6 +191,11 @@ pub struct LayoutStyle { /// missing dimension from the given one, so a `width:100%` image gets a /// real height instead of collapsing to zero. pub aspect_ratio: Option, + /// Fetched intrinsic CSS-pixel size for a replaced element. Kept alongside + /// `aspect_ratio` so its taffy leaf can contribute a real min/max-content + /// size when percentage dimensions are resolved through an auto-sized + /// wrapper (`img { width:100%; height:auto }`). + pub intrinsic_size: Option<(f32, f32)>, pub margin: Edges, /// Which margin sides are `auto` (top, right, bottom, left). `margin: 0 /// auto` / `margin-inline: auto` centering needs a real Auto margin, which @@ -279,6 +284,9 @@ pub struct LayoutStyle { pub justify_content: Option, pub flex_grow: Option, pub flex_shrink: Option, + /// Flex/grid item order. The formatting algorithm consumes children in + /// order-modified document order, with source order breaking ties. + pub order: i32, /// `flex-basis` (longhand, or the length in a `flex:` shorthand). `Auto` /// is the default. Fixed-basis sidebars/columns (`flex: 0 0 260px`) /// collapse to content width without it. diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 3e88d9b..12fb2fd 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -943,12 +943,14 @@ fn collect_image_intrinsics( } let Some((url, density)) = resolve_img_url(tree, nid) else { continue }; let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue }; - if let Some((w, h)) = image_dimensions(&bytes) { - if w > 0 && h > 0 { + let dimensions = image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) + .or_else(|| svg_intrinsic(&bytes)); + if let Some((w, h)) = dimensions { + if w > 0.0 && h > 0.0 { // A 2x (or w-descriptor) candidate's raw pixels are density // times its CSS size; divide so layout sees CSS px, or every // responsive image occupies twice its design size. - out.insert(nid, (w as f32 / density, h as f32 / density)); + out.insert(nid, (w / density, h / density)); } } } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 966fcf8..9a11a75 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -432,6 +432,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { }, "flex-grow" => { if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { style.flex_grow = Some(v); } } "flex-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::().ok()) { style.flex_shrink = Some(v); } } + "order" => { + if let Ok(order) = value.trim().parse::() { + style.order = order; + } + } "flex-basis" => { style.flex_basis = dimension_value(value.trim()); } "flex" => parse_flex_shorthand(style, value), "position" => {