From 1329d174bb1d7443bef2b4f5d98287d58e449a2e Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 27 Aug 2026 22:05:42 +0800 Subject: [PATCH] fix(layout): resolve CSSOM metrics in absolute zoom space --- moli-layout/src/builder.rs | 10 +- moli-layout/src/layout_tree/model.rs | 6 + moli-layout/src/layout_tree/query.rs | 7 +- moli-layout/src/layout_tree/source_query.rs | 65 ++++- moli-layout/src/projection.rs | 1 + moli-layout/src/style.rs | 13 + .../src/runtime/page_vm/tests/cssom_zoom.rs | 234 ++++++++++++++++++ .../src/runtime/page_vm/tests/mod.rs | 1 + 8 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 moli-renderer-v8/src/runtime/page_vm/tests/cssom_zoom.rs diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 909740d715..b865e83ffe 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -364,7 +364,15 @@ where ) { layout_box.css_images = self.css_image_resources(source_node, &style); } - layout_box.scroll_offset = self.source.scroll_offset(source_node); + // DOM scroll state is stored in CSSOM units. Numeric layout operates + // in effective-zoomed CSS pixels, so import the sampled state into the + // same coordinate space as the box geometry. + let scroll_offset = self.source.scroll_offset(source_node); + let effective_zoom = style.effective_zoom(); + layout_box.scroll_offset = crate::LayoutPoint::new( + scroll_offset.x * effective_zoom, + scroll_offset.y * effective_zoom, + ); layout_box } diff --git a/moli-layout/src/layout_tree/model.rs b/moli-layout/src/layout_tree/model.rs index 6d520fb936..561ef0e5e3 100644 --- a/moli-layout/src/layout_tree/model.rs +++ b/moli-layout/src/layout_tree/model.rs @@ -325,6 +325,12 @@ pub struct LayoutScrollExtent { #[derive(Clone, Debug, PartialEq)] pub struct LayoutBoxGeometry { pub id: LayoutOutputBoxId, + /// Accumulated CSS `zoom` already applied to this box's layout lengths. + /// + /// Transformed client geometry keeps the zoomed values. CSSOM integer box + /// metrics divide their untransformed layout values by this factor, as + /// Blink's `AdjustForAbsoluteZoom` does at its DOM binding boundary. + pub effective_zoom: f32, /// Source/LayoutObject ancestry before formatting-tree normalization. pub structural_parent: Option, /// Parent in the normalized formatting tree. diff --git a/moli-layout/src/layout_tree/query.rs b/moli-layout/src/layout_tree/query.rs index 328db9da3a..596416982c 100644 --- a/moli-layout/src/layout_tree/query.rs +++ b/moli-layout/src/layout_tree/query.rs @@ -35,8 +35,11 @@ pub struct LayoutDocumentMetrics { /// CSSOM View and observer metrics for one source element. /// -/// Transformed quads use viewport CSS pixels. Offset and size fields retain -/// the untransformed layout values required by offset/client/scroll APIs. +/// Transformed quads use viewport CSS pixels and therefore include CSS +/// `zoom`. Offset, size, and scroll fields are untransformed and have the +/// queried box's absolute zoom removed, as required by CSSOM View. Root client +/// dimensions come directly from the layout viewport and are not adjusted a +/// second time. #[derive(Clone, Debug, PartialEq)] pub struct LayoutElementMetrics { pub offset_parent: Option, diff --git a/moli-layout/src/layout_tree/source_query.rs b/moli-layout/src/layout_tree/source_query.rs index da66469748..07733bb4b7 100644 --- a/moli-layout/src/layout_tree/source_query.rs +++ b/moli-layout/src/layout_tree/source_query.rs @@ -135,6 +135,7 @@ where geometry.border_box.y, )) }); + let unzoom = CssomAbsoluteZoom::new(geometry.effective_zoom); let client_size = if is_root { // CSSOM defines root client dimensions from the viewport and only // subtracts an actually-present viewport scrollbar. Stable empty @@ -153,20 +154,25 @@ where .max(0.0), ) } else { - LayoutSize::new(extent.scrollport.width, extent.scrollport.height) + unzoom.size(LayoutSize::new( + extent.scrollport.width, + extent.scrollport.height, + )) }; - let scroll_size = extent.scroll_size; Some(LayoutElementMetrics { offset_parent, - offset_position: LayoutPoint::new( + offset_position: unzoom.point(LayoutPoint::new( layout_origin.x - offset_parent_origin.x, layout_origin.y - offset_parent_origin.y, - ), + )), border_origin_in_viewport_ignoring_css_transforms, - offset_size, - content_size: LayoutSize::new(geometry.content_box.width, geometry.content_box.height), + offset_size: unzoom.size(offset_size), + content_size: unzoom.size(LayoutSize::new( + geometry.content_box.width, + geometry.content_box.height, + )), client_size, - client_border: LayoutPoint::new( + client_border: unzoom.point(LayoutPoint::new( if is_root { 0.0 } else { @@ -177,11 +183,11 @@ where } else { extent.scrollport.y - geometry.border_box.y }, - ), - scroll_size, - scroll_offset: extent.applied_offset, - minimum_scroll_offset: extent.minimum_offset, - maximum_scroll_offset: extent.maximum_offset, + )), + scroll_size: unzoom.size(extent.scroll_size), + scroll_offset: unzoom.point(extent.applied_offset), + minimum_scroll_offset: unzoom.point(extent.minimum_offset), + maximum_scroll_offset: unzoom.point(extent.maximum_offset), scrollport: if is_root { LayoutTransform2D::IDENTITY.map_rect(extent.scrollport) } else { @@ -449,6 +455,31 @@ where } } +/// Converts effective-zoomed layout scalars to the coordinate space exposed +/// by CSSOM integer box and scroll metrics. Viewport quads intentionally stay +/// zoomed: their normalized bases map points back into these unzoomed sizes. +#[derive(Clone, Copy)] +struct CssomAbsoluteZoom(f32); + +impl CssomAbsoluteZoom { + fn new(effective_zoom: f32) -> Self { + debug_assert!(effective_zoom.is_finite() && effective_zoom > 0.0); + Self(if effective_zoom.is_finite() && effective_zoom > 0.0 { + effective_zoom + } else { + 1.0 + }) + } + + fn point(self, point: LayoutPoint) -> LayoutPoint { + LayoutPoint::new(point.x / self.0, point.y / self.0) + } + + fn size(self, size: LayoutSize) -> LayoutSize { + LayoutSize::new(size.width / self.0, size.height / self.0) + } +} + impl FrozenLayoutTree where N: Copy + Debug + Eq + Hash, @@ -573,6 +604,7 @@ where return None; } let base_is_positioned = geometry.position != LayoutPosition::Static; + let base_effective_zoom = geometry.effective_zoom; let mut in_fixed_position_chain = geometry.position == LayoutPosition::Fixed; let mut candidate = geometry.structural_parent; while let Some(id) = candidate { @@ -606,6 +638,14 @@ where { return Some(id); } + // CSSOM View preserves WebKit/Blink's long-standing extension: + // offsetParent stops at the first exposed layout ancestor whose + // absolute zoom differs from the target. Resolve ordinary + // containing-block candidates first, exactly as Blink does, then + // admit this geometry-coordinate boundary. + if base_effective_zoom != parent.effective_zoom { + return Some(id); + } in_fixed_position_chain |= parent.position == LayoutPosition::Fixed; candidate = parent.structural_parent; } @@ -698,6 +738,7 @@ mod tests { vec![FrozenLayoutBox { geometry: LayoutBoxGeometry { id: box_id, + effective_zoom: 1.0, structural_parent: None, parent: None, layout_parent: None, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index cddddcd6c1..a65d245932 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -545,6 +545,7 @@ where .global_layout_origin(LayoutBoxId::from_index(index)); self.boxes.push(LayoutBoxGeometry { id, + effective_zoom: layout_box.style.effective_zoom(), structural_parent: layout_box .structural_parent .map(|parent| LayoutOutputBoxId::from_index(parent.index())), diff --git a/moli-layout/src/style.rs b/moli-layout/src/style.rs index bc4d0a41c1..fa28312c13 100644 --- a/moli-layout/src/style.rs +++ b/moli-layout/src/style.rs @@ -1333,6 +1333,19 @@ impl ResolvedLayoutStyle { self.paint_containment } + /// Returns the accumulated CSS `zoom` applied to this box's layout values. + /// + /// Stylo has already applied this factor to computed CSS lengths. Layout, + /// paint, and client rects retain that zoomed space. Browser state is + /// scaled into it on import, while CSSOM integer box and scroll metrics + /// remove the factor when they are published. Synthetic styles have no + /// Stylo value and therefore use the initial factor. + pub(crate) fn effective_zoom(&self) -> f32 { + self.computed + .as_ref() + .map_or(1.0, |computed| computed.effective_zoom.value()) + } + pub(crate) const fn is_visible(&self) -> bool { self.visible } diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/cssom_zoom.rs b/moli-renderer-v8/src/runtime/page_vm/tests/cssom_zoom.rs new file mode 100644 index 0000000000..24d81f0f47 --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_vm/tests/cssom_zoom.rs @@ -0,0 +1,234 @@ +use super::*; + +fn numeric(value: &serde_json::Value, path: &str) -> f64 { + value + .as_f64() + .unwrap_or_else(|| panic!("missing numeric {path}: {value}")) +} + +#[tokio::test(flavor = "current_thread")] +async fn cssom_box_metrics_remove_effective_zoom_without_unzooming_client_rects() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/cssom-absolute-zoom-box-metrics.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = `
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(1600, 1200, 1.0))? + .expect("CSS zoom box-metric screenshot layout"); + + let metrics = page_vm.vm_mut().eval( + r#"JSON.stringify(Object.fromEntries(['plain','zoomed','nested','button'].map(id=>{ + const element=document.getElementById(id),rect=element.getBoundingClientRect(); + return [id,{ + offset:[element.offsetWidth,element.offsetHeight], + client:[element.clientWidth,element.clientHeight,element.clientLeft,element.clientTop], + rect:[rect.width,rect.height] + }]; +})))"#, + )?; + let metrics: serde_json::Value = serde_json::from_str(&metrics)?; + for id in ["plain", "zoomed", "nested"] { + assert_eq!(numeric(&metrics[id]["offset"][0], id), 72.0, "{metrics}"); + assert_eq!(numeric(&metrics[id]["offset"][1], id), 72.0, "{metrics}"); + assert_eq!(numeric(&metrics[id]["client"][0], id), 64.0, "{metrics}"); + assert_eq!(numeric(&metrics[id]["client"][1], id), 64.0, "{metrics}"); + assert_eq!(numeric(&metrics[id]["client"][2], id), 4.0, "{metrics}"); + assert_eq!(numeric(&metrics[id]["client"][3], id), 4.0, "{metrics}"); + } + assert_eq!(numeric(&metrics["plain"]["rect"][0], "plain rect"), 72.0); + assert_eq!(numeric(&metrics["zoomed"]["rect"][0], "zoomed rect"), 288.0); + assert_eq!(numeric(&metrics["nested"]["rect"][0], "nested rect"), 1152.0); + assert_eq!(numeric(&metrics["button"]["offset"][0], "button offset"), 200.0); + assert_eq!(numeric(&metrics["button"]["rect"][0], "button rect"), 400.0); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("CSS zoom box-metric fixture should run"); +} + +#[tokio::test(flavor = "current_thread")] +async fn cssom_root_client_size_remains_in_layout_viewport_space_under_zoom() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/cssom-root-zoom-client-size.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.documentElement.style.zoom='2'; +document.body.style.margin='0'; +document.body.innerHTML='
'; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(800, 600, 1.0))? + .expect("root zoom CSSOM screenshot layout"); + + let metrics = page_vm.vm_mut().eval( + r#"JSON.stringify((()=>{const root=document.documentElement,child=document.getElementById('child'),rootRect=root.getBoundingClientRect(),childRect=child.getBoundingClientRect();return {rootClient:[root.clientWidth,root.clientHeight],rootWidth:[root.offsetWidth,rootRect.width],childWidth:[child.clientWidth,child.offsetWidth,childRect.width]}})())"#, + )?; + let metrics: serde_json::Value = serde_json::from_str(&metrics)?; + assert_eq!(metrics["rootClient"], serde_json::json!([800, 600])); + assert_eq!(numeric(&metrics["rootWidth"][0], "root offset width"), 400.0); + assert_eq!(numeric(&metrics["rootWidth"][1], "root rect width"), 800.0); + for (index, expected) in [100.0, 100.0, 200.0].into_iter().enumerate() { + assert_eq!(numeric(&metrics["childWidth"][index], "child width"), expected); + } + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("root zoom CSSOM fixture should run"); +} + +#[tokio::test(flavor = "current_thread")] +async fn cssom_offsets_resolve_the_offset_parent_before_removing_target_zoom() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/cssom-absolute-zoom-offsets.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` +
+
+
+
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1200, 1.0))? + .expect("CSS zoom offset screenshot layout"); + + let offsets = page_vm.vm_mut().eval( + r#"JSON.stringify(Object.fromEntries(['u1','u2','u3','z1','z2','z3','inner-in-zoom','zoomed-inner'].map(id=>{const e=document.getElementById(id);return [id,[e.offsetTop,e.offsetLeft,e.offsetWidth,e.offsetHeight,e.offsetParent&&e.offsetParent.id]]})))"#, + )?; + let offsets: serde_json::Value = serde_json::from_str(&offsets)?; + for (id, expected) in [ + ("u1", [11.0, 11.0]), + ("u2", [21.0, 21.0]), + ("u3", [11.0, 51.0]), + ("z1", [11.0, 11.0]), + ("z2", [21.0, 21.0]), + ("z3", [11.0, 51.0]), + ("inner-in-zoom", [10.0, 11.0]), + ("zoomed-inner", [0.0, 1.0]), + ] { + assert_eq!(numeric(&offsets[id][0], id), expected[0], "{offsets}"); + assert_eq!(numeric(&offsets[id][1], id), expected[1], "{offsets}"); + } + assert_eq!(offsets["zoomed-inner"][4], "zoom-boundary"); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("CSS zoom offset fixture should run"); +} + +#[tokio::test(flavor = "current_thread")] +async fn cssom_scroll_metrics_and_scroll_state_share_unzoomed_units() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/cssom-absolute-zoom-scroll.html")?, + ); + page_vm.vm_mut().eval( + r#" +document.head.innerHTML = ``; +document.body.innerHTML = ` +
+
+
+
`; +'installed' +"#, + )?; + page_vm.vm_mut().sync_live_document_style_sources(); + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1400, 1.0))? + .expect("CSS zoom initial scroll layout"); + + let initial = page_vm.vm_mut().eval( + r#"JSON.stringify(Object.fromEntries(['plain','zoomed','zoomed-content','nested'].map(id=>{const e=document.getElementById(id);return [id,[e.clientWidth,e.clientHeight,e.scrollWidth,e.scrollHeight]]})))"#, + )?; + let initial: serde_json::Value = serde_json::from_str(&initial)?; + // Chromium keeps the classic scrollbar at 15 layout pixels instead + // of scaling it with CSS zoom. Removing each container's effective + // zoom therefore exposes 85px, 96.25px, and 92.5px client widths, + // rounded by the CSSOM integer API to 85, 96, and 93 respectively. + for (id, expected) in [ + ("plain", [85.0, 85.0, 250.0, 250.0]), + ("zoomed", [96.0, 96.0, 250.0, 250.0]), + ("zoomed-content", [85.0, 85.0, 500.0, 500.0]), + ("nested", [93.0, 93.0, 250.0, 250.0]), + ] { + for (index, expected) in expected.into_iter().enumerate() { + assert_eq!(numeric(&initial[id][index], id), expected, "{initial}"); + } + } + + page_vm.vm_mut().eval( + "document.getElementById('plain').scrollTo(125,125);document.getElementById('zoomed').scrollTo(125,125);'scrolled'", + )?; + page_vm + .vm_mut() + .screenshot_layout_snapshot(moli_layout::PaintViewport::new(1200, 1400, 1.0))? + .expect("CSS zoom scrolled layout"); + let offsets = page_vm.vm_mut().eval( + "JSON.stringify(['plain','zoomed'].map(id=>{const e=document.getElementById(id);return [e.scrollLeft,e.scrollTop]}))", + )?; + let offsets: serde_json::Value = serde_json::from_str(&offsets)?; + assert_eq!(offsets, serde_json::json!([[125, 125], [125, 125]])); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("CSS zoom scroll fixture should run"); +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index 073c53ea83..ef5fe5ef7f 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -116,6 +116,7 @@ mod child_parser_module_root_start_completion; mod child_realm_materialization; mod child_realm_materialization_completion; mod command_checkpoint; +mod cssom_zoom; mod dedicated_worker_client_event; mod document_script_completion; mod element_toggle_event;