plumb_core/snapshot.rs
1//! In-memory snapshot of a rendered page at a single viewport.
2//!
3//! The real `PlumbSnapshot` is populated by `plumb-cdp` via the Chromium
4//! DevTools Protocol. For the walking skeleton and tests, a canned
5//! constructor is available behind the `test-fake` feature.
6
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9
10use crate::report::{Rect, ViewportKey};
11
12/// A single post-layout text box within a node.
13///
14/// CDP returns one text box per rendered line fragment. Multi-line text
15/// generates multiple boxes for the same `dom_order`. The bounds are
16/// absolute viewport coordinates for that line fragment.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct TextBox {
19 /// `dom_order` of the owning element node.
20 pub dom_order: u64,
21 /// Absolute bounding rect of this text fragment.
22 pub bounds: Rect,
23 /// Starting character index (UTF-16 code units).
24 pub start: u32,
25 /// Character count (UTF-16 code units).
26 pub length: u32,
27}
28
29/// A single DOM node as the engine sees it.
30///
31/// This is intentionally a narrow view: just enough to identify the element
32/// and evaluate rules against its computed styles and geometry. The full
33/// DOM tree is reconstructable via the `parent`/`children` indices.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct SnapshotNode {
36 /// Stable document-order index.
37 pub dom_order: u64,
38 /// CSS selector path from the document root.
39 pub selector: String,
40 /// HTML tag name (lowercase).
41 pub tag: String,
42 /// Attributes as an ordered map — preserves parse order.
43 pub attrs: IndexMap<String, String>,
44 /// Computed styles relevant to any rule — ordered alphabetically on
45 /// insertion.
46 pub computed_styles: IndexMap<String, String>,
47 /// Bounding rect.
48 pub rect: Option<Rect>,
49 /// Parent `dom_order`, or `None` for the root.
50 pub parent: Option<u64>,
51 /// `dom_order` of direct children, in document order.
52 pub children: Vec<u64>,
53}
54
55/// The full snapshot at a single viewport.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PlumbSnapshot {
58 /// Source URL (may be a `plumb-fake://` URL in tests).
59 pub url: String,
60 /// The viewport this snapshot was taken at.
61 pub viewport: ViewportKey,
62 /// Viewport width in CSS pixels.
63 pub viewport_width: u32,
64 /// Viewport height in CSS pixels.
65 pub viewport_height: u32,
66 /// All nodes, ordered by `dom_order`.
67 pub nodes: Vec<SnapshotNode>,
68 /// Post-layout text boxes, sorted by `(dom_order, start)` for determinism.
69 pub text_boxes: Vec<TextBox>,
70}
71
72impl PlumbSnapshot {
73 /// Build an in-memory `hello, world` snapshot. Available only in tests
74 /// and in the `plumb-fake://` CLI code path.
75 ///
76 /// The shape is intentionally minimal: one `<html>` root with two
77 /// children (`<head>`, `<body>`). Rules that run against this snapshot
78 /// should produce deterministic output.
79 #[cfg(any(test, feature = "test-fake"))]
80 #[must_use]
81 pub fn canned() -> Self {
82 let mut html_attrs = IndexMap::new();
83 html_attrs.insert("lang".into(), "en".into());
84
85 let mut body_styles = IndexMap::new();
86 // Longhands match what `getComputedStyle` returns in the real
87 // CDP driver (PRD §10.3). `padding-top: 13px` is deliberately
88 // off-grid against the default `spacing.base_unit = 4`, so the
89 // walking-skeleton smoke path produces one violation from
90 // `spacing/grid-conformance`.
91 body_styles.insert("margin-top".into(), "0".into());
92 body_styles.insert("margin-right".into(), "0".into());
93 body_styles.insert("margin-bottom".into(), "0".into());
94 body_styles.insert("margin-left".into(), "0".into());
95 body_styles.insert("padding-top".into(), "13px".into());
96 body_styles.insert("padding-right".into(), "0".into());
97 body_styles.insert("padding-bottom".into(), "0".into());
98 body_styles.insert("padding-left".into(), "0".into());
99
100 Self {
101 url: "plumb-fake://hello".into(),
102 viewport: ViewportKey::new("desktop"),
103 viewport_width: 1280,
104 viewport_height: 800,
105 text_boxes: Vec::new(),
106 nodes: vec![
107 SnapshotNode {
108 dom_order: 0,
109 selector: "html".into(),
110 tag: "html".into(),
111 attrs: html_attrs,
112 computed_styles: IndexMap::new(),
113 rect: Some(Rect {
114 x: 0,
115 y: 0,
116 width: 1280,
117 height: 800,
118 }),
119 parent: None,
120 children: vec![1, 2],
121 },
122 SnapshotNode {
123 dom_order: 1,
124 selector: "html > head".into(),
125 tag: "head".into(),
126 attrs: IndexMap::new(),
127 computed_styles: IndexMap::new(),
128 rect: None,
129 parent: Some(0),
130 children: vec![],
131 },
132 SnapshotNode {
133 dom_order: 2,
134 selector: "html > body".into(),
135 tag: "body".into(),
136 attrs: IndexMap::new(),
137 computed_styles: body_styles,
138 rect: Some(Rect {
139 x: 0,
140 y: 0,
141 width: 1280,
142 height: 800,
143 }),
144 parent: Some(0),
145 children: vec![],
146 },
147 ],
148 }
149 }
150}
151
152/// A borrowed view over a snapshot, handed to rules during evaluation.
153///
154/// Keeping this a distinct type (rather than handing `&PlumbSnapshot`
155/// directly) lets us extend the engine with cross-cutting context (e.g.
156/// precomputed selector indexes) without breaking the [`crate::rules::Rule`] trait.
157#[derive(Debug)]
158pub struct SnapshotCtx<'a> {
159 snapshot: &'a PlumbSnapshot,
160 viewports: Vec<ViewportKey>,
161 rects_by_dom_order: IndexMap<u64, Rect>,
162 /// Maps `dom_order` → `(start_index, count)` into `snapshot.text_boxes`.
163 text_box_ranges: IndexMap<u64, (usize, usize)>,
164}
165
166impl<'a> SnapshotCtx<'a> {
167 /// Wrap a borrowed snapshot.
168 #[must_use]
169 pub fn new(snapshot: &'a PlumbSnapshot) -> Self {
170 Self::with_viewports(snapshot, [snapshot.viewport.clone()])
171 }
172
173 /// Wrap a borrowed snapshot with the full viewport set for this run.
174 ///
175 /// The caller-provided order is preserved.
176 #[must_use]
177 pub fn with_viewports(
178 snapshot: &'a PlumbSnapshot,
179 viewports: impl IntoIterator<Item = ViewportKey>,
180 ) -> Self {
181 Self {
182 snapshot,
183 viewports: viewports.into_iter().collect(),
184 rects_by_dom_order: rect_index(snapshot),
185 text_box_ranges: text_box_index(snapshot),
186 }
187 }
188
189 /// The underlying snapshot.
190 #[must_use]
191 pub fn snapshot(&self) -> &'a PlumbSnapshot {
192 self.snapshot
193 }
194
195 /// The viewports included in the current engine run.
196 #[must_use]
197 pub fn viewports(&self) -> &[ViewportKey] {
198 &self.viewports
199 }
200
201 /// Return the bounding rect for a node by document-order index.
202 #[must_use]
203 pub fn rect_for(&self, dom_order: u64) -> Option<Rect> {
204 self.rects_by_dom_order.get(&dom_order).copied()
205 }
206
207 /// Return text boxes for a node by document-order index.
208 ///
209 /// Returns an empty slice when no text boxes exist for `dom_order`.
210 #[must_use]
211 pub fn text_boxes_for(&self, dom_order: u64) -> &[TextBox] {
212 match self.text_box_ranges.get(&dom_order) {
213 Some(&(start, count)) => &self.snapshot.text_boxes[start..start + count],
214 None => &[],
215 }
216 }
217
218 /// Iterate nodes in document order.
219 pub fn nodes(&self) -> impl Iterator<Item = &SnapshotNode> {
220 self.snapshot.nodes.iter()
221 }
222}
223
224fn rect_index(snapshot: &PlumbSnapshot) -> IndexMap<u64, Rect> {
225 snapshot
226 .nodes
227 .iter()
228 .filter_map(|node| node.rect.map(|rect| (node.dom_order, rect)))
229 .collect()
230}
231
232/// Build a `(dom_order → (start_idx, count))` index over text boxes.
233///
234/// Requires `text_boxes` to be sorted by `(dom_order, start)`.
235fn text_box_index(snapshot: &PlumbSnapshot) -> IndexMap<u64, (usize, usize)> {
236 debug_assert!(
237 snapshot
238 .text_boxes
239 .windows(2)
240 .all(|w| { (w[0].dom_order, w[0].start) <= (w[1].dom_order, w[1].start) }),
241 "text_boxes must be sorted by (dom_order, start)"
242 );
243 let mut index: IndexMap<u64, (usize, usize)> = IndexMap::new();
244 let boxes = &snapshot.text_boxes;
245 let mut i = 0;
246 while i < boxes.len() {
247 let dom_order = boxes[i].dom_order;
248 let start = i;
249 while i < boxes.len() && boxes[i].dom_order == dom_order {
250 i += 1;
251 }
252 index.insert(dom_order, (start, i - start));
253 }
254 index
255}