diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 05c2487..d8374b7 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -247,6 +247,10 @@ pub struct DomLayout { /// px. Only nodes with a non-zero accumulation are present; paint shifts /// each box by this to reach screen space. pub translates: HashMap, + /// Accumulated authored transforms in immutable document coordinates. + /// Entries exist only inside a non-identity transformed subtree, keeping + /// ordinary documents on the allocation-free identity path. + pub transforms: HashMap, /// Per-word geometry for text nodes: a text DOM node lays out as one /// taffy leaf per word (see `build_text_words`), each wrapping /// independently within its container, so its rendered content is a list @@ -489,6 +493,7 @@ impl DomLayout { &self.rects, &self.styles, &self.translates, + &self.transforms, &mut right, &mut bottom, ); @@ -653,6 +658,7 @@ fn accumulate_scrolling_overflow( rects: &HashMap, styles: &HashMap, translates: &HashMap, + transforms: &HashMap, right: &mut f32, bottom: &mut f32, ) { @@ -661,13 +667,11 @@ fn accumulate_scrolling_overflow( } let translated = rects.get(&id).map(|rect| { - let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0)); - Rect { - x: rect.x + tx, - y: rect.y + ty, - width: rect.width, - height: rect.height, - } + transforms + .get(&id) + .copied() + .map(|transform| transform.map_rect(*rect)) + .unwrap_or(*rect) }); if let Some(overflow) = translated { let visible = if let Some(clip) = inherited_clip { @@ -700,7 +704,7 @@ fn accumulate_scrolling_overflow( }; for child in tree.children(id) { accumulate_scrolling_overflow( - tree, child, child_clip, fixed, rects, styles, translates, right, bottom, + tree, child, child_clip, fixed, rects, styles, translates, transforms, right, bottom, ); } } @@ -1092,6 +1096,8 @@ fn resolve_clip_rects( styles: &HashMap, clip_rects: &mut HashMap>, translates: &mut HashMap, + parent_transform: crate::Affine2, + transforms: &mut HashMap, root_font_size: f32, viewport: (f32, f32), ) { @@ -1106,6 +1112,14 @@ fn resolve_clip_rects( if tx != 0.0 || ty != 0.0 { translates.insert(id, (tx, ty)); } + let own_transform = styles.get(&id).map_or(crate::Affine2::IDENTITY, |style| { + let rect = rects.get(&id).copied().unwrap_or_default(); + resolved_transform_matrix(style, &rect, root_font_size, viewport) + }); + let transform = parent_transform.then(own_transform); + if !transform.is_identity() { + transforms.insert(id, transform); + } // Overflow propagated from html/body establishes the root scrolling // viewport. It is anchored to the capture surface, not to document // coordinates, and the pixmap already supplies that viewport clip. @@ -1134,6 +1148,8 @@ fn resolve_clip_rects( styles, clip_rects, translates, + transform, + transforms, root_font_size, viewport, ); @@ -1146,37 +1162,112 @@ fn resolved_own_translate( root_font_size: f32, viewport: (f32, f32), ) -> (f32, f32) { - let mut offset = style - .transform_translate - .map(|(x, y)| { - ( - resolve_translate(x, rect.width), - resolve_translate(y, rect.height), + let transform = resolved_transform_matrix(style, rect, root_font_size, viewport); + if transform.is_translation() { + (transform.e, transform.f) + } else { + (0.0, 0.0) + } +} + +fn resolve_transform_length( + length: &crate::TransformLength, + axis: usize, + style: &crate::LayoutStyle, + rect: &Rect, + root_font_size: f32, + viewport: (f32, f32), +) -> f32 { + let basis = if axis == 0 { rect.width } else { rect.height }; + let em = style.font_size.unwrap_or(16.0); + length + .expression + .as_deref() + .and_then(|expression| { + crate::style::resolve_contextual_length( + expression, + em, + root_font_size, + viewport.0 / 100.0, + viewport.1 / 100.0, + basis, ) }) - .unwrap_or((0.0, 0.0)); - let Some((x, y)) = style.individual_translate else { - return offset; - }; - let em = style.font_size.unwrap_or(16.0); - let resolve_axis = |axis: usize, value: crate::Dimension, basis: f32| { - style.individual_translate_expressions[axis] - .as_deref() - .and_then(|expression| { - crate::style::resolve_contextual_length( - expression, - em, - root_font_size, - viewport.0 / 100.0, - viewport.1 / 100.0, - basis, - ) - }) - .unwrap_or_else(|| resolve_translate(value, basis)) - }; - offset.0 += resolve_axis(0, x, rect.width); - offset.1 += resolve_axis(1, y, rect.height); - offset + .unwrap_or_else(|| resolve_translate(length.value, basis)) +} + +pub(crate) fn resolved_transform_matrix( + style: &crate::LayoutStyle, + rect: &Rect, + root_font_size: f32, + viewport: (f32, f32), +) -> crate::Affine2 { + let mut matrix = crate::Affine2::IDENTITY; + if let Some((x, y)) = style.individual_translate { + let lengths = [ + crate::TransformLength { + value: x, + expression: style.individual_translate_expressions[0].clone(), + }, + crate::TransformLength { + value: y, + expression: style.individual_translate_expressions[1].clone(), + }, + ]; + matrix = matrix.then(crate::Affine2::translate( + resolve_transform_length(&lengths[0], 0, style, rect, root_font_size, viewport), + resolve_transform_length(&lengths[1], 1, style, rect, root_font_size, viewport), + )); + } + if let Some(angle) = style.individual_rotate { + matrix = matrix.then(crate::Affine2::rotate(angle)); + } + if let Some((x, y)) = style.individual_scale { + matrix = matrix.then(crate::Affine2::scale(x, y)); + } + matrix = matrix.then(resolved_transform_property_matrix( + style, + rect, + root_font_size, + viewport, + )); + if matrix.is_identity() { + return matrix; + } + let (origin_x, origin_y) = style.transform_origin.unwrap_or(( + crate::Dimension::Percent(0.5), + crate::Dimension::Percent(0.5), + )); + matrix.around(( + rect.x + resolve_translate(origin_x, rect.width), + rect.y + resolve_translate(origin_y, rect.height), + )) +} + +/// Resolved value of the `transform` property alone. Individual transform +/// properties and `transform-origin` are intentionally excluded: CSSOM +/// serializes them as separate computed properties. +pub(crate) fn resolved_transform_property_matrix( + style: &crate::LayoutStyle, + rect: &Rect, + root_font_size: f32, + viewport: (f32, f32), +) -> crate::Affine2 { + let mut matrix = crate::Affine2::IDENTITY; + for operation in &style.transform_ops { + let operation = match operation { + crate::TransformOp::Translate(x, y) => crate::Affine2::translate( + resolve_transform_length(x, 0, style, rect, root_font_size, viewport), + resolve_transform_length(y, 1, style, rect, root_font_size, viewport), + ), + crate::TransformOp::Scale(x, y) => crate::Affine2::scale(*x, *y), + crate::TransformOp::Rotate(angle) => crate::Affine2::rotate(*angle), + crate::TransformOp::Skew(x, y) => crate::Affine2::skew(*x, *y), + crate::TransformOp::Matrix(matrix) => *matrix, + }; + matrix = matrix.then(operation); + } + matrix } /// The CSS overflow clip is the padding box, not the outer border box. This is @@ -4086,6 +4177,7 @@ fn layout_dom_once( let mut clip_rects = HashMap::new(); let mut translates = HashMap::new(); + let mut transforms = HashMap::new(); if let Some(root_id) = root { let root_font_size = styles .get(&root_id) @@ -4101,6 +4193,8 @@ fn layout_dom_once( &styles, &mut clip_rects, &mut translates, + crate::Affine2::IDENTITY, + &mut transforms, root_font_size, viewport, ); @@ -4211,6 +4305,7 @@ fn layout_dom_once( custom_properties, clip_rects, translates, + transforms, text_runs, #[cfg(feature = "paint")] text_engine: engine, @@ -6514,9 +6609,10 @@ fn pseudo_requires_generated_box(style: &crate::LayoutStyle, content: Option<&st || style.box_shadow.is_some() || !style.border_model.radii.is_zero() || style.overflow_hidden - || style.transform_translate.is_some() + || !style.transform_ops.is_empty() || style.individual_translate.is_some() - || style.transform_scale.is_some() + || style.individual_rotate.is_some() + || style.individual_scale.is_some() } fn has_in_flow_generated_pseudo(pseudo: Option<&crate::LayoutStyle>) -> bool { diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 61cb3fc..a7c509a 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -176,6 +176,160 @@ pub struct Rect { pub height: f32, } +/// One two-dimensional affine transform in CSS pixel coordinates. +/// +/// The six components use the CSS `matrix(a,b,c,d,e,f)` convention: +/// `x' = a*x + c*y + e`, `y' = b*x + d*y + f`. Keeping this renderer-owned +/// type independent of the raster backend lets layout geometry, scrolling +/// overflow, CSSOM, and paint consume exactly the same resolved transform. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Affine2 { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} + +impl Default for Affine2 { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Affine2 { + pub 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))`. + pub 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, + } + } + + pub fn translate(x: f32, y: f32) -> Self { + Self { e: x, f: y, ..Self::IDENTITY } + } + + pub fn scale(x: f32, y: f32) -> Self { + Self { a: x, d: y, ..Self::IDENTITY } + } + + pub fn rotate(degrees: f32) -> Self { + let (sin, cos) = degrees.to_radians().sin_cos(); + Self { a: cos, b: sin, c: -sin, d: cos, e: 0.0, f: 0.0 } + } + + pub fn skew(x_degrees: f32, y_degrees: f32) -> Self { + Self { + a: 1.0, + b: y_degrees.to_radians().tan(), + c: x_degrees.to_radians().tan(), + d: 1.0, + e: 0.0, + f: 0.0, + } + } + + pub fn around(self, origin: (f32, f32)) -> Self { + Self::translate(origin.0, origin.1) + .then(self) + .then(Self::translate(-origin.0, -origin.1)) + } + + pub fn map_point(self, x: f32, y: f32) -> (f32, f32) { + (self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f) + } + + pub fn map_rect(self, rect: Rect) -> Rect { + let points = [ + self.map_point(rect.x, rect.y), + self.map_point(rect.x + rect.width, rect.y), + self.map_point(rect.x, rect.y + rect.height), + self.map_point(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); + Rect { + x: left, + y: top, + width: (right - left).max(0.0), + height: (bottom - top).max(0.0), + } + } + + pub fn inverse(self) -> Option { + let determinant = self.a * self.d - self.b * self.c; + if !determinant.is_finite() || determinant.abs() < 1.0e-8 { + return None; + } + let inverse = 1.0 / determinant; + Some(Self { + a: self.d * inverse, + b: -self.b * inverse, + c: -self.c * inverse, + d: self.a * inverse, + e: (self.c * self.f - self.d * self.e) * inverse, + f: (self.b * self.e - self.a * self.f) * inverse, + }) + } + + pub 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 + } + + pub fn is_translation(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 + } +} + +#[derive(Clone, Debug)] +pub struct TransformLength { + pub value: Dimension, + pub expression: Option, +} + +impl TransformLength { + pub fn px(value: f32) -> Self { + Self { value: Dimension::Px(value), expression: None } + } +} + +/// One authored operation in a `transform` function list. Source order is +/// significant and is intentionally retained until the final reference box +/// resolves percentage translations. +#[derive(Clone, Debug)] +pub enum TransformOp { + Translate(TransformLength, TransformLength), + Scale(f32, f32), + Rotate(f32), + Skew(f32, f32), + Matrix(Affine2), +} + impl Rect { /// The overlap of two rects, or `None` if they do not intersect (or the /// overlap is degenerate). Used to accumulate an ancestor clip chain for @@ -993,35 +1147,25 @@ pub struct LayoutStyle { /// `Fill` (default) stretches to the box, the rest preserve aspect ratio. /// Only consulted in the image paint path. pub object_fit: ObjectFit, - /// `transform: translate(x[, y])` / `translateX` / `translateY`, stored - /// unresolved as (dx, dy). Applied at paint time as an offset to the - /// element's own box AND its whole descendant subtree; percentages resolve - /// against the element's own border-box size then. This is what makes the - /// canonical `translate(-50%,-50%)` centering of an absolutely-positioned - /// box land in the right place, and moves a `translate(-9999px,0)` - /// off-screen skip-link out of view instead of painting it on-screen. Not - /// inherited (transform is a non-inherited property). - pub transform_translate: Option<(Dimension, Dimension)>, + /// Ordered operations in the non-inherited `transform` property. Length + /// percentages remain unresolved until the final border box is known. + pub transform_ops: Vec, /// Individual CSS `translate` property. This composes independently with /// the legacy `transform` property, so `transform:none` must not clear it. /// Functional values are retained separately until the final border box /// is known because percentages resolve against that box's own axes. pub individual_translate: Option<(Dimension, Dimension)>, pub individual_translate_expressions: [Option; 2], + /// Individual CSS `rotate` property, in degrees. It composes after + /// individual translate and before individual scale and `transform`. + pub individual_rotate: Option, + /// Individual CSS `scale` property. Kept separate from `transform` so + /// declaration order cannot accidentally overwrite either property. + pub individual_scale: Option<(f32, f32)>, /// Independent CSS-property triggers that establish containing blocks for /// 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`, 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)>, @@ -1060,6 +1204,8 @@ pub(crate) const CB_TRIGGER_CONTAIN: u16 = 1 << 4; pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5; pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6; pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7; +pub(crate) const CB_TRIGGER_ROTATE: u16 = 1 << 8; +pub(crate) const CB_TRIGGER_SCALE: u16 = 1 << 9; impl LayoutStyle { /// Whether CSS box sizes compute normally but do not apply to this box's diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index c9993d4..bc89dd5 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -310,6 +310,7 @@ pub struct SelectedImage { /// The DOM must not be mutated while this value is reused. pub struct PreparedRender { viewport: (f32, f32), + root_font_size: f32, base_url: Option, content_size: (f32, f32), viewport_fixed: std::collections::HashSet, @@ -360,24 +361,21 @@ impl PreparedRender { ) } - /// Border box in immutable document space, including authored translate - /// transforms but excluding root-scroll and sticky movement. + /// Axis-aligned bounds of the transformed border box in immutable document + /// space, excluding root-scroll and sticky movement. pub fn document_rect( &self, id: obscura_dom::tree::NodeId, ) -> Option { let rect = *self.layout.rects.get(&id)?; - let offset = self - .layout - .translates - .get(&id) - .copied() - .unwrap_or((0.0, 0.0)); - Some(crate::Rect { - x: rect.x + offset.0, - y: rect.y + offset.1, - ..rect - }) + Some( + self.layout + .transforms + .get(&id) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect), + ) } /// Unscaled padding-box size used by CSSOM View's `clientWidth` and @@ -739,9 +737,33 @@ impl PreparedRender { .to_string(), ); - out.insert("transform", transform_css(style)); + out.insert( + "transform", + transform_css(style, rect, self.root_font_size, self.viewport), + ); out .insert("transform-origin", transform_origin_css(style, rect)); + out.insert( + "translate", + style.individual_translate.map_or_else( + || "none".to_string(), + |(x, y)| format!("{} {}", dimension_css(x, "0px"), dimension_css(y, "0px")), + ), + ); + out.insert( + "rotate", + style.individual_rotate.map_or_else( + || "none".to_string(), + |angle| format!("{}deg", css_number(angle)), + ), + ); + out.insert( + "scale", + style.individual_scale.map_or_else( + || "none".to_string(), + |(x, y)| format!("{} {}", css_number(x), css_number(y)), + ), + ); Some(out) } @@ -790,29 +812,29 @@ impl PreparedRender { .get(&id) .cloned() .or_else(|| self.layout.rects.get(&id).copied().map(|rect| vec![rect]))?; - let transform = self - .layout - .translates - .get(&id) - .copied() - .unwrap_or((0.0, 0.0)); let scroll = self.clamp_scroll(requested_scroll); let movement = if self.viewport_fixed.contains(&id) { - transform + (0.0, 0.0) } else { let sticky = self.sticky.translation_for(id, self.viewport, scroll); - ( - transform.0 + sticky.0 - scroll.0, - transform.1 + sticky.1 - scroll.1, - ) + (sticky.0 - scroll.0, sticky.1 - scroll.1) }; Some( source .into_iter() - .map(|rect| crate::Rect { - x: rect.x + movement.0, - y: rect.y + movement.1, - ..rect + .map(|rect| { + let rect = self + .layout + .transforms + .get(&id) + .copied() + .map(|transform| transform.map_rect(rect)) + .unwrap_or(rect); + crate::Rect { + x: rect.x + movement.0, + y: rect.y + movement.1, + ..rect + } }) .collect(), ) @@ -919,39 +941,27 @@ fn align_content_css(value: taffy::AlignContent) -> String { } } -fn transform_css(style: &crate::LayoutStyle) -> String { - let mut parts = Vec::new(); - if let Some((x, y)) = style.transform_translate { - parts.push(format!( - "translate({}, {})", - dimension_css(x, "0px"), - dimension_css(y, "0px") - )); - } - if let Some((x, y)) = style.individual_translate { - parts.push(format!( - "translate({}, {})", - dimension_css(x, "0px"), - dimension_css(y, "0px") - )); - } - if let Some((x, y)) = style.transform_scale { - parts.push(format!("scale({}, {})", css_number(x), css_number(y))); - } - if let Some([a, b, c, d]) = style.transform_projection { - parts.push(format!( - "matrix({}, {}, {}, {}, 0, 0)", - css_number(a), - css_number(b), - css_number(c), - css_number(d) - )); - } - if parts.is_empty() { - "none".to_string() - } else { - parts.join(" ") +fn transform_css( + style: &crate::LayoutStyle, + rect: Option<&crate::Rect>, + root_font_size: f32, + viewport: (f32, f32), +) -> String { + if style.transform_ops.is_empty() { + return "none".to_string(); } + let fallback = crate::Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; + let matrix = crate::dom::resolved_transform_property_matrix( + style, + rect.unwrap_or(&fallback), + root_font_size, + viewport, + ); + format!( + "matrix({}, {}, {}, {}, {}, {})", + css_number(matrix.a), css_number(matrix.b), css_number(matrix.c), + css_number(matrix.d), css_number(matrix.e), css_number(matrix.f), + ) } fn transform_origin_css(style: &crate::LayoutStyle, rect: Option<&crate::Rect>) -> String { @@ -1051,8 +1061,16 @@ pub fn prepare_dom_with_dynamic_fonts( let content_size = laid.scrolling_content_size(tree, viewport); let viewport_fixed = laid.viewport_fixed_nodes(tree); let sticky = laid.root_sticky_layout(tree, viewport); + let root_font_size = tree + .query_selector("html") + .ok() + .flatten() + .and_then(|root| laid.styles.get(&root)) + .and_then(|style| style.font_size) + .unwrap_or(16.0); Some(PreparedRender { viewport, + root_font_size, base_url: base_url.map(str::to_string), content_size, viewport_fixed, @@ -1090,6 +1108,9 @@ pub fn paint_prepared( None, None, None, + None, + None, + (0.0, 0.0), ) } @@ -1131,6 +1152,63 @@ fn stacking_z_index( None } +fn has_authored_transform(style: &crate::LayoutStyle) -> bool { + !style.transform_ops.is_empty() + || style.individual_translate.is_some() + || style.individual_rotate.is_some() + || style.individual_scale.is_some() +} + +/// Untransformed source bounds for one atomic transform layer, expressed in +/// the parent surface's coordinates. Keeping this tight both preserves source +/// pixels that begin outside the viewport and avoids a viewport-sized surface +/// for every transformed icon or badge. +fn transform_subtree_source_bounds( + tree: &DomTree, + laid: &crate::DomLayout, + scroll_state: &ScrollPaintState<'_>, + root: obscura_dom::tree::NodeId, +) -> Option { + let mut bounds: Option = None; + for id in std::iter::once(root).chain(tree.descendants(root)) { + let Some(rect) = laid.rects.get(&id) else { + continue; + }; + let (x, y) = scroll_state.translation_for(laid, id); + let mut visual = crate::Rect { + x: rect.x + x, + y: rect.y + y, + width: rect.width, + height: rect.height, + }; + if let Some(shadow) = laid + .styles + .get(&id) + .and_then(|style| style.box_shadow) + .filter(|shadow| !shadow.inset && shadow.color[3] != 0) + { + let expansion = shadow.spread + shadow.blur.max(0.0); + let shadow_rect = crate::Rect { + x: visual.x + shadow.offset_x - expansion, + y: visual.y + shadow.offset_y - expansion, + width: (visual.width + 2.0 * expansion).max(0.0), + height: (visual.height + 2.0 * expansion).max(0.0), + }; + visual = visual.union(&shadow_rect); + } + bounds = Some(match bounds { + Some(current) => current.union(&visual), + None => visual, + }); + } + bounds.map(|bounds| crate::Rect { + x: bounds.x - 2.0, + y: bounds.y - 2.0, + width: bounds.width + 4.0, + height: bounds.height + 4.0, + }) +} + /// Paint an already prepared layout without changing its document-space /// geometry. Root scrolling and sticky positioning are per-shot visual state, /// so alternating captures can safely reuse the same layout. @@ -1150,13 +1228,20 @@ fn paint_laid_dom_scrolled( paint_root: Option, suppress_opacity_for: Option, suppress_stacking_for: Option, + suppress_transform_for: Option, + clip_scope_root: Option, + surface_offset: (f32, f32), ) -> Option { - let scroll_state = - ScrollPaintState::new(viewport, scroll, content_size, viewport_fixed, sticky); - // 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, &scroll_state); + let scroll_state = ScrollPaintState::new( + tree, + viewport, + scroll, + content_size, + viewport_fixed, + sticky, + clip_scope_root, + surface_offset, + ); let root_font_size = tree .query_selector("html") .ok() @@ -1209,10 +1294,12 @@ fn paint_laid_dom_scrolled( .get(&nid) .and_then(|style| style.opacity) .is_some_and(|opacity| opacity.clamp(0.0, 1.0) < 1.0); + let is_transform_root = suppress_transform_for != Some(nid) + && laid.styles.get(&nid).is_some_and(has_authored_transform); let z = (suppress_stacking_for != Some(nid)) .then(|| stacking_z_index(tree, laid, nid)) .flatten(); - if is_opacity_root { + if is_opacity_root || is_transform_root { let mut sub = vec![nid]; sub.extend(tree.descendants(nid)); for &member in &sub { @@ -1334,6 +1421,9 @@ fn paint_laid_dom_scrolled( Some(nid), suppress_opacity_for, Some(nid), + suppress_transform_for, + clip_scope_root, + surface_offset, )?; continue; } @@ -1377,6 +1467,141 @@ fn paint_laid_dom_scrolled( None => continue, }; + let style = match laid.styles.get(&nid) { + Some(s) => s, + None => continue, + }; + + if suppress_transform_for != Some(nid) && has_authored_transform(style) { + opacity_subtree_skip.insert(nid); + opacity_subtree_skip.extend(tree.descendants(nid)); + let transform = crate::dom::resolved_transform_matrix( + style, + &rect, + root_font_size, + viewport, + ); + if transform.is_translation() { + // Translation-only transforms retain the direct offset path: + // no surface allocation and no resampling for carousels and + // centering utilities. + pixmap = paint_laid_dom_scrolled( + tree, + viewport, + base_url, + scroll, + pixmap, + image_cache, + selected_images, + svg_fonts, + content_size, + viewport_fixed, + sticky, + laid, + Some(nid), + suppress_opacity_for, + suppress_stacking_for, + Some(nid), + clip_scope_root, + surface_offset, + )?; + continue; + } + + // A transform wraps the complete atomic child display list. Paint + // that list in its untransformed coordinate space, with clips + // established inside this subtree, then map the finished surface. + // This makes text, borders, shadows, SVG, images, and pseudos share + // one transform exactly like Gecko's nsDisplayTransform wrapper. + let outside_clip = scroll_state.clip_for(laid, nid); + let movement = scroll_state.translation_for(laid, nid); + let display_transform = crate::Affine2::translate(movement.0, movement.1) + .then(transform) + .then(crate::Affine2::translate(-movement.0, -movement.1)); + let target = crate::Rect { + x: 0.0, + y: 0.0, + width: pixmap.width() as f32, + height: pixmap.height() as f32, + }; + let target = match outside_clip { + Some(clip) => match target.intersect(&clip) { + Some(target) => target, + None => continue, + }, + None => target, + }; + let Some(inverse) = display_transform.inverse() else { + // A singular transform has no two-dimensional painted area. + continue; + }; + let needed_source = inverse.map_rect(target); + let Some(source_bounds) = transform_subtree_source_bounds( + tree, + laid, + &scroll_state, + nid, + ) + .and_then(|bounds| bounds.intersect(&needed_source)) else { + continue; + }; + let left = source_bounds.x.floor(); + let top = source_bounds.y.floor(); + let right = (source_bounds.x + source_bounds.width).ceil(); + let bottom = (source_bounds.y + source_bounds.height).ceil(); + let layer_width = (right - left).max(1.0) as u32; + let layer_height = (bottom - top).max(1.0) as u32; + let layer_delta = (-left, -top); + let layer = Pixmap::new(layer_width, layer_height)?; + let layer = paint_laid_dom_scrolled( + tree, + viewport, + base_url, + scroll, + layer, + image_cache, + selected_images, + svg_fonts, + content_size, + viewport_fixed, + sticky, + laid, + Some(nid), + suppress_opacity_for, + suppress_stacking_for, + Some(nid), + Some(nid), + ( + surface_offset.0 + layer_delta.0, + surface_offset.1 + layer_delta.1, + ), + )?; + let transform = display_transform.then(crate::Affine2::translate( + -layer_delta.0, + -layer_delta.1, + )); + let transform = Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + ); + let clip_mask = outside_clip + .as_ref() + .and_then(|clip| box_clip_mask(pixmap.width(), pixmap.height(), clip)); + pixmap.draw_pixmap( + 0, + 0, + layer.as_ref(), + &tiny_skia::PixmapPaint::default(), + transform, + clip_mask.as_ref(), + ); + continue; + } + let own_opacity = laid .styles .get(&nid) @@ -1409,6 +1634,9 @@ fn paint_laid_dom_scrolled( Some(nid), Some(nid), suppress_stacking_for, + suppress_transform_for, + clip_scope_root, + surface_offset, )?; let group_paint = tiny_skia::PixmapPaint { opacity: own_opacity, @@ -1425,11 +1653,6 @@ fn paint_laid_dom_scrolled( continue; } - let style = match laid.styles.get(&nid) { - Some(s) => s, - None => continue, - }; - if style.effectively_invisible { continue; } @@ -1448,10 +1671,7 @@ fn paint_laid_dom_scrolled( // ubiquitous 1x1 clipped "visually hidden" accessibility pattern // actually invisible instead of painting text wherever it lands). let clip = scroll_state.clip_for(laid, nid); - let projected_image = projected_images.get(&nid).copied(); - let cull_rect = projected_image - .map(|transform| transform.map_rect(rect)) - .unwrap_or(rect); + let cull_rect = rect; let visible_rect = match clip { Some(c) => match cull_rect.intersect(&c) { Some(r) => r, @@ -1710,7 +1930,7 @@ fn paint_laid_dom_scrolled( style.object_fit, &mut pixmap, image_cache, - projected_image, + None, radius, clip_path_mask.as_ref(), ); @@ -2008,23 +2228,28 @@ fn paint_laid_dom_scrolled( /// document-space [`DomLayout`]. Keeping these deltas out of the layout avoids /// accumulating movement when the same prepared document paints more than one /// frame. -#[derive(Debug)] struct ScrollPaintState<'a> { + tree: &'a DomTree, viewport: (f32, f32), scroll: (f32, f32), viewport_fixed: &'a std::collections::HashSet, sticky: std::collections::HashMap, sticky_clips: std::collections::HashMap, + clip_scope_root: Option, + surface_offset: (f32, f32), active: bool, } impl<'a> ScrollPaintState<'a> { fn new( + tree: &'a DomTree, viewport: (f32, f32), requested: (f32, f32), content: (f32, f32), viewport_fixed: &'a std::collections::HashSet, sticky_layout: &crate::StickyLayout, + clip_scope_root: Option, + surface_offset: (f32, f32), ) -> Self { let scroll_x = if requested.0.is_finite() { requested @@ -2044,11 +2269,14 @@ impl<'a> ScrollPaintState<'a> { let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty(); if !active { return Self { + tree, viewport, scroll, viewport_fixed, sticky: std::collections::HashMap::new(), sticky_clips: std::collections::HashMap::new(), + clip_scope_root, + surface_offset, active, }; } @@ -2056,11 +2284,14 @@ impl<'a> ScrollPaintState<'a> { let sticky = sticky_layout.translations(viewport, scroll); let sticky_clips = sticky_layout.clip_translations_from(&sticky); Self { + tree, viewport, scroll, viewport_fixed, sticky, sticky_clips, + clip_scope_root, + surface_offset, active, } } @@ -2072,12 +2303,12 @@ impl<'a> ScrollPaintState<'a> { ) -> (f32, f32) { let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0)); if !self.active || self.viewport_fixed.contains(&id) { - return base; + return (base.0 + self.surface_offset.0, base.1 + self.surface_offset.1); } let sticky = self.sticky.get(&id).copied().unwrap_or((0.0, 0.0)); ( - base.0 + sticky.0 - self.scroll.0, - base.1 + sticky.1 - self.scroll.1, + base.0 + sticky.0 - self.scroll.0 + self.surface_offset.0, + base.1 + sticky.1 - self.scroll.1 + self.surface_offset.1, ) } @@ -2095,19 +2326,55 @@ impl<'a> ScrollPaintState<'a> { laid: &crate::DomLayout, id: obscura_dom::tree::NodeId, ) -> Option { - let mut clip = laid.clip_rects.get(&id).copied().flatten()?; - if !self.active || self.viewport_fixed.contains(&id) { - return Some(clip); + if let Some(root) = self.clip_scope_root { + if id == root { + return None; + } + let mut owners = Vec::new(); + let mut current = self.tree.get_node(id).and_then(|node| node.parent); + let mut found = false; + while let Some(owner) = current { + owners.push(owner); + if owner == root { + found = true; + break; + } + current = self.tree.get_node(owner).and_then(|node| node.parent); + } + if found { + let mut clip: Option = None; + for owner in owners.into_iter().rev() { + let (Some(style), Some(rect)) = + (laid.styles.get(&owner), laid.rects.get(&owner)) + else { + continue; + }; + if !style.overflow_hidden || style.overflow_propagated_to_viewport { + continue; + } + let (x, y) = self.translation_for(laid, owner); + let own = crate::dom::OverflowClip::for_box(rect, style, x, y); + clip = Some(match clip { + Some(current) => current.intersect(own), + None => own, + }); + } + return clip; + } } - let sticky = self - .sticky_clips - .get(&id) - .copied() - .unwrap_or((0.0, 0.0)); - clip.translate( - sticky.0 - self.scroll.0, - sticky.1 - self.scroll.1, - ); + let mut clip = laid.clip_rects.get(&id).copied().flatten()?; + if self.active && !self.viewport_fixed.contains(&id) { + let sticky = self + .sticky_clips + .get(&id) + .copied() + .unwrap_or((0.0, 0.0)); + clip.translate( + sticky.0 - self.scroll.0, + sticky.1 - self.scroll.1, + ); + } + clip.translate(self.surface_offset.0, self.surface_offset.1); Some(clip) } @@ -2146,186 +2413,6 @@ impl<'a> ScrollPaintState<'a> { } } -#[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 comes from the -/// per-shot visual state so the document-space layout remains reusable. -fn collect_projected_image_transforms( - tree: &DomTree, - layout: &crate::DomLayout, - scroll_state: &ScrollPaintState, -) -> 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, - scroll_state: &ScrollPaintState, - 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 = scroll_state.translation_for(layout, id); - 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, - scroll_state, - child, - combined, - transformed, - out, - ); - } - } - - let mut out = std::collections::HashMap::new(); - walk( - tree, - layout, - scroll_state, - 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). The /// horizontal and vertical radii are scaled together when necessary, matching @@ -5320,7 +5407,7 @@ fn paint_image( object_fit: crate::ObjectFit, pixmap: &mut Pixmap, cache: &mut RenderResourceCache, - transform: Option, + transform: Option, clip_radius: crate::ResolvedBorderRadii, extra_clip: Option<&tiny_skia::Mask>, ) -> bool { @@ -5413,7 +5500,14 @@ fn paint_image( content.as_ref(), &tiny_skia::PixmapPaint::default(), transform - .map(ImageAffine::tiny_skia) + .map(|transform| Transform::from_row( + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f, + )) .unwrap_or_else(Transform::identity), clip.as_ref(), ); @@ -8219,6 +8313,152 @@ mod tests { assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255)); } + #[test] + fn rotate_and_scale_paint_complete_mixed_subtrees() { + let tree = parse_html( + r#" +
+ MMMM + +
+
+ M + +
+ "#, + ); + let pixmap = paint_dom(&tree, (240.0, 120.0), None).expect("pixmap"); + + let scaled_box = pixmap.pixel(30, 70).expect("scaled box"); + assert!(scaled_box.red() > 220 && scaled_box.green() < 40); + let scaled_image = pixmap.pixel(110, 70).expect("scaled image"); + assert!(scaled_image.green() > 220 && scaled_image.red() < 40); + assert!( + (20..100).any(|x| (20..60).any(|y| { + let pixel = pixmap.pixel(x, y).expect("scaled text region"); + pixel.red() < 80 && pixel.green() < 80 && pixel.blue() < 80 + })), + "text must be rasterized inside the scaled atomic subtree" + ); + + let rotated_box = pixmap.pixel(165, 25).expect("rotated box"); + assert!(rotated_box.red() > 220 && rotated_box.blue() > 220); + let rotated_image = pixmap.pixel(175, 45).expect("rotated image"); + assert!(rotated_image.green() > 220 && rotated_image.blue() > 220); + } + + #[test] + fn transform_function_order_nested_world_matrices_and_cssom_aabbs() { + let tree = parse_html( + r#" +
+
+
+
+
+
+
+
+ "#, + ); + let mut resources = RenderResourceCache::default(); + let prepared = prepare_dom(&tree, (500.0, 600.0), None, &mut resources) + .expect("prepared transform geometry"); + let assert_rect = |actual: crate::Rect, expected: crate::Rect| { + assert!((actual.x - expected.x).abs() < 0.02, "x: {actual:?}"); + assert!((actual.y - expected.y).abs() < 0.02, "y: {actual:?}"); + assert!((actual.width - expected.width).abs() < 0.02, "width: {actual:?}"); + assert!((actual.height - expected.height).abs() < 0.02, "height: {actual:?}"); + }; + let first = tree.get_element_by_id("translate-rotate").unwrap(); + let second = tree.get_element_by_id("rotate-translate").unwrap(); + assert_rect( + prepared.document_rect(first).unwrap(), + crate::Rect { x: 140.0, y: 50.0, width: 10.0, height: 20.0 }, + ); + assert_rect( + prepared.document_rect(second).unwrap(), + crate::Rect { x: 40.0, y: 150.0, width: 10.0, height: 20.0 }, + ); + let child = tree.get_element_by_id("child").unwrap(); + let child_rect = crate::Rect { x: 30.0, y: 240.0, width: 40.0, height: 20.0 }; + assert_rect(prepared.document_rect(child).unwrap(), child_rect); + assert_rect( + prepared.viewport_client_rects(child, (0.0, 0.0)).unwrap()[0], + child_rect, + ); + let fixed = tree.get_element_by_id("captured-fixed").unwrap(); + assert_rect( + prepared.document_rect(fixed).unwrap(), + crate::Rect { x: 50.0, y: 200.0, width: 20.0, height: 20.0 }, + ); + let centered = tree.get_element_by_id("center-origin").unwrap(); + assert_rect( + prepared.document_rect(centered).unwrap(), + crate::Rect { x: 105.0, y: 395.0, width: 10.0, height: 20.0 }, + ); + let cssom = tree.get_element_by_id("cssom-transform").unwrap(); + let computed = prepared.computed_style(cssom).unwrap(); + assert_eq!(computed["transform"], "matrix(1, 0, 0, 1, 10, 0)"); + assert_eq!(computed["translate"], "7px 0px"); + assert_eq!(computed["rotate"], "30deg"); + assert_eq!(computed["scale"], "2 2"); + } + + #[test] + fn transformed_subtree_is_clipped_by_outside_ancestor() { + let tree = parse_html( + r#" +
+
+
+ "#, + ); + let pixmap = paint_dom(&tree, (100.0, 80.0), None).expect("pixmap"); + let inside = pixmap.pixel(55, 40).expect("inside ancestor clip"); + assert!(inside.green() > 120 && inside.red() < 40); + let outside = pixmap.pixel(65, 40).expect("outside ancestor clip"); + assert_eq!((outside.red(), outside.green(), outside.blue()), (255, 255, 255)); + } + + #[test] + fn no_transform_keeps_identity_geometry_and_baseline_paint() { + let tree = parse_html( + r#"
+ "#, + ); + let mut resources = RenderResourceCache::default(); + let prepared = prepare_dom(&tree, (80.0, 60.0), None, &mut resources).unwrap(); + let plain = tree.get_element_by_id("plain").unwrap(); + assert!(prepared.layout().transforms.is_empty()); + assert_eq!( + prepared.document_rect(plain), + Some(crate::Rect { x: 10.0, y: 12.0, width: 20.0, height: 15.0 }) + ); + let pixmap = paint_dom(&tree, (80.0, 60.0), None).unwrap(); + let painted = pixmap.pixel(15, 15).unwrap(); + assert!(painted.red() > 220 && painted.green() < 40); + let empty = pixmap.pixel(5, 5).unwrap(); + assert_eq!((empty.red(), empty.green(), empty.blue()), (255, 255, 255)); + } + #[test] fn transformed_image_is_clipped_inside_overflow_border() { // CSS overflow clipping belongs to the owner's padding box. The diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 576dbc8..de52983 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -1611,6 +1611,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "transform" => parse_transform(style, value), "transform-origin" => style.transform_origin = parse_transform_origin(value), "translate" => parse_individual_translate(style, value), + "rotate" => parse_individual_rotate(style, value), "scale" => parse_individual_scale(style, value), "filter" => set_containing_block_trigger( style, @@ -1918,6 +1919,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "transform" | "transform-origin" | "translate" + | "rotate" | "scale" | "filter" | "backdrop-filter" @@ -2409,6 +2411,7 @@ fn supports_conservative_known_value(name: &str, value: &str) -> bool { "grid-column-start" | "grid-column-end" | "grid-row-start" | "grid-row-end" => parse_grid_line_kind(value).is_some(), "transform-origin" => parse_transform_origin(value).is_some(), "translate" => lower == "none" || dimensions(value, false, 3), + "rotate" => lower == "none" || angle_degrees(value).is_some(), "scale" => lower == "none" || { let values = value.split_whitespace().collect::>(); !values.is_empty() && values.len() <= 2 && values.iter().all(|value| scale_number(value).is_some()) @@ -2466,19 +2469,7 @@ fn supports_transform_value(value: &str) -> bool { if value.trim().eq_ignore_ascii_case("none") { return true; } - let functions = transform_functions(value); - !functions.is_empty() && functions.into_iter().all(|(name, arguments)| { - let values = arguments.split(',').map(str::trim).filter(|value| !value.is_empty()).collect::>(); - match name.to_ascii_lowercase().as_str() { - "translate" => (1..=2).contains(&values.len()) && values.iter().all(|value| !matches!(dimension_value(value), crate::Dimension::Auto)), - "translate3d" => values.len() == 3 && values.iter().all(|value| !matches!(dimension_value(value), crate::Dimension::Auto)), - "translatex" | "translatey" => values.len() == 1 && !matches!(dimension_value(values[0]), crate::Dimension::Auto), - "scale" => (1..=2).contains(&values.len()) && values.iter().all(|value| scale_number(value).is_some()), - "scalex" | "scaley" => values.len() == 1 && scale_number(values[0]).is_some(), - "rotate" | "rotatex" | "rotatey" | "rotatez" => values.len() == 1 && angle_degrees(values[0]).is_some(), - _ => false, - } - }) + parse_transform_ops(value).is_some() } fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) { @@ -2858,143 +2849,203 @@ fn content_alignment_pair( None } -/// 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() { return; } - // `transform: none` resets any transform from a lower-priority rule (the - // carousel idiom: a class translates the track, an inline style clears it - // for the untransformed state). - if v.eq_ignore_ascii_case("none") { - style.transform_translate = None; - style.transform_scale = None; - style.transform_projection = None; + if matches!( + v.to_ascii_lowercase().as_str(), + "none" | "initial" | "unset" | "revert" | "revert-layer" + ) { + style.transform_ops.clear(); set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false); return; } - let functions = transform_functions(v); - if functions.is_empty() { + let Some(operations) = parse_transform_ops(v) else { return; - } + }; + style.transform_ops = operations; set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true); - 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() { - // translate3d's z component is ignored (no perspective model); - // Swiper and similar carousels position their track exclusively - // through translate3d, so dropping it left every slide stacked. - "translate" | "translate3d" => { - if let Some(a) = parts.first() { - tx = Some(dimension_value(a)); - } - // A missing second component is 0 (translateY), not inherited. - ty = Some(parts.get(1).map(|a| dimension_value(a)).unwrap_or(zero)); - } - "translatex" => { - if let Some(a) = parts.first() { - tx = Some(dimension_value(a)); - } - } - "translatey" => { - if let Some(a) = parts.first() { - ty = Some(dimension_value(a)); - } - } - "scale" => { - if let Some(a) = parts.first().and_then(|s| scale_number(s)) { - // scale(s) is uniform; scale(sx, sy) sets each axis. - sy = Some(parts.get(1).and_then(|s| scale_number(s)).unwrap_or(a)); - sx = Some(a); - } - } - "scalex" => { - if let Some(a) = parts.first().and_then(|s| scale_number(s)) { - sx = Some(a); - } - } - "scaley" => { - if let Some(a) = parts.first().and_then(|s| scale_number(s)) { - sy = Some(a); - } - } - "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. - _ => {} +} + +fn parse_transform_length(value: &str) -> Option { + let value = value.trim(); + if value.contains('(') { + let functions = transform_functions(value); + if functions.len() != 1 { + return None; } - } - if tx.is_some() || ty.is_some() { - style.transform_translate = Some((tx.unwrap_or(zero), ty.unwrap_or(zero))); - } - 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(); + let (name, arguments) = &functions[0]; + let valid = match name.to_ascii_lowercase().as_str() { + "calc" | "min" | "max" | "clamp" => resolve_contextual_length( + value, 16.0, 16.0, 10.0, 10.0, 100.0, + ) + .is_some_and(f32::is_finite), + "var" => { + let parts = split_top_level(arguments, ','); + (1..=2).contains(&parts.len()) + && parts[0].trim().starts_with("--") + && parts[0].trim().len() > 2 + && parts.get(1).is_none_or(|fallback| { + !fallback.trim().is_empty() + && parse_transform_length(fallback).is_some() + }) + } + _ => false, + }; + if !valid { + return None; } + return Some(crate::TransformLength { + value: crate::Dimension::Px(0.0), + expression: Some(value.to_string()), + }); } - out + let value = dimension_value(value); + (!matches!(value, crate::Dimension::Auto)).then_some(crate::TransformLength { + value, + expression: None, + }) } -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 parse_transform_z_length(value: &str) -> Option { + // The Z coordinate is ``, not ``. + if value.contains('%') { + return None; + } + parse_transform_length(value) } -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 parse_transform_ops(value: &str) -> Option> { + let functions = transform_functions(value); + if functions.is_empty() { + return None; + } + let mut operations = Vec::with_capacity(functions.len()); + for (name, arguments) in functions { + let values = split_top_level(&arguments, ',') + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + let operation = match name.to_ascii_lowercase().as_str() { + "translate" if (1..=2).contains(&values.len()) => { + let y = if let Some(value) = values.get(1) { + parse_transform_length(value)? + } else { + crate::TransformLength::px(0.0) + }; + crate::TransformOp::Translate( + parse_transform_length(values[0])?, + y, + ) + } + "translatex" if values.len() == 1 => crate::TransformOp::Translate( + parse_transform_length(values[0])?, + crate::TransformLength::px(0.0), + ), + "translatey" if values.len() == 1 => crate::TransformOp::Translate( + crate::TransformLength::px(0.0), + parse_transform_length(values[0])?, + ), + "translate3d" if values.len() == 3 => { + // Without perspective, translation along Z does not change the + // projection of the element's plane. Keep accepting it so the + // useful X/Y part of common compositor transforms is retained. + parse_transform_z_length(values[2])?; + crate::TransformOp::Translate( + parse_transform_length(values[0])?, + parse_transform_length(values[1])?, + ) + } + "translatez" if values.len() == 1 => { + parse_transform_z_length(values[0])?; + crate::TransformOp::Matrix(crate::Affine2::IDENTITY) + } + "scale" if (1..=2).contains(&values.len()) => { + let x = scale_number(values[0])?; + crate::TransformOp::Scale(x, values.get(1).and_then(|value| scale_number(value)).unwrap_or(x)) + } + "scalex" if values.len() == 1 => crate::TransformOp::Scale(scale_number(values[0])?, 1.0), + "scaley" if values.len() == 1 => crate::TransformOp::Scale(1.0, scale_number(values[0])?), + "scale3d" if values.len() == 3 => { + let x = scale_number(values[0])?; + let y = scale_number(values[1])?; + scale_number(values[2])?; + crate::TransformOp::Scale(x, y) + } + "scalez" if values.len() == 1 && scale_number(values[0]).is_some() => { + crate::TransformOp::Matrix(crate::Affine2::IDENTITY) + } + "rotate" | "rotatez" if values.len() == 1 => { + crate::TransformOp::Rotate(angle_degrees(values[0])?) + } + "rotatex" if values.len() == 1 => { + let radians = angle_degrees(values[0])?.to_radians(); + crate::TransformOp::Scale(1.0, radians.cos()) + } + "rotatey" if values.len() == 1 => { + let radians = angle_degrees(values[0])?.to_radians(); + crate::TransformOp::Scale(radians.cos(), 1.0) + } + "rotate3d" if values.len() == 4 => { + let x = values[0].parse::().ok()?; + let y = values[1].parse::().ok()?; + let z = values[2].parse::().ok()?; + if !x.is_finite() || !y.is_finite() || !z.is_finite() { + return None; + } + let angle = angle_degrees(values[3])?; + if x != 0.0 && y == 0.0 && z == 0.0 { + crate::TransformOp::Scale(1.0, (angle * x.signum()).to_radians().cos()) + } else if x == 0.0 && y != 0.0 && z == 0.0 { + crate::TransformOp::Scale((angle * y.signum()).to_radians().cos(), 1.0) + } else if x == 0.0 && y == 0.0 && z != 0.0 { + crate::TransformOp::Rotate(angle * z.signum()) + } else { + // A mixed 3D rotation axis cannot be represented by the + // affine renderer without tracking Z between operations. + return None; + } + } + "skew" if (1..=2).contains(&values.len()) => crate::TransformOp::Skew( + angle_degrees(values[0])?, + values.get(1).and_then(|value| angle_degrees(value)).unwrap_or(0.0), + ), + "skewx" if values.len() == 1 => crate::TransformOp::Skew(angle_degrees(values[0])?, 0.0), + "skewy" if values.len() == 1 => crate::TransformOp::Skew(0.0, angle_degrees(values[0])?), + "matrix" if values.len() == 6 => { + let numbers = values.iter().map(|value| value.parse::().ok()).collect::>>()?; + if numbers.iter().any(|value| !value.is_finite()) { + return None; + } + crate::TransformOp::Matrix(crate::Affine2 { + a: numbers[0], b: numbers[1], c: numbers[2], + d: numbers[3], e: numbers[4], f: numbers[5], + }) + } + "matrix3d" if values.len() == 16 => { + let numbers = values.iter().map(|value| value.parse::().ok()).collect::>>()?; + if numbers.iter().any(|value| !value.is_finite()) + || numbers[2] != 0.0 || numbers[3] != 0.0 + || numbers[6] != 0.0 || numbers[7] != 0.0 + || numbers[8] != 0.0 || numbers[9] != 0.0 + || numbers[10] != 1.0 || numbers[11] != 0.0 + || numbers[14] != 0.0 || numbers[15] != 1.0 + { + return None; + } + crate::TransformOp::Matrix(crate::Affine2 { + a: numbers[0], b: numbers[1], c: numbers[4], + d: numbers[5], e: numbers[12], f: numbers[13], + }) + } + _ => return None, + }; + operations.push(operation); + } + Some(operations) } fn angle_degrees(value: &str) -> Option { @@ -3024,16 +3075,19 @@ fn angle_degrees(value: &str) -> Option { .strip_prefix("calc(") .and_then(|value| value.strip_suffix(')')) else { - return primitive(value); + return primitive(value).filter(|value| value.is_finite()); }; - 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 result = if let Some((angle, factor)) = inner.split_once('*') { + Some(primitive(angle)? * factor.trim().parse::().ok()?) + } else 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) + (divisor != 0.0) + .then(|| primitive(angle).map(|angle| angle / divisor)) + .flatten() + } else { + primitive(inner) + }; + result.filter(|value| value.is_finite()) } fn parse_transform_origin(value: &str) -> Option<(crate::Dimension, crate::Dimension)> { @@ -3061,8 +3115,12 @@ fn parse_transform_origin(value: &str) -> Option<(crate::Dimension, crate::Dimen } fn parse_individual_scale(style: &mut LayoutStyle, value: &str) { - if value.trim().eq_ignore_ascii_case("none") { - style.transform_scale = None; + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "none" | "initial" | "unset" | "revert" | "revert-layer" + ) { + style.individual_scale = None; + set_containing_block_trigger(style, crate::CB_TRIGGER_SCALE, false); return; } let values: Vec = value @@ -3072,8 +3130,23 @@ fn parse_individual_scale(style: &mut LayoutStyle, value: &str) { .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); + style.individual_scale = Some((x, y)); + set_containing_block_trigger(style, crate::CB_TRIGGER_SCALE, true); +} + +fn parse_individual_rotate(style: &mut LayoutStyle, value: &str) { + let value = value.trim(); + if matches!( + value.to_ascii_lowercase().as_str(), + "none" | "initial" | "unset" | "revert" | "revert-layer" + ) { + style.individual_rotate = None; + set_containing_block_trigger(style, crate::CB_TRIGGER_ROTATE, false); + return; + } + let Some(angle) = angle_degrees(value) else { return }; + style.individual_rotate = Some(angle); + set_containing_block_trigger(style, crate::CB_TRIGGER_ROTATE, true); } fn parse_individual_translate(style: &mut LayoutStyle, value: &str) { @@ -3137,43 +3210,50 @@ fn set_containing_block_trigger(style: &mut LayoutStyle, trigger: u16, enabled: } } -/// Split a `transform` value into its `name(args)` functions, in source order. -/// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an -/// argument stays intact); the function name is the trailing identifier run -/// just before each `(`. +/// Strictly split a `transform` value into `name(args)` functions in source +/// order. Any non-whitespace between functions, missing delimiter, or +/// unbalanced parenthesis invalidates the whole declaration. fn transform_functions(value: &str) -> Vec<(String, String)> { let mut out = Vec::new(); - let mut rest = value; - while let Some(open) = rest.find('(') { - let name: String = rest[..open] - .chars() - .rev() - .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') - .collect::>() - .into_iter() - .rev() - .collect(); - let after = &rest[open + 1..]; + let bytes = value.as_bytes(); + let mut cursor = 0usize; + while cursor < bytes.len() { + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor == bytes.len() { + break; + } + let name_start = cursor; + while cursor < bytes.len() + && (bytes[cursor].is_ascii_alphanumeric() || bytes[cursor] == b'-') + { + cursor += 1; + } + if cursor == name_start || cursor >= bytes.len() || bytes[cursor] != b'(' { + return Vec::new(); + } + let name = value[name_start..cursor].to_string(); + cursor += 1; + let args_start = cursor; let mut depth = 1i32; let mut end = None; - for (i, c) in after.char_indices() { + for (offset, c) in value[cursor..].char_indices() { match c { '(' => depth += 1, ')' => { depth -= 1; if depth == 0 { - end = Some(i); + end = Some(cursor + offset); break; } } _ => {} } } - let Some(e) = end else { break }; - if !name.is_empty() { - out.push((name, after[..e].to_string())); - } - rest = &after[e + 1..]; + let Some(end) = end else { return Vec::new() }; + out.push((name, value[args_start..end].to_string())); + cursor = end + 1; } out } @@ -3183,9 +3263,14 @@ fn transform_functions(value: &str) -> Vec<(String, String)> { fn scale_number(s: &str) -> Option { let t = s.trim(); if let Some(p) = t.strip_suffix('%') { - return p.trim().parse::().ok().map(|v| v / 100.0); + return p + .trim() + .parse::() + .ok() + .map(|value| value / 100.0) + .filter(|value| value.is_finite()); } - t.parse::().ok() + t.parse::().ok().filter(|value| value.is_finite()) } /// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem @@ -6879,6 +6964,37 @@ mod tests { assert!(!supports_declaration("display", "banana")); } + #[test] + fn transform_support_rejects_invalid_z_types_nonfinite_numbers_and_fake_math() { + for value in [ + "translateZ(10px)", + "translate3d(1px, 2px, 3rem)", + "scale3d(1, 2, 3)", + "rotate3d(0, 0, 1, 45deg)", + "translateX(calc(10px + 5%))", + "translateX(var(--offset))", + "translateX(var(--offset, 10px))", + ] { + assert!(supports_declaration("transform", value), "{value}"); + } + for value in [ + "translateZ(10%)", + "translate3d(1px, 2px, 10%)", + "scale3d(1, 2, garbage)", + "scale3d(1, 2, NaN)", + "scale(NaN)", + "rotate(NaNdeg)", + "rotate(calc(1deg / 0))", + "rotate3d(NaN, 0, 1, 45deg)", + "translateX(garbage(10px))", + "translateX(calc(garbage))", + "translateX(var(x))", + "translateX(var(--x, garbage(1px)))", + ] { + assert!(!supports_declaration("transform", value), "{value}"); + } + } + #[test] fn clip_path_polygon_supports_only_geometry_it_can_resolve() { assert!(supports_declaration(