From f050c30a783d33de4cfac0e7d9bbdd16c4b319de Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Tue, 28 Jul 2026 08:45:03 -0700 Subject: [PATCH 1/6] fix(layout): resolve the real cascade; add a UA stylesheet and border-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five layout defects, all found by rendering a page for the first time (browser_oxide_app PoC-2). Every one was invisible while the engine was headless, and every one moves getBoundingClientRect() — so these are fingerprint fixes as much as rendering fixes. Measured end to end against Chrome 150 on the PoC's test page: 84.8% of pixels differing before, 11.1% after; mean per-channel delta 92.8 -> 8.9. ## 1. Layout ignored the cascade LayoutEngine resolved every element as ComputedStyle::resolve(&HashMap::new(), None) — an empty cascaded map — then overlaid the style attribute. A \ +
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.width, rect.height), (250.0, 40.0)); + } + + #[test] + fn border_width_without_border_style_is_not_used() { + // `border-width: medium` is the initial value and the initial + // `border-style` is `none`, so an element with neither set must have no + // border. The engine applied 3px to every element on every page. + let (dom, mut engine) = + laid_out("
"); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!( + (rect.width, rect.height), + (100.0, 100.0), + "a div with no border-style must not gain one" + ); + } + + #[test] + fn border_width_with_border_style_is_used() { + // The other half: the gate must not swallow real borders. Default + // `box-sizing: content-box`, so a 10px border grows the box by 20px. + let (dom, mut engine) = laid_out( + "
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.width, rect.height), (120.0, 120.0)); + } + + #[test] + fn border_shorthand_expands() { + let (dom, mut engine) = laid_out( + "
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.width, rect.height), (110.0, 110.0)); + } + + #[test] + fn border_shorthand_without_a_style_draws_nothing() { + // `border: 5px` sets a width but leaves style at its initial `none`, + // so the used width is zero. This is the case that reads wrong until + // you check it against a browser. + let (dom, mut engine) = laid_out( + "
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.width, rect.height), (100.0, 100.0)); + } + + #[test] + fn head_is_not_laid_out() { + // Without a UA stylesheet, and were visible blocks and + // pushed <body> down the page. + let (dom, mut engine) = + laid_out("<html><head><title>a title"); + let head = rect_of(&dom, &mut engine, "head"); + assert_eq!( + (head.width, head.height), + (0.0, 0.0), + "head must not occupy space" + ); + } + + #[test] + fn whitespace_between_elements_has_no_box() { + // Each newline-plus-indent text node used to become a full line box, so + // an ordinary document gained one per element. Two divs separated by a + // newline must stack with nothing between them. + let (dom, mut engine) = laid_out( + "\n
\n \ +
\n", + ); + let ids = dom.get_elements_by_tag_name(NodeId::DOCUMENT, "div"); + assert_eq!(ids.len(), 2); + let first = engine.get_bounding_rect(&dom, ids[0]); + let second = engine.get_bounding_rect(&dom, ids[1]); + assert_eq!( + second.y - first.y, + 10.0, + "the second div must sit directly below the first, not a line box lower" + ); + } + + #[test] + fn body_has_the_default_margin() { + // The UA stylesheet's `body { margin: 8px }`. Without it every element + // on an unstyled page sits 8px off from where a browser puts it. + let (dom, mut engine) = laid_out("
"); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.x, rect.y), (8.0, 8.0)); + } + + #[test] + fn font_size_inherits_and_em_resolves_against_it() { + // `font-size` used to be a fixed 16px for the whole document, so an + // `em` length anywhere resolved against the wrong number. + let (dom, mut engine) = laid_out( + "\ +
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!( + (rect.width, rect.height), + (40.0, 20.0), + "2em must be 40px under an inherited 20px font-size, not 32px" + ); + } + + #[test] + fn display_none_from_a_stylesheet_removes_the_box() { + let (dom, mut engine) = laid_out( + "\ +
", + ); + let rect = rect_of(&dom, &mut engine, "div"); + assert_eq!((rect.width, rect.height), (0.0, 0.0)); + } +} diff --git a/crates/browser_oxide/src/layout/style_map.rs b/crates/browser_oxide/src/layout/style_map.rs index d0822460..049a7ec5 100644 --- a/crates/browser_oxide/src/layout/style_map.rs +++ b/crates/browser_oxide/src/layout/style_map.rs @@ -48,10 +48,30 @@ pub fn computed_to_taffy(style: &ComputedStyle, ctx: &ResolveContext) -> taffy:: ts.padding.left = css_to_lp(style, &PropertyId::PaddingLeft, ctx); // Border - ts.border.top = css_to_border(style, &PropertyId::BorderTopWidth, ctx); - ts.border.right = css_to_border(style, &PropertyId::BorderRightWidth, ctx); - ts.border.bottom = css_to_border(style, &PropertyId::BorderBottomWidth, ctx); - ts.border.left = css_to_border(style, &PropertyId::BorderLeftWidth, ctx); + ts.border.top = css_to_border( + style, + &PropertyId::BorderTopWidth, + &PropertyId::BorderTopStyle, + ctx, + ); + ts.border.right = css_to_border( + style, + &PropertyId::BorderRightWidth, + &PropertyId::BorderRightStyle, + ctx, + ); + ts.border.bottom = css_to_border( + style, + &PropertyId::BorderBottomWidth, + &PropertyId::BorderBottomStyle, + ctx, + ); + ts.border.left = css_to_border( + style, + &PropertyId::BorderLeftWidth, + &PropertyId::BorderLeftStyle, + ctx, + ); // Flex if let Some(CssValue::FlexDirection(fd)) = style.get(&PropertyId::FlexDirection) { @@ -159,12 +179,28 @@ fn css_to_lp( } } +/// The *used* border width for one side. +/// +/// CSS: a border whose style is `none` or `hidden` has a used width of zero, +/// whatever `border-width` says. The initial `border-width` is `medium` (3px) +/// and the initial `border-style` is `none`, so without this gate every +/// element in every document carries a 3px border on all four sides — which is +/// exactly what the engine did, and it moved every box on every page. fn css_to_border( style: &ComputedStyle, - prop: &PropertyId, + width_prop: &PropertyId, + style_prop: &PropertyId, ctx: &ResolveContext, ) -> taffy::LengthPercentage { - match style.get(prop) { + let draws = match style.get(style_prop) { + Some(CssValue::BorderStyle(s)) => s.is_visible(), + // No border-style at all: treat as `none`, matching the initial value. + _ => false, + }; + if !draws { + return taffy::LengthPercentage::length(0.0); + } + match style.get(width_prop) { Some(CssValue::Length(l)) => taffy::LengthPercentage::length(resolve_length(l, ctx)), _ => taffy::LengthPercentage::length(0.0), } diff --git a/crates/browser_oxide/src/lib.rs b/crates/browser_oxide/src/lib.rs index 4aef9ae0..3d4ea90e 100644 --- a/crates/browser_oxide/src/lib.rs +++ b/crates/browser_oxide/src/lib.rs @@ -17,6 +17,8 @@ pub mod layout; pub mod net; pub mod protocol; pub mod stealth; +/// Style resolution: DOM + stylesheets -> a ComputedStyle per element. +pub mod style; pub mod workers; pub mod challenge; diff --git a/crates/browser_oxide/src/style/mod.rs b/crates/browser_oxide/src/style/mod.rs new file mode 100644 index 00000000..60c5853f --- /dev/null +++ b/crates/browser_oxide/src/style/mod.rs @@ -0,0 +1,382 @@ +//! Style resolution: DOM + stylesheets → a `ComputedStyle` per element. +//! +//! Every piece of this already existed — `css_parser` produces rules, +//! `css_selectors` matches them, `css_cascade` sorts and inherits. What did not +//! exist was anything that *assembled* them for layout. `LayoutEngine` built +//! each element's style as +//! +//! ```ignore +//! let computed = ComputedStyle::resolve(&HashMap::new(), None); +//! ``` +//! +//! — an empty cascaded map, with inline `style` attributes overlaid — so a +//! `\ +
x
", + ); + let div = first(&dom, "div"); + let style = tree.get(div).expect("div has a computed style"); + assert!( + matches!( + style.get(&PropertyId::Width), + Some(CssValue::LengthPercentageAuto(_)) + ), + "width from a stylesheet should be present, got {:?}", + style.get(&PropertyId::Width) + ); + } + + #[test] + fn ua_stylesheet_hides_head() { + let (dom, tree) = styles_for("tx"); + let head = first(&dom, "head"); + assert_eq!( + tree.get(head).and_then(|s| s.get(&PropertyId::Display)), + Some(&CssValue::Display(Display::None)), + "the UA stylesheet must make display:none" + ); + } + + #[test] + fn inline_style_beats_a_more_specific_selector() { + let (dom, tree) = styles_for( + "\ +
x
", + ); + let div = first(&dom, "div"); + let width = tree.get(div).and_then(|s| s.get(&PropertyId::Width)); + let text = format!("{width:?}"); + assert!( + text.contains("999"), + "style attribute must win over any selector, got {text}" + ); + } + + #[test] + fn inheritance_flows_through_non_element_nodes() { + let (dom, tree) = styles_for( + "\ +
x
", + ); + let span = first(&dom, "span"); + let color = tree.get(span).and_then(|s| s.get(&PropertyId::Color)); + assert!( + format!("{color:?}").contains('1'), + "color must inherit from body through div, got {color:?}" + ); + } + + #[test] + fn author_rules_beat_the_ua_stylesheet() { + let (dom, tree) = styles_for( + "\ +
x
", + ); + let div = first(&dom, "div"); + assert_eq!( + tree.get(div).and_then(|s| s.get(&PropertyId::Display)), + Some(&CssValue::Display(Display::Inline)), + "author origin must outrank user-agent origin" + ); + } +} diff --git a/crates/browser_oxide/src/style/ua.css b/crates/browser_oxide/src/style/ua.css new file mode 100644 index 00000000..74e00480 --- /dev/null +++ b/crates/browser_oxide/src/style/ua.css @@ -0,0 +1,83 @@ +/* Minimal user-agent stylesheet. + * + * The engine had none. That is invisible while it is headless — taffy's default + * display is block, which makes documents look approximately sane — but it + * means `head`, `title`, `meta`, `script` and `style` are laid out as ordinary + * visible blocks, and `getBoundingClientRect()` on a body element reports a + * position that no real browser would report. An anti-bot script comparing + * geometry against a real Chrome is reading a difference here. + * + * Deliberately not the whole of html.css. This covers the elements whose + * *absence* is observable: what does not render, what is block, and the default + * margins that decide where everything sits. Fonts, colours, form controls and + * table defaults come later with the features that need them. + */ + +/* --- not rendered ------------------------------------------------------- */ + +head, meta, title, script, style, link, base, noscript, template, +datalist, param, source, track, area, col, colgroup { + display: none; +} + +/* --- block-level -------------------------------------------------------- */ + +html, body, div, p, h1, h2, h3, h4, h5, h6, +section, article, aside, nav, header, footer, main, figure, figcaption, +blockquote, pre, address, hr, dl, dd, dt, ol, ul, li, +form, fieldset, legend, details, summary, dialog, hgroup, search { + display: block; +} + +li { display: list-item; } + +table { display: table; } +thead { display: table-header-group; } +tbody { display: table-row-group; } +tfoot { display: table-footer-group; } +tr { display: table-row; } +td, th { display: table-cell; } +caption { display: table-caption; } + +/* --- default margins ---------------------------------------------------- */ +/* The numbers Chrome uses. They are load-bearing: `body { margin: 8px }` alone + * shifts every element on an unstyled page by 8px in both axes. */ + +body { margin: 8px; } + +p, blockquote, figure, dl, ol, ul, pre { + margin-top: 1em; + margin-bottom: 1em; +} + +h1 { font-size: 2em; margin-top: 0.67em; margin-bottom: 0.67em; } +h2 { font-size: 1.5em; margin-top: 0.83em; margin-bottom: 0.83em; } +h3 { font-size: 1.17em; margin-top: 1em; margin-bottom: 1em; } +h4 { font-size: 1em; margin-top: 1.33em; margin-bottom: 1.33em; } +h5 { font-size: 0.83em; margin-top: 1.67em; margin-bottom: 1.67em; } +h6 { font-size: 0.67em; margin-top: 2.33em; margin-bottom: 2.33em; } + +blockquote { margin-left: 40px; margin-right: 40px; } +figure { margin-left: 40px; margin-right: 40px; } + +ol, ul { padding-left: 40px; } + +/* --- inline ------------------------------------------------------------- */ + +span, a, em, strong, b, i, u, s, small, big, code, kbd, samp, var, cite, +abbr, dfn, q, sub, sup, mark, time, label, br, wbr, bdi, bdo, ruby, rt, rp { + display: inline; +} + +img, video, audio, canvas, iframe, embed, object, svg, picture, progress, meter { + display: inline-block; +} + +button, input, select, textarea { display: inline-block; } + +/* --- typography --------------------------------------------------------- */ + +h1, h2, h3, h4, h5, h6, strong, b, th { font-weight: bold; } +em, i, cite, dfn, var, address { font-style: italic; } +pre, code, kbd, samp { font-family: monospace; } +small { font-size: 0.83em; } diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index bbbd56df..a463e790 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -32,6 +32,58 @@ If these return `0` or `undefined`, sites break or flag us as a bot. taffy takes a tree of nodes with `Style` structs and computes `Layout` (position + size) for each node. +## Style resolution + +Layout does not resolve styles itself. `crate::style` does, once per `compute`, +and layout reads the result: + +``` +DOM ──┬─► style::compute_styles ──► StyleTree (ComputedStyle per element) + │ ▲ ▲ ▲ │ + │ │ │ └── style="…" attributes │ + │ │ └────── \ +
", + 200, + 200, + ); + assert_eq!(pixel(&target, 40, 40), (0, 0, 255, 255)); + } + + #[test] + fn borders_are_painted_in_their_own_colour() { + let (target, _, _) = render( + "\ +
\ + ", + 200, + 200, + ); + // Inside the top border. + assert_eq!(pixel(&target, 50, 5), (0, 255, 0, 255)); + // Below it, the background. + assert_eq!(pixel(&target, 50, 50), (255, 255, 255, 255)); + } + + #[test] + fn a_border_with_no_style_paints_nothing() { + // `border-width` alone has no effect in CSS. This is the defect the + // engine had, checked at the pixel level. + let (target, _, _) = render( + "\ +
\ + ", + 200, + 200, + ); + assert_eq!(pixel(&target, 50, 5), (255, 255, 255, 255)); + } + + #[test] + fn text_produces_glyphs_and_dark_pixels() { + let (target, stats, list) = render( + "

