Fix framework layout and image transforms

This commit is contained in:
SGavrl committed 2026-07-30 20:58:05 +00:00
1 parent a5555d78c8
commit 661212e6aa
6 files changed
+562 -44

No files matched your search

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