Keep scrolling clips in capture coordinates

This commit is contained in:
SGavrl
2026-07-31 02:33:45 +00:00
parent 04277acd54
commit c216e29ac5
3 changed files with 183 additions and 9 deletions

View File

@@ -621,8 +621,36 @@ fn resolve_clip_rects(
if tx != 0.0 || ty != 0.0 {
translates.insert(id, (tx, ty));
}
// Overflow propagated from html/body establishes the root scrolling
// viewport. It is anchored to the capture surface, not to document
// coordinates, and the pixmap already supplies that viewport clip.
// Materializing it here as an ordinary descendant clip would make root
// scrolling translate the viewport itself offscreen.
let is_viewport_overflow_source = tree.get_node(id).is_some_and(|node| {
node.as_element().is_some_and(|element| {
if element.local.as_ref() == "html" {
return true;
}
if element.local.as_ref() != "body" {
return false;
}
// Body overflow propagates to the viewport only while the root's
// own overflow is visible. Once html establishes overflow, body
// remains an ordinary clipping box.
node.parent
.and_then(|parent| tree.get_node(parent).map(|parent_node| (parent, parent_node)))
.is_some_and(|(parent, parent_node)| {
parent_node
.as_element()
.is_some_and(|name| name.local.as_ref() == "html")
&& !styles
.get(&parent)
.is_some_and(|style| style.overflow_hidden)
})
})
});
let next = match (styles.get(&id), rects.get(&id)) {
(Some(style), Some(rect)) if style.overflow_hidden => {
(Some(style), Some(rect)) if style.overflow_hidden && !is_viewport_overflow_source => {
let own = overflow_clip_rect(rect, style, tx, ty);
Some(match inherited {
Some(clip) => clip.intersect(&own).unwrap_or(Rect::default()),

View File

@@ -1739,12 +1739,24 @@ fn sample_gradient(fill: &(f32, Vec<([u8; 4], Option<f32>)>), x: f32, y: f32, w:
}
impl TextEngine {
/// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses
/// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color
/// from the span attributes). `offset` is the accumulated `transform:
/// translate()` of the container's ancestors, shifting both the glyph
/// origin and the clip so shaped text moves with a transformed box.
/// Rasterize inline context `idx` into `pixmap`, honoring its finalized
/// clip. Tests and generated items whose coordinates do not change between
/// layout and paint use this path.
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) {
self.paint_item_with_clip(idx, pixmap, offset, None);
}
/// Rasterize an inline context with a capture-space clip override. Root
/// scrolling is layered over immutable document layout, so the glyph
/// origin and any overflow clip must both be sampled in the current
/// viewport rather than mixing viewport and document coordinates.
pub fn paint_item_with_clip(
&mut self,
idx: usize,
pixmap: &mut tiny_skia::Pixmap,
offset: (f32, f32),
clip_override: Option<Rect>,
) {
let TextEngine {
font_system,
swash,
@@ -1758,7 +1770,7 @@ impl TextEngine {
// but the clip is already in screen space (owner-shifted at
// `resolve_clip_rects`) and must not move with the container, or a
// translated slide would drag its viewport's clip along with it.
let clip = item.clip;
let clip = clip_override.or(item.clip);
let pw = pixmap.width() as i32;
let ph = pixmap.height() as i32;
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));

View File

@@ -1194,14 +1194,17 @@ fn paint_laid_dom_scrolled(
// container's box so text under a transformed ancestor moves with
// it. Computed before the mutable `paint_item` borrow.
let off = scroll_state.translation_for(laid, nid);
let clip = scroll_state.shaped_text_clip_for(laid, nid);
if let Some(idx) = whole {
laid.text_engine.paint_item(idx, &mut pixmap, off);
laid.text_engine
.paint_item_with_clip(idx, &mut pixmap, off, clip);
}
// Anonymous inline-run leaves of a mixed block (see
// `build_mixed_block`), pinned to their own boxes at finalize.
if let Some(items) = run_items {
for idx in items {
laid.text_engine.paint_item(idx, &mut pixmap, off);
laid.text_engine
.paint_item_with_clip(idx, &mut pixmap, off, clip);
}
}
}
@@ -1301,6 +1304,34 @@ impl<'a> ScrollPaintState<'a> {
clip.y += sticky.1 - self.scroll.1;
Some(clip)
}
/// Capture-space clip for a shaped inline context. `clip_for` supplies the
/// adjusted ancestor chain; text owned by an `overflow:hidden` element
/// also needs that element's own padding-box clip in the same viewport
/// coordinates.
fn shaped_text_clip_for(
&self,
laid: &crate::DomLayout,
id: obscura_dom::tree::NodeId,
) -> Option<crate::Rect> {
let inherited = self.clip_for(laid, id);
let style = laid.styles.get(&id)?;
if !style.overflow_hidden {
return inherited;
}
let rect = laid.rects.get(&id)?;
let (ox, oy) = self.translation_for(laid, id);
let own = crate::Rect {
x: rect.x + ox + style.border.left,
y: rect.y + oy + style.border.top,
width: (rect.width - style.border.left - style.border.right).max(0.0),
height: (rect.height - style.border.top - style.border.bottom).max(0.0),
};
Some(match inherited {
Some(clip) => clip.intersect(&own).unwrap_or(crate::Rect::default()),
None => own,
})
}
}
#[derive(Clone, Copy, Debug)]
@@ -4537,6 +4568,109 @@ mod tests {
}
}
#[test]
fn repeated_scroll_capture_moves_shaped_text_and_its_overflow_clip_together() {
let tree = parse_html(
r#"<html style="margin:0;overflow:auto"><body style="margin:0">
<div style="height:1000px;background:#ff0000"></div>
<section style="height:160px;overflow:hidden;background:#000000;color:#ffffff">
<h2 style="margin:0;font-size:32px;line-height:40px">VISIBLE SCROLLED TEXT</h2>
<p style="margin:0;font-size:20px;line-height:28px">SECOND SHAPED LINE</p>
<div style="position:absolute;left:260px;top:1100px;width:20px;height:20px;background:#00ff00"></div>
<svg style="position:absolute;left:260px;top:1040px;width:20px;height:20px"
viewBox="0 0 20 20"><rect width="20" height="20" fill="cyan"/></svg>
<div style="position:absolute;left:20px;top:1150px;width:20px;height:30px;background:#ff00ff"></div>
</section>
<div style="height:200px"></div>
</body></html>"#,
);
let viewport = (300.0, 180.0);
let mut resources = RenderResourceCache::default();
let mut prepared =
prepare_dom(&tree, viewport, None, &mut resources).expect("prepared render");
let top = paint_prepared(
&tree,
&mut prepared,
&mut resources,
(0.0, 0.0),
)
.expect("top capture");
let scrolled = paint_prepared(
&tree,
&mut prepared,
&mut resources,
(0.0, 1000.0),
)
.expect("scrolled capture");
let top_repeat = paint_prepared(
&tree,
&mut prepared,
&mut resources,
(0.0, 0.0),
)
.expect("repeated top capture");
let scrolled_repeat = paint_prepared(
&tree,
&mut prepared,
&mut resources,
(0.0, 1000.0),
)
.expect("repeated scrolled capture");
assert_eq!(top, top_repeat, "returning to the top must not accumulate scroll");
assert_eq!(
scrolled, scrolled_repeat,
"repeated bottom paint must reuse immutable document geometry"
);
let white_ink = (0..90)
.flat_map(|y| (0..250).map(move |x| (x, y)))
.filter(|&(x, y)| {
let pixel = scrolled.pixel(x, y).expect("text pixel");
pixel.red() > 220 && pixel.green() > 220 && pixel.blue() > 220
})
.count();
assert!(
white_ink > 100,
"visible shaped text must share the viewport-space overflow clip, found {white_ink} white pixels"
);
let marker = scrolled.pixel(270, 110).expect("marker pixel");
assert!(
marker.green() > 220 && marker.red() < 40 && marker.blue() < 40,
"a non-text box in the same clipped section must remain visible: {marker:?}"
);
let svg_marker = scrolled.pixel(270, 50).expect("svg marker pixel");
assert!(
svg_marker.green() > 220 && svg_marker.blue() > 220 && svg_marker.red() < 40,
"an inline svg in the same clipped section must remain visible: {svg_marker:?}"
);
let clipped_marker = scrolled.pixel(30, 165).expect("clipped marker pixel");
assert!(
clipped_marker.red() > 240
&& clipped_marker.green() > 240
&& clipped_marker.blue() > 240,
"nested overflow must still clip content below its document-space padding box: {clipped_marker:?}"
);
}
#[test]
fn body_overflow_stays_a_content_clip_when_html_owns_root_overflow() {
let tree = parse_html(
r#"<html style="margin:0;overflow:auto">
<body style="margin:0;width:100px;height:50px;overflow:hidden">
<div style="position:absolute;left:10px;top:60px;width:20px;height:20px;background:red"></div>
</body>
</html>"#,
);
let output = paint_dom(&tree, (100.0, 100.0), None).expect("paint");
let below_body = output.pixel(15, 65).expect("pixel");
assert!(
below_body.red() > 240
&& below_body.green() > 240
&& below_body.blue() > 240,
"body overflow must not be mistaken for a viewport clip when html already owns overflow: {below_body:?}"
);
}
#[test]
fn repeated_scroll_captures_reuse_immutable_layout_geometry() {
let tree = parse_html(