Implement affine transform geometry
This commit is contained in:
1 parent
a3f3c32392
commit
e6ec5dca59
4 files changed
+1073
-475
No files matched your search
@@ -247,6 +247,10 @@ pub struct DomLayout {
|
|||||||
/// px. Only nodes with a non-zero accumulation are present; paint shifts
|
/// px. Only nodes with a non-zero accumulation are present; paint shifts
|
||||||
/// each box by this to reach screen space.
|
/// each box by this to reach screen space.
|
||||||
pub translates: HashMap<NodeId, (f32, f32)>,
|
pub translates: HashMap<NodeId, (f32, f32)>,
|
||||||
|
/// 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<NodeId, crate::Affine2>,
|
||||||
/// Per-word geometry for text nodes: a text DOM node lays out as one
|
/// Per-word geometry for text nodes: a text DOM node lays out as one
|
||||||
/// taffy leaf per word (see `build_text_words`), each wrapping
|
/// taffy leaf per word (see `build_text_words`), each wrapping
|
||||||
/// independently within its container, so its rendered content is a list
|
/// independently within its container, so its rendered content is a list
|
||||||
@@ -489,6 +493,7 @@ impl DomLayout {
|
|||||||
&self.rects,
|
&self.rects,
|
||||||
&self.styles,
|
&self.styles,
|
||||||
&self.translates,
|
&self.translates,
|
||||||
|
&self.transforms,
|
||||||
&mut right,
|
&mut right,
|
||||||
&mut bottom,
|
&mut bottom,
|
||||||
);
|
);
|
||||||
@@ -653,6 +658,7 @@ fn accumulate_scrolling_overflow(
|
|||||||
rects: &HashMap<NodeId, Rect>,
|
rects: &HashMap<NodeId, Rect>,
|
||||||
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
||||||
translates: &HashMap<NodeId, (f32, f32)>,
|
translates: &HashMap<NodeId, (f32, f32)>,
|
||||||
|
transforms: &HashMap<NodeId, crate::Affine2>,
|
||||||
right: &mut f32,
|
right: &mut f32,
|
||||||
bottom: &mut f32,
|
bottom: &mut f32,
|
||||||
) {
|
) {
|
||||||
@@ -661,13 +667,11 @@ fn accumulate_scrolling_overflow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let translated = rects.get(&id).map(|rect| {
|
let translated = rects.get(&id).map(|rect| {
|
||||||
let (tx, ty) = translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
transforms
|
||||||
Rect {
|
.get(&id)
|
||||||
x: rect.x + tx,
|
.copied()
|
||||||
y: rect.y + ty,
|
.map(|transform| transform.map_rect(*rect))
|
||||||
width: rect.width,
|
.unwrap_or(*rect)
|
||||||
height: rect.height,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if let Some(overflow) = translated {
|
if let Some(overflow) = translated {
|
||||||
let visible = if let Some(clip) = inherited_clip {
|
let visible = if let Some(clip) = inherited_clip {
|
||||||
@@ -700,7 +704,7 @@ fn accumulate_scrolling_overflow(
|
|||||||
};
|
};
|
||||||
for child in tree.children(id) {
|
for child in tree.children(id) {
|
||||||
accumulate_scrolling_overflow(
|
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<NodeId, crate::LayoutStyle>,
|
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
||||||
clip_rects: &mut HashMap<NodeId, Option<OverflowClip>>,
|
clip_rects: &mut HashMap<NodeId, Option<OverflowClip>>,
|
||||||
translates: &mut HashMap<NodeId, (f32, f32)>,
|
translates: &mut HashMap<NodeId, (f32, f32)>,
|
||||||
|
parent_transform: crate::Affine2,
|
||||||
|
transforms: &mut HashMap<NodeId, crate::Affine2>,
|
||||||
root_font_size: f32,
|
root_font_size: f32,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
) {
|
) {
|
||||||
@@ -1106,6 +1112,14 @@ fn resolve_clip_rects(
|
|||||||
if tx != 0.0 || ty != 0.0 {
|
if tx != 0.0 || ty != 0.0 {
|
||||||
translates.insert(id, (tx, ty));
|
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
|
// Overflow propagated from html/body establishes the root scrolling
|
||||||
// viewport. It is anchored to the capture surface, not to document
|
// viewport. It is anchored to the capture surface, not to document
|
||||||
// coordinates, and the pixmap already supplies that viewport clip.
|
// coordinates, and the pixmap already supplies that viewport clip.
|
||||||
@@ -1134,6 +1148,8 @@ fn resolve_clip_rects(
|
|||||||
styles,
|
styles,
|
||||||
clip_rects,
|
clip_rects,
|
||||||
translates,
|
translates,
|
||||||
|
transform,
|
||||||
|
transforms,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
viewport,
|
viewport,
|
||||||
);
|
);
|
||||||
@@ -1146,21 +1162,26 @@ fn resolved_own_translate(
|
|||||||
root_font_size: f32,
|
root_font_size: f32,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
) -> (f32, f32) {
|
) -> (f32, f32) {
|
||||||
let mut offset = style
|
let transform = resolved_transform_matrix(style, rect, root_font_size, viewport);
|
||||||
.transform_translate
|
if transform.is_translation() {
|
||||||
.map(|(x, y)| {
|
(transform.e, transform.f)
|
||||||
(
|
} else {
|
||||||
resolve_translate(x, rect.width),
|
(0.0, 0.0)
|
||||||
resolve_translate(y, rect.height),
|
}
|
||||||
)
|
}
|
||||||
})
|
|
||||||
.unwrap_or((0.0, 0.0));
|
fn resolve_transform_length(
|
||||||
let Some((x, y)) = style.individual_translate else {
|
length: &crate::TransformLength,
|
||||||
return offset;
|
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);
|
let em = style.font_size.unwrap_or(16.0);
|
||||||
let resolve_axis = |axis: usize, value: crate::Dimension, basis: f32| {
|
length
|
||||||
style.individual_translate_expressions[axis]
|
.expression
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|expression| {
|
.and_then(|expression| {
|
||||||
crate::style::resolve_contextual_length(
|
crate::style::resolve_contextual_length(
|
||||||
@@ -1172,11 +1193,81 @@ fn resolved_own_translate(
|
|||||||
basis,
|
basis,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| resolve_translate(value, basis))
|
.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,
|
||||||
};
|
};
|
||||||
offset.0 += resolve_axis(0, x, rect.width);
|
matrix = matrix.then(operation);
|
||||||
offset.1 += resolve_axis(1, y, rect.height);
|
}
|
||||||
offset
|
matrix
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The CSS overflow clip is the padding box, not the outer border box. This is
|
/// 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 clip_rects = HashMap::new();
|
||||||
let mut translates = HashMap::new();
|
let mut translates = HashMap::new();
|
||||||
|
let mut transforms = HashMap::new();
|
||||||
if let Some(root_id) = root {
|
if let Some(root_id) = root {
|
||||||
let root_font_size = styles
|
let root_font_size = styles
|
||||||
.get(&root_id)
|
.get(&root_id)
|
||||||
@@ -4101,6 +4193,8 @@ fn layout_dom_once(
|
|||||||
&styles,
|
&styles,
|
||||||
&mut clip_rects,
|
&mut clip_rects,
|
||||||
&mut translates,
|
&mut translates,
|
||||||
|
crate::Affine2::IDENTITY,
|
||||||
|
&mut transforms,
|
||||||
root_font_size,
|
root_font_size,
|
||||||
viewport,
|
viewport,
|
||||||
);
|
);
|
||||||
@@ -4211,6 +4305,7 @@ fn layout_dom_once(
|
|||||||
custom_properties,
|
custom_properties,
|
||||||
clip_rects,
|
clip_rects,
|
||||||
translates,
|
translates,
|
||||||
|
transforms,
|
||||||
text_runs,
|
text_runs,
|
||||||
#[cfg(feature = "paint")]
|
#[cfg(feature = "paint")]
|
||||||
text_engine: engine,
|
text_engine: engine,
|
||||||
@@ -6514,9 +6609,10 @@ fn pseudo_requires_generated_box(style: &crate::LayoutStyle, content: Option<&st
|
|||||||
|| style.box_shadow.is_some()
|
|| style.box_shadow.is_some()
|
||||||
|| !style.border_model.radii.is_zero()
|
|| !style.border_model.radii.is_zero()
|
||||||
|| style.overflow_hidden
|
|| style.overflow_hidden
|
||||||
|| style.transform_translate.is_some()
|
|| !style.transform_ops.is_empty()
|
||||||
|| style.individual_translate.is_some()
|
|| 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 {
|
fn has_in_flow_generated_pseudo(pseudo: Option<&crate::LayoutStyle>) -> bool {
|
||||||
|
|||||||
@@ -176,6 +176,160 @@ pub struct Rect {
|
|||||||
pub height: f32,
|
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<Self> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
impl Rect {
|
||||||
/// The overlap of two rects, or `None` if they do not intersect (or the
|
/// 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
|
/// 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.
|
/// `Fill` (default) stretches to the box, the rest preserve aspect ratio.
|
||||||
/// Only consulted in the image paint path.
|
/// Only consulted in the image paint path.
|
||||||
pub object_fit: ObjectFit,
|
pub object_fit: ObjectFit,
|
||||||
/// `transform: translate(x[, y])` / `translateX` / `translateY`, stored
|
/// Ordered operations in the non-inherited `transform` property. Length
|
||||||
/// unresolved as (dx, dy). Applied at paint time as an offset to the
|
/// percentages remain unresolved until the final border box is known.
|
||||||
/// element's own box AND its whole descendant subtree; percentages resolve
|
pub transform_ops: Vec<TransformOp>,
|
||||||
/// 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)>,
|
|
||||||
/// Individual CSS `translate` property. This composes independently with
|
/// Individual CSS `translate` property. This composes independently with
|
||||||
/// the legacy `transform` property, so `transform:none` must not clear it.
|
/// the legacy `transform` property, so `transform:none` must not clear it.
|
||||||
/// Functional values are retained separately until the final border box
|
/// Functional values are retained separately until the final border box
|
||||||
/// is known because percentages resolve against that box's own axes.
|
/// is known because percentages resolve against that box's own axes.
|
||||||
pub individual_translate: Option<(Dimension, Dimension)>,
|
pub individual_translate: Option<(Dimension, Dimension)>,
|
||||||
pub individual_translate_expressions: [Option<String>; 2],
|
pub individual_translate_expressions: [Option<String>; 2],
|
||||||
|
/// Individual CSS `rotate` property, in degrees. It composes after
|
||||||
|
/// individual translate and before individual scale and `transform`.
|
||||||
|
pub individual_rotate: Option<f32>,
|
||||||
|
/// 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
|
/// Independent CSS-property triggers that establish containing blocks for
|
||||||
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
|
/// absolute and fixed descendants. Kept as a bitset so `filter:none`
|
||||||
/// cannot clear a transform/containment trigger from another property.
|
/// cannot clear a transform/containment trigger from another property.
|
||||||
pub containing_block_triggers: u16,
|
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
|
/// Authored `transform-origin`, unresolved so percentages use the final
|
||||||
/// border-box dimensions. `None` is the CSS initial value, 50% 50%.
|
/// border-box dimensions. `None` is the CSS initial value, 50% 50%.
|
||||||
pub transform_origin: Option<(Dimension, Dimension)>,
|
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_WILL_CHANGE: u16 = 1 << 5;
|
||||||
pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6;
|
pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6;
|
||||||
pub(crate) const CB_TRIGGER_TRANSLATE: u16 = 1 << 7;
|
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 {
|
impl LayoutStyle {
|
||||||
/// Whether CSS box sizes compute normally but do not apply to this box's
|
/// Whether CSS box sizes compute normally but do not apply to this box's
|
||||||
|
|||||||
+502
-262
@@ -310,6 +310,7 @@ pub struct SelectedImage {
|
|||||||
/// The DOM must not be mutated while this value is reused.
|
/// The DOM must not be mutated while this value is reused.
|
||||||
pub struct PreparedRender {
|
pub struct PreparedRender {
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
|
root_font_size: f32,
|
||||||
base_url: Option<String>,
|
base_url: Option<String>,
|
||||||
content_size: (f32, f32),
|
content_size: (f32, f32),
|
||||||
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
@@ -360,24 +361,21 @@ impl PreparedRender {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Border box in immutable document space, including authored translate
|
/// Axis-aligned bounds of the transformed border box in immutable document
|
||||||
/// transforms but excluding root-scroll and sticky movement.
|
/// space, excluding root-scroll and sticky movement.
|
||||||
pub fn document_rect(
|
pub fn document_rect(
|
||||||
&self,
|
&self,
|
||||||
id: obscura_dom::tree::NodeId,
|
id: obscura_dom::tree::NodeId,
|
||||||
) -> Option<crate::Rect> {
|
) -> Option<crate::Rect> {
|
||||||
let rect = *self.layout.rects.get(&id)?;
|
let rect = *self.layout.rects.get(&id)?;
|
||||||
let offset = self
|
Some(
|
||||||
.layout
|
self.layout
|
||||||
.translates
|
.transforms
|
||||||
.get(&id)
|
.get(&id)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or((0.0, 0.0));
|
.map(|transform| transform.map_rect(rect))
|
||||||
Some(crate::Rect {
|
.unwrap_or(rect),
|
||||||
x: rect.x + offset.0,
|
)
|
||||||
y: rect.y + offset.1,
|
|
||||||
..rect
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unscaled padding-box size used by CSSOM View's `clientWidth` and
|
/// Unscaled padding-box size used by CSSOM View's `clientWidth` and
|
||||||
@@ -739,9 +737,33 @@ impl PreparedRender {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
out.insert("transform", transform_css(style));
|
out.insert(
|
||||||
|
"transform",
|
||||||
|
transform_css(style, rect, self.root_font_size, self.viewport),
|
||||||
|
);
|
||||||
out
|
out
|
||||||
.insert("transform-origin", transform_origin_css(style, rect));
|
.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)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,29 +812,29 @@ impl PreparedRender {
|
|||||||
.get(&id)
|
.get(&id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| self.layout.rects.get(&id).copied().map(|rect| vec![rect]))?;
|
.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 scroll = self.clamp_scroll(requested_scroll);
|
||||||
let movement = if self.viewport_fixed.contains(&id) {
|
let movement = if self.viewport_fixed.contains(&id) {
|
||||||
transform
|
(0.0, 0.0)
|
||||||
} else {
|
} else {
|
||||||
let sticky = self.sticky.translation_for(id, self.viewport, scroll);
|
let sticky = self.sticky.translation_for(id, self.viewport, scroll);
|
||||||
(
|
(sticky.0 - scroll.0, sticky.1 - scroll.1)
|
||||||
transform.0 + sticky.0 - scroll.0,
|
|
||||||
transform.1 + sticky.1 - scroll.1,
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
Some(
|
Some(
|
||||||
source
|
source
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|rect| crate::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,
|
x: rect.x + movement.0,
|
||||||
y: rect.y + movement.1,
|
y: rect.y + movement.1,
|
||||||
..rect
|
..rect
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
@@ -919,39 +941,27 @@ fn align_content_css(value: taffy::AlignContent) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_css(style: &crate::LayoutStyle) -> String {
|
fn transform_css(
|
||||||
let mut parts = Vec::new();
|
style: &crate::LayoutStyle,
|
||||||
if let Some((x, y)) = style.transform_translate {
|
rect: Option<&crate::Rect>,
|
||||||
parts.push(format!(
|
root_font_size: f32,
|
||||||
"translate({}, {})",
|
viewport: (f32, f32),
|
||||||
dimension_css(x, "0px"),
|
) -> String {
|
||||||
dimension_css(y, "0px")
|
if style.transform_ops.is_empty() {
|
||||||
));
|
return "none".to_string();
|
||||||
}
|
|
||||||
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(" ")
|
|
||||||
}
|
}
|
||||||
|
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 {
|
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 content_size = laid.scrolling_content_size(tree, viewport);
|
||||||
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
let viewport_fixed = laid.viewport_fixed_nodes(tree);
|
||||||
let sticky = laid.root_sticky_layout(tree, viewport);
|
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 {
|
Some(PreparedRender {
|
||||||
viewport,
|
viewport,
|
||||||
|
root_font_size,
|
||||||
base_url: base_url.map(str::to_string),
|
base_url: base_url.map(str::to_string),
|
||||||
content_size,
|
content_size,
|
||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
@@ -1090,6 +1108,9 @@ pub fn paint_prepared(
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
(0.0, 0.0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,6 +1152,63 @@ fn stacking_z_index(
|
|||||||
None
|
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<crate::Rect> {
|
||||||
|
let mut bounds: Option<crate::Rect> = 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
|
/// Paint an already prepared layout without changing its document-space
|
||||||
/// geometry. Root scrolling and sticky positioning are per-shot visual state,
|
/// geometry. Root scrolling and sticky positioning are per-shot visual state,
|
||||||
/// so alternating captures can safely reuse the same layout.
|
/// so alternating captures can safely reuse the same layout.
|
||||||
@@ -1150,13 +1228,20 @@ fn paint_laid_dom_scrolled(
|
|||||||
paint_root: Option<obscura_dom::tree::NodeId>,
|
paint_root: Option<obscura_dom::tree::NodeId>,
|
||||||
suppress_opacity_for: Option<obscura_dom::tree::NodeId>,
|
suppress_opacity_for: Option<obscura_dom::tree::NodeId>,
|
||||||
suppress_stacking_for: Option<obscura_dom::tree::NodeId>,
|
suppress_stacking_for: Option<obscura_dom::tree::NodeId>,
|
||||||
|
suppress_transform_for: Option<obscura_dom::tree::NodeId>,
|
||||||
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
|
surface_offset: (f32, f32),
|
||||||
) -> Option<Pixmap> {
|
) -> Option<Pixmap> {
|
||||||
let scroll_state =
|
let scroll_state = ScrollPaintState::new(
|
||||||
ScrollPaintState::new(viewport, scroll, content_size, viewport_fixed, sticky);
|
tree,
|
||||||
// Only raster images inside an actually rotated/scaled subtree receive an
|
viewport,
|
||||||
// affine entry. Ordinary pages pay one fast style scan and allocate no
|
scroll,
|
||||||
// matrix map; transformed pages do matrix work only along those subtrees.
|
content_size,
|
||||||
let projected_images = collect_projected_image_transforms(tree, laid, &scroll_state);
|
viewport_fixed,
|
||||||
|
sticky,
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
|
);
|
||||||
let root_font_size = tree
|
let root_font_size = tree
|
||||||
.query_selector("html")
|
.query_selector("html")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1209,10 +1294,12 @@ fn paint_laid_dom_scrolled(
|
|||||||
.get(&nid)
|
.get(&nid)
|
||||||
.and_then(|style| style.opacity)
|
.and_then(|style| style.opacity)
|
||||||
.is_some_and(|opacity| opacity.clamp(0.0, 1.0) < 1.0);
|
.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))
|
let z = (suppress_stacking_for != Some(nid))
|
||||||
.then(|| stacking_z_index(tree, laid, nid))
|
.then(|| stacking_z_index(tree, laid, nid))
|
||||||
.flatten();
|
.flatten();
|
||||||
if is_opacity_root {
|
if is_opacity_root || is_transform_root {
|
||||||
let mut sub = vec![nid];
|
let mut sub = vec![nid];
|
||||||
sub.extend(tree.descendants(nid));
|
sub.extend(tree.descendants(nid));
|
||||||
for &member in &sub {
|
for &member in &sub {
|
||||||
@@ -1334,6 +1421,9 @@ fn paint_laid_dom_scrolled(
|
|||||||
Some(nid),
|
Some(nid),
|
||||||
suppress_opacity_for,
|
suppress_opacity_for,
|
||||||
Some(nid),
|
Some(nid),
|
||||||
|
suppress_transform_for,
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
)?;
|
)?;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1377,6 +1467,141 @@ fn paint_laid_dom_scrolled(
|
|||||||
None => continue,
|
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
|
let own_opacity = laid
|
||||||
.styles
|
.styles
|
||||||
.get(&nid)
|
.get(&nid)
|
||||||
@@ -1409,6 +1634,9 @@ fn paint_laid_dom_scrolled(
|
|||||||
Some(nid),
|
Some(nid),
|
||||||
Some(nid),
|
Some(nid),
|
||||||
suppress_stacking_for,
|
suppress_stacking_for,
|
||||||
|
suppress_transform_for,
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
)?;
|
)?;
|
||||||
let group_paint = tiny_skia::PixmapPaint {
|
let group_paint = tiny_skia::PixmapPaint {
|
||||||
opacity: own_opacity,
|
opacity: own_opacity,
|
||||||
@@ -1425,11 +1653,6 @@ fn paint_laid_dom_scrolled(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let style = match laid.styles.get(&nid) {
|
|
||||||
Some(s) => s,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if style.effectively_invisible {
|
if style.effectively_invisible {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1448,10 +1671,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||||
// actually invisible instead of painting text wherever it lands).
|
// actually invisible instead of painting text wherever it lands).
|
||||||
let clip = scroll_state.clip_for(laid, nid);
|
let clip = scroll_state.clip_for(laid, nid);
|
||||||
let projected_image = projected_images.get(&nid).copied();
|
let cull_rect = rect;
|
||||||
let cull_rect = projected_image
|
|
||||||
.map(|transform| transform.map_rect(rect))
|
|
||||||
.unwrap_or(rect);
|
|
||||||
let visible_rect = match clip {
|
let visible_rect = match clip {
|
||||||
Some(c) => match cull_rect.intersect(&c) {
|
Some(c) => match cull_rect.intersect(&c) {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
@@ -1710,7 +1930,7 @@ fn paint_laid_dom_scrolled(
|
|||||||
style.object_fit,
|
style.object_fit,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
image_cache,
|
image_cache,
|
||||||
projected_image,
|
None,
|
||||||
radius,
|
radius,
|
||||||
clip_path_mask.as_ref(),
|
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
|
/// document-space [`DomLayout`]. Keeping these deltas out of the layout avoids
|
||||||
/// accumulating movement when the same prepared document paints more than one
|
/// accumulating movement when the same prepared document paints more than one
|
||||||
/// frame.
|
/// frame.
|
||||||
#[derive(Debug)]
|
|
||||||
struct ScrollPaintState<'a> {
|
struct ScrollPaintState<'a> {
|
||||||
|
tree: &'a DomTree,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
scroll: (f32, f32),
|
scroll: (f32, f32),
|
||||||
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
sticky_clips: std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)>,
|
||||||
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
|
surface_offset: (f32, f32),
|
||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ScrollPaintState<'a> {
|
impl<'a> ScrollPaintState<'a> {
|
||||||
fn new(
|
fn new(
|
||||||
|
tree: &'a DomTree,
|
||||||
viewport: (f32, f32),
|
viewport: (f32, f32),
|
||||||
requested: (f32, f32),
|
requested: (f32, f32),
|
||||||
content: (f32, f32),
|
content: (f32, f32),
|
||||||
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
viewport_fixed: &'a std::collections::HashSet<obscura_dom::tree::NodeId>,
|
||||||
sticky_layout: &crate::StickyLayout,
|
sticky_layout: &crate::StickyLayout,
|
||||||
|
clip_scope_root: Option<obscura_dom::tree::NodeId>,
|
||||||
|
surface_offset: (f32, f32),
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let scroll_x = if requested.0.is_finite() {
|
let scroll_x = if requested.0.is_finite() {
|
||||||
requested
|
requested
|
||||||
@@ -2044,11 +2269,14 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty();
|
let active = scroll != (0.0, 0.0) || !sticky_layout.is_empty();
|
||||||
if !active {
|
if !active {
|
||||||
return Self {
|
return Self {
|
||||||
|
tree,
|
||||||
viewport,
|
viewport,
|
||||||
scroll,
|
scroll,
|
||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
sticky: std::collections::HashMap::new(),
|
sticky: std::collections::HashMap::new(),
|
||||||
sticky_clips: std::collections::HashMap::new(),
|
sticky_clips: std::collections::HashMap::new(),
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
active,
|
active,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2056,11 +2284,14 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
let sticky = sticky_layout.translations(viewport, scroll);
|
let sticky = sticky_layout.translations(viewport, scroll);
|
||||||
let sticky_clips = sticky_layout.clip_translations_from(&sticky);
|
let sticky_clips = sticky_layout.clip_translations_from(&sticky);
|
||||||
Self {
|
Self {
|
||||||
|
tree,
|
||||||
viewport,
|
viewport,
|
||||||
scroll,
|
scroll,
|
||||||
viewport_fixed,
|
viewport_fixed,
|
||||||
sticky,
|
sticky,
|
||||||
sticky_clips,
|
sticky_clips,
|
||||||
|
clip_scope_root,
|
||||||
|
surface_offset,
|
||||||
active,
|
active,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2072,12 +2303,12 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
) -> (f32, f32) {
|
) -> (f32, f32) {
|
||||||
let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
let base = laid.translates.get(&id).copied().unwrap_or((0.0, 0.0));
|
||||||
if !self.active || self.viewport_fixed.contains(&id) {
|
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));
|
let sticky = self.sticky.get(&id).copied().unwrap_or((0.0, 0.0));
|
||||||
(
|
(
|
||||||
base.0 + sticky.0 - self.scroll.0,
|
base.0 + sticky.0 - self.scroll.0 + self.surface_offset.0,
|
||||||
base.1 + sticky.1 - self.scroll.1,
|
base.1 + sticky.1 - self.scroll.1 + self.surface_offset.1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2095,10 +2326,44 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
laid: &crate::DomLayout,
|
laid: &crate::DomLayout,
|
||||||
id: obscura_dom::tree::NodeId,
|
id: obscura_dom::tree::NodeId,
|
||||||
) -> Option<crate::dom::OverflowClip> {
|
) -> Option<crate::dom::OverflowClip> {
|
||||||
let mut clip = laid.clip_rects.get(&id).copied().flatten()?;
|
if let Some(root) = self.clip_scope_root {
|
||||||
if !self.active || self.viewport_fixed.contains(&id) {
|
if id == root {
|
||||||
return Some(clip);
|
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<crate::dom::OverflowClip> = 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 mut clip = laid.clip_rects.get(&id).copied().flatten()?;
|
||||||
|
if self.active && !self.viewport_fixed.contains(&id) {
|
||||||
let sticky = self
|
let sticky = self
|
||||||
.sticky_clips
|
.sticky_clips
|
||||||
.get(&id)
|
.get(&id)
|
||||||
@@ -2108,6 +2373,8 @@ impl<'a> ScrollPaintState<'a> {
|
|||||||
sticky.0 - self.scroll.0,
|
sticky.0 - self.scroll.0,
|
||||||
sticky.1 - self.scroll.1,
|
sticky.1 - self.scroll.1,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
clip.translate(self.surface_offset.0, self.surface_offset.1);
|
||||||
Some(clip)
|
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 `<img>` 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<obscura_dom::tree::NodeId, ImageAffine> {
|
|
||||||
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<obscura_dom::tree::NodeId, ImageAffine>,
|
|
||||||
) {
|
|
||||||
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
|
/// A closed rounded-rectangle path, corners approximated by quadratic curves
|
||||||
/// (visually indistinguishable from true arcs at typical UI radii). The
|
/// (visually indistinguishable from true arcs at typical UI radii). The
|
||||||
/// horizontal and vertical radii are scaled together when necessary, matching
|
/// horizontal and vertical radii are scaled together when necessary, matching
|
||||||
@@ -5320,7 +5407,7 @@ fn paint_image(
|
|||||||
object_fit: crate::ObjectFit,
|
object_fit: crate::ObjectFit,
|
||||||
pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
cache: &mut RenderResourceCache,
|
cache: &mut RenderResourceCache,
|
||||||
transform: Option<ImageAffine>,
|
transform: Option<crate::Affine2>,
|
||||||
clip_radius: crate::ResolvedBorderRadii,
|
clip_radius: crate::ResolvedBorderRadii,
|
||||||
extra_clip: Option<&tiny_skia::Mask>,
|
extra_clip: Option<&tiny_skia::Mask>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@@ -5413,7 +5500,14 @@ fn paint_image(
|
|||||||
content.as_ref(),
|
content.as_ref(),
|
||||||
&tiny_skia::PixmapPaint::default(),
|
&tiny_skia::PixmapPaint::default(),
|
||||||
transform
|
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),
|
.unwrap_or_else(Transform::identity),
|
||||||
clip.as_ref(),
|
clip.as_ref(),
|
||||||
);
|
);
|
||||||
@@ -8219,6 +8313,152 @@ mod tests {
|
|||||||
assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255));
|
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#"<html><body style="margin:0">
|
||||||
|
<div style="position:absolute;left:20px;top:20px;width:60px;height:40px;
|
||||||
|
background:#ff0000;transform-origin:0 0;transform:scale(2)">
|
||||||
|
<span style="color:#0000ff;font:12px sans-serif">MMMM</span>
|
||||||
|
<img alt="" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='10'%3E%3Crect%20width='10'%20height='10'%20fill='%2300ff00'/%3E%3C/svg%3E"
|
||||||
|
style="position:absolute;left:40px;top:20px;width:10px;height:10px">
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:180px;top:20px;width:30px;height:20px;
|
||||||
|
background:#ff00ff;transform-origin:0 0;transform:rotate(90deg)">
|
||||||
|
<span style="color:#0000ff;font:10px sans-serif">M</span>
|
||||||
|
<img alt="" src="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='10'%3E%3Crect%20width='10'%20height='10'%20fill='%2300ffff'/%3E%3C/svg%3E"
|
||||||
|
style="position:absolute;left:20px;top:0;width:10px;height:10px">
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html><body style="margin:0">
|
||||||
|
<div id="translate-rotate" style="position:absolute;left:50px;top:50px;
|
||||||
|
width:20px;height:10px;transform-origin:0 0;
|
||||||
|
transform:translateX(100px) rotate(90deg)"></div>
|
||||||
|
<div id="rotate-translate" style="position:absolute;left:50px;top:50px;
|
||||||
|
width:20px;height:10px;transform-origin:0 0;
|
||||||
|
transform:rotate(90deg) translateX(100px)"></div>
|
||||||
|
<div id="parent" style="position:absolute;left:50px;top:200px;width:100px;
|
||||||
|
height:100px;transform-origin:0 0;transform:scale(2)">
|
||||||
|
<div id="child" style="position:absolute;left:10px;top:20px;width:10px;
|
||||||
|
height:20px;transform-origin:0 0;transform:rotate(90deg)"></div>
|
||||||
|
<div id="captured-fixed" style="position:fixed;left:0;top:0;width:10px;
|
||||||
|
height:10px"></div>
|
||||||
|
</div>
|
||||||
|
<div id="center-origin" style="position:absolute;left:100px;top:400px;
|
||||||
|
width:20px;height:10px;transform:rotate(90deg)"></div>
|
||||||
|
<div id="cssom-transform" style="position:absolute;left:200px;top:400px;
|
||||||
|
width:20px;height:10px;transform:translateX(50%);translate:7px;
|
||||||
|
rotate:30deg;scale:2"></div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html><body style="margin:0">
|
||||||
|
<div style="position:absolute;left:20px;top:20px;width:40px;height:40px;
|
||||||
|
overflow:hidden">
|
||||||
|
<div style="position:absolute;left:30px;top:10px;width:20px;height:20px;
|
||||||
|
background:#00aa00;transform-origin:0 0;transform:scale(2)"></div>
|
||||||
|
</div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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#"<html><body style="margin:0"><div id="plain" style="position:absolute;
|
||||||
|
left:10px;top:12px;width:20px;height:15px;background:#ff0000"></div>
|
||||||
|
</body></html>"#,
|
||||||
|
);
|
||||||
|
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]
|
#[test]
|
||||||
fn transformed_image_is_clipped_inside_overflow_border() {
|
fn transformed_image_is_clipped_inside_overflow_border() {
|
||||||
// CSS overflow clipping belongs to the owner's padding box. The
|
// CSS overflow clipping belongs to the owner's padding box. The
|
||||||
|
|||||||
+282
-166
@@ -1611,6 +1611,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
|||||||
"transform" => parse_transform(style, value),
|
"transform" => parse_transform(style, value),
|
||||||
"transform-origin" => style.transform_origin = parse_transform_origin(value),
|
"transform-origin" => style.transform_origin = parse_transform_origin(value),
|
||||||
"translate" => parse_individual_translate(style, value),
|
"translate" => parse_individual_translate(style, value),
|
||||||
|
"rotate" => parse_individual_rotate(style, value),
|
||||||
"scale" => parse_individual_scale(style, value),
|
"scale" => parse_individual_scale(style, value),
|
||||||
"filter" => set_containing_block_trigger(
|
"filter" => set_containing_block_trigger(
|
||||||
style,
|
style,
|
||||||
@@ -1918,6 +1919,7 @@ pub fn supports_declaration(name: &str, value: &str) -> bool {
|
|||||||
| "transform"
|
| "transform"
|
||||||
| "transform-origin"
|
| "transform-origin"
|
||||||
| "translate"
|
| "translate"
|
||||||
|
| "rotate"
|
||||||
| "scale"
|
| "scale"
|
||||||
| "filter"
|
| "filter"
|
||||||
| "backdrop-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(),
|
"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(),
|
"transform-origin" => parse_transform_origin(value).is_some(),
|
||||||
"translate" => lower == "none" || dimensions(value, false, 3),
|
"translate" => lower == "none" || dimensions(value, false, 3),
|
||||||
|
"rotate" => lower == "none" || angle_degrees(value).is_some(),
|
||||||
"scale" => lower == "none" || {
|
"scale" => lower == "none" || {
|
||||||
let values = value.split_whitespace().collect::<Vec<_>>();
|
let values = value.split_whitespace().collect::<Vec<_>>();
|
||||||
!values.is_empty() && values.len() <= 2 && values.iter().all(|value| scale_number(value).is_some())
|
!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") {
|
if value.trim().eq_ignore_ascii_case("none") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let functions = transform_functions(value);
|
parse_transform_ops(value).is_some()
|
||||||
!functions.is_empty() && functions.into_iter().all(|(name, arguments)| {
|
|
||||||
let values = arguments.split(',').map(str::trim).filter(|value| !value.is_empty()).collect::<Vec<_>>();
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
|
fn apply_animation_shorthand(style: &mut LayoutStyle, value: &str) {
|
||||||
@@ -2858,143 +2849,203 @@ fn content_alignment_pair(
|
|||||||
None
|
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) {
|
fn parse_transform(style: &mut LayoutStyle, value: &str) {
|
||||||
let v = value.trim();
|
let v = value.trim();
|
||||||
if v.is_empty() {
|
if v.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// `transform: none` resets any transform from a lower-priority rule (the
|
if matches!(
|
||||||
// carousel idiom: a class translates the track, an inline style clears it
|
v.to_ascii_lowercase().as_str(),
|
||||||
// for the untransformed state).
|
"none" | "initial" | "unset" | "revert" | "revert-layer"
|
||||||
if v.eq_ignore_ascii_case("none") {
|
) {
|
||||||
style.transform_translate = None;
|
style.transform_ops.clear();
|
||||||
style.transform_scale = None;
|
|
||||||
style.transform_projection = None;
|
|
||||||
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false);
|
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let functions = transform_functions(v);
|
let Some(operations) = parse_transform_ops(v) else {
|
||||||
if functions.is_empty() {
|
|
||||||
return;
|
return;
|
||||||
}
|
};
|
||||||
|
style.transform_ops = operations;
|
||||||
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true);
|
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true);
|
||||||
let zero = crate::Dimension::Px(0.0);
|
|
||||||
let (mut tx, mut ty): (Option<crate::Dimension>, Option<crate::Dimension>) = (None, None);
|
|
||||||
let (mut sx, mut sy): (Option<f32>, Option<f32>) = (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.
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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] {
|
fn parse_transform_length(value: &str) -> Option<crate::TransformLength> {
|
||||||
let mut out = [0.0; 9];
|
let value = value.trim();
|
||||||
for row in 0..3 {
|
if value.contains('(') {
|
||||||
for column in 0..3 {
|
let functions = transform_functions(value);
|
||||||
out[row * 3 + column] = (0..3)
|
if functions.len() != 1 {
|
||||||
.map(|inner| left[row * 3 + inner] * right[inner * 3 + column])
|
return None;
|
||||||
.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()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
out
|
_ => false,
|
||||||
|
};
|
||||||
|
if !valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return Some(crate::TransformLength {
|
||||||
|
value: crate::Dimension::Px(0.0),
|
||||||
|
expression: Some(value.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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] {
|
fn parse_transform_z_length(value: &str) -> Option<crate::TransformLength> {
|
||||||
let (sin, cos) = angle.to_radians().sin_cos();
|
// The Z coordinate is `<length>`, not `<length-percentage>`.
|
||||||
[1.0, 0.0, 0.0, 0.0, cos, -sin, 0.0, sin, cos]
|
if value.contains('%') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
parse_transform_length(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rotate_y_matrix(angle: f32) -> [f32; 9] {
|
fn parse_transform_ops(value: &str) -> Option<Vec<crate::TransformOp>> {
|
||||||
let (sin, cos) = angle.to_radians().sin_cos();
|
let functions = transform_functions(value);
|
||||||
[cos, 0.0, sin, 0.0, 1.0, 0.0, -sin, 0.0, cos]
|
if functions.is_empty() {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
let mut operations = Vec::with_capacity(functions.len());
|
||||||
fn rotate_z_matrix(angle: f32) -> [f32; 9] {
|
for (name, arguments) in functions {
|
||||||
let (sin, cos) = angle.to_radians().sin_cos();
|
let values = split_top_level(&arguments, ',')
|
||||||
[cos, -sin, 0.0, sin, cos, 0.0, 0.0, 0.0, 1.0]
|
.into_iter()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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::<f32>().ok()?;
|
||||||
|
let y = values[1].parse::<f32>().ok()?;
|
||||||
|
let z = values[2].parse::<f32>().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::<f32>().ok()).collect::<Option<Vec<_>>>()?;
|
||||||
|
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::<f32>().ok()).collect::<Option<Vec<_>>>()?;
|
||||||
|
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<f32> {
|
fn angle_degrees(value: &str) -> Option<f32> {
|
||||||
@@ -3024,16 +3075,19 @@ fn angle_degrees(value: &str) -> Option<f32> {
|
|||||||
.strip_prefix("calc(")
|
.strip_prefix("calc(")
|
||||||
.and_then(|value| value.strip_suffix(')'))
|
.and_then(|value| value.strip_suffix(')'))
|
||||||
else {
|
else {
|
||||||
return primitive(value);
|
return primitive(value).filter(|value| value.is_finite());
|
||||||
};
|
};
|
||||||
if let Some((angle, factor)) = inner.split_once('*') {
|
let result = if let Some((angle, factor)) = inner.split_once('*') {
|
||||||
return Some(primitive(angle)? * factor.trim().parse::<f32>().ok()?);
|
Some(primitive(angle)? * factor.trim().parse::<f32>().ok()?)
|
||||||
}
|
} else if let Some((angle, divisor)) = inner.split_once('/') {
|
||||||
if let Some((angle, divisor)) = inner.split_once('/') {
|
|
||||||
let divisor = divisor.trim().parse::<f32>().ok()?;
|
let divisor = divisor.trim().parse::<f32>().ok()?;
|
||||||
return (divisor != 0.0).then(|| primitive(angle).map(|angle| angle / divisor)).flatten();
|
(divisor != 0.0)
|
||||||
}
|
.then(|| primitive(angle).map(|angle| angle / divisor))
|
||||||
|
.flatten()
|
||||||
|
} else {
|
||||||
primitive(inner)
|
primitive(inner)
|
||||||
|
};
|
||||||
|
result.filter(|value| value.is_finite())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_transform_origin(value: &str) -> Option<(crate::Dimension, crate::Dimension)> {
|
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) {
|
fn parse_individual_scale(style: &mut LayoutStyle, value: &str) {
|
||||||
if value.trim().eq_ignore_ascii_case("none") {
|
if matches!(
|
||||||
style.transform_scale = None;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
let values: Vec<f32> = value
|
let values: Vec<f32> = value
|
||||||
@@ -3072,8 +3130,23 @@ fn parse_individual_scale(style: &mut LayoutStyle, value: &str) {
|
|||||||
.collect();
|
.collect();
|
||||||
let Some(&x) = values.first() else { return };
|
let Some(&x) = values.first() else { return };
|
||||||
let y = values.get(1).copied().unwrap_or(x);
|
let y = values.get(1).copied().unwrap_or(x);
|
||||||
style.transform_scale = Some((x, y));
|
style.individual_scale = Some((x, y));
|
||||||
set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true);
|
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) {
|
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.
|
/// Strictly split a `transform` value into `name(args)` functions in source
|
||||||
/// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an
|
/// order. Any non-whitespace between functions, missing delimiter, or
|
||||||
/// argument stays intact); the function name is the trailing identifier run
|
/// unbalanced parenthesis invalidates the whole declaration.
|
||||||
/// just before each `(`.
|
|
||||||
fn transform_functions(value: &str) -> Vec<(String, String)> {
|
fn transform_functions(value: &str) -> Vec<(String, String)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut rest = value;
|
let bytes = value.as_bytes();
|
||||||
while let Some(open) = rest.find('(') {
|
let mut cursor = 0usize;
|
||||||
let name: String = rest[..open]
|
while cursor < bytes.len() {
|
||||||
.chars()
|
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||||
.rev()
|
cursor += 1;
|
||||||
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
|
}
|
||||||
.collect::<Vec<_>>()
|
if cursor == bytes.len() {
|
||||||
.into_iter()
|
break;
|
||||||
.rev()
|
}
|
||||||
.collect();
|
let name_start = cursor;
|
||||||
let after = &rest[open + 1..];
|
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 depth = 1i32;
|
||||||
let mut end = None;
|
let mut end = None;
|
||||||
for (i, c) in after.char_indices() {
|
for (offset, c) in value[cursor..].char_indices() {
|
||||||
match c {
|
match c {
|
||||||
'(' => depth += 1,
|
'(' => depth += 1,
|
||||||
')' => {
|
')' => {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
end = Some(i);
|
end = Some(cursor + offset);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let Some(e) = end else { break };
|
let Some(end) = end else { return Vec::new() };
|
||||||
if !name.is_empty() {
|
out.push((name, value[args_start..end].to_string()));
|
||||||
out.push((name, after[..e].to_string()));
|
cursor = end + 1;
|
||||||
}
|
|
||||||
rest = &after[e + 1..];
|
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -3183,9 +3263,14 @@ fn transform_functions(value: &str) -> Vec<(String, String)> {
|
|||||||
fn scale_number(s: &str) -> Option<f32> {
|
fn scale_number(s: &str) -> Option<f32> {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
if let Some(p) = t.strip_suffix('%') {
|
if let Some(p) = t.strip_suffix('%') {
|
||||||
return p.trim().parse::<f32>().ok().map(|v| v / 100.0);
|
return p
|
||||||
|
.trim()
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.map(|value| value / 100.0)
|
||||||
|
.filter(|value| value.is_finite());
|
||||||
}
|
}
|
||||||
t.parse::<f32>().ok()
|
t.parse::<f32>().ok().filter(|value| value.is_finite())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
|
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
|
||||||
@@ -6879,6 +6964,37 @@ mod tests {
|
|||||||
assert!(!supports_declaration("display", "banana"));
|
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]
|
#[test]
|
||||||
fn clip_path_polygon_supports_only_geometry_it_can_resolve() {
|
fn clip_path_polygon_supports_only_geometry_it_can_resolve() {
|
||||||
assert!(supports_declaration(
|
assert!(supports_declaration(
|
||||||
|
|||||||
Reference in new issue
Block a user