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.
This commit is contained in:
SGavrl committed 2026-07-01 15:58:27 +00:00
1 parent 399181b975
commit 4ca85e87d8
6 files changed
+228

No files matched your search

Generated
+67
View File
@@ -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"
+2
View File
@@ -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
+7
View File
@@ -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"
+7
View File
@@ -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
+95
View File
@@ -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<Pixmap> {
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(
"<html><body><div style=\"background-color: #ff0000; width: 100px; height: 80px\"></div></body></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(
"<html><body>\
<div style=\"background-color:red; width:100px; height:100px\">\
<div style=\"background-color:blue; width:50px; height:50px\"></div>\
</div>\
</body></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);
}
}
+50
View File
@@ -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<f32>) {