Fix framework layout and image transforms
This commit is contained in:
1 parent
a5555d78c8
commit
661212e6aa
6 files changed
+562
-44
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user