\ + Hello from the engine

", + 400, + 200, + ); + assert!(stats.text_runs > 0, "expected a text run: {stats:?}"); + assert_eq!(stats.unresolved_fonts, 0); + assert!( + list.items + .iter() + .any(|i| matches!(i, DisplayItem::Text { glyphs, .. } if !glyphs.is_empty())), + "expected positioned glyphs in the display list" + ); + + // Somewhere in the first line there must be ink. + let rgba = target.to_rgba8(); + let has_dark = rgba + .chunks_exact(4) + .any(|p| p[0] < 128 && p[1] < 128 && p[2] < 128 && p[3] > 0); + assert!(has_dark, "text must actually darken some pixels"); + } + + #[test] + fn display_none_paints_nothing() { + let (target, _, _) = render( + "\ +
\ + ", + 200, + 200, + ); + assert_eq!(pixel(&target, 50, 50), (255, 255, 255, 255)); + } + + #[test] + fn png_encodes() { + let dom = crate::html_parser::parse_html( + "
x
", + ); + let mut layout = LayoutEngine::new(Viewport::new(120.0, 80.0)); + let png = render_to_png(&dom, &mut layout, 120, 80).expect("png encodes"); + assert!(png.starts_with(&[0x89, b'P', b'N', b'G']), "PNG magic"); + assert!(png.len() > 100); + } + + #[test] + fn an_unbalanced_clip_does_not_corrupt_the_surface() { + // A PopClip with no PushClip must not unbalance Skia's save stack. + let mut list = DisplayList::default(); + list.push(DisplayItem::PopClip); + list.push(DisplayItem::Rect { + rect: Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }, + color: Rgba::WHITE, + }); + list.push(DisplayItem::PushClip { + rect: Rect { + x: 0.0, + y: 0.0, + width: 5.0, + height: 5.0, + }, + }); + let mut target = Target::new(10, 10); + assert!(target.paint(&list)); + assert_eq!(pixel(&target, 1, 1), (255, 255, 255, 255)); + } + + #[test] + fn a_zero_sized_target_is_refused_rather_than_panicking() { + let mut target = Target::new(0, 0); + assert!(!target.paint(&DisplayList::default())); + } +} diff --git a/crates/browser_oxide/src/render/painter.rs b/crates/browser_oxide/src/render/painter.rs new file mode 100644 index 00000000..662ce338 --- /dev/null +++ b/crates/browser_oxide/src/render/painter.rs @@ -0,0 +1,334 @@ +//! Walk the box tree in paint order and emit a display list. +//! +//! Paint order is CSS 2.1 Appendix E, minimally: in-flow content in tree order +//! — background, then border, then children — and text on top of the box it +//! sits in. Stacking contexts and `z-index` are not modelled yet; getting paint +//! order wrong is the most common source of "looks subtly broken", so that gap +//! is worth stating rather than discovering. + +use crate::css_cascade::ComputedStyle; +use crate::css_values::property::{CssValue, PropertyId}; +use crate::css_values::types::color::Color as CssColor; +use crate::css_values::types::length::{LengthPercentage, LengthPercentageAuto}; +use crate::dom::node::{NodeData, NodeId}; +use crate::dom::Dom; +use crate::layout::inline::InlineLayout; +use crate::layout::resolve::{resolve_length, ResolveContext}; +use crate::layout::LayoutEngine; + +use super::display_list::{DisplayItem, DisplayList, FontRef, Glyph, Rect, Rgba, SideOffsets}; + +/// What the painter saw on the way through. +#[derive(Debug, Default, Clone, Copy)] +pub struct PaintStats { + pub elements: usize, + pub text_runs: usize, + /// Text runs whose family could not be resolved to any face. + pub unresolved_fonts: usize, +} + +/// Build a display list for `dom`, using geometry and styles already computed +/// by `layout`. +/// +/// `layout.compute()` must have run. The painter reads +/// `LayoutEngine::styles()` rather than re-resolving anything: two style +/// resolutions per element would be two chances to disagree, and a box painted +/// with a style that did not decide its geometry looks like a paint bug. +pub fn paint( + dom: &Dom, + layout: &mut LayoutEngine, + inline: &mut InlineLayout, + viewport_width: f32, + viewport_height: f32, +) -> (DisplayList, PaintStats) { + let mut list = DisplayList::default(); + let mut stats = PaintStats::default(); + + // A browser's canvas starts white. Without this the page composites over + // whatever was in the buffer. + list.push(DisplayItem::Rect { + rect: Rect { + x: 0.0, + y: 0.0, + width: viewport_width, + height: viewport_height, + }, + color: Rgba::WHITE, + }); + + let ctx = ResolveContext { + font_size: 16.0, + root_font_size: 16.0, + viewport_w: viewport_width, + viewport_h: viewport_height, + }; + + let mut painter = Painter { + dom, + layout, + inline, + ctx, + list: &mut list, + stats: &mut stats, + }; + painter.node(NodeId::DOCUMENT); + + (list, stats) +} + +struct Painter<'a> { + dom: &'a Dom, + layout: &'a mut LayoutEngine, + inline: &'a mut InlineLayout, + ctx: ResolveContext, + list: &'a mut DisplayList, + stats: &'a mut PaintStats, +} + +impl Painter<'_> { + fn node(&mut self, node_id: NodeId) { + let Some(node) = self.dom.get(node_id) else { + return; + }; + + match &node.data { + NodeData::Document | NodeData::DocumentFragment => { + for child in self.dom.children(node_id) { + self.node(child); + } + } + NodeData::Element(_) => { + let style = self.layout.styles().get_or_initial(node_id); + if is_display_none(&style) || is_hidden(&style) { + return; + } + self.stats.elements += 1; + + let rect = self.rect_of(node_id); + self.box_decoration(&rect, &style); + + let clips = clips_overflow(&style); + if clips { + self.list.push(DisplayItem::PushClip { rect }); + } + for child in self.dom.children(node_id) { + self.node(child); + } + if clips { + self.list.push(DisplayItem::PopClip); + } + } + NodeData::Text(_) => self.text(node_id), + _ => {} + } + } + + fn rect_of(&mut self, node_id: NodeId) -> Rect { + let r = self.layout.get_bounding_rect(self.dom, node_id); + Rect { + x: r.x as f32, + y: r.y as f32, + width: r.width as f32, + height: r.height as f32, + } + } + + fn box_decoration(&mut self, rect: &Rect, style: &ComputedStyle) { + if rect.is_empty() { + return; + } + let background = color_of(style, PropertyId::BackgroundColor, Rgba::TRANSPARENT); + if background.is_visible() { + self.list.push(DisplayItem::Rect { + rect: *rect, + color: background, + }); + } + + // Used widths, not specified widths: a border whose style is `none` + // has a used width of zero, and layout reserved space accordingly. + // Painting the specified width would draw outside the box. + let widths = SideOffsets { + top: self.border_width( + style, + PropertyId::BorderTopWidth, + PropertyId::BorderTopStyle, + ), + right: self.border_width( + style, + PropertyId::BorderRightWidth, + PropertyId::BorderRightStyle, + ), + bottom: self.border_width( + style, + PropertyId::BorderBottomWidth, + PropertyId::BorderBottomStyle, + ), + left: self.border_width( + style, + PropertyId::BorderLeftWidth, + PropertyId::BorderLeftStyle, + ), + }; + if !widths.any() { + return; + } + let colors = [ + color_of(style, PropertyId::BorderTopColor, Rgba::BLACK), + color_of(style, PropertyId::BorderRightColor, Rgba::BLACK), + color_of(style, PropertyId::BorderBottomColor, Rgba::BLACK), + color_of(style, PropertyId::BorderLeftColor, Rgba::BLACK), + ]; + if colors.iter().any(Rgba::is_visible) { + self.list.push(DisplayItem::Border { + rect: *rect, + widths, + colors, + }); + } + } + + fn text(&mut self, node_id: NodeId) { + let Some(node) = self.dom.get(node_id) else { + return; + }; + let NodeData::Text(raw) = &node.data else { + return; + }; + + let parent_style = node + .parent + .map(|p| self.layout.styles().get_or_initial(p)) + .unwrap_or_else(|| ComputedStyle::resolve(&std::collections::HashMap::new(), None)); + + // The same white-space processing layout applied. If the painter drew a + // different string from the one that was measured, the text would not + // fit the box reserved for it. + let collapsed = crate::layout::engine::collapse_white_space(raw, &parent_style); + if collapsed.is_empty() { + return; + } + + let rect = self.rect_of(node_id); + let text_style = self.layout.text_style_for(&parent_style, &self.ctx); + let (_summary, lines) = + self.inline + .layout_lines(&collapsed, &text_style, Some(rect.width.max(0.0))); + if lines.is_empty() { + return; + } + + let Some(font) = resolve_font(&text_style) else { + self.stats.unresolved_fonts += 1; + return; + }; + let color = color_of(&parent_style, PropertyId::Color, Rgba::BLACK); + + for line in &lines { + if line.glyphs.is_empty() { + continue; + } + self.stats.text_runs += 1; + self.list.push(DisplayItem::Text { + origin: (rect.x, rect.y + line.y + line.baseline), + glyphs: line + .glyphs + .iter() + .map(|g| Glyph { + id: g.id, + x: g.x, + y: g.y, + }) + .collect(), + font, + color, + }); + } + } + + fn border_width( + &self, + style: &ComputedStyle, + width: PropertyId, + style_prop: PropertyId, + ) -> f32 { + let draws = matches!( + style.get(&style_prop), + Some(CssValue::BorderStyle(s)) if s.is_visible() + ); + if !draws { + return 0.0; + } + length_of(style, width, &self.ctx) + } +} + +/// Resolve a text style's family chain to the face the shaper used. +fn resolve_font(style: &crate::layout::inline::TextStyle) -> Option { + let db = crate::canvas::text::font_database::FontDatabase::get(); + let id = db.query_chain(&style.families, style.weight, style.italic, &style.os_name)?; + let (data, face_index) = db.face_data(id)?; + Some(FontRef { + data, + face_index, + size_px: style.size_px, + }) +} + +fn color_of(style: &ComputedStyle, property: PropertyId, fallback: Rgba) -> Rgba { + match style.get(&property) { + Some(CssValue::Color(c)) => css_color(c), + _ => fallback, + } +} + +fn css_color(c: &CssColor) -> Rgba { + let (r, g, b, a) = c.to_rgba(); + Rgba { + r, + g, + b, + a: (a.clamp(0.0, 1.0) * 255.0).round() as u8, + } +} + +fn length_of(style: &ComputedStyle, property: PropertyId, ctx: &ResolveContext) -> f32 { + match style.get(&property) { + Some(CssValue::Length(l)) => resolve_length(l, ctx), + Some(CssValue::LengthPercentage(LengthPercentage::Length(l))) => resolve_length(l, ctx), + Some(CssValue::LengthPercentageAuto(LengthPercentageAuto::Length(l))) => { + resolve_length(l, ctx) + } + _ => 0.0, + } +} + +fn is_display_none(style: &ComputedStyle) -> bool { + use crate::css_values::types::display::Display; + matches!( + style.get(&PropertyId::Display), + Some(CssValue::Display(Display::None)) + ) +} + +fn is_hidden(style: &ComputedStyle) -> bool { + use crate::css_values::types::display::Visibility; + matches!( + style.get(&PropertyId::Visibility), + Some(CssValue::Visibility(Visibility::Hidden)) + ) +} + +fn clips_overflow(style: &ComputedStyle) -> bool { + use crate::css_values::types::display::Overflow; + [PropertyId::OverflowX, PropertyId::OverflowY] + .iter() + .any(|p| { + matches!( + style.get(p), + Some(CssValue::Overflow( + Overflow::Hidden | Overflow::Scroll | Overflow::Auto + )) + ) + }) +} diff --git a/crates/browser_oxide/src/render/raster.rs b/crates/browser_oxide/src/render/raster.rs new file mode 100644 index 00000000..5279068a --- /dev/null +++ b/crates/browser_oxide/src/render/raster.rs @@ -0,0 +1,196 @@ +//! Replay a display list onto a Skia raster surface. +//! +//! CPU only. The Skia setup mirrors `canvas/canvas2d.rs`, which already proves +//! the engine can drive `SkCanvas` correctly: a raster surface wrapping a plain +//! pixel buffer via `surfaces::wrap_pixels`, RGBA8888 premultiplied. +//! +//! Glyph rasterization uses the same `SkFont` settings as the 2D canvas — +//! grayscale antialiasing, subpixel positioning, no hinting. Matching them is +//! not cosmetic: text rasterization is a fingerprint surface, and a browser +//! whose page text and `` text were rasterized differently would be +//! reporting two different renderers. + +use skia_safe::{ + surfaces, AlphaType, Canvas as SkCanvas, Color4f, ColorType, Font, FontHinting, FontMgr, + ImageInfo, Paint, PathBuilder, Point, Rect as SkRect, +}; + +use super::display_list::{DisplayItem, DisplayList, Rect, Rgba}; + +/// A CPU raster target. `pixels` is RGBA8888 premultiplied, top-down. +pub struct Target { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Target { + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + pixels: vec![0; (width as usize) * (height as usize) * 4], + } + } + + /// Paint a display list into this target. + /// + /// Returns false if Skia would not wrap the buffer — a zero dimension, or a + /// size that overflows. + pub fn paint(&mut self, list: &DisplayList) -> bool { + if self.width == 0 || self.height == 0 { + return false; + } + let info = ImageInfo::new( + (self.width as i32, self.height as i32), + ColorType::RGBA8888, + AlphaType::Premul, + None, + ); + let row_bytes = self.width as usize * 4; + let Some(mut surface) = + surfaces::wrap_pixels(&info, &mut self.pixels, Some(row_bytes), None) + else { + return false; + }; + replay(surface.canvas(), list); + true + } + + /// Un-premultiplied RGBA8, which is what an image encoder expects. + pub fn to_rgba8(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.chunks_exact_mut(4) { + let a = px[3]; + if a != 0 && a != 255 { + for c in px[..3].iter_mut() { + *c = ((u32::from(*c) * 255) / u32::from(a)).min(255) as u8; + } + } + } + out + } + + /// Encode as PNG. + pub fn to_png(&self) -> Option> { + let rgba = self.to_rgba8(); + let mut out = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut out, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().ok()?; + writer.write_image_data(&rgba).ok()?; + } + Some(out) + } +} + +fn sk_color(c: Rgba) -> Color4f { + Color4f::new( + f32::from(c.r) / 255.0, + f32::from(c.g) / 255.0, + f32::from(c.b) / 255.0, + f32::from(c.a) / 255.0, + ) +} + +fn sk_rect(r: &Rect) -> SkRect { + SkRect::from_xywh(r.x, r.y, r.width, r.height) +} + +pub fn replay(canvas: &SkCanvas, list: &DisplayList) { + // A `PopClip` with no matching `PushClip` would unbalance Skia's save + // stack, so track depth rather than trusting the list. + let mut clip_depth = 0usize; + + for item in &list.items { + match item { + DisplayItem::Rect { rect, color } => { + let mut paint = Paint::new(sk_color(*color), None); + paint.set_anti_alias(true); + canvas.draw_rect(sk_rect(rect), &paint); + } + + DisplayItem::Border { + rect, + widths, + colors, + } => { + // Four trapezoids, not four rectangles: adjacent sides meet at + // a mitre, and overlapping rectangles paint one colour over the + // other at every corner. + let (l, t, r, b) = (rect.x, rect.y, rect.right(), rect.bottom()); + let (il, it, ir, ib) = ( + l + widths.left, + t + widths.top, + r - widths.right, + b - widths.bottom, + ); + let sides: [(Rgba, [(f32, f32); 4]); 4] = [ + (colors[0], [(l, t), (r, t), (ir, it), (il, it)]), + (colors[1], [(r, t), (r, b), (ir, ib), (ir, it)]), + (colors[2], [(r, b), (l, b), (il, ib), (ir, ib)]), + (colors[3], [(l, b), (l, t), (il, it), (il, ib)]), + ]; + let side_widths = [widths.top, widths.right, widths.bottom, widths.left]; + for (i, (color, pts)) in sides.iter().enumerate() { + if side_widths[i] <= 0.0 || !color.is_visible() { + continue; + } + let mut builder = PathBuilder::new(); + builder.move_to(Point::new(pts[0].0, pts[0].1)); + for p in &pts[1..] { + builder.line_to(Point::new(p.0, p.1)); + } + builder.close(); + let mut paint = Paint::new(sk_color(*color), None); + paint.set_anti_alias(true); + canvas.draw_path(&builder.detach(), &paint); + } + } + + DisplayItem::Text { + origin, + glyphs, + font, + color, + } => { + let Some(typeface) = + FontMgr::new().new_from_data(font.data, Some(font.face_index as usize)) + else { + continue; + }; + let mut sk_font = Font::from_typeface(typeface, Some(font.size_px)); + sk_font.set_edging(skia_safe::font::Edging::AntiAlias); + sk_font.set_subpixel(true); + sk_font.set_hinting(FontHinting::None); + + let ids: Vec = glyphs.iter().map(|g| g.id).collect(); + let pos: Vec = glyphs + .iter() + .map(|g| Point::new(origin.0 + g.x, origin.1 + g.y)) + .collect(); + let mut paint = Paint::new(sk_color(*color), None); + paint.set_anti_alias(true); + canvas.draw_glyphs_at(&ids, &pos[..], Point::new(0.0, 0.0), &sk_font, &paint); + } + + DisplayItem::PushClip { rect } => { + canvas.save(); + clip_depth += 1; + canvas.clip_rect(sk_rect(rect), None, Some(true)); + } + DisplayItem::PopClip => { + if clip_depth > 0 { + canvas.restore(); + clip_depth -= 1; + } + } + } + } + + for _ in 0..clip_depth { + canvas.restore(); + } +} diff --git a/docs/RENDER.md b/docs/RENDER.md new file mode 100644 index 00000000..c58d7cff --- /dev/null +++ b/docs/RENDER.md @@ -0,0 +1,106 @@ +# render — box tree to pixels + +The half of a browser engine `browser_oxide` did not have. + +Before this module, Skia was a dependency used only by the `` element: +nothing painted a background, a border, or a text run belonging to a DOM +element. The CDP surface implemented 48 methods and `Page.captureScreenshot` +was not one of them — a CDP server that cannot screenshot is a CDP server with +no rasterizer behind it. + +## Pipeline + +``` +Dom + LayoutEngine ──► painter ──► DisplayList ──► raster ──► RGBA8 / PNG +``` + +Three stages, deliberately separable. + +| Stage | Module | What it does | +|---|---|---| +| Paint | `render::painter` | Walks the box tree in paint order, reads geometry and styles from the `LayoutEngine`, emits display items | +| List | `render::display_list` | Flat, ordered, paint-only. No references back into the DOM | +| Raster | `render::raster` | Replays onto an `SkCanvas` over a plain pixel buffer | + +**The display list in the middle is the point.** It is flat and holds no DOM +references, which is what makes it cacheable and diffable — repainting a scroll +should replay a diff, not re-run layout. Nothing exploits that yet; the +structure exists so that it can. + +## Entry points + +```rust +// The whole thing +let png = browser_oxide::render::render_to_png(&dom, &mut layout, 1280, 800); + +// Or from a Page +let png = page.screenshot_png(1280, 800); +let rgba = page.screenshot_rgba(1280, 800); + +// Or over CDP +// { "method": "Page.captureScreenshot", "params": { "clip": { "width": 1280, "height": 800 } } } +``` + +There is also `cargo run --release --example screenshot -- out.png`. + +## Two invariants worth knowing + +**Text carries positioned glyphs, never strings.** A display list holding +strings would have to reshape on every replay, and shaping is the expensive +half. `DisplayItem::Text` carries glyph ids and offsets, already shaped. + +**A `FontRef` carries the face's bytes, not a family name.** Glyph ids index +into *that* face. Handing a rasterizer a family name lets it resolve to a +different file — a different version, a bold variant — and the same ids then +draw different letters. Silent, and invisible in a screenshot. + +## Glyph rasterization matches `` + +`SkFont` is configured exactly as `canvas/canvas2d.rs` configures it: grayscale +antialiasing, subpixel positioning, no hinting. This is not cosmetic. Text +rasterization is a fingerprint surface, and a browser whose page text and +`` text were rasterized differently would be reporting two different +renderers. + +The painter also uses `LayoutEngine::styles()` rather than re-resolving styles, +and `layout::engine::collapse_white_space` rather than its own white-space +handling. Both for the same reason: if the painter formed a second opinion +about what layout decided, a box would be painted with a style that did not +size it, and it would look like a paint bug. + +## Borders are trapezoids + +Four trapezoids, not four rectangles. Adjacent sides meet at a mitre, and +overlapping rectangles paint one colour over the other at every corner — +visible the moment two sides have different colours. + +Used width, not specified width: a border whose `border-style` is `none` has a +used width of zero and layout reserved no space for it, so painting the +specified width would draw outside the box. + +## What is not here + +- **No compositing.** No layer tree, no damage tracking, no incremental + invalidation. A screenshot rasterizes the whole page every time. +- **No GPU surface.** CPU raster only. On a small page rasterization is ~90% of + the frame, so this is the first thing to change if anything needs to animate. +- **No stacking contexts or `z-index`.** Paint order is tree order. Getting + paint order wrong is the most common source of "looks subtly broken", so this + is a known gap rather than a discovered one. +- **No images, SVG, form controls, transforms, opacity, filters, blend modes, + border radius, box shadows or gradients.** +- **No hit-testing.** The display list is not queryable by point. + +## Accuracy + +Measured against Chrome 150 on a page of nested divs with backgrounds, borders +and text, at 900×700, tolerance 16 per channel: + +**1.66% of pixels differ, mean per-channel delta 0.80.** + +The residual is sub-pixel text positioning and glyph rasterization. Note the +engine renders with its bundled Liberation faces while the reference is +Chrome-on-Windows using Arial — Liberation is metric-compatible with Arial by +design, which is why the geometry agrees. + +The harness is `firstpixel --diff a.png b.png` in the app repo. From a63c7d7f1cc7e86d1aeb664e7a5992e5531b50a7 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Tue, 28 Jul 2026 10:06:12 -0700 Subject: [PATCH 4/6] =?UTF-8?q?feat(render):=20compositing=20=E2=80=94=20l?= =?UTF-8?q?ayer=20tree,=20cached=20surfaces,=20hit-testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth renderer stage. A scroll changes no layer's *content*, so a compositor that keeps each layer's rasterized surface answers the next frame by blitting at a new offset — no paint, no shaping, no Skia. Without it every scroll frame re-rasterizes the page, and on a CPU surface rasterization is ~90% of the frame. painter -> LayerTree -> Compositor -> RGBA8 A test asserts the claim directly: scrolling a real 2000px page rasterizes nothing and reuses every surface. ## Promotion Every layer costs a surface and the memory behind it, so the list of reasons is short on purpose: opacity below 1, a non-identity transform, and position:fixed. `fixed` is not an optimisation. A fixed element must *not* move when the page scrolls, and the only way to say that to a compositor that scrolls by translating surfaces is to give it a surface of its own. `will-change` is absent because the engine has no such property yet. When it arrives it belongs in `promotion_reason` and nowhere else. ## `transform` was never parsed PropertyId::Transform, CssValue::Transform and TransformFunction all existed. Nothing parsed into them, so `transform` was silently dropped on every page that used it. Compositing found this immediately, because a transform is one of the few reasons to promote a layer and no page ever produced one. Adds the parser: translate/translateX/translateY/translate3d, scale/scaleX/ scaleY/scale3d, rotate, skewX/skewY, matrix. `none` parses to an empty list rather than an error. 3D rotations and matrix3d are **rejected** rather than flattened to their 2x2 part. Approximating them would be wrong and invisible; rejecting the declaration is wrong and visible, and CSS says an unsupported function invalidates the whole declaration anyway. `transform-origin` defaults to the element's centre, so a rotation spins in place rather than swinging the element around the page origin — the classic version of this bug, and it has a test. ## Damage A layer is re-rasterized when its display list differs from the one it was last rasterized from — a *structural* comparison, not pointer identity. A relayout rebuilds the list from scratch even when nothing changed, and treating that as damage would defeat the cache entirely. That comparison is only cheap because the display list is flat and holds no DOM references, which is the reason it is structured that way. Layers entirely outside the viewport are culled before rasterization, and surfaces for layers that no longer exist are dropped, so a long-lived compositor over a changing document does not grow without bound. ## Coordinates A layer's display list holds *page* coordinates — that is what lets hit regions and damage comparison work without every layer rebasing them — while its surface is only as big as its own bounds. Rasterization translates by -bounds.origin and the compositor puts the surface back. Getting this wrong is why the first version drew a fixed layer's content entirely off its own surface. ## Hit-testing `paint_layered` returns hit regions alongside the tree, and `render::hit_test(®ions, x, y)` returns the topmost element at a point. Regions are in paint order so the last one containing the point wins, which is what document.elementFromPoint means. The display list holds no DOM references, so this is a parallel list. RENDERER_DESIGN.md says hit-testing should reuse the fragment tree; there is no fragment tree yet and this is the honest interim. ## Verification - 21 new tests. The load-bearing ones: scrolling a real page rasterizes nothing; a fixed layer does not move while a scrolling one does; an identical relayout is not damage; a colour change is; offscreen layers are culled; surfaces for removed layers are dropped; a rotation spins about the element centre; and compositing produces pixel-identical output to painting directly, because it is an optimisation and not a rendering mode. - One test deliberately asserts a full RGBA tuple rather than a single channel: checking only the red channel passes on white too, which is exactly the wrong thing to be reassured by. It caught a real bug. - 679 lib tests pass. The one failure, perf_ext's jitter distribution test, is pre-existing and reproduces on the base commit. - Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713. - clippy --workspace --all-targets -D warnings, cargo fmt --all, cargo doc with RUSTDOCFLAGS=-D warnings all clean. ## What this is not No GPU surface — compositing removes the repaint cost of scrolling but the final blit is still CPU. No damage *rectangles*: damage is per layer, so a one-pixel change re-rasterizes its whole layer. No scroll containers — only the document scrolls; `overflow: scroll` on an element clips but does not scroll. No stacking contexts or z-index; paint order is still tree order. Signed-off-by: Yury Fedoseev --- crates/browser_oxide/src/css_values/parse.rs | 140 +++++ crates/browser_oxide/src/render/composite.rs | 608 +++++++++++++++++++ crates/browser_oxide/src/render/layer.rs | 243 ++++++++ crates/browser_oxide/src/render/mod.rs | 225 ++++++- crates/browser_oxide/src/render/painter.rs | 311 ++++++++++ crates/browser_oxide/src/render/raster.rs | 17 +- docs/RENDER.md | 77 ++- 7 files changed, 1612 insertions(+), 9 deletions(-) create mode 100644 crates/browser_oxide/src/render/composite.rs create mode 100644 crates/browser_oxide/src/render/layer.rs diff --git a/crates/browser_oxide/src/css_values/parse.rs b/crates/browser_oxide/src/css_values/parse.rs index 66b3d4e6..921817c9 100644 --- a/crates/browser_oxide/src/css_values/parse.rs +++ b/crates/browser_oxide/src/css_values/parse.rs @@ -89,6 +89,7 @@ pub fn parse_property( "color" | "background-color" => parse_color(value_trimmed)?, "visibility" => parse_visibility(value_trimmed)?, "opacity" => parse_number(value_trimmed)?, + "transform" => parse_transform(value_trimmed)?, "z-index" => parse_z_index(value_trimmed)?, "content-visibility" => parse_content_visibility(value_trimmed)?, _ if name_lower.starts_with("--") => { @@ -1525,3 +1526,142 @@ fn parse_border_style(value: &[ComponentValue<'_>]) -> Result]) -> Result { + use crate::css_values::types::transform::TransformFunction as T; + + if let Some(ident) = single_ident(value) { + if ident.eq_ignore_ascii_case("none") { + return Ok(CssValue::Transform(Vec::new())); + } + } + + let mut out = Vec::new(); + for group in split_on_whitespace(value) { + for cv in &group { + let ComponentValue::Function(f) = cv else { + return Err(ValueError::InvalidValue("transform takes functions".into())); + }; + let args = split_on_comma(&f.arguments); + let name = f.name.to_ascii_lowercase(); + + let lp = |i: usize| -> Option { + args.get(i) + .and_then(|g| g.first().and_then(try_length_percentage)) + }; + let num = |i: usize| -> Option { + args.get(i) + .and_then(|g| g.first().and_then(try_number_value)) + }; + let ang = |i: usize| -> Option { + args.get(i).and_then(|g| g.first().and_then(try_angle)) + }; + let zero = LengthPercentage::Length(Length::Zero); + + let parsed = match name.as_str() { + "translate" => T::Translate( + lp(0).ok_or_else(|| bad("translate"))?, + lp(1).unwrap_or(zero), + ), + "translatex" => T::TranslateX(lp(0).ok_or_else(|| bad("translateX"))?), + "translatey" => T::TranslateY(lp(0).ok_or_else(|| bad("translateY"))?), + "scale" => { + let x = num(0).ok_or_else(|| bad("scale"))?; + // `scale(2)` is uniform; `scale(2, 3)` is not. + T::Scale(x, num(1).unwrap_or(x)) + } + "scalex" => T::ScaleX(num(0).ok_or_else(|| bad("scaleX"))?), + "scaley" => T::ScaleY(num(0).ok_or_else(|| bad("scaleY"))?), + "rotate" => T::Rotate(ang(0).ok_or_else(|| bad("rotate"))?), + "skewx" => T::SkewX(ang(0).ok_or_else(|| bad("skewX"))?), + "skewy" => T::SkewY(ang(0).ok_or_else(|| bad("skewY"))?), + "matrix" => { + let mut m = [0.0f64; 6]; + for (i, slot) in m.iter_mut().enumerate() { + *slot = num(i).ok_or_else(|| bad("matrix"))?; + } + T::Matrix(m[0], m[1], m[2], m[3], m[4], m[5]) + } + other => { + // An unknown or 3D function makes the whole declaration + // invalid, per CSS. Dropping just the one function would + // apply a transform the author did not write. + return Err(ValueError::InvalidValue(format!( + "unsupported transform function {other}()" + ))); + } + }; + out.push(parsed); + } + } + + if out.is_empty() { + return Err(ValueError::InvalidValue("empty transform".into())); + } + Ok(CssValue::Transform(out)) +} + +fn bad(name: &str) -> ValueError { + ValueError::InvalidValue(format!("invalid arguments to {name}()")) +} + +fn try_number_value(cv: &ComponentValue<'_>) -> Option { + match cv { + ComponentValue::Token(Token { + kind: TokenKind::Number { value, .. }, + .. + }) => Some(*value), + _ => None, + } +} + +fn try_angle(cv: &ComponentValue<'_>) -> Option { + match cv { + ComponentValue::Token(Token { + kind: TokenKind::Dimension { value, unit, .. }, + .. + }) => match unit.to_ascii_lowercase().as_str() { + "deg" => Some(Angle::Deg(*value)), + "rad" => Some(Angle::Rad(*value)), + "grad" => Some(Angle::Grad(*value)), + "turn" => Some(Angle::Turn(*value)), + _ => None, + }, + // `rotate(0)` is legal. + ComponentValue::Token(Token { + kind: TokenKind::Number { value, .. }, + .. + }) if *value == 0.0 => Some(Angle::Deg(0.0)), + _ => None, + } +} + +/// Split a function's arguments on commas. +fn split_on_comma<'a>(value: &[ComponentValue<'a>]) -> Vec>> { + let mut groups = Vec::new(); + let mut current: Vec> = Vec::new(); + for cv in value { + match cv { + ComponentValue::Token(Token { + kind: TokenKind::Comma, + .. + }) => groups.push(std::mem::take(&mut current)), + ComponentValue::Token(Token { + kind: TokenKind::Whitespace, + .. + }) => {} + other => current.push(other.clone()), + } + } + groups.push(current); + groups +} diff --git a/crates/browser_oxide/src/render/composite.rs b/crates/browser_oxide/src/render/composite.rs new file mode 100644 index 00000000..d43d06bc --- /dev/null +++ b/crates/browser_oxide/src/render/composite.rs @@ -0,0 +1,608 @@ +//! The compositor: rasterize layers once, then move them. +//! +//! This is what makes scrolling cheap. A scroll changes no layer's *content*, +//! so a compositor that keeps each layer's rasterized surface can answer the +//! next frame by blitting at a new offset — no paint, no shaping, no Skia. +//! Without it every scroll frame re-rasterizes the page, which on a CPU +//! surface is ~90% of the frame budget spent redrawing pixels that did not +//! change. +//! +//! Damage is decided by comparing each layer's display list against the one it +//! was last rasterized from. That comparison is only cheap because the display +//! list is flat and holds no DOM references — which is the reason it is +//! structured that way. + +use std::collections::HashMap; + +use skia_safe::{surfaces, AlphaType, ColorType, ImageInfo, Paint, SamplingOptions}; + +use super::display_list::{DisplayList, Rgba}; +use super::layer::{Layer, LayerId, LayerTree}; +use super::raster::Target; + +/// What the compositor did this frame. The numbers exist so "is compositing +/// actually helping" is answerable rather than assumed. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct CompositeStats { + pub layers: usize, + /// Layers rasterized this frame because their content changed. + pub rasterized: usize, + /// Layers served from a cached surface. + pub reused: usize, + /// Layers skipped because they fall outside the viewport. + pub culled: usize, +} + +impl CompositeStats { + pub fn summary(&self) -> String { + format!( + "{} layers: {} rasterized, {} reused, {} culled", + self.layers, self.rasterized, self.reused, self.culled + ) + } +} + +/// A rasterized layer, kept between frames. +struct CachedSurface { + /// The list this was rasterized from. Compared by content, not identity — + /// a relayout rebuilds the list even when nothing about it changed. + list: DisplayList, + width: u32, + height: u32, + /// Premultiplied RGBA8, the layer's own coordinate space. + pixels: Vec, +} + +/// Keeps rasterized layers across frames. +#[derive(Default)] +pub struct Compositor { + surfaces: HashMap, +} + +impl Compositor { + pub fn new() -> Self { + Self::default() + } + + /// Forget every cached surface. Call when the document changes wholesale. + pub fn invalidate_all(&mut self) { + self.surfaces.clear(); + } + + pub fn cached_layers(&self) -> usize { + self.surfaces.len() + } + + /// Composite `tree` into a viewport of `width` x `height`, scrolled to + /// `scroll_y`. + /// + /// Layers whose content is unchanged since the last call are reused rather + /// than rasterized — which is the point, and which is why scrolling is + /// nearly free. + pub fn composite( + &mut self, + tree: &LayerTree, + scroll_y: f32, + width: u32, + height: u32, + ) -> (Target, CompositeStats) { + let mut stats = CompositeStats { + layers: tree.layers.len(), + ..Default::default() + }; + let mut out = Target::new(width, height); + if width == 0 || height == 0 { + return (out, stats); + } + + let scroll_y = scroll_y.max(0.0); + // Drop surfaces for layers that no longer exist, so a long-lived + // compositor over a changing document does not grow without bound. + let live: std::collections::HashSet = tree.layers.iter().map(|l| l.id).collect(); + self.surfaces.retain(|id, _| live.contains(id)); + + let info = ImageInfo::new( + (width as i32, height as i32), + ColorType::RGBA8888, + AlphaType::Premul, + None, + ); + let row_bytes = width as usize * 4; + let Some(mut surface) = + surfaces::wrap_pixels(&info, &mut out.pixels, Some(row_bytes), None) + else { + return (out, stats); + }; + let canvas = surface.canvas(); + canvas.clear(skia_safe::Color::WHITE); + + for layer in &tree.layers { + // A layer's on-screen position: its own transform, then the scroll + // offset if it participates in scrolling. Fixed layers do not. + let offset_y = if layer.scrolls { -scroll_y } else { 0.0 }; + let placed = layer + .transform + .then(&super::layer::Transform2D::translate(0.0, offset_y)); + let on_screen = placed.transform_rect(&layer.bounds); + + // Cull anything entirely outside the viewport. On a long document + // this is most of it. + if on_screen.bottom() < 0.0 + || on_screen.y > height as f32 + || on_screen.right() < 0.0 + || on_screen.x > width as f32 + { + stats.culled += 1; + continue; + } + + let (w, h) = layer_surface_size(layer, width, height); + if w == 0 || h == 0 { + continue; + } + + // Reuse only if the surface is the right size *and* the drawing is + // unchanged. Size alone is not enough and content alone is not + // either — a resized viewport changes the root layer's surface + // without changing a single display item. + let reused = matches!( + self.surfaces.get(&layer.id), + Some(cached) + if cached.width == w + && cached.height == h + && lists_equal(&cached.list, &layer.display_list) + ); + if reused { + stats.reused += 1; + } else { + stats.rasterized += 1; + let mut target = Target::new(w, h); + // The list holds page coordinates; the surface starts at the + // layer's own origin. + target.paint_translated(&layer.display_list, -layer.bounds.x, -layer.bounds.y); + self.surfaces.insert( + layer.id, + CachedSurface { + list: layer.display_list.clone(), + width: w, + height: h, + pixels: target.pixels, + }, + ); + } + + let Some(cached) = self.surfaces.get(&layer.id) else { + continue; + }; + let Some(image) = image_from_pixels(cached) else { + continue; + }; + + canvas.save(); + canvas.concat(&skia_safe::Matrix::new_all( + placed.a, placed.c, placed.e, placed.b, placed.d, placed.f, 0.0, 0.0, 1.0, + )); + let mut paint = Paint::default(); + if layer.opacity < 1.0 { + paint.set_alpha_f(layer.opacity.clamp(0.0, 1.0)); + } + canvas.draw_image_with_sampling_options( + &image, + (layer.bounds.x, layer.bounds.y), + SamplingOptions::default(), + Some(&paint), + ); + canvas.restore(); + } + + drop(surface); + (out, stats) + } +} + +/// How big a surface a layer needs. +/// +/// The root layer is the viewport; everything else is its own bounds, clamped +/// so one absurd element cannot ask for a gigabyte. +fn layer_surface_size(layer: &Layer, viewport_w: u32, viewport_h: u32) -> (u32, u32) { + const MAX: f32 = 8192.0; + match layer.reason { + super::layer::LayerReason::Root => (viewport_w, viewport_h), + _ => ( + layer.bounds.width.clamp(0.0, MAX).ceil() as u32, + layer.bounds.height.clamp(0.0, MAX).ceil() as u32, + ), + } +} + +fn image_from_pixels(cached: &CachedSurface) -> Option { + let info = ImageInfo::new( + (cached.width as i32, cached.height as i32), + ColorType::RGBA8888, + AlphaType::Premul, + None, + ); + let row_bytes = cached.width as usize * 4; + let data = skia_safe::Data::new_copy(&cached.pixels); + skia_safe::images::raster_from_data(&info, data, row_bytes) +} + +/// Are two display lists the same drawing? +/// +/// Structural comparison, not pointer identity: a relayout rebuilds the list +/// from scratch even when the page did not change, and treating that as damage +/// would defeat the cache entirely. +fn lists_equal(a: &DisplayList, b: &DisplayList) -> bool { + if a.items.len() != b.items.len() { + return false; + } + a.items + .iter() + .zip(b.items.iter()) + .all(|(x, y)| item_eq(x, y)) +} + +fn item_eq(a: &super::display_list::DisplayItem, b: &super::display_list::DisplayItem) -> bool { + use super::display_list::DisplayItem as D; + match (a, b) { + ( + D::Rect { + rect: r1, + color: c1, + }, + D::Rect { + rect: r2, + color: c2, + }, + ) => r1 == r2 && c1 == c2, + ( + D::Border { + rect: r1, + widths: w1, + colors: k1, + }, + D::Border { + rect: r2, + widths: w2, + colors: k2, + }, + ) => r1 == r2 && w1 == w2 && k1 == k2, + ( + D::Text { + origin: o1, + glyphs: g1, + font: f1, + color: c1, + }, + D::Text { + origin: o2, + glyphs: g2, + font: f2, + color: c2, + }, + ) => { + o1 == o2 + && c1 == c2 + && (f1.size_px - f2.size_px).abs() < f32::EPSILON + && f1.face_index == f2.face_index + && std::ptr::eq(f1.data.as_ptr(), f2.data.as_ptr()) + && g1 == g2 + } + (D::PushClip { rect: r1 }, D::PushClip { rect: r2 }) => r1 == r2, + (D::PopClip, D::PopClip) => true, + _ => false, + } +} + +/// Blend a colour over white, for tests that want a plain expected value. +pub fn over_white(c: Rgba) -> (u8, u8, u8) { + let a = f32::from(c.a) / 255.0; + let blend = |v: u8| ((f32::from(v) * a) + 255.0 * (1.0 - a)).round() as u8; + (blend(c.r), blend(c.g), blend(c.b)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render::display_list::{DisplayItem, Rect}; + use crate::render::layer::{LayerReason, Transform2D}; + + fn solid_layer(id: u32, reason: LayerReason, rect: Rect, color: Rgba) -> Layer { + let mut list = DisplayList::default(); + list.push(DisplayItem::Rect { rect, color }); + Layer { + id: LayerId(id), + reason, + display_list: list, + bounds: rect, + transform: Transform2D::IDENTITY, + opacity: 1.0, + scrolls: true, + } + } + + fn pixel(t: &Target, x: u32, y: u32) -> (u8, u8, u8, u8) { + let rgba = t.to_rgba8(); + let i = ((y * t.width + x) * 4) as usize; + (rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]) + } + + const RED: Rgba = Rgba { + r: 255, + g: 0, + b: 0, + a: 255, + }; + + #[test] + fn a_layer_reaches_the_screen() { + let tree = LayerTree { + layers: vec![solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 50.0, + height: 50.0, + }, + RED, + )], + content_height: 50.0, + }; + let mut c = Compositor::new(); + let (target, stats) = c.composite(&tree, 0.0, 100, 100); + assert_eq!(pixel(&target, 10, 10), (255, 0, 0, 255)); + assert_eq!(stats.rasterized, 1); + } + + #[test] + fn scrolling_reuses_every_surface() { + // The claim compositing exists to make: a scroll rasterizes nothing. + let tree = LayerTree { + layers: vec![solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + }, + RED, + )], + content_height: 1000.0, + }; + let mut c = Compositor::new(); + let (_, first) = c.composite(&tree, 0.0, 100, 100); + assert_eq!(first.rasterized, 1); + + for scroll in [10.0, 20.0, 40.0] { + let (_, s) = c.composite(&tree, scroll, 100, 100); + assert_eq!( + (s.rasterized, s.reused), + (0, 1), + "scrolling to {scroll} must not rasterize anything" + ); + } + } + + #[test] + fn a_scrolled_layer_moves_and_a_fixed_one_does_not() { + let scrolling = solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 20.0, + }, + RED, + ); + let mut fixed = solid_layer( + 1, + LayerReason::Fixed, + Rect { + x: 0.0, + y: 80.0, + width: 100.0, + height: 20.0, + }, + Rgba { + r: 0, + g: 0, + b: 255, + a: 255, + }, + ); + fixed.scrolls = false; + + let tree = LayerTree { + layers: vec![scrolling, fixed], + content_height: 500.0, + }; + let mut c = Compositor::new(); + + let (before, _) = c.composite(&tree, 0.0, 100, 100); + assert_eq!( + pixel(&before, 50, 10), + (255, 0, 0, 255), + "red band at the top" + ); + assert_eq!( + pixel(&before, 50, 90), + (0, 0, 255, 255), + "blue band at the bottom" + ); + + let (after, _) = c.composite(&tree, 30.0, 100, 100); + // White, not red: comparing only the red channel would pass on white + // too, which is exactly the wrong thing to be reassured by. + assert_eq!( + pixel(&after, 50, 10), + (255, 255, 255, 255), + "the scrolling layer must have moved off the top" + ); + assert_eq!( + pixel(&after, 50, 90), + (0, 0, 255, 255), + "the fixed layer must not have moved" + ); + } + + #[test] + fn changed_content_is_rasterized_again() { + let make = |color: Rgba| LayerTree { + layers: vec![solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 50.0, + height: 50.0, + }, + color, + )], + content_height: 50.0, + }; + let mut c = Compositor::new(); + c.composite(&make(RED), 0.0, 100, 100); + let (target, stats) = c.composite( + &make(Rgba { + r: 0, + g: 255, + b: 0, + a: 255, + }), + 0.0, + 100, + 100, + ); + assert_eq!(stats.rasterized, 1, "a colour change is damage"); + assert_eq!(pixel(&target, 10, 10), (0, 255, 0, 255)); + } + + #[test] + fn an_identical_relayout_is_not_damage() { + // A relayout rebuilds the display list from scratch. Comparing by + // identity rather than content would treat that as damage and the + // cache would never hit. + let tree = || LayerTree { + layers: vec![solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 50.0, + height: 50.0, + }, + RED, + )], + content_height: 50.0, + }; + let mut c = Compositor::new(); + c.composite(&tree(), 0.0, 100, 100); + let (_, stats) = c.composite(&tree(), 0.0, 100, 100); + assert_eq!(stats.rasterized, 0); + assert_eq!(stats.reused, 1); + } + + #[test] + fn offscreen_layers_are_culled() { + let mut far = solid_layer( + 1, + LayerReason::Transform, + Rect { + x: 0.0, + y: 5000.0, + width: 50.0, + height: 50.0, + }, + RED, + ); + far.scrolls = true; + let tree = LayerTree { + layers: vec![far], + content_height: 6000.0, + }; + let mut c = Compositor::new(); + let (_, stats) = c.composite(&tree, 0.0, 100, 100); + assert_eq!(stats.culled, 1); + assert_eq!(stats.rasterized, 0, "a culled layer costs no rasterization"); + } + + #[test] + fn opacity_blends_without_repainting() { + let mut half = solid_layer( + 0, + LayerReason::Opacity, + Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + }, + RED, + ); + half.opacity = 0.5; + let tree = LayerTree { + layers: vec![half], + content_height: 100.0, + }; + let mut c = Compositor::new(); + let (target, _) = c.composite(&tree, 0.0, 100, 100); + let (r, g, b, _) = pixel(&target, 50, 50); + // Red at 50% over white. + assert!( + r > 200 && g > 100 && g < 160 && b > 100 && b < 160, + "got {r},{g},{b}" + ); + } + + #[test] + fn surfaces_for_removed_layers_are_dropped() { + let two = LayerTree { + layers: vec![ + solid_layer( + 0, + LayerReason::Root, + Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }, + RED, + ), + solid_layer( + 1, + LayerReason::Opacity, + Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }, + RED, + ), + ], + content_height: 10.0, + }; + let one = LayerTree { + layers: vec![two.layers[0].clone()], + content_height: 10.0, + }; + let mut c = Compositor::new(); + c.composite(&two, 0.0, 100, 100); + assert_eq!(c.cached_layers(), 2); + c.composite(&one, 0.0, 100, 100); + assert_eq!( + c.cached_layers(), + 1, + "a long-lived compositor must not accumulate dead surfaces" + ); + } +} diff --git a/crates/browser_oxide/src/render/layer.rs b/crates/browser_oxide/src/render/layer.rs new file mode 100644 index 00000000..ed2ba184 --- /dev/null +++ b/crates/browser_oxide/src/render/layer.rs @@ -0,0 +1,243 @@ +//! The layer tree. +//! +//! A layer is a subtree that can be rasterized once and then moved, faded or +//! transformed without repainting its contents. That is the whole point: +//! scrolling a page should translate a surface, not re-run paint, and an +//! opacity animation should blend a cached bitmap rather than re-rasterize +//! every frame underneath it. +//! +//! Promotion is not free — every layer is a separate surface and separate +//! memory — so a subtree is only promoted when it has a reason. + +use super::display_list::{DisplayList, Rect}; + +/// Identifies a layer within one tree. Stable across frames as long as the +/// document structure does not change, which is what lets the compositor reuse +/// a rasterized surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LayerId(pub u32); + +/// Why a subtree was promoted. +/// +/// Recorded rather than discarded because "why does this page have 400 layers" +/// is a question that gets asked, and because a reason that turns out not to +/// need a layer is the first thing to remove when memory is tight. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayerReason { + /// The document itself. Always present, always first. + Root, + /// `opacity` below 1 — the subtree must composite as a unit, or overlapping + /// children would show through each other. + Opacity, + /// A `transform` other than the identity. + Transform, + /// `position: fixed` — does not move when the page scrolls. + Fixed, +} + +/// A 2D affine transform, row-major: `[a c e; b d f]`. +/// +/// 3D transform functions are flattened to their 2D part. A real +/// implementation needs a 4x4 and a perspective-correct compositor; this is +/// enough for translate, scale, rotate and skew, which is what pages actually +/// animate. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Transform2D { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} + +impl Default for Transform2D { + fn default() -> Self { + Self::IDENTITY + } +} + +impl Transform2D { + pub const IDENTITY: Self = Self { + a: 1.0, + b: 0.0, + c: 0.0, + d: 1.0, + e: 0.0, + f: 0.0, + }; + + pub fn translate(x: f32, y: f32) -> Self { + Self { + e: x, + f: y, + ..Self::IDENTITY + } + } + + pub fn is_identity(&self) -> bool { + *self == Self::IDENTITY + } + + /// `self` then `other`. + pub fn then(&self, other: &Self) -> Self { + Self { + a: self.a * other.a + self.b * other.c, + b: self.a * other.b + self.b * other.d, + c: self.c * other.a + self.d * other.c, + d: self.c * other.b + self.d * other.d, + e: self.e * other.a + self.f * other.c + other.e, + f: self.e * other.b + self.f * other.d + other.f, + } + } + + pub fn apply(&self, x: f32, y: f32) -> (f32, f32) { + ( + self.a * x + self.c * y + self.e, + self.b * x + self.d * y + self.f, + ) + } + + /// Axis-aligned bounding box of a transformed rectangle. + pub fn transform_rect(&self, r: &Rect) -> Rect { + let corners = [ + self.apply(r.x, r.y), + self.apply(r.right(), r.y), + self.apply(r.right(), r.bottom()), + self.apply(r.x, r.bottom()), + ]; + let (mut min_x, mut min_y) = corners[0]; + let (mut max_x, mut max_y) = corners[0]; + for (x, y) in &corners[1..] { + min_x = min_x.min(*x); + min_y = min_y.min(*y); + max_x = max_x.max(*x); + max_y = max_y.max(*y); + } + Rect { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } + } +} + +/// One compositing layer. +#[derive(Debug, Clone)] +pub struct Layer { + pub id: LayerId, + pub reason: LayerReason, + /// Content, in the layer's own coordinate space. + pub display_list: DisplayList, + /// Union of the content's bounds, before transform. + pub bounds: Rect, + /// Applied at composite time, not bake time — changing it does not + /// invalidate the layer's rasterized surface. + pub transform: Transform2D, + pub opacity: f32, + /// Does this layer move with the page scroll? `position: fixed` says no, + /// and that is the whole reason it needs its own layer. + pub scrolls: bool, +} + +impl Layer { + pub fn is_trivial(&self) -> bool { + self.transform.is_identity() && self.opacity >= 1.0 + } +} + +/// Layers in paint order: back to front. +#[derive(Debug, Clone, Default)] +pub struct LayerTree { + pub layers: Vec, + /// Total document height, for clamping scroll. + pub content_height: f32, +} + +impl LayerTree { + pub fn len(&self) -> usize { + self.layers.len() + } + + pub fn is_empty(&self) -> bool { + self.layers.is_empty() + } + + /// Total display items across all layers. + pub fn item_count(&self) -> usize { + self.layers.iter().map(|l| l.display_list.len()).sum() + } + + pub fn summary(&self) -> String { + let fixed = self.layers.iter().filter(|l| !l.scrolls).count(); + format!( + "{} layers ({} fixed), {} items, content {}px", + self.layers.len(), + fixed, + self.item_count(), + self.content_height.round() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_composes_to_nothing() { + let t = Transform2D::IDENTITY; + assert!(t.then(&Transform2D::IDENTITY).is_identity()); + assert_eq!(t.apply(3.0, 4.0), (3.0, 4.0)); + } + + #[test] + fn translation_composes() { + let a = Transform2D::translate(10.0, 5.0); + let b = Transform2D::translate(1.0, 2.0); + assert_eq!(a.then(&b).apply(0.0, 0.0), (11.0, 7.0)); + } + + #[test] + fn a_rotated_rect_gets_a_bigger_bounding_box() { + // 45 degrees: a square's AABB grows by sqrt(2). + let s = std::f32::consts::FRAC_1_SQRT_2; + let rot = Transform2D { + a: s, + b: s, + c: -s, + d: s, + e: 0.0, + f: 0.0, + }; + let r = Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }; + let out = rot.transform_rect(&r); + assert!( + (out.width - 10.0 * std::f32::consts::SQRT_2).abs() < 0.01, + "got {}", + out.width + ); + } + + #[test] + fn scale_then_translate_is_not_translate_then_scale() { + // Composition order matters, and getting it backwards is the classic + // transform bug. + let scale = Transform2D { + a: 2.0, + d: 2.0, + ..Transform2D::IDENTITY + }; + let translate = Transform2D::translate(10.0, 0.0); + assert_ne!( + scale.then(&translate).apply(1.0, 0.0), + translate.then(&scale).apply(1.0, 0.0) + ); + } +} diff --git a/crates/browser_oxide/src/render/mod.rs b/crates/browser_oxide/src/render/mod.rs index 763d8f33..3164e941 100644 --- a/crates/browser_oxide/src/render/mod.rs +++ b/crates/browser_oxide/src/render/mod.rs @@ -15,16 +15,30 @@ //! no references back into the DOM, which is what makes it cacheable and //! diffable — repainting a scroll should replay a diff, not re-run layout. //! -//! What is not here: compositing, layer trees, damage tracking, incremental -//! invalidation, GPU surfaces, images, SVG and form controls. A screenshot -//! rasterizes the whole page every time. +//! A fourth stage sits alongside them for anything that moves: +//! +//! ```text +//! painter ──► LayerTree ──► Compositor ──► RGBA / PNG +//! ``` +//! +//! The compositor keeps each layer's rasterized surface between frames, so a +//! scroll translates surfaces rather than repainting. `render_to_png` does not +//! use it — a one-off screenshot has nothing to reuse — but anything animating +//! or scrolling should. +//! +//! What is not here: GPU surfaces, images, SVG, form controls, and stacking +//! contexts. Paint order is tree order. +pub mod composite; pub mod display_list; +pub mod layer; pub mod painter; pub mod raster; +pub use composite::{CompositeStats, Compositor}; pub use display_list::{DisplayItem, DisplayList, FontRef, Glyph, Rect, Rgba, SideOffsets}; -pub use painter::PaintStats; +pub use layer::{Layer, LayerId, LayerReason, LayerTree, Transform2D}; +pub use painter::{hit_test, HitRegion, PaintStats}; pub use raster::Target; use crate::dom::Dom; @@ -59,6 +73,20 @@ pub fn render_to_target( (target, stats) } +/// Build a layer tree, and the hit regions that go with it. +/// +/// Use this rather than [`build_display_list`] when the result will be +/// scrolled, animated or hit-tested. +pub fn build_layer_tree( + dom: &Dom, + layout: &mut LayoutEngine, + inline: &mut InlineLayout, + width: f32, + height: f32, +) -> (LayerTree, Vec, PaintStats) { + painter::paint_layered(dom, layout, inline, width, height) +} + /// Lay out and rasterize a document to PNG bytes. pub fn render_to_png( dom: &Dom, @@ -231,3 +259,192 @@ mod tests { assert!(!target.paint(&DisplayList::default())); } } + +/// Compositing over real documents. +/// +/// `composite::tests` covers the compositor against hand-built layer trees. +/// These check the seam: that CSS actually produces the layers it should, and +/// that scrolling a real page rasterizes nothing. +#[cfg(test)] +mod compositing_integration { + use super::*; + use crate::layout::Viewport; + + fn layers(html: &str, w: f32, h: f32) -> (LayerTree, Vec, PaintStats) { + let dom = crate::html_parser::parse_html(html); + let mut layout = LayoutEngine::new(Viewport::new(w, h)); + layout.compute(&dom); + let mut inline = InlineLayout::new(); + build_layer_tree(&dom, &mut layout, &mut inline, w, h) + } + + #[test] + fn an_ordinary_page_is_one_layer() { + // Promotion costs a surface each. A page with nothing to composite + // separately must not pay for any. + let (tree, _, _) = layers( + "
plain
\ +

