From 4ca85e87d8e186056f504fb6f6971291811459d0 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:58:27 +0000 Subject: [PATCH] render: paint backgrounds to a tiny-skia pixmap Adds a paint feature to obscura-render (tiny-skia: pure Rust, CPU, deterministic, no system deps). paint_dom lays out the DOM and fills each element's border box with its parsed background-color over a white page, in tree order so descendants paint over ancestors. Style parsing now also extracts background-color (hex #rgb/#rgba/ later enhancements. --- Cargo.lock | 67 +++++++++++++++++++++ Cargo.toml | 2 + crates/obscura-render/Cargo.toml | 7 +++ crates/obscura-render/src/lib.rs | 7 +++ crates/obscura-render/src/paint.rs | 95 ++++++++++++++++++++++++++++++ crates/obscura-render/src/style.rs | 50 ++++++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 crates/obscura-render/src/paint.rs diff --git a/Cargo.lock b/Cargo.lock index 31aff56..31d35ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.7" @@ -342,6 +348,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -796,6 +808,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1811,6 +1832,7 @@ version = "0.1.0" dependencies = [ "obscura-dom", "taffy", + "tiny-skia", ] [[package]] @@ -1987,6 +2009,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polyval" version = "0.6.2" @@ -2619,6 +2654,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "string_cache" version = "0.8.9" @@ -2844,6 +2885,32 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 419da0f..0019a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,8 @@ servo_arc = "0.4" cssparser = "0.34" # Layout engine for the optional render layer (obscura-render). Pure Rust. taffy = "0.7" +# CPU-only pure-Rust 2D rasterizer for the render layer's paint step. +tiny-skia = "0.12" tokio = { version = "1", features = ["full"] } tokio-tungstenite = "0.26" # No "cookies" feature: obscura manages cookies through its own CookieJar diff --git a/crates/obscura-render/Cargo.toml b/crates/obscura-render/Cargo.toml index 98f5e53..a3bc18f 100644 --- a/crates/obscura-render/Cargo.toml +++ b/crates/obscura-render/Cargo.toml @@ -6,9 +6,16 @@ license.workspace = true repository.workspace = true description = "Optional scoped render layer for Obscura: layout (taffy) and, with the paint feature, text and raster output for screenshots." +[features] +default = [] +# Rasterize the laid-out tree to a pixmap (tiny-skia). Off by default; the +# layout core (getBoundingClientRect etc.) works without it. +paint = ["dep:tiny-skia"] + [dependencies] taffy = { workspace = true } obscura-dom = { workspace = true } +tiny-skia = { workspace = true, optional = true } [lib] path = "src/lib.rs" diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 978f987..366ff5d 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -19,6 +19,11 @@ pub use style::compute_style; mod dom; pub use dom::{layout_dom, DomLayout}; +#[cfg(feature = "paint")] +mod paint; +#[cfg(feature = "paint")] +pub use paint::paint_dom; + /// An axis-aligned rectangle in CSS pixels, relative to the containing block. #[derive(Debug, Clone, Copy, Default, PartialEq)] pub struct Rect { @@ -58,6 +63,8 @@ pub struct LayoutStyle { pub margin: Edges, pub padding: Edges, pub border: Edges, + /// RGBA for the paint step. Parsed always (cheap), used only with `paint`. + pub background_color: Option<[u8; 4]>, } /// A node in the input layout tree. `text` is carried for the paint phase; it diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs new file mode 100644 index 0000000..7a2d763 --- /dev/null +++ b/crates/obscura-render/src/paint.rs @@ -0,0 +1,95 @@ +//! Paint: rasterize the laid-out DOM into a [`tiny_skia::Pixmap`]. +//! +//! Phase 5a. Fills each element's border box with its background color over a +//! white page. Text rendering arrives with the text step; borders and images +//! are later enhancements. Pure Rust (tiny-skia, CPU), deterministic, no system +//! dependencies, so a screenshot is reproducible across hosts. + +use obscura_dom::tree::DomTree; +use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Transform}; + +use crate::{compute_style, layout_dom}; + +/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or +/// None if the viewport is zero-sized. +pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option { + let (w, h) = (viewport.0 as u32, viewport.1 as u32); + let mut pixmap = Pixmap::new(w, h)?; + pixmap.fill(Color::WHITE); + + let laid = layout_dom(tree, viewport); + // Tree order so later elements paint over earlier ones (normal flow). + for nid in tree.descendants(tree.document()) { + let bg = match bg_color_for(tree, nid) { + Some(c) => c, + None => continue, + }; + let rect = match laid.rects.get(&nid) { + Some(r) => r, + None => continue, + }; + let box_rect = match Rect::from_xywh(rect.x, rect.y, rect.width, rect.height) { + Some(r) => r, + None => continue, + }; + let mut path = PathBuilder::new(); + path.push_rect(box_rect); + let path = match path.finish() { + Some(p) => p, + None => continue, + }; + let mut paint = Paint::default(); + paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); + paint.anti_alias = false; + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + Some(pixmap) +} + +fn bg_color_for(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<[u8; 4]> { + let node = tree.get_node(nid)?; + let name = node.as_element()?; + let style = compute_style(name.local.as_ref(), node.get_attribute("style")); + style.background_color +} + +#[cfg(test)] +mod tests { + use super::*; + use obscura_dom::tree_sink::parse_html; + + #[test] + fn paints_background_color() { + let tree = parse_html( + "
", + ); + let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap"); + assert_eq!(pixmap.width(), 200); + // The red div is laid out at the origin; sample inside it. + let inside = pixmap.pixel(10, 10).expect("pixel"); + assert!(inside.red() > 200, "expected red bg, got {:?}", inside); + assert!(inside.green() < 60); + assert!(inside.blue() < 60); + // Outside the 100x80 div the page background is white. + let outside = pixmap.pixel(150, 150).expect("pixel"); + assert_eq!(outside.red(), 255); + assert_eq!(outside.green(), 255); + assert_eq!(outside.blue(), 255); + } + + #[test] + fn later_element_paints_over_earlier() { + // A blue div nested inside a red one: both cover the origin, and blue + // (a descendant, later in tree order) paints over red. + let tree = parse_html( + "\ +
\ +
\ +
\ + ", + ); + let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap"); + let p = pixmap.pixel(5, 5).expect("pixel"); + assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p); + } +} diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index f245427..25103ea 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -71,10 +71,60 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)), "border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)), "border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)), + "background-color" | "background" => style.background_color = parse_color(value), _ => {} } } +/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex and a +/// small set of named colors. Returns None for anything else (transparent). +fn parse_color(value: &str) -> Option<[u8; 4]> { + let v = value.split_whitespace().next()?.to_ascii_lowercase(); + if let Some(h) = v.strip_prefix('#') { + let (r, g, b, a) = match h.len() { + 3 => ( + u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?, + u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?, + u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?, + 255u8, + ), + 4 => ( + u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?, + u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?, + u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?, + u8::from_str_radix(&h[3..4].repeat(2), 16).ok()?, + ), + 6 => ( + u8::from_str_radix(&h[0..2], 16).ok()?, + u8::from_str_radix(&h[2..4], 16).ok()?, + u8::from_str_radix(&h[4..6], 16).ok()?, + 255u8, + ), + 8 => ( + u8::from_str_radix(&h[0..2], 16).ok()?, + u8::from_str_radix(&h[2..4], 16).ok()?, + u8::from_str_radix(&h[4..6], 16).ok()?, + u8::from_str_radix(&h[6..8], 16).ok()?, + ), + _ => return None, + }; + return Some([r, g, b, a]); + } + // Named colors (the common few). + let named = match v.as_str() { + "transparent" => [0, 0, 0, 0], + "black" => [0, 0, 0, 255], + "white" => [255, 255, 255, 255], + "red" => [255, 0, 0, 255], + "green" => [0, 128, 0, 255], + "blue" => [0, 0, 255, 255], + "yellow" => [255, 255, 0, 255], + "gray" | "grey" => [128, 128, 128, 255], + _ => return None, + }; + Some(named) +} + enum Side { Top, Right, Bottom, Left } fn set_edge(edges: &mut Edges, side: Side, v: Option) {