render: scaffold obscura-render crate with taffy layout core

New optional crate obscura-render, the scoped render layer for Obscura.
Phase 1 adds the layout core: a LayoutNode tree plus a viewport is laid
out with taffy (pure Rust) and border-box geometry is returned per node,
mirroring the input tree.

The crate is a workspace member but not yet a dependency of any other
crate, so the default scraping build is unchanged. Geometry-from-DOM,
JS integration, text, paint, and screenshot capture land in later
phases.

Unit tests cover block stacking, flex row layout, and padding.
This commit is contained in:
SGavrl
2026-06-30 16:20:10 +00:00
parent 9d0e7d63dc
commit 1971380da8
4 changed files with 299 additions and 0 deletions

40
Cargo.lock generated
View File

@@ -129,6 +129,12 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "async-compression"
version = "0.4.42"
@@ -1055,6 +1061,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "grid"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36119f3a540b086b4e436bb2b588cf98a68863470e0e880f4d0842f112a3183a"
[[package]]
name = "gzip-header"
version = "1.1.0"
@@ -1792,6 +1804,13 @@ dependencies = [
"wreq-util",
]
[[package]]
name = "obscura-render"
version = "0.1.0"
dependencies = [
"taffy",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2543,6 +2562,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.2"
@@ -2704,6 +2732,18 @@ dependencies = [
"syn",
]
[[package]]
name = "taffy"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab4f4d046dd956a47a7e1a2947083d7ac3e6aa3cfaaead36173ceaa5ab11878c"
dependencies = [
"arrayvec",
"grid",
"serde",
"slotmap",
]
[[package]]
name = "tap"
version = "1.0.1"

View File

@@ -7,6 +7,7 @@ members = [
"crates/obscura-cdp",
"crates/obscura-js",
"crates/obscura-mcp",
"crates/obscura-render",
"crates/obscura-cli",
"crates/obscura",
]
@@ -24,12 +25,15 @@ obscura-browser = { path = "crates/obscura-browser" }
obscura-cdp = { path = "crates/obscura-cdp" }
obscura-js = { path = "crates/obscura-js" }
obscura-mcp = { path = "crates/obscura-mcp" }
obscura-render = { path = "crates/obscura-render" }
html5ever = "0.29"
markup5ever = "0.14"
selectors = "0.26"
servo_arc = "0.4"
cssparser = "0.34"
# Layout engine for the optional render layer (obscura-render). Pure Rust.
taffy = "0.7"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.26"
# No "cookies" feature: obscura manages cookies through its own CookieJar

View File

@@ -0,0 +1,13 @@
[package]
name = "obscura-render"
version.workspace = true
edition.workspace = true
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."
[dependencies]
taffy = { workspace = true }
[lib]
path = "src/lib.rs"

View File

@@ -0,0 +1,242 @@
//! obscura-render: the optional scoped render layer for Obscura.
//!
//! Obscura's default build has no layout or paint engine, which is the source
//! of its speed and low memory. This crate adds a render layer behind a feature
//! flag: real CSS box geometry (so getBoundingClientRect, elementFromPoint, and
//! IntersectionObserver return true values) and, with the paint feature,
//! rasterized PNG screenshots.
//!
//! Phase 1 (this file): the layout core. A `LayoutNode` tree plus a viewport is
//! laid out with taffy and the resulting border-box geometry is returned per
//! node. Later phases build the `LayoutNode` tree from the live DOM + computed
//! styles, feed geometry back to JS, and add text + paint.
use taffy::prelude::*;
/// An axis-aligned rectangle in CSS pixels, relative to the containing block.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Rect {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
/// Per-edge box values (margin / padding / border) in CSS pixels.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Edges {
pub top: f32,
pub right: f32,
pub bottom: f32,
pub left: f32,
}
/// The display modes obscura-render cares about for phase 1. Inline text layout
/// arrives with the text/paint phase and is folded in then.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum Display {
#[default]
Block,
Flex,
#[allow(dead_code)]
None,
}
/// The subset of CSS that influences box layout. Expanded in later phases.
#[derive(Debug, Clone, Default)]
pub struct LayoutStyle {
pub display: Display,
/// Content-box size. None means auto.
pub width: Option<f32>,
pub height: Option<f32>,
pub margin: Edges,
pub padding: Edges,
pub border: Edges,
}
/// A node in the input layout tree. `text` is carried for the paint phase; it
/// does not affect layout in phase 1 (inline/text layout comes later).
#[derive(Debug, Clone)]
pub struct LayoutNode {
pub style: LayoutStyle,
#[allow(dead_code)]
pub text: Option<String>,
pub children: Vec<LayoutNode>,
}
impl LayoutNode {
pub fn leaf(style: LayoutStyle) -> Self {
LayoutNode { style, text: None, children: Vec::new() }
}
}
/// Computed geometry for one node and its subtree.
#[derive(Debug, Clone, Default)]
pub struct NodeRect {
/// Border box, in viewport coordinates.
pub border_box: Rect,
pub children: Vec<NodeRect>,
}
/// Lay out `root` within a `viewport` (width, height) in CSS pixels and return
/// the border-box geometry per node, mirroring the input tree.
pub fn layout(root: &LayoutNode, viewport: (f32, f32)) -> NodeRect {
let mut tree: TaffyTree = TaffyTree::new();
let root_id = build_node(&mut tree, root);
let _ = tree.compute_layout(
root_id,
taffy::Size {
width: taffy::AvailableSpace::Definite(viewport.0),
height: taffy::AvailableSpace::Definite(viewport.1),
},
);
read_node(&tree, root_id)
}
fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId {
let style = to_taffy_style(&node.style);
if node.children.is_empty() {
tree.new_leaf(style).expect("taffy new_leaf")
} else {
let child_ids: Vec<NodeId> =
node.children.iter().map(|c| build_node(tree, c)).collect();
tree.new_with_children(style, &child_ids).expect("taffy new_with_children")
}
}
fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
let layout = tree.layout(id).expect("taffy layout");
NodeRect {
border_box: Rect {
x: layout.location.x,
y: layout.location.y,
width: layout.size.width,
height: layout.size.height,
},
children: tree
.children(id)
.unwrap_or_default()
.iter()
.map(|&cid| read_node(tree, cid))
.collect(),
}
}
fn to_taffy_style(style: &LayoutStyle) -> Style {
let mut s = Style::DEFAULT;
s.display = match style.display {
Display::Block => taffy::style::Display::Block,
Display::Flex => taffy::style::Display::Flex,
Display::None => taffy::style::Display::None,
};
s.size = taffy::Size {
width: dimension(style.width),
height: dimension(style.height),
};
s.margin = rect_auto(style.margin);
s.padding = rect_lp(style.padding);
s.border = rect_lp(style.border);
s
}
fn dimension(v: Option<f32>) -> taffy::style::Dimension {
match v {
Some(px) => taffy::style::Dimension::Length(px),
None => taffy::style::Dimension::Auto,
}
}
fn rect_lp(e: Edges) -> taffy::Rect<taffy::style::LengthPercentage> {
taffy::Rect {
top: taffy::style::LengthPercentage::Length(e.top),
right: taffy::style::LengthPercentage::Length(e.right),
bottom: taffy::style::LengthPercentage::Length(e.bottom),
left: taffy::style::LengthPercentage::Length(e.left),
}
}
fn rect_auto(e: Edges) -> taffy::Rect<taffy::style::LengthPercentageAuto> {
taffy::Rect {
top: taffy::style::LengthPercentageAuto::Length(e.top),
right: taffy::style::LengthPercentageAuto::Length(e.right),
bottom: taffy::style::LengthPercentageAuto::Length(e.bottom),
left: taffy::style::LengthPercentageAuto::Length(e.left),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn styled(display: Display, w: Option<f32>, h: Option<f32>) -> LayoutStyle {
LayoutStyle { display, width: w, height: h, ..Default::default() }
}
#[test]
fn block_children_stack_vertically() {
// A 1000px-wide viewport, two fixed-size block children: they should
// stack top-to-bottom at the expected y offsets.
let root = LayoutNode {
style: styled(Display::Block, Some(1000.0), None),
text: None,
children: vec![
LayoutNode::leaf(styled(Display::Block, Some(1000.0), Some(50.0))),
LayoutNode::leaf(styled(Display::Block, Some(1000.0), Some(30.0))),
],
};
let out = layout(&root, (1000.0, 800.0));
assert_eq!(out.border_box.width, 1000.0);
assert_eq!(out.children.len(), 2);
assert_eq!(out.children[0].border_box.height, 50.0);
assert_eq!(out.children[1].border_box.height, 30.0);
// Second block begins where the first ended.
assert!(
(out.children[1].border_box.y - out.children[0].border_box.y).abs()
>= out.children[0].border_box.height - 0.01,
"blocks should stack: c0.y={} c1.y={}",
out.children[0].border_box.y,
out.children[1].border_box.y
);
}
#[test]
fn flex_row_lays_out_horizontally() {
let root = LayoutNode {
style: LayoutStyle { display: Display::Flex, width: Some(600.0), height: Some(100.0), ..Default::default() },
text: None,
children: vec![
LayoutNode::leaf(styled(Display::Block, Some(200.0), Some(100.0))),
LayoutNode::leaf(styled(Display::Block, Some(200.0), Some(100.0))),
],
};
let out = layout(&root, (600.0, 400.0));
assert_eq!(out.border_box.width, 600.0);
assert_eq!(out.children.len(), 2);
// In a row the second child is to the right of the first.
assert!(
out.children[1].border_box.x > out.children[0].border_box.x,
"flex row should place children horizontally: c0.x={} c1.x={}",
out.children[0].border_box.x,
out.children[1].border_box.x
);
}
#[test]
fn padding_insets_the_box() {
let root = LayoutNode {
style: LayoutStyle {
display: Display::Block,
width: Some(100.0),
height: Some(100.0),
padding: Edges { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 },
..Default::default()
},
text: None,
children: vec![],
};
let out = layout(&root, (1000.0, 800.0));
// Border box still matches the requested size; padding affects content,
// not the outer border box here.
assert_eq!(out.border_box.width, 100.0);
}
}