From 661212e6aa9c07fe79c5e2999cc48c19d9fa6815 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:58:05 +0000 Subject: [PATCH] Fix framework layout and image transforms --- crates/obscura-render/src/dom.rs | 146 +++++++++++-- crates/obscura-render/src/inline.rs | 10 + crates/obscura-render/src/lib.rs | 17 +- crates/obscura-render/src/paint.rs | 233 ++++++++++++++++++++- crates/obscura-render/src/style.rs | 166 +++++++++++++-- crates/obscura-render/tests/layout_test.rs | 34 +++ 6 files changed, 562 insertions(+), 44 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index e336162..7cd899b 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -3709,6 +3709,84 @@ fn build_any( build(tree, id, taffy_tree, id_map, words, engine, ifc_items, styles).into_iter().collect() } +/// Build the direct children of a genuine flex/grid container. +/// +/// CSS wraps every contiguous run of in-flow text in one anonymous flex/grid +/// item whose contents form an inline formatting context. Splitting a text +/// node into one taffy item per word is only valid inside our flex-wrap IFC +/// stand-in; doing it at this outer level makes `flex-wrap:nowrap` lay an +/// entire paragraph on one line (MDN's contributor quote). Fold text runs to +/// one measured/shaped anonymous item and leave authored element children as +/// their own flex/grid items. +#[allow(clippy::too_many_arguments)] +fn build_flex_grid_children( + tree: &DomTree, + parent: NodeId, + dom_children: &[NodeId], + taffy_tree: &mut TaffyTree, + id_map: &mut HashMap, + words: &mut HashMap, + engine: &mut crate::inline::TextEngine, + ifc_items: &mut IfcRegistry, + styles: &HashMap, +) -> Vec { + let mut children = Vec::new(); + let mut index = 0; + while index < dom_children.len() { + let is_text = tree.get_node(dom_children[index]).map_or(false, |node| { + matches!(node.data, obscura_dom::tree::NodeData::Text { .. }) + }); + if !is_text { + children.extend(build_any( + tree, + dom_children[index], + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + )); + index += 1; + continue; + } + + let start = index; + while index < dom_children.len() + && tree.get_node(dom_children[index]).map_or(false, |node| { + matches!(node.data, obscura_dom::tree::NodeData::Text { .. }) + }) + { + index += 1; + } + let run = &dom_children[start..index]; + if let Some(item) = engine.try_build_run(tree, parent, run, styles) { + let style = taffy::Style { + display: taffy::style::Display::Block, + ..Default::default() + }; + if let Ok(leaf) = taffy_tree.new_leaf_with_context(style, item) { + ifc_items.runs.entry(parent).or_default().push(item); + children.push(leaf); + continue; + } + } + for &text in run { + children.extend(build_any( + tree, + text, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + )); + } + } + children +} + /// Is `id` a `display: inline` element with no box appearance or sizing of /// its own — safe to flatten into its parent's child list instead of giving /// it an independent (and, for wrapping auto-width containers, buggy) flex @@ -4862,6 +4940,20 @@ fn build( let mut child_ids: Vec = if has_float_child { build_children_with_float_zone(tree, id, &dom_children, taffy_tree, id_map, words, engine, ifc_items, styles) + } else if matches!(style.display, crate::Display::Flex | crate::Display::Grid) + && !style.internal_flex_container + { + build_flex_grid_children( + tree, + id, + &dom_children, + taffy_tree, + id_map, + words, + engine, + ifc_items, + styles, + ) } else { dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect() }; @@ -6236,6 +6328,26 @@ mod tests { assert!((second.y - first.y - 100.0).abs() < 0.1, "{second:?}"); } + #[test] + fn direct_flex_text_is_one_wrapping_anonymous_item() { + // Chromium 140: the pseudo is one flex item and the direct text run + // is one anonymous flex item with a 276px inline formatting context. + // The sentence wraps to three 20px lines; treating every word as an + // outer flex item instead produces one overflowing 20px line. + let tree = parse_html( + "\ +
This anonymous text item must wrap inside the remaining flex space instead of overflowing.
", + ); + let laid = layout_dom(&tree, (500.0, 200.0)); + let quote = laid.rects[&tree.get_element_by_id("quote").unwrap()]; + assert!((quote.width - 300.0).abs() < 0.1, "{quote:?}"); + assert!((quote.height - 60.0).abs() < 0.1, "{quote:?}"); + } + #[test] fn empty_document_is_safe() { // html5ever always synthesizes html/head/body, so an empty document @@ -6360,20 +6472,11 @@ mod tests { let tree = parse_html( r#"
get started
"#, ); - let text = tree - .descendants(tree.document()) - .into_iter() - .find(|id| tree.get_node(*id).is_some_and(|node| node.is_text())) - .unwrap(); let laid = layout_dom(&tree, (1280.0, 720.0)); - let painted = laid - .text_runs - .get(&text) - .unwrap() - .iter() - .map(|(_, word)| word.as_str()) - .collect::(); - assert_eq!(painted, "GET STARTED"); + let cta = tree.get_element_by_id("cta").unwrap(); + let items = &laid.run_ifc_items[&cta]; + assert_eq!(items.len(), 1); + assert_eq!(laid.text_engine.item_text(items[0]), "GET STARTED"); assert_eq!( transform_word_leaf_text("hELLO wORLD", crate::TextTransform::Capitalize), "HELLO WORLD" @@ -6383,17 +6486,14 @@ mod tests { #[test] fn word_leaves_use_the_computed_line_height() { let tree = parse_html( - r#"
code
"#, + r#"
code
"#, ); - let text = tree - .descendants(tree.document()) - .into_iter() - .find(|id| tree.get_node(*id).is_some_and(|node| node.is_text())) - .unwrap(); - let laid = layout_dom(&tree, (1280.0, 720.0)); - let runs = laid.text_runs.get(&text).unwrap(); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].0.height, 32.0); + let mut laid = layout_dom(&tree, (1280.0, 720.0)); + let code = tree.get_element_by_id("code").unwrap(); + let items = &laid.run_ifc_items[&code]; + assert_eq!(items.len(), 1); + let (_, height) = laid.text_engine.measure(items[0], Some(1280.0)); + assert_eq!(height, 32.0); } #[test] diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index a55ceca..454befb 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -563,6 +563,16 @@ impl TextEngine { self.items.is_empty() } + #[cfg(test)] + pub(crate) fn item_text(&self, idx: usize) -> String { + self.items[idx] + .buffer + .lines + .iter() + .map(|line| line.text()) + .collect() + } + /// Build an inline formatting context for `id`'s subtree if it is one we /// can collapse to shaped text (see [`is_pure_text_ifc`]); returns the /// item index to store as the leaf's taffy context. `None` means the diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 6f65620..8906f88 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -577,12 +577,19 @@ pub struct LayoutStyle { /// absolute and fixed descendants. Kept as a bitset so `filter:none` /// cannot clear a transform/containment trigger from another property. pub containing_block_triggers: u16, - /// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy). - /// Parsed and stored so a value that mixes scale with translate still yields - /// its translate part; scale is not yet folded into paint geometry (doing so - /// correctly would require scaling every descendant's size and text, outside - /// the paint-time translate-offset model used here). + /// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, or the individual + /// `scale` property, captured as (sx, sy). Raster-image paint applies it to + /// the element's subtree together with [`LayoutStyle::transform_projection`]. pub transform_scale: Option<(f32, f32)>, + /// The affine x/y projection of authored `rotate`, `rotateX`, `rotateY`, + /// and `rotateZ` transform functions. CSS transforms without perspective + /// remain affine when their input plane has z=0, so this preserves modern + /// 3D-tilted image cards without introducing a full scene graph. + /// Stored as `[a, b, c, d]` for `x'=a*x+c*y`, `y'=b*x+d*y`. + pub transform_projection: Option<[f32; 4]>, + /// Authored `transform-origin`, unresolved so percentages use the final + /// border-box dimensions. `None` is the CSS initial value, 50% 50%. + pub transform_origin: Option<(Dimension, Dimension)>, /// `box-shadow` (first layer only). Painted behind the element's own /// background/border box: cards, buttons, menus, and modals across the /// modern web rely on it for depth, and without it those elements paint diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 553b022..1415646 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -74,6 +74,10 @@ pub fn paint_dom_scrolled( laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); } apply_document_scroll(tree, &mut laid, viewport, scroll); + // Only raster images inside an actually rotated/scaled subtree receive an + // affine entry. Ordinary pages pay one fast style scan and allocate no + // matrix map; transformed pages do matrix work only along those subtrees. + let projected_images = collect_projected_image_transforms(tree, &laid); let root_font_size = tree .query_selector("html") .ok() @@ -251,12 +255,16 @@ pub fn paint_dom_scrolled( // ubiquitous 1x1 clipped "visually hidden" accessibility pattern // actually invisible instead of painting text wherever it lands). let clip = laid.clip_rects.get(&nid).copied().flatten(); + let projected_image = projected_images.get(&nid).copied(); + let cull_rect = projected_image + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect); let visible_rect = match clip { - Some(c) => match rect.intersect(&c) { + Some(c) => match cull_rect.intersect(&c) { Some(r) => r, None => continue, }, - None => rect, + None => cull_rect, }; let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) { Some(r) => r, @@ -365,6 +373,7 @@ pub fn paint_dom_scrolled( crate::ObjectFit::Fill, &mut pixmap, &mut image_cache, + None, ); } } @@ -447,7 +456,16 @@ pub fn paint_dom_scrolled( // half-scrolled carousel slide's image otherwise bleeds over // the viewport edge). let painted = - paint_image(&src, base_url, &rect, &visible_rect, style.object_fit, &mut pixmap, &mut image_cache); + paint_image( + &src, + base_url, + &rect, + &visible_rect, + style.object_fit, + &mut pixmap, + &mut image_cache, + projected_image, + ); // Fall back when the image itself did not paint, following // what browsers show for a broken image: a non-empty alt // renders as text in place of the image (no placeholder box), @@ -768,6 +786,175 @@ fn apply_document_scroll( } } +#[derive(Clone, Copy, Debug)] +struct ImageAffine { + a: f32, + b: f32, + c: f32, + d: f32, + e: f32, + f: f32, +} + +impl ImageAffine { + const IDENTITY: Self = Self { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + + /// Compose `self(other(point))`. + fn then(self, other: Self) -> Self { + Self { + a: self.a * other.a + self.c * other.b, + b: self.b * other.a + self.d * other.b, + c: self.a * other.c + self.c * other.d, + d: self.b * other.c + self.d * other.d, + e: self.a * other.e + self.c * other.f + self.e, + f: self.b * other.e + self.d * other.f + self.f, + } + } + + fn around(origin: (f32, f32), linear: [f32; 4]) -> Self { + let [a, b, c, d] = linear; + Self { + a, + b, + c, + d, + e: origin.0 - a * origin.0 - c * origin.1, + f: origin.1 - b * origin.0 - d * origin.1, + } + } + + fn map(self, x: f32, y: f32) -> (f32, f32) { + ( + self.a * x + self.c * y + self.e, + self.b * x + self.d * y + self.f, + ) + } + + fn map_rect(self, rect: crate::Rect) -> crate::Rect { + let points = [ + self.map(rect.x, rect.y), + self.map(rect.x + rect.width, rect.y), + self.map(rect.x, rect.y + rect.height), + self.map(rect.x + rect.width, rect.y + rect.height), + ]; + let left = points.iter().map(|point| point.0).fold(f32::INFINITY, f32::min); + let top = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min); + let right = points + .iter() + .map(|point| point.0) + .fold(f32::NEG_INFINITY, f32::max); + let bottom = points + .iter() + .map(|point| point.1) + .fold(f32::NEG_INFINITY, f32::max); + crate::Rect { + x: left, + y: top, + width: (right - left).max(0.0), + height: (bottom - top).max(0.0), + } + } + + fn tiny_skia(self) -> Transform { + Transform::from_row(self.a, self.b, self.c, self.d, self.e, self.f) + } + + fn is_identity(self) -> bool { + (self.a - 1.0).abs() < f32::EPSILON + && self.b.abs() < f32::EPSILON + && self.c.abs() < f32::EPSILON + && (self.d - 1.0).abs() < f32::EPSILON + && self.e.abs() < f32::EPSILON + && self.f.abs() < f32::EPSILON + } +} + +/// Accumulate rotation/scale matrices through transformed subtrees, storing +/// entries only for raster `` descendants. Translation remains on +/// `DomLayout::translates`, whose offsets already include root scroll. +fn collect_projected_image_transforms( + tree: &DomTree, + layout: &crate::DomLayout, +) -> std::collections::HashMap { + if !layout.styles.values().any(|style| { + style.transform_projection.is_some() || style.transform_scale.is_some() + }) { + return std::collections::HashMap::new(); + } + + fn walk( + tree: &DomTree, + layout: &crate::DomLayout, + id: obscura_dom::tree::NodeId, + parent: ImageAffine, + active: bool, + out: &mut std::collections::HashMap, + ) { + let style = layout.styles.get(&id); + let own_active = style.is_some_and(|style| { + style.transform_projection.is_some() || style.transform_scale.is_some() + }); + let mut combined = parent; + if own_active { + let style = style.unwrap(); + let projection = style + .transform_projection + .unwrap_or([1.0, 0.0, 0.0, 1.0]); + let (scale_x, scale_y) = style.transform_scale.unwrap_or((1.0, 1.0)); + let linear = [ + scale_x * projection[0], + scale_y * projection[1], + scale_x * projection[2], + scale_y * projection[3], + ]; + if let Some(rect) = layout.rects.get(&id) { + let offset = layout.translates.get(&id).copied().unwrap_or((0.0, 0.0)); + let (origin_x, origin_y) = style.transform_origin.unwrap_or(( + crate::Dimension::Percent(0.5), + crate::Dimension::Percent(0.5), + )); + let origin = ( + rect.x + offset.0 + crate::dom::resolve_translate(origin_x, rect.width), + rect.y + offset.1 + crate::dom::resolve_translate(origin_y, rect.height), + ); + combined = parent.then(ImageAffine::around(origin, linear)); + } + } + + let transformed = active || own_active; + if transformed + && !combined.is_identity() + && tree.get_node(id).is_some_and(|node| { + node.as_element() + .is_some_and(|element| element.local.as_ref() == "img") + }) + { + out.insert(id, combined); + } + for child in tree.children(id) { + walk(tree, layout, child, combined, transformed, out); + } + } + + let mut out = std::collections::HashMap::new(); + walk( + tree, + layout, + tree.document(), + ImageAffine::IDENTITY, + false, + &mut out, + ); + out +} + /// A closed rounded-rectangle path, corners approximated by quadratic curves /// (visually indistinguishable from true arcs at typical UI radii). `r` is /// clamped so it never exceeds half the shorter side. @@ -2155,6 +2342,7 @@ fn paint_in_flow_generated_box( crate::ObjectFit::Fill, pixmap, image_cache, + None, ); } } @@ -2343,6 +2531,7 @@ fn paint_positioned_pseudo( crate::ObjectFit::Fill, pixmap, image_cache, + None, ); } } @@ -2694,6 +2883,7 @@ fn paint_image( object_fit: crate::ObjectFit, pixmap: &mut Pixmap, cache: &mut std::collections::HashMap>>, + transform: Option, ) -> bool { if rect.width <= 0.0 || rect.height <= 0.0 { return false; @@ -2745,7 +2935,9 @@ fn paint_image( dest.y as i32, content.as_ref(), &tiny_skia::PixmapPaint::default(), - Transform::identity(), + transform + .map(ImageAffine::tiny_skia) + .unwrap_or_else(Transform::identity), clip.as_ref(), ); true @@ -4175,6 +4367,39 @@ mod tests { ); } + #[test] + fn projected_image_transform_enters_overflow_clip() { + // Chromium reduction: without the projected rotate/scale, this image is + // wholly left of the clip. Its transformed right edge reaches x=53.14. + let tree = parse_html( + r#" +
+
+ +
+
+ "#, + ); + let pixmap = paint_dom(&tree, (160.0, 120.0), None).expect("pixmap"); + + assert!( + (0..100).any(|y| (0..120).any(|x| { + let pixel = pixmap.pixel(x, y).expect("inside viewport"); + pixel.red() > 220 && pixel.green() < 40 && pixel.blue() < 40 + })), + "projected image should enter the overflow clip" + ); + assert!( + (0..120).all(|y| (120..160).all(|x| { + let pixel = pixmap.pixel(x, y).expect("outside clip"); + pixel.red() > 240 && pixel.green() > 240 && pixel.blue() > 240 + })), + "projected image must remain clipped to its overflow ancestor" + ); + } + #[test] fn translate_offscreen_box_is_not_painted() { // translate(-10000px,0) shoves the box far off the left edge (the old diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index e5ec6c9..f8dc18b 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1020,6 +1020,8 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "grid-row-start" => set_grid_placement_side(style, value, false, true), "grid-row-end" => set_grid_placement_side(style, value, false, false), "transform" => parse_transform(style, value), + "transform-origin" => style.transform_origin = parse_transform_origin(value), + "scale" => parse_individual_scale(style, value), "filter" => set_containing_block_trigger( style, crate::CB_TRIGGER_FILTER, @@ -1225,6 +1227,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool { | "grid-row-start" | "grid-row-end" | "transform" + | "transform-origin" + | "scale" | "filter" | "backdrop-filter" | "-webkit-backdrop-filter" @@ -1449,12 +1453,11 @@ fn content_alignment_pair( None } -/// Parse the subset of `transform` obscura applies at paint time: `translate`, -/// `translateX`, `translateY` (px and %, stored unresolved so % can resolve -/// against the element's own border box later) and `scale`/`scaleX`/`scaleY`. -/// `rotate`, `skew`, `matrix`, and `perspective` are ignored (left unhandled) -/// rather than erroring, so a value that mixes them still contributes its -/// translate and scale parts. +/// Parse the transform functions used by the scoped paint model. Translation +/// stays unresolved until the final border box is known. Scale is captured +/// separately, while rotations are multiplied as 3×3 matrices and projected +/// onto the input z=0 plane; this exactly represents rotateX/Y/Z chains that +/// do not introduce perspective. fn parse_transform(style: &mut LayoutStyle, value: &str) { let v = value.trim(); if v.is_empty() { @@ -1466,6 +1469,7 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { if v.eq_ignore_ascii_case("none") { style.transform_translate = None; style.transform_scale = None; + style.transform_projection = None; set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false); return; } @@ -1477,6 +1481,12 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { let zero = crate::Dimension::Px(0.0); let (mut tx, mut ty): (Option, Option) = (None, None); let (mut sx, mut sy): (Option, Option) = (None, None); + let mut rotation = [ + 1.0f32, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0, + ]; + let mut has_rotation = false; for (func, args) in functions { let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); match func.to_ascii_lowercase().as_str() { @@ -1517,7 +1527,27 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { sy = Some(a); } } - // rotate / skew / matrix / perspective: not modeled, skip. + "rotate" | "rotatez" => { + if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) { + rotation = matrix3_mul(rotation, rotate_z_matrix(angle)); + has_rotation = true; + } + } + "rotatex" => { + if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) { + rotation = matrix3_mul(rotation, rotate_x_matrix(angle)); + has_rotation = true; + } + } + "rotatey" => { + if let Some(angle) = parts.first().and_then(|value| angle_degrees(value)) { + rotation = matrix3_mul(rotation, rotate_y_matrix(angle)); + has_rotation = true; + } + } + // Skew, matrix, and perspective remain outside the scoped affine + // image projection. Other supported functions in the same value + // still take effect. _ => {} } } @@ -1527,6 +1557,118 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { if sx.is_some() || sy.is_some() { style.transform_scale = Some((sx.unwrap_or(1.0), sy.unwrap_or(1.0))); } + style.transform_projection = has_rotation.then_some([ + rotation[0], + rotation[3], + rotation[1], + rotation[4], + ]); +} + +fn matrix3_mul(left: [f32; 9], right: [f32; 9]) -> [f32; 9] { + let mut out = [0.0; 9]; + for row in 0..3 { + for column in 0..3 { + out[row * 3 + column] = (0..3) + .map(|inner| left[row * 3 + inner] * right[inner * 3 + column]) + .sum(); + } + } + out +} + +fn rotate_x_matrix(angle: f32) -> [f32; 9] { + let (sin, cos) = angle.to_radians().sin_cos(); + [1.0, 0.0, 0.0, 0.0, cos, -sin, 0.0, sin, cos] +} + +fn rotate_y_matrix(angle: f32) -> [f32; 9] { + let (sin, cos) = angle.to_radians().sin_cos(); + [cos, 0.0, sin, 0.0, 1.0, 0.0, -sin, 0.0, cos] +} + +fn rotate_z_matrix(angle: f32) -> [f32; 9] { + let (sin, cos) = angle.to_radians().sin_cos(); + [cos, -sin, 0.0, sin, cos, 0.0, 0.0, 0.0, 1.0] +} + +fn angle_degrees(value: &str) -> Option { + fn primitive(value: &str) -> Option { + let value = value.trim().to_ascii_lowercase(); + if let Some(number) = value.strip_suffix("deg") { + number.trim().parse().ok() + } else if let Some(number) = value.strip_suffix("grad") { + number.trim().parse::().ok().map(|value| value * 0.9) + } else if let Some(number) = value.strip_suffix("rad") { + number + .trim() + .parse::() + .ok() + .map(f32::to_degrees) + } else if let Some(number) = value.strip_suffix("turn") { + number.trim().parse::().ok().map(|value| value * 360.0) + } else if value == "0" { + Some(0.0) + } else { + None + } + } + + let value = value.trim(); + let Some(inner) = value + .strip_prefix("calc(") + .and_then(|value| value.strip_suffix(')')) + else { + return primitive(value); + }; + if let Some((angle, factor)) = inner.split_once('*') { + return Some(primitive(angle)? * factor.trim().parse::().ok()?); + } + if let Some((angle, divisor)) = inner.split_once('/') { + let divisor = divisor.trim().parse::().ok()?; + return (divisor != 0.0).then(|| primitive(angle).map(|angle| angle / divisor)).flatten(); + } + primitive(inner) +} + +fn parse_transform_origin(value: &str) -> Option<(crate::Dimension, crate::Dimension)> { + let mut x = None; + let mut y = None; + for token in value.split_whitespace().take(2) { + match token.to_ascii_lowercase().as_str() { + "left" => x = Some(crate::Dimension::Percent(0.0)), + "right" => x = Some(crate::Dimension::Percent(1.0)), + "top" => y = Some(crate::Dimension::Percent(0.0)), + "bottom" => y = Some(crate::Dimension::Percent(1.0)), + "center" if x.is_none() => x = Some(crate::Dimension::Percent(0.5)), + "center" => y = Some(crate::Dimension::Percent(0.5)), + _ if x.is_none() => x = Some(dimension_value(token)), + _ => y = Some(dimension_value(token)), + } + } + if x.is_none() && y.is_none() { + return None; + } + Some(( + x.unwrap_or(crate::Dimension::Percent(0.5)), + y.unwrap_or(crate::Dimension::Percent(0.5)), + )) +} + +fn parse_individual_scale(style: &mut LayoutStyle, value: &str) { + if value.trim().eq_ignore_ascii_case("none") { + style.transform_scale = None; + return; + } + let values: Vec = value + .split_whitespace() + .take(2) + .filter_map(scale_number) + .collect(); + let Some(&x) = values.first() else { return }; + let y = values.get(1).copied().unwrap_or(x); + style.transform_scale = Some((x, y)); + set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true); } fn non_none_value(value: &str) -> bool { @@ -3233,9 +3375,9 @@ fn edges(value: &str) -> Option { /// Split a 1-or-2 value shorthand into (start, end); a single value applies to /// both. Used by the logical-property axes (`margin-inline`, `padding-block`). fn two(value: &str) -> (&str, &str) { - let mut it = value.split_whitespace(); - let a = it.next().unwrap_or("0"); - let b = it.next().unwrap_or(a); + let values = split_ws_paren(value); + let a = values.first().copied().unwrap_or("0"); + let b = values.get(1).copied().unwrap_or(a); (a, b) } @@ -3349,7 +3491,7 @@ fn set_padding_px(padding: &mut Edges, idx: usize, px: f32) { /// `padding: ? ? ?`, percentage-aware per side. fn apply_padding_shorthand(style: &mut LayoutStyle, value: &str) { - let toks: Vec<&str> = value.split_whitespace().collect(); + let toks = split_ws_paren(value); let (t, r, b, l) = match toks.as_slice() { [a] => (*a, *a, *a, *a), [v, h] => (*v, *h, *v, *h), @@ -3379,7 +3521,7 @@ fn percent_fraction(tok: &str) -> Option { /// `margin: ? ? ?` with per-side `auto` (so `margin: 0 auto` /// centers). fn apply_margin_shorthand(style: &mut LayoutStyle, value: &str) { - let toks: Vec<&str> = value.split_whitespace().collect(); + let toks = split_ws_paren(value); let (t, r, b, l) = match toks.as_slice() { [a] => (*a, *a, *a, *a), [v, h] => (*v, *h, *v, *h), diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index ebeb1af..06185ea 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -1698,6 +1698,40 @@ fn selected_picture_source_dimensions_override_fallback_image_ratio() { assert_eq!(narrow.styles[&image].aspect_ratio, Some(1.0)); } +#[test] +fn responsive_escaped_utility_classes_apply_logical_padding() { + let tree = parse_html( + r#" + + + + + "#, + ); + let cell_id = tree.get_element_by_id("cell").unwrap(); + assert_eq!( + tree.query_selector(r".sm\:px-2").unwrap(), + Some(cell_id), + "the escaped utility selector must decode to the literal class token" + ); + let layout = layout_dom(&tree, (1280.0, 900.0)); + let cell = layout.rects[&cell_id]; + let child = layout.rects[&tree.get_element_by_id("child").unwrap()]; + + assert!((cell.width - 270.0).abs() < 0.01, "{cell:?}"); + assert!((cell.height - 52.0).abs() < 0.01, "{cell:?}"); + assert!((child.x - cell.x - 8.0).abs() < 0.01, "{child:?}"); + assert!((child.y - cell.y - 16.0).abs() < 0.01, "{child:?}"); +} + /// HTML width/height attributes are provisional presentational hints. Once /// the image is decoded, its natural dimensions replace that mapped ratio. #[test]