more content

", + 400.0, + 300.0, + ); + assert_eq!(tree.layers.len(), 1); + assert_eq!(tree.layers[0].reason, LayerReason::Root); + } + + #[test] + fn opacity_promotes() { + let (tree, _, _) = layers( + "
x
", + 400.0, + 300.0, + ); + assert_eq!(tree.layers.len(), 2); + assert_eq!(tree.layers[1].reason, LayerReason::Opacity); + assert!((tree.layers[1].opacity - 0.5).abs() < 0.01); + } + + #[test] + fn a_transform_promotes_and_is_flattened_to_a_matrix() { + let (tree, _, _) = layers( + "
x
", + 400.0, + 300.0, + ); + assert_eq!(tree.layers.len(), 2); + assert_eq!(tree.layers[1].reason, LayerReason::Transform); + let t = tree.layers[1].transform; + assert!(!t.is_identity()); + assert!( + (t.e - 20.0).abs() < 0.01, + "expected a 20px x-translation, got {t:?}" + ); + } + + #[test] + fn a_rotation_spins_about_the_element_centre() { + // CSS `transform-origin` defaults to the centre. Rotating about the + // page origin instead swings the element across the screen, which is + // the classic version of this bug. + let (tree, _, _) = layers( + "
x
", + 400.0, + 300.0, + ); + let layer = &tree.layers[1]; + let moved = layer.transform.transform_rect(&layer.bounds); + // A square rotated about its own centre lands back on itself. + assert!( + (moved.x - layer.bounds.x).abs() < 1.0 && (moved.y - layer.bounds.y).abs() < 1.0, + "a square rotated about its centre should not move: {:?} -> {:?}", + layer.bounds, + moved + ); + } + + #[test] + fn position_fixed_promotes_and_does_not_scroll() { + let (tree, _, _) = layers( + "
x
", + 400.0, + 300.0, + ); + let fixed = tree + .layers + .iter() + .find(|l| l.reason == LayerReason::Fixed) + .expect("a fixed element must get its own layer"); + assert!( + !fixed.scrolls, + "a fixed layer must not move with the page scroll" + ); + } + + #[test] + fn scrolling_a_real_page_rasterizes_nothing() { + // The whole justification for compositing, end to end. + let html = "
\ +

