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(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
|
/// 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
|
/// 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
|
/// 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 {
|
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)
|
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 {
|
} else {
|
||||||
dom_children.into_iter().flat_map(|cid| build_any(tree, cid, taffy_tree, id_map, words, engine, ifc_items, styles)).collect()
|
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:?}");
|
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]
|
#[test]
|
||||||
fn empty_document_is_safe() {
|
fn empty_document_is_safe() {
|
||||||
// html5ever always synthesizes html/head/body, so an empty document
|
// html5ever always synthesizes html/head/body, so an empty document
|
||||||
@@ -6360,20 +6472,11 @@ mod tests {
|
|||||||
let tree = parse_html(
|
let tree = parse_html(
|
||||||
r#"<div id="cta" style="display:flex;text-transform:uppercase">get started</div>"#,
|
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 laid = layout_dom(&tree, (1280.0, 720.0));
|
||||||
let painted = laid
|
let cta = tree.get_element_by_id("cta").unwrap();
|
||||||
.text_runs
|
let items = &laid.run_ifc_items[&cta];
|
||||||
.get(&text)
|
assert_eq!(items.len(), 1);
|
||||||
.unwrap()
|
assert_eq!(laid.text_engine.item_text(items[0]), "GET STARTED");
|
||||||
.iter()
|
|
||||||
.map(|(_, word)| word.as_str())
|
|
||||||
.collect::<String>();
|
|
||||||
assert_eq!(painted, "GET STARTED");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
transform_word_leaf_text("hELLO wORLD", crate::TextTransform::Capitalize),
|
transform_word_leaf_text("hELLO wORLD", crate::TextTransform::Capitalize),
|
||||||
"HELLO WORLD"
|
"HELLO WORLD"
|
||||||
@@ -6383,17 +6486,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn word_leaves_use_the_computed_line_height() {
|
fn word_leaves_use_the_computed_line_height() {
|
||||||
let tree = parse_html(
|
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
|
let mut laid = layout_dom(&tree, (1280.0, 720.0));
|
||||||
.descendants(tree.document())
|
let code = tree.get_element_by_id("code").unwrap();
|
||||||
.into_iter()
|
let items = &laid.run_ifc_items[&code];
|
||||||
.find(|id| tree.get_node(*id).is_some_and(|node| node.is_text()))
|
assert_eq!(items.len(), 1);
|
||||||
.unwrap();
|
let (_, height) = laid.text_engine.measure(items[0], Some(1280.0));
|
||||||
let laid = layout_dom(&tree, (1280.0, 720.0));
|
assert_eq!(height, 32.0);
|
||||||
let runs = laid.text_runs.get(&text).unwrap();
|
|
||||||
assert_eq!(runs.len(), 1);
|
|
||||||
assert_eq!(runs[0].0.height, 32.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -563,6 +563,16 @@ impl TextEngine {
|
|||||||
self.items.is_empty()
|
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
|
/// 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
|
/// 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
|
/// 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`
|
/// 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`, captured as (sx, sy).
|
/// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, or the individual
|
||||||
/// Parsed and stored so a value that mixes scale with translate still yields
|
/// `scale` property, captured as (sx, sy). Raster-image paint applies it to
|
||||||
/// its translate part; scale is not yet folded into paint geometry (doing so
|
/// the element's subtree together with [`LayoutStyle::transform_projection`].
|
||||||
/// correctly would require scaling every descendant's size and text, outside
|
|
||||||
/// the paint-time translate-offset model used here).
|
|
||||||
pub transform_scale: Option<(f32, f32)>,
|
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
|
/// `box-shadow` (first layer only). Painted behind the element's own
|
||||||
/// background/border box: cards, buttons, menus, and modals across the
|
/// background/border box: cards, buttons, menus, and modals across the
|
||||||
/// modern web rely on it for depth, and without it those elements paint
|
/// 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);
|
laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts);
|
||||||
}
|
}
|
||||||
apply_document_scroll(tree, &mut laid, viewport, scroll);
|
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
|
let root_font_size = tree
|
||||||
.query_selector("html")
|
.query_selector("html")
|
||||||
.ok()
|
.ok()
|
||||||
@@ -251,12 +255,16 @@ pub fn paint_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 = laid.clip_rects.get(&nid).copied().flatten();
|
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 {
|
let visible_rect = match clip {
|
||||||
Some(c) => match rect.intersect(&c) {
|
Some(c) => match cull_rect.intersect(&c) {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => continue,
|
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) {
|
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
@@ -365,6 +373,7 @@ pub fn paint_dom_scrolled(
|
|||||||
crate::ObjectFit::Fill,
|
crate::ObjectFit::Fill,
|
||||||
&mut pixmap,
|
&mut pixmap,
|
||||||
&mut image_cache,
|
&mut image_cache,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,7 +456,16 @@ pub fn paint_dom_scrolled(
|
|||||||
// half-scrolled carousel slide's image otherwise bleeds over
|
// half-scrolled carousel slide's image otherwise bleeds over
|
||||||
// the viewport edge).
|
// the viewport edge).
|
||||||
let painted =
|
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
|
// Fall back when the image itself did not paint, following
|
||||||
// what browsers show for a broken image: a non-empty alt
|
// what browsers show for a broken image: a non-empty alt
|
||||||
// renders as text in place of the image (no placeholder box),
|
// 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
|
/// A closed rounded-rectangle path, corners approximated by quadratic curves
|
||||||
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
|
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
|
||||||
/// clamped so it never exceeds half the shorter side.
|
/// clamped so it never exceeds half the shorter side.
|
||||||
@@ -2155,6 +2342,7 @@ fn paint_in_flow_generated_box(
|
|||||||
crate::ObjectFit::Fill,
|
crate::ObjectFit::Fill,
|
||||||
pixmap,
|
pixmap,
|
||||||
image_cache,
|
image_cache,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2343,6 +2531,7 @@ fn paint_positioned_pseudo(
|
|||||||
crate::ObjectFit::Fill,
|
crate::ObjectFit::Fill,
|
||||||
pixmap,
|
pixmap,
|
||||||
image_cache,
|
image_cache,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2694,6 +2883,7 @@ fn paint_image(
|
|||||||
object_fit: crate::ObjectFit,
|
object_fit: crate::ObjectFit,
|
||||||
pixmap: &mut Pixmap,
|
pixmap: &mut Pixmap,
|
||||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||||
|
transform: Option<ImageAffine>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||||
return false;
|
return false;
|
||||||
@@ -2745,7 +2935,9 @@ fn paint_image(
|
|||||||
dest.y as i32,
|
dest.y as i32,
|
||||||
content.as_ref(),
|
content.as_ref(),
|
||||||
&tiny_skia::PixmapPaint::default(),
|
&tiny_skia::PixmapPaint::default(),
|
||||||
Transform::identity(),
|
transform
|
||||||
|
.map(ImageAffine::tiny_skia)
|
||||||
|
.unwrap_or_else(Transform::identity),
|
||||||
clip.as_ref(),
|
clip.as_ref(),
|
||||||
);
|
);
|
||||||
true
|
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]
|
#[test]
|
||||||
fn translate_offscreen_box_is_not_painted() {
|
fn translate_offscreen_box_is_not_painted() {
|
||||||
// translate(-10000px,0) shoves the box far off the left edge (the old
|
// 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-start" => set_grid_placement_side(style, value, false, true),
|
||||||
"grid-row-end" => set_grid_placement_side(style, value, false, false),
|
"grid-row-end" => set_grid_placement_side(style, value, false, false),
|
||||||
"transform" => parse_transform(style, value),
|
"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(
|
"filter" => set_containing_block_trigger(
|
||||||
style,
|
style,
|
||||||
crate::CB_TRIGGER_FILTER,
|
crate::CB_TRIGGER_FILTER,
|
||||||
@@ -1225,6 +1227,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
|
|||||||
| "grid-row-start"
|
| "grid-row-start"
|
||||||
| "grid-row-end"
|
| "grid-row-end"
|
||||||
| "transform"
|
| "transform"
|
||||||
|
| "transform-origin"
|
||||||
|
| "scale"
|
||||||
| "filter"
|
| "filter"
|
||||||
| "backdrop-filter"
|
| "backdrop-filter"
|
||||||
| "-webkit-backdrop-filter"
|
| "-webkit-backdrop-filter"
|
||||||
@@ -1449,12 +1453,11 @@ fn content_alignment_pair(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the subset of `transform` obscura applies at paint time: `translate`,
|
/// Parse the transform functions used by the scoped paint model. Translation
|
||||||
/// `translateX`, `translateY` (px and %, stored unresolved so % can resolve
|
/// stays unresolved until the final border box is known. Scale is captured
|
||||||
/// against the element's own border box later) and `scale`/`scaleX`/`scaleY`.
|
/// separately, while rotations are multiplied as 3×3 matrices and projected
|
||||||
/// `rotate`, `skew`, `matrix`, and `perspective` are ignored (left unhandled)
|
/// onto the input z=0 plane; this exactly represents rotateX/Y/Z chains that
|
||||||
/// rather than erroring, so a value that mixes them still contributes its
|
/// do not introduce perspective.
|
||||||
/// translate and scale parts.
|
|
||||||
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() {
|
||||||
@@ -1466,6 +1469,7 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) {
|
|||||||
if v.eq_ignore_ascii_case("none") {
|
if v.eq_ignore_ascii_case("none") {
|
||||||
style.transform_translate = None;
|
style.transform_translate = None;
|
||||||
style.transform_scale = None;
|
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;
|
||||||
}
|
}
|
||||||
@@ -1477,6 +1481,12 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) {
|
|||||||
let zero = crate::Dimension::Px(0.0);
|
let zero = crate::Dimension::Px(0.0);
|
||||||
let (mut tx, mut ty): (Option<crate::Dimension>, Option<crate::Dimension>) = (None, None);
|
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 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 {
|
for (func, args) in functions {
|
||||||
let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||||
match func.to_ascii_lowercase().as_str() {
|
match func.to_ascii_lowercase().as_str() {
|
||||||
@@ -1517,7 +1527,27 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) {
|
|||||||
sy = Some(a);
|
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() {
|
if sx.is_some() || sy.is_some() {
|
||||||
style.transform_scale = Some((sx.unwrap_or(1.0), sy.unwrap_or(1.0)));
|
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 {
|
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
|
/// 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`).
|
/// both. Used by the logical-property axes (`margin-inline`, `padding-block`).
|
||||||
fn two(value: &str) -> (&str, &str) {
|
fn two(value: &str) -> (&str, &str) {
|
||||||
let mut it = value.split_whitespace();
|
let values = split_ws_paren(value);
|
||||||
let a = it.next().unwrap_or("0");
|
let a = values.first().copied().unwrap_or("0");
|
||||||
let b = it.next().unwrap_or(a);
|
let b = values.get(1).copied().unwrap_or(a);
|
||||||
(a, b)
|
(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.
|
/// `padding: <t> <r>? <b>? <l>?`, percentage-aware per side.
|
||||||
fn apply_padding_shorthand(style: &mut LayoutStyle, value: &str) {
|
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() {
|
let (t, r, b, l) = match toks.as_slice() {
|
||||||
[a] => (*a, *a, *a, *a),
|
[a] => (*a, *a, *a, *a),
|
||||||
[v, h] => (*v, *h, *v, *h),
|
[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`
|
/// `margin: <t> <r>? <b>? <l>?` with per-side `auto` (so `margin: 0 auto`
|
||||||
/// centers).
|
/// centers).
|
||||||
fn apply_margin_shorthand(style: &mut LayoutStyle, value: &str) {
|
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() {
|
let (t, r, b, l) = match toks.as_slice() {
|
||||||
[a] => (*a, *a, *a, *a),
|
[a] => (*a, *a, *a, *a),
|
||||||
[v, h] => (*v, *h, *v, *h),
|
[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));
|
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
|
/// HTML width/height attributes are provisional presentational hints. Once
|
||||||
/// the image is decoded, its natural dimensions replace that mapped ratio.
|
/// the image is decoded, its natural dimensions replace that mapped ratio.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user