Keep scrolling clips in capture coordinates
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user