a long page that scrolls

"; + let (tree, _, _) = layers(html, 400.0, 300.0); + + let mut compositor = Compositor::new(); + let (_, first) = compositor.composite(&tree, 0.0, 400, 300); + assert!(first.rasterized > 0, "the first frame must paint"); + + let (_, second) = compositor.composite(&tree, 120.0, 400, 300); + assert_eq!( + second.rasterized, + 0, + "scrolling must not rasterize: {}", + second.summary() + ); + assert!(second.reused > 0); + } + + #[test] + fn hit_testing_finds_the_topmost_element() { + let (_, regions, _) = layers( + "\ +
\ +
\ +
", + 400.0, + 300.0, + ); + let hit = hit_test(®ions, 25.0, 25.0).expect("something is under the point"); + // The inner div is painted after the outer, so it wins. + let dom = crate::html_parser::parse_html( + "\ +
\ +
\ +
", + ); + let inner = dom.get_element_by_id("inner"); + assert_eq!(Some(hit), inner, "the innermost element must win"); + } + + #[test] + fn hit_testing_outside_everything_finds_nothing() { + let (_, regions, _) = layers( + "
\ + ", + 400.0, + 300.0, + ); + assert_eq!(hit_test(®ions, 380.0, 290.0), None); + } + + #[test] + fn a_composited_page_looks_like_an_uncomposited_one() { + // Compositing is an optimisation, so it must not change the picture. + let html = "\ +
\ + "; + let dom = crate::html_parser::parse_html(html); + + let mut layout = LayoutEngine::new(Viewport::new(200.0, 200.0)); + let (direct, _) = render_to_target(&dom, &mut layout, 200, 200); + + let mut layout2 = LayoutEngine::new(Viewport::new(200.0, 200.0)); + layout2.compute(&dom); + let mut inline = InlineLayout::new(); + let (tree, _, _) = build_layer_tree(&dom, &mut layout2, &mut inline, 200.0, 200.0); + let (composited, _) = Compositor::new().composite(&tree, 0.0, 200, 200); + + let a = direct.to_rgba8(); + let b = composited.to_rgba8(); + let differing = a + .chunks_exact(4) + .zip(b.chunks_exact(4)) + .filter(|(p, q)| (0..3).any(|i| p[i].abs_diff(q[i]) > 2)) + .count(); + assert_eq!( + differing, 0, + "compositing changed {differing} pixels; it is an optimisation, not a rendering mode" + ); + } +} diff --git a/crates/browser_oxide/src/render/painter.rs b/crates/browser_oxide/src/render/painter.rs index 662ce338..754a0677 100644 --- a/crates/browser_oxide/src/render/painter.rs +++ b/crates/browser_oxide/src/render/painter.rs @@ -17,6 +17,7 @@ use crate::layout::resolve::{resolve_length, ResolveContext}; use crate::layout::LayoutEngine; use super::display_list::{DisplayItem, DisplayList, FontRef, Glyph, Rect, Rgba, SideOffsets}; +use super::layer::{Layer, LayerId, LayerReason, LayerTree, Transform2D}; /// What the painter saw on the way through. #[derive(Debug, Default, Clone, Copy)] @@ -25,6 +26,20 @@ pub struct PaintStats { pub text_runs: usize, /// Text runs whose family could not be resolved to any face. pub unresolved_fonts: usize, + /// Subtrees promoted to their own compositing layer. + pub layers: usize, +} + +/// A paintable area attributed back to the element that produced it. +/// +/// The display list itself holds no DOM references — that is what makes it +/// cacheable — so hit-testing needs this parallel list. `RENDERER_DESIGN.md` +/// says hit-testing should reuse the fragment tree; there is no fragment tree +/// yet, and this is the honest interim. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HitRegion { + pub rect: Rect, + pub node: NodeId, } /// Build a display list for `dom`, using geometry and styles already computed @@ -70,12 +85,97 @@ pub fn paint( ctx, list: &mut list, stats: &mut stats, + extra_layers: Vec::new(), + hit_regions: Vec::new(), + next_layer_id: 1, }; painter.node(NodeId::DOCUMENT); (list, stats) } +/// Paint into a layer tree rather than a single list. +/// +/// Subtrees are promoted when they have a reason to composite separately: +/// `opacity` below 1, a non-identity `transform`, or `position: fixed`. Every +/// promotion costs a surface, so the list of reasons is deliberately short. +pub fn paint_layered( + dom: &Dom, + layout: &mut LayoutEngine, + inline: &mut InlineLayout, + viewport_width: f32, + viewport_height: f32, +) -> (LayerTree, Vec, PaintStats) { + let mut root_list = DisplayList::default(); + let mut stats = PaintStats::default(); + + root_list.push(DisplayItem::Rect { + rect: Rect { + x: 0.0, + y: 0.0, + width: viewport_width, + height: viewport_height, + }, + color: Rgba::WHITE, + }); + + let ctx = ResolveContext { + font_size: 16.0, + root_font_size: 16.0, + viewport_w: viewport_width, + viewport_h: viewport_height, + }; + + let mut painter = Painter { + dom, + layout, + inline, + ctx, + list: &mut root_list, + stats: &mut stats, + extra_layers: Vec::new(), + hit_regions: Vec::new(), + next_layer_id: 1, + }; + painter.node(NodeId::DOCUMENT); + let extra = std::mem::take(&mut painter.extra_layers); + let hit_regions = std::mem::take(&mut painter.hit_regions); + + // The root document height, for scroll clamping. + let content_height = hit_regions + .iter() + .map(|r| r.rect.bottom()) + .fold(viewport_height, f32::max); + + let root_bounds = Rect { + x: 0.0, + y: 0.0, + width: viewport_width, + height: viewport_height, + }; + let mut layers = Vec::with_capacity(extra.len() + 1); + layers.push(Layer { + id: LayerId(0), + reason: LayerReason::Root, + display_list: root_list, + bounds: root_bounds, + transform: Transform2D::IDENTITY, + opacity: 1.0, + scrolls: true, + }); + layers.extend(extra); + stats.layers = layers.len(); + + ( + LayerTree { + layers, + content_height, + }, + hit_regions, + stats, + ) +} + struct Painter<'a> { dom: &'a Dom, layout: &'a mut LayoutEngine, @@ -83,6 +183,10 @@ struct Painter<'a> { ctx: ResolveContext, list: &'a mut DisplayList, stats: &'a mut PaintStats, + /// Layers split off from the main list, in the order they were found. + extra_layers: Vec, + hit_regions: Vec, + next_layer_id: u32, } impl Painter<'_> { @@ -105,6 +209,18 @@ impl Painter<'_> { self.stats.elements += 1; let rect = self.rect_of(node_id); + if !rect.is_empty() { + self.hit_regions.push(HitRegion { + rect, + node: node_id, + }); + } + + if let Some(reason) = promotion_reason(&style) { + self.promote(node_id, &style, rect, reason); + return; + } + self.box_decoration(&rect, &style); let clips = clips_overflow(&style); @@ -123,6 +239,56 @@ impl Painter<'_> { } } + /// Paint a subtree into its own layer. + /// + /// The layer's content is painted in the *page's* coordinate space and the + /// layer's bounds record where it sits, so the compositor can place it + /// without the painter having to rebase every coordinate. + fn promote(&mut self, node_id: NodeId, style: &ComputedStyle, rect: Rect, reason: LayerReason) { + let id = LayerId(self.next_layer_id); + self.next_layer_id += 1; + + let mut sub_list = DisplayList::default(); + let mut sub_extra = Vec::new(); + let mut sub_regions = Vec::new(); + std::mem::swap(self.list, &mut sub_list); + std::mem::swap(&mut self.extra_layers, &mut sub_extra); + std::mem::swap(&mut self.hit_regions, &mut sub_regions); + + self.box_decoration(&rect, style); + for child in self.dom.children(node_id) { + self.node(child); + } + + std::mem::swap(self.list, &mut sub_list); + std::mem::swap(&mut self.extra_layers, &mut sub_extra); + std::mem::swap(&mut self.hit_regions, &mut sub_regions); + // Regions from inside the layer still describe page coordinates, so + // hit-testing does not need to know about layers at all. + self.hit_regions.extend(sub_regions); + + let opacity = opacity_of(style); + let transform = transform_of(style, &rect, &self.ctx); + let scrolls = !matches!( + style.get(&PropertyId::Position), + Some(CssValue::Position( + crate::css_values::types::display::Position::Fixed + )) + ); + + self.extra_layers.push(Layer { + id, + reason, + display_list: sub_list, + bounds: rect, + transform, + opacity, + scrolls, + }); + // Layers found inside this one composite after it. + self.extra_layers.extend(sub_extra); + } + fn rect_of(&mut self, node_id: NodeId) -> Rect { let r = self.layout.get_bounding_rect(self.dom, node_id); Rect { @@ -332,3 +498,148 @@ fn clips_overflow(style: &ComputedStyle) -> bool { ) }) } + +/// Why this element needs its own compositing layer, if it does. +/// +/// Every promotion costs a surface and the memory behind it, so the list is +/// short on purpose. `will-change` is not here because the engine has no such +/// property yet; when it arrives it belongs in this function and nowhere else. +fn promotion_reason(style: &ComputedStyle) -> Option { + use crate::css_values::types::display::Position; + + if matches!( + style.get(&PropertyId::Position), + Some(CssValue::Position(Position::Fixed)) + ) { + // Not an optimisation: a fixed element must *not* move when the page + // scrolls, and the only way to express that to a compositor that + // scrolls by translating surfaces is to give it a surface of its own. + return Some(LayerReason::Fixed); + } + if opacity_of(style) < 1.0 { + // The subtree has to composite as a unit. Applying opacity per item + // would let overlapping children show through each other. + return Some(LayerReason::Opacity); + } + if let Some(CssValue::Transform(fns)) = style.get(&PropertyId::Transform) { + if !fns.is_empty() { + return Some(LayerReason::Transform); + } + } + None +} + +fn opacity_of(style: &ComputedStyle) -> f32 { + match style.get(&PropertyId::Opacity) { + Some(CssValue::Number(n)) => (*n as f32).clamp(0.0, 1.0), + _ => 1.0, + } +} + +/// Flatten a CSS transform list to a 2D affine matrix. +/// +/// Percentages resolve against the element's own border box, and the transform +/// origin is its centre — the CSS default. 3D functions contribute their 2D +/// part only; a correct implementation needs a 4x4 and a perspective-aware +/// compositor. +fn transform_of(style: &ComputedStyle, rect: &Rect, ctx: &ResolveContext) -> Transform2D { + use crate::css_values::types::transform::TransformFunction as T; + + let Some(CssValue::Transform(fns)) = style.get(&PropertyId::Transform) else { + return Transform2D::IDENTITY; + }; + if fns.is_empty() { + return Transform2D::IDENTITY; + } + + let px = |lp: &LengthPercentage, base: f32| -> f32 { + match lp { + LengthPercentage::Length(l) => resolve_length(l, ctx), + LengthPercentage::Percentage(p) => (*p as f32) / 100.0 * base, + _ => 0.0, + } + }; + + let mut m = Transform2D::IDENTITY; + for f in fns { + let step = match f { + T::Translate(x, y) => Transform2D::translate(px(x, rect.width), px(y, rect.height)), + T::TranslateX(x) => Transform2D::translate(px(x, rect.width), 0.0), + T::TranslateY(y) => Transform2D::translate(0.0, px(y, rect.height)), + T::Translate3d(x, y, _) => { + Transform2D::translate(px(x, rect.width), px(y, rect.height)) + } + T::Scale(x, y) => Transform2D { + a: *x as f32, + d: *y as f32, + ..Transform2D::IDENTITY + }, + T::ScaleX(x) => Transform2D { + a: *x as f32, + ..Transform2D::IDENTITY + }, + T::ScaleY(y) => Transform2D { + d: *y as f32, + ..Transform2D::IDENTITY + }, + T::Scale3d(x, y, _) => Transform2D { + a: *x as f32, + d: *y as f32, + ..Transform2D::IDENTITY + }, + T::Rotate(a) => { + let r = (a.to_degrees() as f32).to_radians(); + Transform2D { + a: r.cos(), + b: r.sin(), + c: -r.sin(), + d: r.cos(), + e: 0.0, + f: 0.0, + } + } + T::SkewX(a) => Transform2D { + c: (a.to_degrees() as f32).to_radians().tan(), + ..Transform2D::IDENTITY + }, + T::SkewY(a) => Transform2D { + b: (a.to_degrees() as f32).to_radians().tan(), + ..Transform2D::IDENTITY + }, + T::Matrix(a, b, c, d, e, f2) => Transform2D { + a: *a as f32, + b: *b as f32, + c: *c as f32, + d: *d as f32, + e: *e as f32, + f: *f2 as f32, + }, + // 3D rotations and matrices need a 4x4 to be meaningful. Treating + // them as identity is wrong but visible; silently approximating + // them with their top-left 2x2 would be wrong and invisible. + _ => Transform2D::IDENTITY, + }; + m = m.then(&step); + } + + // CSS `transform-origin` defaults to the element's centre. Translate to the + // origin, transform, translate back — otherwise a rotation swings the + // element around the page origin instead of spinning in place. + let cx = rect.x + rect.width / 2.0; + let cy = rect.y + rect.height / 2.0; + Transform2D::translate(-cx, -cy) + .then(&m) + .then(&Transform2D::translate(cx, cy)) +} + +/// The topmost element containing `point`, in page coordinates. +/// +/// Regions are recorded in paint order, so the last one containing the point is +/// the one on top — which is what `document.elementFromPoint` means. +pub fn hit_test(regions: &[HitRegion], x: f32, y: f32) -> Option { + regions + .iter() + .rev() + .find(|r| x >= r.rect.x && x < r.rect.right() && y >= r.rect.y && y < r.rect.bottom()) + .map(|r| r.node) +} diff --git a/crates/browser_oxide/src/render/raster.rs b/crates/browser_oxide/src/render/raster.rs index 5279068a..93334431 100644 --- a/crates/browser_oxide/src/render/raster.rs +++ b/crates/browser_oxide/src/render/raster.rs @@ -38,6 +38,17 @@ impl Target { /// Returns false if Skia would not wrap the buffer — a zero dimension, or a /// size that overflows. pub fn paint(&mut self, list: &DisplayList) -> bool { + self.paint_translated(list, 0.0, 0.0) + } + + /// Paint a display list shifted by `(dx, dy)`. + /// + /// Compositing layers need this. A layer's display list holds *page* + /// coordinates — that is what makes hit regions and damage comparison work + /// without every layer rebasing them — but its surface is only as big as + /// its own bounds. Translating by `-bounds.origin` puts the content where + /// the surface can hold it, and the compositor puts the surface back. + pub fn paint_translated(&mut self, list: &DisplayList, dx: f32, dy: f32) -> bool { if self.width == 0 || self.height == 0 { return false; } @@ -53,7 +64,11 @@ impl Target { else { return false; }; - replay(surface.canvas(), list); + let canvas = surface.canvas(); + if dx != 0.0 || dy != 0.0 { + canvas.translate((dx, dy)); + } + replay(canvas, list); true } diff --git a/docs/RENDER.md b/docs/RENDER.md index c58d7cff..f47a72df 100644 --- a/docs/RENDER.md +++ b/docs/RENDER.md @@ -27,6 +27,70 @@ references, which is what makes it cacheable and diffable — repainting a scrol should replay a diff, not re-run layout. Nothing exploits that yet; the structure exists so that it can. +## Compositing + +A fourth stage, for anything that moves: + +``` +painter ──► LayerTree ──► Compositor ──► RGBA8 +``` + +The compositor keeps each layer's rasterized surface between frames. A scroll +changes no layer's *content*, so the next frame is a blit at a new offset — no +paint, no shaping, no Skia. Without it every scroll frame re-rasterizes the +page, which on a CPU surface is ~90% of the frame budget spent redrawing pixels +that did not change. + +`render_to_png` does not use it — a one-off screenshot has nothing to reuse — +but anything scrolling or animating should. + +### Promotion + +Every layer is a separate surface and separate memory, so the list of reasons is +short on purpose: + +| Reason | Why | +|---|---| +| `Root` | The document. Always present, always first | +| `Opacity` | `opacity < 1`. The subtree must composite as a unit, or overlapping children show through each other | +| `Transform` | A non-identity `transform` | +| `Fixed` | `position: fixed`. Not an optimisation — a fixed element must *not* move when the page scrolls, and the only way to say that to a compositor that scrolls by translating surfaces is to give it a surface of its own | + +`will-change` is not here because the engine has no such property yet. When it +arrives it belongs in `promotion_reason` and nowhere else. + +### Damage + +A layer is re-rasterized when its display list differs from the one it was last +rasterized from — a **structural** comparison, not pointer identity. A relayout +rebuilds the list from scratch even when nothing changed, and treating that as +damage would defeat the cache entirely. + +That comparison is only cheap because the display list is flat and holds no DOM +references, which is the reason it is structured that way. + +Layers entirely outside the viewport are culled before rasterization, and +surfaces for layers that no longer exist are dropped, so a long-lived compositor +over a changing document does not grow without bound. + +### Coordinates + +A layer's display list holds **page** coordinates — that is what lets hit +regions and damage comparison work without every layer rebasing them — while its +surface is only as big as its own bounds. Rasterization translates by +`-bounds.origin`; the compositor puts the surface back. + +## Hit-testing + +`painter::paint_layered` returns hit regions alongside the layer tree, and +`render::hit_test(®ions, x, y)` returns the topmost element at a point in +page coordinates. Regions are recorded in paint order, so the last one +containing the point wins — which is what `document.elementFromPoint` means. + +The display list itself holds no DOM references, so this is a parallel list. +`RENDERER_DESIGN.md` says hit-testing should reuse the fragment tree; there is +no fragment tree yet, and this is the honest interim. + ## Entry points ```rust @@ -80,10 +144,15 @@ specified width would draw outside the box. ## What is not here -- **No compositing.** No layer tree, no damage tracking, no incremental - invalidation. A screenshot rasterizes the whole page every time. -- **No GPU surface.** CPU raster only. On a small page rasterization is ~90% of - the frame, so this is the first thing to change if anything needs to animate. +- **No GPU surface.** CPU raster only. Compositing removes the repaint cost of + scrolling, but the final blit is still CPU. +- **No damage *rectangles*.** Damage is per layer, not per region: a one-pixel + change re-rasterizes its whole layer. +- **No scroll containers.** Only the document scrolls; `overflow: scroll` on an + element clips but does not scroll. +- **No 3D transforms.** `rotate3d`, `matrix3d` and perspective are rejected at + parse time rather than silently flattened — wrong and visible beats wrong and + invisible. - **No stacking contexts or `z-index`.** Paint order is tree order. Getting paint order wrong is the most common source of "looks subtly broken", so this is a known gap rather than a discovered one. From 6d68d3045f0ad25f8f4f1c7956b51275d319c9f8 Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Tue, 28 Jul 2026 10:25:02 -0700 Subject: [PATCH 5/6] feat(page): Page::with_dom and Page::stylesheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Borrow the live DOM instead of consuming the Page to get it. The DOM lives inside the JS runtime's op state, which is where V8 needs it. The only way out was `take_dom(self)`, which consumes the whole Page — no use to a caller that wants to keep browsing, and the app layer wants exactly that: render the current document, then navigate again with the same tab. `stylesheets()` hands back the sheets a navigation collected, so a caller building its own LayoutEngine can pass them to `set_extra_css` rather than re-fetching them. Both are what a browser shell needs to drive rendering per frame. Used by browser_oxide_app's desktop browser to build a layer tree from the live document without tearing the page down. 679 lib tests pass (the one failure, perf_ext's jitter distribution test, is pre-existing and reproduces on the base commit). Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713. clippy and fmt clean. Signed-off-by: Yury Fedoseev --- crates/browser_oxide/src/page.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/browser_oxide/src/page.rs b/crates/browser_oxide/src/page.rs index 01cc7efa..57210cfd 100644 --- a/crates/browser_oxide/src/page.rs +++ b/crates/browser_oxide/src/page.rs @@ -1045,6 +1045,36 @@ impl Page { }) } + /// Run `f` against the live DOM. + /// + /// The DOM lives inside the JS runtime's op state, which is where V8 needs + /// it; `take_dom` consumes the whole `Page` to get it out, which is no use + /// to a caller that wants to keep browsing. This borrows instead. + /// + /// Returns `None` if the page has no document yet. + pub fn with_dom(&mut self, f: impl FnOnce(&crate::dom::Dom) -> T) -> Option { + let runtime = self.event_loop.runtime_mut().inner(); + let op_state = runtime.op_state(); + let state = op_state.borrow(); + let dom_state = state.borrow::(); + if dom_state.dom.is_empty() { + return None; + } + Some(f(&dom_state.dom)) + } + + /// The stylesheets the navigation collected, for a caller building its own + /// `LayoutEngine`. + pub fn stylesheets(&mut self) -> Vec { + let runtime = self.event_loop.runtime_mut().inner(); + let op_state = runtime.op_state(); + let state = op_state.borrow(); + state + .borrow::() + .stylesheets + .clone() + } + /// Borrow the DOM and layout engine out of the JS runtime's op state and /// run `f` against them. /// From 015f3ca626df6dc8bd914386a7e4e7bcd408f66a Mon Sep 17 00:00:00 2001 From: Yury Fedoseev Date: Tue, 28 Jul 2026 12:01:10 -0700 Subject: [PATCH 6/6] feat(dom): resolve a hit-tested node to the link a click should follow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render::hit_test` answers "which node is under this point". That is not the question a shell has to answer when the user clicks, and the gap between the two is why none of the five shells can follow a link yet. Two things are missing. The node under the pointer is almost never the `` — clicking a bold link lands on the ``, or the text node inside it, or an `` in a card whose whole surface is wrapped in an anchor — so finding the anchor is a walk up the tree, not a lookup. And `href` is whatever the author wrote: `/about`, `../x`, `#top`, `mailto:`, `javascript:void(0)`. Handing that to a navigation call unresolved is how a browser fetches `https://host/#top`. `dom::links::link_target` does the walk; `Page::link_for` does the resolution, and lives on `Page` because only the page knows the document's own URL. `classify` sorts an href into navigate / scroll / hand-to-the-OS / refuse. `javascript:` is the only refusal: running author script because the user clicked is exactly the capability an engine should not hand out by accident, and a shell that received the string would either execute it or show its source. It strips control characters before checking, because HTML's own URL parser strips them — `java\nscript:alert(1)` is a real historical bypass, and a check that skips the stripping is checking a different string than the one that would be navigated. `mailto:` and `tel:` are not refused; they are legitimate and they are the platform's business, so the classification is returned and the shell decides. An `` with no `href` returns None. HTML calls that a placeholder, it gets no link styling, and a shell treating it as a link would send the user to the current page on every click. Eleven tests. Nine cover the walk and the classification against a hand-built tree. The other two are the ones that matter: one lays a real document out, hit-tests points inside the painted link and asserts `/about` resolves to `https://example.com/about` against a `/docs/index.html` base — proving that the node `hit_test` actually returns for a glyph is a node the walk can reach the anchor from, which the unit tests cannot show. It scans regions rather than hard-coding a coordinate, so it does not encode this month's font metrics into a pass. The other pushes `href='javascript:alert(1)'` through the real parser, since an attribute arrives at this code having been entity-decoded and that is where several historical bypasses lived. Canvas fingerprint unchanged: len=17502 fnv1a=5b1d42ee9bdc9713. Signed-off-by: Yury Fedoseev --- crates/browser_oxide/src/dom/links.rs | 271 ++++++++++++++++++++++++++ crates/browser_oxide/src/dom/mod.rs | 2 + crates/browser_oxide/src/lib.rs | 2 +- crates/browser_oxide/src/page.rs | 142 ++++++++++++++ 4 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 crates/browser_oxide/src/dom/links.rs diff --git a/crates/browser_oxide/src/dom/links.rs b/crates/browser_oxide/src/dom/links.rs new file mode 100644 index 00000000..a10c9509 --- /dev/null +++ b/crates/browser_oxide/src/dom/links.rs @@ -0,0 +1,271 @@ +//! Turning a hit-tested node into something a browser can navigate to. +//! +//! [`crate::render::hit_test`] answers "which node is under this point". That +//! is not the same question a shell needs to answer when the user clicks, for +//! two reasons: +//! +//! * The node under the pointer is almost never the ``. Clicking a link +//! whose text is bold lands on the ``, or on the text node inside it, or +//! on an `` in a card whose whole surface is wrapped in an anchor. The +//! anchor is an ancestor, so finding it is a walk and not a lookup. +//! * `href` is whatever the author wrote — `/about`, `../x`, `#top`, +//! `mailto:`, `javascript:void(0)`. Handing that to a navigation call +//! unresolved is how a browser ends up fetching `https://host/#top`. +//! +//! This module answers the first question. [`crate::Page::link_for`] answers +//! the second, because resolution needs the document's own URL. + +use super::arena::Dom; +use super::node::{NodeData, NodeId}; + +/// What an anchor points at, before resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkTarget { + /// The `href` exactly as authored. + pub href: String, + /// The `` element itself, not the node that was hit. A shell that wants + /// to style the link it is about to follow needs this one. + pub anchor: NodeId, + /// `target="_blank"` and friends, lowercased. `None` when absent. + pub target: Option, + /// `download`, present or not. A shell that cannot download should say so + /// rather than navigating to the file and rendering bytes as text. + pub download: bool, +} + +/// Walk up from `node` to the nearest ``. +/// +/// Returns `None` for a node that is not inside a link, and — deliberately — +/// for an `` with no `href`. An anchor without an href is not a link; HTML +/// calls it a placeholder, it does not get link styling, and it must not +/// navigate. +/// +/// The walk is unbounded up the tree, but a DOM is a tree and `parent` strictly +/// decreases in depth, so it terminates at the document. It stops at the first +/// anchor found: nested anchors are invalid HTML and the parser does not +/// produce them, but if one arrived some other way, the innermost is the one a +/// browser follows. +pub fn link_target(dom: &Dom, node: NodeId) -> Option { + let mut current = Some(node); + while let Some(id) = current { + let n = dom.get(id)?; + if let NodeData::Element(e) = &n.data { + if e.name.local.eq_ignore_ascii_case("a") { + let attr = |want: &str| { + e.attrs + .iter() + .find(|a| a.name.local.eq_ignore_ascii_case(want)) + }; + if let Some(href) = attr("href") { + return Some(LinkTarget { + href: href.value.trim().to_string(), + anchor: id, + target: attr("target").map(|a| a.value.trim().to_lowercase()), + download: attr("download").is_some(), + }); + } + } + } + current = n.parent; + } + None +} + +/// Whether `href` is a scheme a page is allowed to send us to on a click. +/// +/// `javascript:` is refused outright. Running author script because the user +/// clicked a link is exactly the capability this browser exists to not hand +/// out casually, and a shell that navigated to it would either execute it or +/// display the source — both wrong. +/// +/// `mailto:`, `tel:` and the like are *not* refused here: they are legitimate, +/// but they are the platform's business rather than the engine's. The +/// classification is returned so a shell can hand them to the OS instead of +/// trying to render them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkKind { + /// Navigate. The engine can load this. + Navigable, + /// A fragment on the current document: scroll, do not reload. + SamePageFragment, + /// Hand to the operating system — `mailto:`, `tel:`, `sms:`. + External, + /// Refuse. `javascript:` only. + Refused, +} + +/// Classify a raw `href`. +/// +/// Case-insensitive on the scheme, because `MAILTO:` is a valid spelling and a +/// case-sensitive check here would be a way past the `javascript:` refusal. +/// Leading whitespace and embedded control characters are stripped first for +/// the same reason — `java\nscript:alert(1)` is a real historical bypass, and +/// HTML's own URL parser strips those characters, so a check that does not is +/// checking a different string than the one that would be navigated. +pub fn classify(href: &str) -> LinkKind { + let cleaned: String = href + .chars() + .filter(|c| !c.is_control() && *c != '\u{feff}') + .collect(); + let trimmed = cleaned.trim(); + + if trimmed.is_empty() { + // `href=""` means the current document, per RFC 3986. + return LinkKind::SamePageFragment; + } + if trimmed.starts_with('#') { + return LinkKind::SamePageFragment; + } + + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("javascript:") { + return LinkKind::Refused; + } + for scheme in ["mailto:", "tel:", "sms:", "callto:", "facetime:"] { + if lower.starts_with(scheme) { + return LinkKind::External; + } + } + LinkKind::Navigable +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dom::node::{Attribute, QualName}; + + /// `click here` plus a bare `

text

`. + fn fixture() -> (Dom, NodeId, NodeId, NodeId, NodeId) { + let mut dom = Dom::new(); + let doc = dom.document(); + + let anchor = dom.create_element( + QualName::new("a"), + vec![Attribute { + name: QualName::new("href"), + value: "/x".to_string(), + }], + ); + let bold = dom.create_element(QualName::new("b"), vec![]); + let italic = dom.create_element(QualName::new("i"), vec![]); + let deep = dom.create_text("here".to_string()); + let outside = dom.create_element(QualName::new("p"), vec![]); + + dom.append_child(doc, anchor); + dom.append_child(anchor, bold); + dom.append_child(bold, italic); + dom.append_child(italic, deep); + dom.append_child(doc, outside); + + (dom, anchor, bold, deep, outside) + } + + #[test] + fn a_deeply_nested_node_finds_its_anchor() { + // The case that matters: nobody ever clicks the itself. + let (dom, anchor, _, deep, _) = fixture(); + let found = link_target(&dom, deep).expect("the text is inside a link"); + assert_eq!(found.href, "/x"); + assert_eq!( + found.anchor, anchor, + "the anchor, not the node that was hit" + ); + } + + #[test] + fn the_anchor_itself_also_works() { + let (dom, anchor, _, _, _) = fixture(); + assert_eq!(link_target(&dom, anchor).unwrap().href, "/x"); + } + + #[test] + fn a_node_outside_any_link_finds_nothing() { + let (dom, _, _, _, outside) = fixture(); + assert!(link_target(&dom, outside).is_none()); + } + + #[test] + fn an_anchor_without_href_is_not_a_link() { + // HTML calls this a placeholder. It gets no link styling and it must + // not navigate — a shell treating it as a link would send the user to + // the current page on every click. + let mut dom = Dom::new(); + let doc = dom.document(); + let anchor = dom.create_element(QualName::new("a"), vec![]); + let text = dom.create_text("not a link".to_string()); + dom.append_child(doc, anchor); + dom.append_child(anchor, text); + + assert!(link_target(&dom, text).is_none()); + } + + #[test] + fn href_is_trimmed_and_extras_are_carried() { + let mut dom = Dom::new(); + let doc = dom.document(); + let anchor = dom.create_element( + QualName::new("a"), + vec![ + Attribute { + name: QualName::new("HREF"), + value: " /y ".to_string(), + }, + Attribute { + name: QualName::new("target"), + value: "_BLANK".to_string(), + }, + Attribute { + name: QualName::new("download"), + value: String::new(), + }, + ], + ); + dom.append_child(doc, anchor); + + let found = link_target(&dom, anchor).unwrap(); + assert_eq!(found.href, "/y", "authors leave whitespace in href"); + assert_eq!( + found.target.as_deref(), + Some("_blank"), + "attribute names and target values are both case-insensitive" + ); + assert!(found.download); + } + + #[test] + fn javascript_urls_are_refused_however_they_are_spelled() { + assert_eq!(classify("javascript:alert(1)"), LinkKind::Refused); + assert_eq!(classify("JavaScript:alert(1)"), LinkKind::Refused); + assert_eq!(classify(" javascript:alert(1)"), LinkKind::Refused); + // The historical bypass: HTML's URL parser strips control characters, + // so a check that does not strip them is checking a different string + // than the one that would be navigated. + assert_eq!(classify("java\nscript:alert(1)"), LinkKind::Refused); + assert_eq!(classify("java\tscript:alert(1)"), LinkKind::Refused); + assert_eq!(classify("\u{0}javascript:alert(1)"), LinkKind::Refused); + } + + #[test] + fn fragments_and_empty_hrefs_do_not_reload() { + assert_eq!(classify("#top"), LinkKind::SamePageFragment); + assert_eq!(classify(""), LinkKind::SamePageFragment); + assert_eq!(classify(" "), LinkKind::SamePageFragment); + } + + #[test] + fn platform_schemes_are_the_operating_systems_business() { + assert_eq!(classify("mailto:a@b.c"), LinkKind::External); + assert_eq!(classify("MAILTO:a@b.c"), LinkKind::External); + assert_eq!(classify("tel:+15551234"), LinkKind::External); + } + + #[test] + fn ordinary_links_are_navigable() { + assert_eq!(classify("/about"), LinkKind::Navigable); + assert_eq!(classify("../x"), LinkKind::Navigable); + assert_eq!(classify("https://example.com/"), LinkKind::Navigable); + // Not a scheme this refuses, and not one the OS wants either — the + // engine should try, and fail honestly if it cannot. + assert_eq!(classify("ftp://example.com/"), LinkKind::Navigable); + } +} diff --git a/crates/browser_oxide/src/dom/mod.rs b/crates/browser_oxide/src/dom/mod.rs index 34cca0a5..9fa4383b 100644 --- a/crates/browser_oxide/src/dom/mod.rs +++ b/crates/browser_oxide/src/dom/mod.rs @@ -10,8 +10,10 @@ pub mod arena; pub mod element; +pub mod links; pub mod node; pub use arena::Dom; pub use element::DomElement; +pub use links::{classify, link_target, LinkKind, LinkTarget}; pub use node::*; diff --git a/crates/browser_oxide/src/lib.rs b/crates/browser_oxide/src/lib.rs index f43317e4..24cd33d7 100644 --- a/crates/browser_oxide/src/lib.rs +++ b/crates/browser_oxide/src/lib.rs @@ -35,6 +35,6 @@ pub mod stylesheet_collector; pub use challenge::{ChallengeKind, ChallengeSolver, SolveOutcome}; pub use classify::{engine_classify, EngineClass}; -pub use page::{ChallengeVerdict, Page}; +pub use page::{ChallengeVerdict, Page, ResolvedLink}; pub use parallel::{NavigateResult, ParallelPager}; pub use pool::PagePool; diff --git a/crates/browser_oxide/src/page.rs b/crates/browser_oxide/src/page.rs index 57210cfd..883b6b73 100644 --- a/crates/browser_oxide/src/page.rs +++ b/crates/browser_oxide/src/page.rs @@ -478,6 +478,23 @@ impl Drop for Page { } } +/// A link a click resolved to. +/// +/// `url` is absolute for [`crate::dom::LinkKind::Navigable`] and left exactly +/// as authored for a fragment or an external scheme, because there is nothing +/// meaningful to resolve those against. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedLink { + pub url: String, + pub kind: crate::dom::LinkKind, + /// `target="_blank"` and friends, lowercased. + pub target: Option, + /// The `download` attribute was present. + pub download: bool, + /// The `` element, for a shell that wants to style what it is following. + pub anchor: crate::dom::NodeId, +} + impl Page { /// Simulate a user switching to another tab and then coming back. /// This defeats macro-behavioral heuristics that flag sessions @@ -1063,6 +1080,49 @@ impl Page { Some(f(&dom_state.dom)) } + /// Resolve a hit-tested node to the link a click on it should follow. + /// + /// This is the second half of link clicking; `render::hit_test` is the + /// first. It exists on `Page` rather than next to `link_target` because + /// resolution needs the document's own URL, which only the page knows — + /// and getting that wrong is how a browser fetches `https://host/#top`. + /// + /// Returns `None` when the node is not inside a link, when the anchor has + /// no `href`, and when the href is `javascript:`. That last one is a + /// refusal rather than an omission: running author script because the user + /// clicked is precisely the capability this engine should not hand out by + /// accident, and a shell that received the string would either execute it + /// or show its source, both wrong. + /// + /// A shell should treat the result as *what the user asked for*, not as + /// permission to navigate — `kind` says whether to load it, scroll to it, + /// or hand it to the operating system. + pub fn link_for(&mut self, node: crate::dom::NodeId) -> Option { + let target = self.with_dom(|dom| crate::dom::link_target(dom, node))??; + let kind = crate::dom::classify(&target.href); + if kind == crate::dom::LinkKind::Refused { + return None; + } + + // A fragment and an external scheme are both handed back unresolved: + // there is nothing to join a `#top` or a `mailto:` against, and + // running them through the URL joiner would only invent a base. + let url = match kind { + crate::dom::LinkKind::Navigable => { + Self::resolve_url(&self.url, &target.href).unwrap_or_else(|| target.href.clone()) + } + _ => target.href.clone(), + }; + + Some(ResolvedLink { + url, + kind, + target: target.target, + download: target.download, + anchor: target.anchor, + }) + } + /// The stylesheets the navigation collected, for a caller building its own /// `LayoutEngine`. pub fn stylesheets(&mut self) -> Vec { @@ -4559,6 +4619,88 @@ mod tests { ); } + /// The whole link-click chain, end to end: lay a real document out, hit-test + /// a point inside the link's text, and resolve what came back. + /// + /// The unit tests in `dom::links` cover the walk and the classification + /// against a hand-built tree. This one is the part they cannot prove — that + /// the node `hit_test` actually returns for a painted glyph is a node the + /// walk can get from to the anchor. Those are different claims, and the + /// second is the one a shell depends on. + #[tokio::test] + async fn a_click_on_link_text_resolves_to_an_absolute_url() { + use crate::layout::inline::InlineLayout; + use crate::layout::{LayoutEngine, Viewport}; + + let mut page = Page::from_html_with_url( + "

About us

", + "https://example.com/docs/index.html", + None::, + ) + .await + .unwrap(); + + let mut layout = LayoutEngine::new(Viewport::new(800.0, 600.0)); + let mut inline = InlineLayout::new(); + let regions = page + .with_dom(|dom| { + layout.compute(dom); + let (_, hit, _) = + crate::render::build_layer_tree(dom, &mut layout, &mut inline, 800.0, 600.0); + hit + }) + .expect("the document laid out"); + + assert!(!regions.is_empty(), "something was painted"); + + // Walk every region and find one that resolves to the link. Not a + // single hard-coded coordinate: that would encode this month's font + // metrics into the test and fail on a machine with different fonts, + // which is a false alarm about the thing being tested. + let mut resolved = None; + for region in ®ions { + let x = region.rect.x + region.rect.width / 2.0; + let y = region.rect.y + region.rect.height / 2.0; + if let Some(node) = crate::render::hit_test(®ions, x, y) { + if let Some(link) = page.link_for(node) { + resolved = Some(link); + break; + } + } + } + + let link = resolved.expect("a point inside the link resolved to it"); + assert_eq!( + link.url, "https://example.com/about", + "the href is root-relative, so it resolves against the origin and not against /docs/" + ); + assert_eq!(link.kind, crate::dom::LinkKind::Navigable); + assert!(!link.download); + } + + #[tokio::test] + async fn a_javascript_link_resolves_to_nothing() { + // The refusal has to survive the real parser, not just the classifier: + // an attribute value arrives here having been through HTML entity + // decoding, which is where several historical bypasses lived. + let mut page = Page::from_html_with_url( + "click", + "https://example.com/", + None::, + ) + .await + .unwrap(); + + let node = page + .with_dom(|dom| dom.get_element_by_id("x")) + .flatten() + .expect("the anchor parsed"); + assert!( + page.link_for(node).is_none(), + "an entity-encoded javascript: URL is still a javascript: URL" + ); + } + #[tokio::test] async fn page_from_html_basic() { let mut page = Page::from_html(