diff --git a/Cargo.lock b/Cargo.lock index 545f907dc8..ad7eade98f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -482,6 +482,7 @@ dependencies = [ "async-trait", "clap", "criterion", + "csscascade", "flatbuffers", "futures", "gl", @@ -499,6 +500,7 @@ dependencies = [ "serde", "serde_json", "skia-safe", + "stylo", "taffy", "tokio", "unicode-segmentation", diff --git a/crates/csscascade/README.md b/crates/csscascade/README.md index 9f936e8773..e4cdc3b947 100644 --- a/crates/csscascade/README.md +++ b/crates/csscascade/README.md @@ -1,263 +1,136 @@ # csscascade -A modern, Rust-native **CSS Cascade & Style Resolution Engine** designed for building browser‑like rendering pipelines. `csscascade` takes an HTML (and later SVG) DOM tree and produces a **style-resolved static tree** ready for layout and painting. +A Rust CSS cascade & style resolution engine for building non-browser rendering pipelines. -Today the crate is powered by [**Stylo**](https://github.com/servo/stylo) — Servo’s production CSS engine. Stylo handles parsing, selectors, specificity, and computed values (and compiles cleanly for `wasm32-unknown-emscripten`), while `csscascade` focuses on DOM adapters, HTML/SVG attribute normalization, font parsing/selection, layout hand-off, and everything else outside the CSS engine’s scope. +Given an HTML DOM tree and CSS, `csscascade` produces a **style-resolved static tree** — every node carrying its fully computed CSS — ready for layout and painting. -This crate implements the hardest and most fundamental part of a rendering engine: the transformation from loosely-typed DOM nodes + CSS rules into a **fully computed, normalized, strongly-typed tree**. +Powered by [Stylo](https://github.com/servo/stylo) (Servo/Firefox's CSS engine). Stylo is the only production-grade, embeddable CSS engine available in Rust; reimplementing the cascade (selectors, specificity, inheritance, shorthand expansion, `!important`, `var()`, `@media`, ...) from scratch is not viable. -Future support for SVG is planned (HTML + SVG share >90% of style logic). +## Pipeline ---- - -### “Isn’t a full CSS engine overkill?” - -Not really. Stylo stays surprisingly lean—our builds land around ~1.5 MB when compiled with `wasm-unknown-unknown` and roughly ~2.5 MB when targeting `wasm32-unknown-emscripten`. More importantly, reproducing the entirety of CSS3 (selectors, cascade rules, media queries, shorthand expansion, inheritance, etc.) is phenomenally difficult; sooner or later any serious renderer ends up needing a browser-grade engine. Stylo already solves that problem with production-ready accuracy, so we embrace it and focus on the rest of the pipeline. - ---- - -## What this crate does - -### ✔ 1. Parse and walk an HTML/XML tree - -Accepts a DOM-like tree (any structure implementing the crate's DOM traits). - -### ✔ 2. Perform full CSS cascade - -Backed by Stylo’s battle-tested cascade implementation: - -- Selector matching -- Specificity and importance resolution -- Inheritance -- Initial values -- Presentation attribute mapping (SVG-ready) -- Shorthand expansion - -### ✔ 3. Produce a Style‑Resolved Tree - -A new tree where **every node has its final computed style** attached. -This tree contains: - -- resolved display modes -- resolved text properties -- resolved sizing/box model values -- resolved transforms & opacity -- fully computed inline and block styles - -### ✔ 4. Ready for layout engine consumption - -`csscascade` does **not** perform layout. -It outputs a static, fully resolved element tree specifically designed to be fed into your layout engine (block/inline/flex/grid/etc.). - -### ✔ 5. Ready for painting after layout - -Since the style is computed and normalized, the next stages can be: - -- layout engine -- display list generation -- painting/rendering - ---- - -## Why this crate exists - -Rendering HTML and SVG is deceptively hard. Even before layout and paint, a renderer must: - -- parse the DOM -- run the CSS cascade -- normalize presentation attributes -- compute final styles -- build a static, render‑ready tree - -None of these steps are specific to a browser — they are fundamental requirements for **any** engine that wants to render HTML or SVG, whether for graphics, documents, UI, or design tools. - -The goal of `csscascade` is **not** to help you build a browser. Instead, it’s designed for developers building: - -- static HTML/SVG renderers -- document processors -- canvas-based UI engines -- PDF or image generators -- design tools (like Figma‑style or illustration tools) -- “bring your own renderer” pipelines - -It handles the universally hard parts (CSS cascade, style normalization, static tree production) by delegating the CSS engine to Stylo and layering our own DOM/font/layout glue on top, so your engine can focus on **layout and painting**, not CSS correctness. - ---- - -## Intended Audience - -### ✔ 1. Engine and renderer authors (non‑browser) - -This crate is specifically for people building a **static** renderer — not a real DOM, not a browser, not an interactive layout engine. - -If you: - -- want to parse HTML/SVG once -- resolve styles correctly -- produce a clean, immutable tree -- feed it to your own layout + painting pipeline - -…then `csscascade` is designed for you. +``` +HTML string + │ + ▼ +html5ever ─── parse ──► RcDom (reference-counted DOM) + │ + ▼ +csscascade ── cascade ─► StyledTree (computed styles per node) + │ + ▼ +taffy ─────── layout ──► positioned boxes + │ + ▼ +grida-canvas ─ convert ► IR nodes (rectangles, text, etc.) +``` -### ✔ 2. Tools that need correct CSS without a browser +This mirrors the SVG import path (`usvg` → `from_usvg` → IR), but for HTML/CSS each stage is a separate crate because no single library (like `usvg` for SVG) handles the full pipeline. -For example: +## Current State -- SVG → PNG converters -- HTML → PDF generators -- print engines -- design tools -- WASM/canvas engines +**Working:** -### ✖ Not for browser makers +- HTML parsing via html5ever into `RcDom` +- DOM tree representation (`rcdom` module) +- `Tree` / `StyledNode` abstractions for style-resolved output +- Stylo infrastructure wired up (`Device`, `Stylist`, `SharedRwLock`) +- Serialization — re-emit HTML, optionally with computed styles inlined +- Builder pattern for programmatic tree construction +- Working examples: `print_tree`, `print_rcdom`, `html2html` -If you need: +**Proof-of-concept (not yet integrated):** -- live DOM mutation -- dynamic style recalculation -- reflow/repaint cycles -- incremental layout -- event-driven DOM +- `examples/exp_impl_telement.rs` — demonstrates full per-element cascade via Stylo's `TElement` trait. This works but has not been promoted to the main crate API. -…this crate intentionally does **not** target that use case. +**Stubbed / not yet functional:** ---- +- `StyleRuntime::compute_for()` returns default `ComputedValues` instead of actually resolving per-element styles +- `SimpleFontProvider` returns hardcoded metrics, not real font data -## What this crate produces +## Roadmap -`csscascade` outputs a **style‑resolved static tree** — every node has fully computed CSS (straight from Stylo) applied, ready for layout. +### Phase 1 — Cascade Resolution ✅ -### (planned) Optional layout integration +Per-element style resolution via Stylo's `TElement` trait. -A future feature flag will allow the crate to output a **layout‑computed, render‑ready tree**, so you can plug it directly into your painter. +- [x] Promote `TElement` implementation into the crate (`adapter.rs`) +- [x] Collect CSS from ` + + +

Hello csscascade

+
+

This paragraph should be 14px #555.

+ +
+ + +"# + .to_string(), + None, + )) + } +} diff --git a/crates/csscascade/src/adapter.rs b/crates/csscascade/src/adapter.rs new file mode 100644 index 0000000000..03219664ec --- /dev/null +++ b/crates/csscascade/src/adapter.rs @@ -0,0 +1,828 @@ +//! Stylo DOM adapter layer. +//! +//! Implements the [`TNode`], [`TElement`], [`TDocument`], and [`selectors::Element`] +//! traits for our arena DOM so that Stylo's cascade engine can match selectors +//! and resolve styles against it. +//! +//! # Limitations (PoC) +//! +//! The DOM is stored in a process-global slot via [`bootstrap_dom`]. Each call +//! replaces the previous document (the old one is leaked). This means only one +//! document is live at a time, but multiple documents can be processed +//! sequentially within the same process. + +use std::borrow::Borrow; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicPtr, Ordering}; + +use atomic_refcell::{AtomicRef, AtomicRefMut}; +use euclid::default::Size2D; +use markup5ever::{Attribute, Namespace as HtmlNamespace, ns}; +use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint}; +use selectors::bloom::BloomFilter; +use selectors::matching::{ElementSelectorFlags, MatchingContext, VisitedHandlingMode}; +use selectors::parser::SelectorImpl as SelectorsParser; +use selectors::{OpaqueElement, sink::Push}; +use style::Namespace as StyleNamespace; +use style::applicable_declarations::ApplicableDeclarationBlock; +use style::context::SharedStyleContext; +use style::data::ElementData; +use style::dom::{LayoutIterator, OpaqueNode, TElement, TNode}; +use style::properties::PropertyDeclarationBlock; +use style::selector_parser::{AttrValue as SelectorAttrValue, Lang, PseudoElement, SelectorImpl}; +use style::servo_arc::{Arc, ArcBorrow}; +use style::shared_lock::{Locked, SharedRwLock}; +use style::stylist::CascadeData; +use style::values::AtomIdent; +use style::values::computed::Au; +use style::values::computed::Display; +use stylo_dom::ElementState; + +use crate::dom::{DemoDom, DemoElementData, DemoNode, DemoNodeData, NodeId}; + +type Impl = SelectorImpl; + +// --------------------------------------------------------------------------- +// Global DOM storage +// --------------------------------------------------------------------------- + +static DEMO_DOM: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); +static STYLE_LOCK: OnceLock = OnceLock::new(); + +/// Install a parsed [`DemoDom`] into the global slot and return a +/// [`HtmlDocument`] handle. +/// +/// Each call replaces the previous document. The old document is intentionally +/// leaked (its `&'static` references remain valid through existing handles). +/// This is acceptable for a dev/import tool; a future iteration will use +/// `Arc`-based context to avoid the leak. +pub fn bootstrap_dom(dom: DemoDom) -> HtmlDocument { + let document = dom.document_id(); + let ptr = Box::into_raw(Box::new(dom)); + // Swap in the new DOM; the old one (if any) is deliberately leaked so + // that any outstanding `&'static DemoDom` references stay valid. + DEMO_DOM.store(ptr, Ordering::Release); + HtmlDocument(document) +} + +/// Returns the process-global [`SharedRwLock`] used for stylesheet data. +pub fn doc_shared_lock() -> &'static SharedRwLock { + STYLE_LOCK.get_or_init(SharedRwLock::new) +} + +/// Returns a reference to the global [`DemoDom`]. +/// +/// # Panics +/// +/// Panics if [`bootstrap_dom`] has not been called yet. +pub fn dom() -> &'static DemoDom { + let ptr = DEMO_DOM.load(Ordering::Acquire); + assert!(!ptr.is_null(), "bootstrap_dom must run first"); + // SAFETY: The pointer was created via Box::into_raw in bootstrap_dom + // and is never deallocated (intentional leak for &'static lifetime). + unsafe { &*ptr } +} + +// --------------------------------------------------------------------------- +// Wrapper types +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct HtmlNode(NodeId); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct HtmlElement(NodeId); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct HtmlDocument(NodeId); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct HtmlShadowRoot { + host: HtmlElement, +} + +// --------------------------------------------------------------------------- +// HtmlDocument +// --------------------------------------------------------------------------- + +impl HtmlDocument { + pub fn root_element(&self) -> Option { + dom().document_children().iter().find_map(|child| { + matches!(dom().node(*child).data, DemoNodeData::Element(_)) + .then_some(HtmlElement(*child)) + }) + } + + pub fn element_count(&self) -> usize { + let mut count = 0; + let mut stack = Vec::new(); + if let Some(root) = self.root_element() { + stack.push(root); + } + while let Some(element) = stack.pop() { + count += 1; + let mut child = element.first_element_child(); + while let Some(next_child) = child { + stack.push(next_child); + child = next_child.next_element_sibling(); + } + } + count + } +} + +// --------------------------------------------------------------------------- +// HtmlElement helpers +// --------------------------------------------------------------------------- + +impl HtmlElement { + /// Returns the underlying DOM [`NodeId`]. + pub fn node_id(&self) -> NodeId { + self.0 + } + + pub fn local_name_string(&self) -> String { + self.element_data().name.local.to_string() + } + + pub fn first_element_child(self) -> Option { + self.node().first_element_child() + } + + pub fn next_element_sibling(self) -> Option { + self.node().next_element_sibling() + } + + fn element_data(&self) -> &DemoElementData { + match &dom().node(self.0).data { + DemoNodeData::Element(data) => data, + _ => panic!("HtmlElement must wrap an element node"), + } + } + + fn node(self) -> HtmlNode { + HtmlNode(self.0) + } + + fn data_slot(&self) -> &'static atomic_refcell::AtomicRefCell> { + dom().element_data_slot(self.0) + } + + fn attr_iter(&self) -> impl Iterator + '_ { + let data = self.element_data(); + data.attrs.iter().zip(data.attr_local_names.iter()) + } + + fn attr_matches_impl( + &self, + ns: &NamespaceConstraint<&StyleNamespace>, + local_name: &style::LocalName, + operation: &AttrSelectorOperation<&SelectorAttrValue>, + ) -> bool { + self.attr_iter() + .filter(|(attr, _)| namespace_matches(ns, &attr.name.ns)) + .find(|(_, stored)| *stored == local_name) + .map_or(false, |(attr, _)| operation.eval_str(attr.value.as_ref())) + } + + fn lang_attribute_value(&self) -> Option<&str> { + self.element_data().attrs.iter().find_map(|attr| { + if !attr.name.local.as_ref().eq_ignore_ascii_case("lang") { + return None; + } + let ns = &attr.name.ns; + if *ns == markup5ever::ns!() || *ns == markup5ever::ns!(xml) { + Some(attr.value.as_ref()) + } else { + None + } + }) + } + + fn has_class_token(&self, name: &AtomIdent, case_sensitivity: CaseSensitivity) -> bool { + let needle = atom_ident_str(name); + self.element_data() + .class_list + .iter() + .any(|class| case_sensitivity.eq(atom_ident_str(class).as_bytes(), needle.as_bytes())) + } + + fn id_string(&self) -> Option<&str> { + self.element_data() + .id_attr + .as_ref() + .map(|atom| atom.as_ref()) + } +} + +// --------------------------------------------------------------------------- +// HtmlNode helpers +// --------------------------------------------------------------------------- + +impl HtmlNode { + fn node(self) -> &'static DemoNode { + dom().node(self.0) + } + + fn parent(self) -> Option { + self.node().parent.map(HtmlNode) + } + + fn to_element(self) -> Option { + matches!(self.node().data, DemoNodeData::Element(_)).then_some(HtmlElement(self.0)) + } + + fn first_element_child(self) -> Option { + let mut child = self.first_child(); + while let Some(node) = child { + if let Some(element) = node.to_element() { + return Some(element); + } + child = node.next_sibling(); + } + None + } + + fn prev_element_sibling(self) -> Option { + let mut prev = self.prev_sibling(); + while let Some(node) = prev { + if let Some(element) = node.to_element() { + return Some(element); + } + prev = node.prev_sibling(); + } + None + } + + fn next_element_sibling(self) -> Option { + let mut next = self.next_sibling(); + while let Some(node) = next { + if let Some(element) = node.to_element() { + return Some(element); + } + next = node.next_sibling(); + } + None + } + + fn first_child(self) -> Option { + self.node().children.first().copied().map(HtmlNode) + } + + fn prev_sibling(self) -> Option { + sibling_pair(self.0).0 + } + + fn next_sibling(self) -> Option { + sibling_pair(self.0).1 + } +} + +// --------------------------------------------------------------------------- +// style::dom::NodeInfo +// --------------------------------------------------------------------------- + +impl ::style::dom::NodeInfo for HtmlNode { + fn is_element(&self) -> bool { + matches!(self.node().data, DemoNodeData::Element(_)) + } + + fn is_text_node(&self) -> bool { + matches!(self.node().data, DemoNodeData::Text(_)) + } +} + +// --------------------------------------------------------------------------- +// style::dom::TNode +// --------------------------------------------------------------------------- + +impl ::style::dom::TNode for HtmlNode { + type ConcreteElement = HtmlElement; + type ConcreteDocument = HtmlDocument; + type ConcreteShadowRoot = HtmlShadowRoot; + + fn parent_node(&self) -> Option { + self.parent() + } + + fn first_child(&self) -> Option { + self.node().children.first().copied().map(HtmlNode) + } + + fn last_child(&self) -> Option { + self.node().children.last().copied().map(HtmlNode) + } + + fn prev_sibling(&self) -> Option { + sibling_pair(self.0).0 + } + + fn next_sibling(&self) -> Option { + sibling_pair(self.0).1 + } + + fn owner_doc(&self) -> Self::ConcreteDocument { + HtmlDocument(dom().document_id()) + } + + fn is_in_document(&self) -> bool { + true + } + + fn traversal_parent(&self) -> Option { + self.parent()?.to_element() + } + + fn opaque(&self) -> OpaqueNode { + OpaqueNode(self.0.idx()) + } + + fn debug_id(self) -> usize { + self.0.idx() + } + + fn as_element(&self) -> Option { + self.to_element() + } + + fn as_document(&self) -> Option { + matches!(self.node().data, DemoNodeData::Document).then_some(HtmlDocument(self.0)) + } + + fn as_shadow_root(&self) -> Option { + None + } +} + +// --------------------------------------------------------------------------- +// style::dom::TDocument +// --------------------------------------------------------------------------- + +impl ::style::dom::TDocument for HtmlDocument { + type ConcreteNode = HtmlNode; + + fn as_node(&self) -> Self::ConcreteNode { + HtmlNode(self.0) + } + + fn is_html_document(&self) -> bool { + true + } + + fn quirks_mode(&self) -> style::context::QuirksMode { + style::context::QuirksMode::NoQuirks + } + + fn shared_lock(&self) -> &SharedRwLock { + doc_shared_lock() + } +} + +// --------------------------------------------------------------------------- +// style::dom::TShadowRoot +// --------------------------------------------------------------------------- + +impl ::style::dom::TShadowRoot for HtmlShadowRoot { + type ConcreteNode = HtmlNode; + + fn as_node(&self) -> Self::ConcreteNode { + self.host.as_node() + } + + fn host(&self) -> ::ConcreteElement { + self.host + } + + fn style_data<'a>(&self) -> Option<&'a CascadeData> + where + Self: 'a, + { + None + } +} + +// --------------------------------------------------------------------------- +// style::dom::TElement +// --------------------------------------------------------------------------- + +impl ::style::dom::TElement for HtmlElement { + type ConcreteNode = HtmlNode; + type TraversalChildrenIterator = std::vec::IntoIter; + + fn as_node(&self) -> Self::ConcreteNode { + HtmlNode(self.0) + } + + fn traversal_children(&self) -> LayoutIterator { + let nodes: Vec<_> = self + .node() + .node() + .children + .iter() + .map(|child| HtmlNode(*child)) + .collect(); + LayoutIterator(nodes.into_iter()) + } + + fn is_html_element(&self) -> bool { + self.element_data().name.ns == ns!(html) + } + + fn is_mathml_element(&self) -> bool { + self.element_data().name.ns == ns!(mathml) + } + + fn is_svg_element(&self) -> bool { + self.element_data().name.ns == ns!(svg) + } + + fn style_attribute(&self) -> Option>> { + self.element_data() + .style_attribute + .as_ref() + .map(|arc| arc.borrow_arc()) + } + + fn animation_rule( + &self, + _context: &SharedStyleContext, + ) -> Option>> { + None + } + + fn transition_rule( + &self, + _context: &SharedStyleContext, + ) -> Option>> { + None + } + + fn state(&self) -> ElementState { + ElementState::empty() + } + + fn has_part_attr(&self) -> bool { + false + } + + fn exports_any_part(&self) -> bool { + false + } + + fn id(&self) -> Option<&stylo_atoms::Atom> { + self.element_data().id_attr.as_ref() + } + + fn each_class(&self, mut callback: F) + where + F: FnMut(&AtomIdent), + { + for class_atom in &self.element_data().class_list { + callback(class_atom); + } + } + + fn each_custom_state(&self, _callback: F) + where + F: FnMut(&AtomIdent), + { + } + + fn each_attr_name(&self, mut callback: F) + where + F: FnMut(&style::LocalName), + { + for attr_name in &self.element_data().attr_local_names { + callback(attr_name); + } + } + + fn has_dirty_descendants(&self) -> bool { + false + } + + fn has_snapshot(&self) -> bool { + false + } + + fn handled_snapshot(&self) -> bool { + false + } + + unsafe fn set_handled_snapshot(&self) {} + unsafe fn set_dirty_descendants(&self) {} + unsafe fn unset_dirty_descendants(&self) {} + + fn store_children_to_process(&self, _n: isize) {} + + fn did_process_child(&self) -> isize { + 0 + } + + unsafe fn ensure_data(&self) -> AtomicRefMut<'_, style::data::ElementData> { + let slot = self.data_slot(); + let mut cell = slot.borrow_mut(); + if cell.is_none() { + *cell = Some(ElementData::default()); + } + AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap()) + } + + unsafe fn clear_data(&self) { + let slot = self.data_slot(); + *slot.borrow_mut() = None; + } + + fn has_data(&self) -> bool { + self.data_slot().borrow().is_some() + } + + fn borrow_data(&self) -> Option> { + let slot = self.data_slot(); + let cell = slot.borrow(); + if cell.is_some() { + Some(AtomicRef::map(cell, |opt| opt.as_ref().unwrap())) + } else { + None + } + } + + fn mutate_data(&self) -> Option> { + let slot = self.data_slot(); + let cell = slot.borrow_mut(); + if cell.is_some() { + Some(AtomicRefMut::map(cell, |opt| opt.as_mut().unwrap())) + } else { + None + } + } + + fn skip_item_display_fixup(&self) -> bool { + false + } + + fn may_have_animations(&self) -> bool { + false + } + + fn has_animations(&self, _context: &SharedStyleContext) -> bool { + false + } + + fn has_css_animations( + &self, + _context: &SharedStyleContext, + _pseudo_element: Option, + ) -> bool { + false + } + + fn has_css_transitions( + &self, + _context: &SharedStyleContext, + _pseudo_element: Option, + ) -> bool { + false + } + + fn shadow_root(&self) -> Option<::ConcreteShadowRoot> { + None + } + + fn containing_shadow(&self) -> Option<::ConcreteShadowRoot> { + None + } + + fn lang_attr(&self) -> Option { + self.lang_attribute_value().map(SelectorAttrValue::from) + } + + fn match_element_lang( + &self, + _override_lang: Option>, + _value: &Lang, + ) -> bool { + false + } + + fn is_html_document_body_element(&self) -> bool { + false + } + + fn synthesize_presentational_hints_for_legacy_attributes( + &self, + _visited_handling: VisitedHandlingMode, + _hints: &mut V, + ) where + V: Push, + { + } + + fn synthesize_view_transition_dynamic_rules(&self, _rules: &mut V) + where + V: Push, + { + } + + fn local_name(&self) -> &::BorrowedLocalName { + self.element_data().style_local_name.borrow() + } + + fn namespace(&self) -> &::BorrowedNamespaceUrl { + self.element_data().style_namespace.borrow() + } + + fn query_container_size(&self, _display: &Display) -> Size2D> { + Size2D::new(None, None) + } + + fn has_selector_flags(&self, _flags: ElementSelectorFlags) -> bool { + false + } + + fn relative_selector_search_direction(&self) -> ElementSelectorFlags { + ElementSelectorFlags::empty() + } +} + +// --------------------------------------------------------------------------- +// selectors::Element +// --------------------------------------------------------------------------- + +impl ::selectors::Element for HtmlElement { + type Impl = Impl; + + fn opaque(&self) -> OpaqueElement { + OpaqueElement::new(dom().node(self.0)) + } + + fn parent_element(&self) -> Option { + self.as_node().parent_node()?.to_element() + } + + fn parent_node_is_shadow_root(&self) -> bool { + false + } + + fn containing_shadow_host(&self) -> Option { + None + } + + fn is_pseudo_element(&self) -> bool { + false + } + + fn pseudo_element_originating_element(&self) -> Option { + None + } + + fn prev_sibling_element(&self) -> Option { + self.as_node().prev_element_sibling() + } + + fn next_sibling_element(&self) -> Option { + self.as_node().next_element_sibling() + } + + fn first_element_child(&self) -> Option { + self.as_node().first_element_child() + } + + fn has_local_name(&self, name: &::BorrowedLocalName) -> bool { + self.element_data().name.local.as_ref() == name.as_ref() + } + + fn has_namespace(&self, ns: &::BorrowedNamespaceUrl) -> bool { + self.element_data().name.ns.as_ref() == ns.as_ref() + } + + fn is_same_type(&self, other: &Self) -> bool { + self.element_data().name == other.element_data().name + } + + fn attr_matches( + &self, + ns: &NamespaceConstraint<&::NamespaceUrl>, + local_name: &::LocalName, + operation: &AttrSelectorOperation<&::AttrValue>, + ) -> bool { + self.attr_matches_impl(ns, local_name, operation) + } + + fn match_non_ts_pseudo_class( + &self, + _pc: &::NonTSPseudoClass, + _context: &mut MatchingContext, + ) -> bool { + false + } + + fn match_pseudo_element( + &self, + _pe: &::PseudoElement, + _context: &mut MatchingContext, + ) -> bool { + false + } + + fn is_link(&self) -> bool { + false + } + + fn has_id( + &self, + id: &::Identifier, + case_sensitivity: CaseSensitivity, + ) -> bool { + let Some(current) = self.id_string() else { + return false; + }; + case_sensitivity.eq(current.as_bytes(), atom_ident_str(id).as_bytes()) + } + + fn is_part(&self, _name: &AtomIdent) -> bool { + false + } + + fn imported_part( + &self, + _name: &::Identifier, + ) -> Option<::Identifier> { + None + } + + fn has_class( + &self, + name: &::Identifier, + case_sensitivity: CaseSensitivity, + ) -> bool { + self.has_class_token(name, case_sensitivity) + } + + fn is_html_element_in_html_document(&self) -> bool { + self.is_html_element() + } + + fn is_html_slot_element(&self) -> bool { + false + } + + fn is_empty(&self) -> bool { + self.as_node().first_child().is_none() + } + + fn is_root(&self) -> bool { + self.as_node().parent_node().is_none() + } + + fn apply_selector_flags(&self, _flags: ElementSelectorFlags) {} + + fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool { + false + } + + fn has_custom_state(&self, _name: &::Identifier) -> bool { + false + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn namespace_matches( + constraint: &NamespaceConstraint<&StyleNamespace>, + attr_ns: &HtmlNamespace, +) -> bool { + match constraint { + NamespaceConstraint::Any => true, + NamespaceConstraint::Specific(ns) => { + let selector_ns_atom = ns.as_ref(); + let selector_ns: &str = selector_ns_atom; + let dom_ns: &str = attr_ns; + selector_ns == dom_ns + } + } +} + +fn atom_ident_str(atom: &AtomIdent) -> &str { + atom.as_ref().as_ref() +} + +fn sibling_pair(id: NodeId) -> (Option, Option) { + let node = dom().node(id); + let Some(parent) = node.parent else { + return (None, None); + }; + + let siblings = &dom().node(parent).children; + let idx = siblings + .iter() + .position(|child| *child == id) + .expect("parent missing child"); + + let prev = idx.checked_sub(1).map(|i| HtmlNode(siblings[i])); + let next = siblings.get(idx + 1).copied().map(HtmlNode); + + (prev, next) +} diff --git a/crates/csscascade/src/cascade.rs b/crates/csscascade/src/cascade.rs new file mode 100644 index 0000000000..7d1f72e036 --- /dev/null +++ b/crates/csscascade/src/cascade.rs @@ -0,0 +1,411 @@ +//! CSS cascade driver. +//! +//! Orchestrates Stylo to resolve computed styles for every element in a +//! [`DemoDom`]. The driver: +//! +//! 1. Collects author CSS from ` + + +

Hello

+
+

Paragraph text

+
+ +"#; + + let graph = from_html_str(html).expect("should parse and convert HTML"); + assert!( + graph.node_count() > 3, + "expected at least 4 nodes, got {}", + graph.node_count() + ); + } + + #[test] + fn test_inline_style_attribute() { + let _guard = lock_html(); + let html = r#" + + +
Styled inline
+ +"#; + let graph = from_html_str(html).expect("should parse inline styles"); + assert!(graph.node_count() >= 3); + } + + #[test] + fn test_borders_and_shadows() { + let _guard = lock_html(); + let html = r#" + + + + + +
bordered
+ +"#; + let graph = from_html_str(html).expect("should parse borders and shadows"); + assert!(graph.node_count() >= 3); + } + + #[test] + fn test_flex_alignment() { + let _guard = lock_html(); + let html = r#" + + + + + +
+ A + B +
+ +"#; + let graph = from_html_str(html).expect("should parse flex alignment"); + assert!(graph.node_count() >= 4); + } + + #[test] + fn test_gradient_backgrounds() { + let _guard = lock_html(); + let html = r#" + + + + + +
linear
+
radial
+ +"#; + let graph = from_html_str(html).expect("should parse gradient backgrounds"); + assert!(graph.node_count() >= 4); + } + + // ----------------------------------------------------------------------- + // Deterministic flex layout tests (divs only, no text) + // ----------------------------------------------------------------------- + + /// 3 fixed-size divs in a flex row with gap. + #[test] + fn test_flex_row_positions() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + // Find the three leaf rectangles (last 3 in DFS of the flex container subtree) + // Tree: html > body > flex-container > [child0, child1, child2] + // DFS order should have the 3 children at the end + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + // Children are 50×50 + if (l.width - 50.0).abs() < 1.0 && (l.height - 50.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 3, "expected 3 leaf children"); + + assert_eq!(leaf_layouts[0].x, 0.0, "child0 x"); + assert_eq!(leaf_layouts[1].x, 60.0, "child1 x = 50 + 10 gap"); + assert_eq!(leaf_layouts[2].x, 120.0, "child2 x = 50+10+50+10"); + } + + /// 3 fixed-size divs in a flex column with gap. + #[test] + fn test_flex_column_positions() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 50.0).abs() < 1.0 && (l.height - 50.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 3, "expected 3 leaf children"); + + assert_eq!(leaf_layouts[0].y, 0.0, "child0 y"); + assert_eq!(leaf_layouts[1].y, 60.0, "child1 y = 50 + 10 gap"); + assert_eq!(leaf_layouts[2].y, 120.0, "child2 y = 50+10+50+10"); + } + + /// justify-content: center with 2 fixed children. + #[test] + fn test_flex_justify_center() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 40.0).abs() < 1.0 && (l.height - 40.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 2, "expected 2 leaf children"); + + // Total child width = 80, remaining = 120, offset = 60 + assert_eq!(leaf_layouts[0].x, 60.0, "child0 x centered"); + assert_eq!(leaf_layouts[1].x, 100.0, "child1 x centered"); + } + + /// justify-content: space-between with 3 fixed children. + #[test] + fn test_flex_justify_space_between() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 40.0).abs() < 1.0 && (l.height - 40.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 3, "expected 3 leaf children"); + + assert_eq!(leaf_layouts[0].x, 0.0, "first child at start"); + assert_eq!(leaf_layouts[2].x, 160.0, "last child at end (200-40)"); + } + + /// align-items: center with a single child shorter than container. + #[test] + fn test_flex_align_center() { + let _guard = lock_html(); + let html = r#" + +
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf = nodes + .iter() + .find_map(|id| { + let l = layouts.get(id)?; + if (l.width - 40.0).abs() < 1.0 && (l.height - 40.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .expect("should find 40×40 child"); + + assert_eq!(leaf.y, 30.0, "child centered: (100-40)/2 = 30"); + } + + /// flex-grow: second child fills remaining space. + #[test] + fn test_flex_grow() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let child_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.height - 50.0).abs() < 1.0 && l.width > 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(child_layouts.len(), 2, "expected 2 children"); + + assert_eq!(child_layouts[0].width, 100.0, "first child fixed 100px"); + assert_eq!(child_layouts[0].x, 0.0, "first child at x=0"); + assert_eq!( + child_layouts[1].width, 200.0, + "second child grows to fill 300-100=200" + ); + assert_eq!(child_layouts[1].x, 100.0, "second child starts after first"); + } + + /// Container padding offsets children. + #[test] + fn test_flex_padding() { + let _guard = lock_html(); + let html = r#" + +
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf = nodes + .iter() + .find_map(|id| { + let l = layouts.get(id)?; + if (l.width - 30.0).abs() < 1.0 && (l.height - 30.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .expect("should find 30×30 child"); + + assert_eq!(leaf.x, 10.0, "child offset by left padding"); + assert_eq!(leaf.y, 10.0, "child offset by top padding"); + } + + /// Flex column gap direction is correct (gap applies vertically). + #[test] + fn test_flex_gap_column_direction() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 40.0).abs() < 1.0 && (l.height - 40.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 2, "expected 2 leaf children"); + + assert_eq!(leaf_layouts[0].y, 0.0, "child0 at y=0"); + assert_eq!(leaf_layouts[1].y, 55.0, "child1 at y=40+15 gap"); + } + + /// Nested flex: outer row, inner column with children. + #[test] + fn test_nested_flex() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + // Find the 80×60 leaves (inner column children) + let inner_leaves: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 80.0).abs() < 1.0 && (l.height - 60.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(inner_leaves.len(), 2, "expected 2 inner column children"); + assert_eq!(inner_leaves[0].y, 0.0, "first inner child at y=0"); + assert_eq!(inner_leaves[1].y, 60.0, "second inner child at y=60"); + + // Find the 100×100 sibling in the outer row + let sibling = nodes + .iter() + .find_map(|id| { + let l = layouts.get(id)?; + if (l.width - 100.0).abs() < 1.0 && (l.height - 100.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .expect("should find 100×100 sibling"); + assert_eq!( + sibling.x, 100.0, + "sibling starts after inner column (width=100)" + ); + } + + /// Explicit width/height dimensions are preserved. + #[test] + fn test_explicit_dimensions() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf = nodes + .iter() + .find_map(|id| { + let l = layouts.get(id)?; + if (l.width - 200.0).abs() < 1.0 && (l.height - 100.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .expect("should find 200×100 div"); + + assert_eq!(leaf.width, 200.0); + assert_eq!(leaf.height, 100.0); + } + + /// flex-wrap: children that overflow wrap to the next line. + #[test] + fn test_flex_wrap() { + let _guard = lock_html(); + let html = r#" + +
+
+
+
+
+"#; + let (scene, layouts) = html_layout(html, 800.0, 600.0); + let nodes = dfs_nodes(&scene.graph); + + let leaf_layouts: Vec<_> = nodes + .iter() + .filter_map(|id| { + let l = layouts.get(id)?; + if (l.width - 60.0).abs() < 1.0 && (l.height - 60.0).abs() < 1.0 { + Some(*l) + } else { + None + } + }) + .collect(); + assert_eq!(leaf_layouts.len(), 3, "expected 3 children"); + + // First two fit on first row (60+60=120 <= 150) + assert_eq!(leaf_layouts[0].y, 0.0, "child0 on first row"); + assert_eq!(leaf_layouts[1].y, 0.0, "child1 on first row"); + // Third wraps to second row + assert!( + leaf_layouts[2].y >= 60.0, + "child2 should wrap to y >= 60, got {}", + leaf_layouts[2].y + ); + } + + // ----------------------------------------------------------------------- + // Effects / blend mode tests + // ----------------------------------------------------------------------- + + /// Parse HTML without running layout — returns just the SceneGraph. + fn html_graph(html: &str) -> SceneGraph { + from_html_str(html).expect("HTML parse failed") + } + + /// text-shadow maps to drop-shadow effects on the TextSpan node. + #[test] + fn test_text_shadow() { + let _guard = lock_html(); + let html = r#" + +
Hello
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + // Find the TextSpan node + let text_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::TextSpan(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a TextSpan node"); + + let effects = text_node.effects().expect("TextSpan should have effects"); + assert_eq!(effects.shadows.len(), 1, "one text-shadow"); + match &effects.shadows[0] { + FilterShadowEffect::DropShadow(s) => { + assert_eq!(s.dx, 2.0); + assert_eq!(s.dy, 3.0); + assert_eq!(s.blur, 4.0); + assert_eq!(s.spread, 0.0, "text-shadow has no spread"); + assert!(s.active); + } + _ => panic!("expected DropShadow"), + } + } + + /// Multiple text-shadows produce multiple DropShadow effects. + #[test] + fn test_text_shadow_multiple() { + let _guard = lock_html(); + let html = r#" + +
Multi
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let text_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::TextSpan(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a TextSpan node"); + + let effects = text_node.effects().expect("TextSpan should have effects"); + assert_eq!(effects.shadows.len(), 2, "two text-shadows"); + } + + /// box-shadow (inset + outer) maps to InnerShadow + DropShadow. + #[test] + fn test_box_shadow() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + let effects = rect_node.effects().expect("Rectangle should have effects"); + assert_eq!(effects.shadows.len(), 2, "two box-shadows"); + assert!( + matches!(&effects.shadows[0], FilterShadowEffect::DropShadow(_)), + "first is DropShadow" + ); + assert!( + matches!(&effects.shadows[1], FilterShadowEffect::InnerShadow(_)), + "second is InnerShadow" + ); + } + + /// filter: blur() maps to FeLayerBlur on a rectangle. + #[test] + fn test_filter_blur() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + let effects = rect_node.effects().expect("Rectangle should have effects"); + let blur = effects.blur.as_ref().expect("should have blur"); + match &blur.blur { + FeBlur::Gaussian(g) => assert_eq!(g.radius, 6.0), + _ => panic!("expected Gaussian blur"), + } + } + + /// filter: drop-shadow() maps to DropShadow effect on a rectangle. + #[test] + fn test_filter_drop_shadow() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + let effects = rect_node.effects().expect("Rectangle should have effects"); + assert_eq!(effects.shadows.len(), 1, "one drop-shadow from filter"); + match &effects.shadows[0] { + FilterShadowEffect::DropShadow(s) => { + assert_eq!(s.dx, 4.0); + assert_eq!(s.dy, 4.0); + assert_eq!(s.blur, 8.0); + } + _ => panic!("expected DropShadow"), + } + } + + /// backdrop-filter: blur() maps to FeBackdropBlur. + /// + /// NOTE: Stylo marks `backdrop-filter` as `servo_pref="layout.unimplemented"`, + /// so in servo mode the property is parsed but treated as initial (none). + /// The mapping code is correct but untestable until a gecko build or pref + /// override is available. This test verifies the pipeline doesn't crash. + #[test] + fn test_backdrop_filter_blur() { + let _guard = lock_html(); + let html = r#" + +
+
+
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + // backdrop-filter is unimplemented in servo mode so effects may lack backdrop_blur. + // Just verify the node exists and effects are accessible (no crash). + let _effects = rect_node.effects().expect("Rectangle should have effects"); + } + + /// mix-blend-mode: multiply maps to LayerBlendMode::Blend(BlendMode::Multiply). + #[test] + fn test_mix_blend_mode() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + assert_eq!( + rect_node.blend_mode(), + LayerBlendMode::Blend(BlendMode::Multiply) + ); + } + + /// mix-blend-mode: normal stays as PassThrough (default). + #[test] + fn test_mix_blend_mode_normal() { + let _guard = lock_html(); + let html = r#" + +
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let rect_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Rectangle(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a Rectangle node"); + + assert_eq!(rect_node.blend_mode(), LayerBlendMode::PassThrough); + } + + /// Effects on containers: filter + blend mode on a flex container. + #[test] + fn test_container_effects() { + let _guard = lock_html(); + let html = r#" + +
+
+
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + // Find the container with non-PassThrough blend mode (the one with mix-blend-mode: screen) + let container_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::Container(_)) && n.blend_mode() != LayerBlendMode::PassThrough + { + Some(n) + } else { + None + } + }) + .expect("should find a Container with non-default blend mode"); + + let effects = container_node + .effects() + .expect("Container should have effects"); + assert!(effects.blur.is_some(), "container should have blur"); + assert_eq!( + container_node.blend_mode(), + LayerBlendMode::Blend(BlendMode::Screen) + ); + } + + // ----------------------------------------------------------------------- + // Text decoration (color, style) tests + // ----------------------------------------------------------------------- + + /// text-decoration-color maps to TextDecorationRec.text_decoration_color. + #[test] + fn test_text_decoration_color() { + let _guard = lock_html(); + let html = r#" + +
Red underline
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let text_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::TextSpan(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a TextSpan node"); + + if let Node::TextSpan(ts) = text_node { + let dec = ts + .text_style + .text_decoration + .as_ref() + .expect("should have decoration"); + assert_eq!(dec.text_decoration_line, TextDecorationLine::Underline); + let color = dec + .text_decoration_color + .expect("should have decoration color"); + assert_eq!(color, CGColor::from_rgba(239, 68, 68, 255)); + } else { + panic!("expected TextSpan"); + } + } + + /// text-decoration-style maps to TextDecorationRec.text_decoration_style. + #[test] + fn test_text_decoration_style() { + let _guard = lock_html(); + let html = r#" + +
Wavy
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let text_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::TextSpan(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a TextSpan node"); + + if let Node::TextSpan(ts) = text_node { + let dec = ts + .text_style + .text_decoration + .as_ref() + .expect("should have decoration"); + assert_eq!(dec.text_decoration_style, Some(TextDecorationStyle::Wavy)); + } else { + panic!("expected TextSpan"); + } + } + + /// Combined: text-decoration with color + style + line. + #[test] + fn test_text_decoration_combined() { + let _guard = lock_html(); + let html = r#" + +
Combo
+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let text_node = nodes + .iter() + .find_map(|id| { + let n = graph.get_node(id).ok()?; + if matches!(n, Node::TextSpan(_)) { + Some(n) + } else { + None + } + }) + .expect("should find a TextSpan node"); + + if let Node::TextSpan(ts) = text_node { + let dec = ts + .text_style + .text_decoration + .as_ref() + .expect("should have decoration"); + assert_eq!(dec.text_decoration_line, TextDecorationLine::LineThrough); + assert_eq!( + dec.text_decoration_color, + Some(CGColor::from_rgba(59, 130, 246, 255)) + ); + assert_eq!(dec.text_decoration_style, Some(TextDecorationStyle::Dashed)); + assert_eq!( + dec.text_decoration_thickness, None, + "thickness unavailable in servo mode" + ); + } else { + panic!("expected TextSpan"); + } + } +} diff --git a/crates/grida-canvas/src/lib.rs b/crates/grida-canvas/src/lib.rs index 3c35a8fce8..381bd0e8a7 100644 --- a/crates/grida-canvas/src/lib.rs +++ b/crates/grida-canvas/src/lib.rs @@ -6,6 +6,7 @@ pub mod export; pub mod fonts; pub mod helpers; pub mod hittest; +pub mod html; pub mod io; pub mod layout; pub mod node; diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs index d92990ca67..301ef821bb 100644 --- a/crates/grida-canvas/src/runtime/scene.rs +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -358,6 +358,13 @@ pub struct Renderer { /// [`apply_changes`] consumes the set once per frame and performs /// the correct invalidation for every cache layer. changes: ChangeSet, + /// Picture cache generation + variant key at the time of the last + /// successful prefill. When the cache generation and variant key + /// match, the prefill loop can be skipped entirely — all pictures + /// are already cached from a previous frame. + last_prefill_generation: u64, + last_prefill_variant_key: u64, + last_prefill_layer_count: usize, } impl Renderer { @@ -385,6 +392,37 @@ impl Renderer { // True when the policy differs from STANDARD only in effect-related // fields — content, compositing, and clip policies are unchanged. let can_unify = variant_key != 0 && policy.is_effect_only_variant(); + + // Skip-prefill fast path: when the picture cache generation hasn't + // changed since the last prefill AND we're using the same variant + // key AND the layer count matches, every picture from the previous + // prefill is still valid. Skip the O(N) iteration entirely. + // + // For variant key tracking: when can_unify is true AND the variant + // store is empty (no per-variant entries — all nodes are effect-free), + // we track key=0 since all pictures live under the default key. This + // is safe across stable/unstable transitions for effect-free scenes. + // Scenes WITH effects track the actual variant_key. + // + // On 135K-node scenes at fit zoom, this eliminates ~800µs of HashMap + // lookups on every cache-warm frame (the common case during view-only + // pan/zoom interaction and settle frames). + let effective_key_for_tracking = + if can_unify && self.scene_cache.picture.variant_store_is_empty() { + 0 + } else { + variant_key + }; + + let current_gen = self.scene_cache.picture.generation(); + let layer_count: usize = plan.regions.iter().map(|(_, idx)| idx.len()).sum(); + if current_gen == self.last_prefill_generation + && effective_key_for_tracking == self.last_prefill_variant_key + && layer_count == self.last_prefill_layer_count + { + return; + } + // Prefill picture cache for visible layers so Painter can reuse pictures even with masks. // Fast path: skip clone + recording when the picture is already cached (common case // on cache-warm frames). The clone of LayerEntry is expensive because it deep-copies @@ -433,6 +471,17 @@ impl Renderer { } } } + + // Update tracking state for future skip-prefill checks. + let effective_key_after = if can_unify && self.scene_cache.picture.variant_store_is_empty() + { + 0 + } else { + variant_key + }; + self.last_prefill_generation = self.scene_cache.picture.generation(); + self.last_prefill_variant_key = effective_key_after; + self.last_prefill_layer_count = layer_count; } /// Pre-extract blit data for all promoted nodes. @@ -608,6 +657,9 @@ impl Renderer { pan_image_cache: None, zoom_image_cache: None, changes: ChangeSet::new(), + last_prefill_generation: u64::MAX, + last_prefill_variant_key: u64::MAX, + last_prefill_layer_count: 0, } } @@ -1488,6 +1540,9 @@ impl Renderer { self.scene_cache = cache::scene::SceneCache::new(); self.pan_image_cache = None; self.zoom_image_cache = None; + self.last_prefill_generation = u64::MAX; + self.last_prefill_variant_key = u64::MAX; + self.last_prefill_layer_count = 0; self.images.clear_missing_tracking(); if let Some(scene) = self.scene.as_ref() { #[cfg(feature = "perf")] diff --git a/crates/grida-dev/src/main.rs b/crates/grida-dev/src/main.rs index 4b2e504aac..9e7b3247a7 100644 --- a/crates/grida-dev/src/main.rs +++ b/crates/grida-dev/src/main.rs @@ -23,7 +23,7 @@ use winit::event_loop::EventLoopProxy; about = "Rust-native dev runtime for previewing grida-canvas scenes with winit.\n\n\ Opens an interactive window. Optionally pass a file path or URL to load it\n\ immediately. Drop files onto the window at any time to replace the scene.\n\n\ - Supported formats: .grida, .grida1, .svg, .png, .jpg, .jpeg, .webp" + Supported formats: .grida, .grida1, .svg, .html, .png, .jpg, .jpeg, .webp" )] struct Cli { /// File path or URL to load on startup (optional). @@ -192,6 +192,7 @@ async fn load_scenes_from_source(source: &str) -> Result> { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { match ext.to_ascii_lowercase().as_str() { "svg" => return scene_from_svg_path(path).map(|s| vec![s]), + "html" | "htm" => return scene_from_html_path(path).map(|s| vec![s]), // Raster images should be loaded via load_raster() by the caller // so bytes can be registered with the renderer. ext if is_raster_ext(ext) => return load_raster(path).map(|r| vec![r.scene]), @@ -317,6 +318,23 @@ fn scene_from_svg_path(path: &Path) -> Result { }) } +fn scene_from_html_path(path: &Path) -> Result { + use cg::cg::prelude::CGColor; + let html_source = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + let graph = cg::html::from_html_str(&html_source) + .map_err(|err| anyhow::anyhow!("failed to convert HTML {}: {err}", path.display()))?; + + Ok(Scene { + name: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "HTML".to_string()), + graph, + background_color: Some(CGColor::from_u32(0xFFFFFFFF)), + }) +} + /// Result of loading a raster image: the scene plus the raw bytes and RID /// so the caller can register the image with the renderer directly. struct RasterScene { @@ -384,6 +402,7 @@ async fn load_master_scenes_from_path(path: &Path) -> Result> { match ext.as_str() { "grida" | "grida1" => load_scenes_from_source(&path.to_string_lossy()).await, "svg" => scene_from_svg_path(path).map(|s| vec![s]), + "html" | "htm" => scene_from_html_path(path).map(|s| vec![s]), // Raster images are handled separately in start_master_drop_task. other => Err(anyhow::anyhow!( "Unsupported dropped file type ({}): {}", diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 59c511c9dc..c830c667d1 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -92,6 +92,7 @@ This prevents all MDX-related parsing issues for the entire file. | directory | name | description | active | | -------------------------------- | ------------- | ---------------------------------------------------------------------- | ------ | | [/docs/wg](./wg) | working group | working group documents, architecture documents, todo list, etc | yes | +| [/docs/wg/format](./wg/format) | format | Grida IR spec and CSS/HTML/SVG import mapping trackers | yes | | [/docs/reference](./reference) | reference | glossary and references (technical documents) | yes | | [/docs/math](./math) | math | Math reference, used for internal docs referencing | yes | | [/docs/platform](./platform) | platform | Grida Platform (API/Spec) documents | yes | diff --git a/docs/wg/feat-2d/optimization.md b/docs/wg/feat-2d/optimization.md index e70bb3d5b5..fe6f5b51ff 100644 --- a/docs/wg/feat-2d/optimization.md +++ b/docs/wg/feat-2d/optimization.md @@ -1187,6 +1187,47 @@ expensive full redraws. - `runtime/scene.rs` — `apply_changes()` for `last_had_data_changes` - `window/application.rs` — `frame()` vs `redraw()` dual-path issue +48. **Picture Cache Prefill Skip (Generation Tracking)** ✅ IMPLEMENTED + + The `prefill_picture_cache_for_plan()` loop iterates ALL visible nodes + each frame to check if their `SkPicture` is cached, doing a HashMap + lookup per node. On cache-warm frames (the common case during view-only + pan/zoom), every lookup succeeds and no work is done — but the iteration + itself costs O(N) per frame. + + **The optimization:** track a monotonically increasing `generation` + counter on `PictureCache` that increments on any mutation (insert, + invalidate). The prefill stores the generation, variant key, and layer + count after each successful pass. On the next frame, if all three + match, the entire loop is skipped in O(1). + + For effect-free scenes (the common case for large design docs), the + variant key unification optimization stores all pictures under key=0 + regardless of stable/unstable quality. The generation-based skip is + safe across stable/unstable transitions because the cache contents + are identical. + + **Measured impact (Apple M2 Pro, GPU benchmark, 01-135k 135K nodes):** + + | Scenario | Metric | Before | After | Delta | + | -------- | ------ | ------ | ----- | ----- | + | rt_pan_fast_fit | p50 frame | 111 µs | 76 µs | **-32%** | + | rt_pan_fast_fit | p95 frame | 263 µs | 153 µs | **-42%** | + | rt_pan_slow_fit | settle | 2,323 µs | 1,836 µs | **-21%** | + | pan_settle_slow_fit | avg | 87 µs | 59 µs | **-32%** | + | pan_settle_slow_fit | settle | 1,034 µs | 709 µs | **-31%** | + + **Criterion (CPU raster, 2000-node scene, statistically rigorous):** + + | Scene | Change | p-value | + | ----- | ------ | ------- | + | large_baseline/pan | **-14.0%** | < 0.01 | + | large_baseline/pan_zoomed_in | -5.4% | 0.02 | + | large_compositing/pan | -4.2% | 0.02 | + + Implementation: `PictureCache.generation` in `cache/picture.rs`, + `Renderer.last_prefill_*` tracking in `runtime/scene.rs`. + --- This list is designed to evolve the renderer from single-threaded mode to diff --git a/docs/wg/format/css.md b/docs/wg/format/css.md new file mode 100644 index 0000000000..7fadd842cb --- /dev/null +++ b/docs/wg/format/css.md @@ -0,0 +1,212 @@ +--- +title: "CSS Property Mapping" +format: md +tags: + - internal + - wg + - format + - css +--- + +# CSS Property Mapping + +CSS → Grida IR property mapping table and TODO tracker. + +**Status key:** ✅ mapped | ⚠️ partial | 🔧 IR exists, not wired | ❌ IR missing | 🚫 out of scope + +**Import pipelines:** HTML import (`crates/grida-canvas/src/html/`), SVG import (via usvg, `crates/grida-canvas/src/svg/`). + +--- + +## Box Model & Sizing + +| CSS Property | Grida IR Field | Status | Notes | +| --------------------- | ------------------------------------------------- | ------ | ------------------------------------- | +| `width` | `LayoutDimensionStyle.layout_target_width` | ✅ | px only; % not resolved | +| `height` | `LayoutDimensionStyle.layout_target_height` | ✅ | px only | +| `min-width` | `LayoutDimensionStyle.layout_min_width` | ✅ | | +| `min-height` | `LayoutDimensionStyle.layout_min_height` | ✅ | | +| `max-width` | `LayoutDimensionStyle.layout_max_width` | ✅ | | +| `max-height` | `LayoutDimensionStyle.layout_max_height` | ✅ | | +| `padding` (all sides) | `LayoutContainerStyle.layout_padding` | ✅ | EdgeInsets (top/right/bottom/left) | +| `margin` | -- | ❌ | Not in `LayoutChildStyle` | +| `box-sizing` | -- | ⚠️ | Assumed border-box; no explicit field | +| `aspect-ratio` | `LayoutDimensionStyle.layout_target_aspect_ratio` | 🔧 | Field exists, CSS import not wired | + +## Display & Layout + +| CSS Property | Grida IR Field | Status | Notes | +| --------------------------------------------- | ------------------------------------------------ | ------ | ---------------------------------------------- | +| `display: flex` | `LayoutContainerStyle.layout_mode = Flex` | ✅ | | +| `display: block` | `layout_mode = Flex` (column) | ⚠️ | Approximated as flex column | +| `display: none` | (element skipped) | ✅ | Node not emitted | +| `display: inline` | -- | ❌ | Treated as block | +| `display: grid` | -- | ❌ | No grid in `LayoutMode` | +| `flex-direction` (row/column) | `layout_direction` | ✅ | | +| `flex-direction` (row-reverse/column-reverse) | `layout_direction` | ⚠️ | Reverse info lost | +| `flex-wrap` | `layout_wrap` | ✅ | | +| `flex-wrap: wrap-reverse` | `layout_wrap` | ⚠️ | Reverse info lost | +| `align-items` | `layout_cross_axis_alignment` | ✅ | start/center/end/stretch | +| `justify-content` | `layout_main_axis_alignment` | ✅ | start/center/end/between/around/evenly/stretch | +| `flex-grow` | `LayoutChildStyle.layout_grow` | ✅ | | +| `flex-shrink` | -- | ❌ | Not in `LayoutChildStyle`; Taffy hardcodes 0.0 | +| `flex-basis` | -- | ❌ | Not in IR | +| `align-self` | -- | ❌ | Not in `LayoutChildStyle` | +| `order` | -- | ❌ | Not in IR | +| `gap` / `row-gap` / `column-gap` | `LayoutContainerStyle.layout_gap` | ✅ | Direction-aware mapping | +| `position: absolute` | `LayoutChildStyle.layout_positioning = Absolute` | ✅ | | +| `position: relative` | `LayoutChildStyle.layout_positioning = Auto` | ✅ | | +| `position: fixed/sticky` | -- | ❌ | No viewport-relative positioning | +| `top/right/bottom/left` | -- | ❌ | No offset in `LayoutChildStyle` | + +## Background & Paint + +| CSS Property | Grida IR Field | Status | Notes | +| ------------------------- | ----------------------- | ------ | ------------------------------- | +| `background-color` | `Paint::Solid` in fills | ✅ | hex/rgb/rgba/named | +| `linear-gradient()` | `Paint::LinearGradient` | ✅ | | +| `radial-gradient()` | `Paint::RadialGradient` | ✅ | | +| `conic-gradient()` | `Paint::SweepGradient` | ✅ | | +| `background-image: url()` | `Paint::Image` | 🔧 | IR exists, CSS import not wired | +| `background-size` | -- | ❌ | Not mapped | +| `background-position` | -- | ❌ | Not mapped | +| `background-repeat` | -- | ❌ | Not mapped | +| `background-clip: text` | -- | ❌ | No text-clip paint | + +## Opacity & Blend + +| CSS Property | Grida IR Field | Status | Notes | +| ---------------- | ---------------------------------- | ------ | ------------ | +| `opacity` | `node.opacity` | ✅ | | +| `mix-blend-mode` | `LayerBlendMode::Blend(BlendMode)` | ✅ | All 16 modes | + +## Border + +| CSS Property | Grida IR Field | Status | Notes | +| ------------------------------------ | -------------------------------------- | ------ | ---------------------------------- | +| `border-width` | `StrokeWidth` (Uniform or Rectangular) | ✅ | Per-side widths | +| `border-color` | `Paints` (strokes) | ✅ | Single color for all sides | +| `border-style` (solid/dashed/dotted) | `StrokeStyle.stroke_dash_array` | ✅ | | +| `border-radius` | `RectangularCornerRadius` | ✅ | Per-corner, elliptical | +| Per-side border colors | -- | ⚠️ | Only first visible side color used | +| `border-image` | -- | ❌ | No gradient/image stroke | + +## Shadow & Effects + +| CSS Property | Grida IR Field | Status | Notes | +| ------------------------- | --------------------------------- | ------ | ------------------------------------ | +| `box-shadow` | `FilterShadowEffect::DropShadow` | ✅ | dx/dy/blur/spread/color | +| `box-shadow: inset` | `FilterShadowEffect::InnerShadow` | ✅ | | +| `text-shadow` | `FilterShadowEffect::DropShadow` | ✅ | On TextSpan nodes; no spread | +| `filter: blur()` | `FeLayerBlur` | ✅ | | +| `filter: drop-shadow()` | `FilterShadowEffect::DropShadow` | ✅ | | +| `backdrop-filter: blur()` | `FeBackdropBlur` | 🔧 | Code exists; Stylo servo mode blocks | +| `filter: brightness()` | -- | ❌ | No IR for non-blur filters | +| `filter: contrast()` | -- | ❌ | | +| `filter: grayscale()` | -- | ❌ | | +| `filter: sepia()` | -- | ❌ | | +| `filter: hue-rotate()` | -- | ❌ | | +| `filter: invert()` | -- | ❌ | | +| `filter: saturate()` | -- | ❌ | | + +## Text + +| CSS Property | Grida IR Field | Status | Notes | +| --------------------------- | --------------------------------------------- | ------ | ---------------------------------------------------------------- | +| `font-size` | `TextStyleRec.font_size` | ✅ | | +| `font-weight` | `TextStyleRec.font_weight` | ✅ | | +| `font-family` | `TextStyleRec.font_family` | ✅ | | +| `font-style: italic` | `TextStyleRec.font_style_italic` | ✅ | | +| `color` | `TextSpanNodeRec.fills` (Solid) | ✅ | Inherited | +| `text-align` | `TextAlign` | ✅ | left/right/center/justify | +| `line-height` | `TextLineHeight` (Factor or Fixed) | ✅ | | +| `letter-spacing` | `TextLetterSpacing::Fixed` | ✅ | | +| `word-spacing` | `TextWordSpacing::Fixed` | ✅ | | +| `text-transform` | `TextTransform` | ✅ | uppercase/lowercase/capitalize | +| `text-decoration-line` | `TextDecorationRec.text_decoration_line` | ✅ | underline/overline/line-through | +| `text-decoration-color` | `TextDecorationRec.text_decoration_color` | ✅ | | +| `text-decoration-style` | `TextDecorationRec.text_decoration_style` | ✅ | solid/double/dotted/dashed/wavy | +| `text-decoration-thickness` | `TextDecorationRec.text_decoration_thickness` | 🔧 | IR field exists; Stylo servo mode doesn't expose it (gecko-only) | +| `text-decoration-skip-ink` | `TextDecorationRec.text_decoration_skip_ink` | 🔧 | IR field exists; Stylo servo mode doesn't expose it (gecko-only) | +| `white-space` | -- | ❌ | Not enforced | +| `text-overflow` | -- | ❌ | No IR field | +| `vertical-align` | -- | ❌ | No baseline offset | +| `text-indent` | -- | ❌ | No IR field | +| `font-variant` | -- | ❌ | Not mapped | + +## Transform + +| CSS Property | Grida IR Field | Status | Notes | +| ------------------ | ----------------- | ------ | --------------------------------------------- | +| `transform` (2D) | `AffineTransform` | 🔧 | IR exists on every node; CSS import not wired | +| `transform` (3D) | -- | ❌ | IR is 2D only | +| `transform-origin` | -- | ❌ | No pivot point in IR | + +## Overflow & Clip + +| CSS Property | Grida IR Field | Status | Notes | +| --------------------------- | ----------------------- | ------ | ------------------------- | +| `overflow` | `ContainerNodeRec.clip` | ✅ | Single bool for both axes | +| `overflow-x` / `overflow-y` | `clip` | ⚠️ | Merged to single bool | +| `clip-path` | -- | ❌ | No arbitrary clip shape | + +## Visibility + +| CSS Property | Grida IR Field | Status | Notes | +| ---------------------- | -------------- | ------ | ------------------------------------------------ | +| `visibility: hidden` | -- | ❌ | Needs dedicated field; NOT opacity:0 (see below) | +| `visibility: collapse` | -- | ❌ | Same | + +> **Design note:** `visibility: hidden` keeps layout space, suppresses paint, blocks pointer events, and is **overridable by children** (`visibility: visible`). None of these semantics match `opacity: 0`. The IR needs a per-node `visible: bool` field. Chromium implements this as a paint-skip flag, not a compositing trick. + +## Interaction (out of scope) + +| CSS Property | Status | Notes | +| -------------------------- | ------ | ------------- | +| `cursor` | 🚫 | Runtime-only | +| `pointer-events` | 🚫 | Runtime-only | +| `user-select` | 🚫 | Runtime-only | +| `transition` / `animation` | 🚫 | Static format | +| `@keyframes` | 🚫 | Static format | + +--- + +## IR Gaps + +Properties blocked by missing schema fields, grouped by the change that would unblock them. + +### 1. `LayoutChildStyle` expansion + +**Unblocks:** `flex-shrink`, `flex-basis`, `align-self`, `margin`, `order`, `top`/`right`/`bottom`/`left` + +Current `LayoutChildStyle` only has `layout_grow: f32` and `layout_positioning: LayoutPositioning`. Adding shrink, basis, self-alignment, and margins would complete the flex-child model. + +### 2. Visibility field + +**Unblocks:** `visibility: hidden`, `visibility: collapse` + +Needs a per-node `visible: bool` (or enum). Must be inherited and child-overridable. Distinct from opacity and active. + +### 3. Grid layout + +**Unblocks:** `display: grid`, `grid-template-columns`, `grid-template-rows`, `grid-area`, `place-items` + +Requires a new `LayoutMode::Grid` and track definition types. + +### 4. Non-blur filter functions + +**Unblocks:** `brightness()`, `contrast()`, `grayscale()`, `sepia()`, `hue-rotate()`, `invert()`, `saturate()` + +Could be modeled as a color matrix or individual filter effect variants. + +### 5. Transform origin + +**Unblocks:** `transform-origin` + +Currently transforms are applied around (0, 0). A pivot point field on `AffineTransform` or the node would enable center/corner-based transforms. + +### 6. Flex direction reverse + +**Unblocks:** `flex-direction: row-reverse` / `column-reverse`, `flex-wrap: wrap-reverse` + +`Axis` enum only has `Horizontal`/`Vertical`. Needs reverse variants or a separate bool. diff --git a/docs/wg/format/grida.md b/docs/wg/format/grida.md new file mode 100644 index 0000000000..0c8a34f58c --- /dev/null +++ b/docs/wg/format/grida.md @@ -0,0 +1,199 @@ +--- +title: "Grida IR" +format: md +tags: + - internal + - wg + - format +--- + +# Grida IR -- Format Reference + +The Grida IR is the in-memory scene graph used by all Grida rendering, layout, and editing pipelines. It is the single representation that CSS, HTML, SVG, and `.grida` files all target. + +**Canonical sources:** + +- Rust runtime model: `crates/grida-canvas/src/node/schema.rs` +- FlatBuffers schema: `format/grida.fbs` +- TypeScript model: `packages/grida-canvas-schema/grida.ts` + +## Node Types + +| Node | Rust Type | Description | +| ------------------ | ----------------------------- | --------------------------------------------------------------------- | +| InitialContainer | `InitialContainerNodeRec` | Viewport root (like ``). Structural only, no visual properties. | +| Container | `ContainerNodeRec` | Box with children. Supports layout, paint, effects, clip. | +| Group | `GroupNodeRec` | Logical grouping. Blend mode + opacity, no own paint. | +| Tray | `TrayNodeRec` | Specialized container (component-like). | +| Rectangle | `RectangleNodeRec` | Filled/stroked rectangle with corner radius. | +| Ellipse | `EllipseNodeRec` | Ellipse with arc data (start/end/inner ratio). | +| Line | `LineNodeRec` | Straight line segment. | +| Path | `PathNodeRec` | Arbitrary SVG-style path. | +| Polygon | `PolygonNodeRec` | Arbitrary polygon vertices. | +| RegularPolygon | `RegularPolygonNodeRec` | N-sided regular polygon. | +| RegularStarPolygon | `RegularStarPolygonNodeRec` | Star with inner/outer radii. | +| Vector | `VectorNodeRec` | Vector network (Figma-style). | +| BooleanOperation | `BooleanPathOperationNodeRec` | Union/subtract/intersect/exclude of child paths. | +| TextSpan | `TextSpanNodeRec` | Single-style text run. | +| AttributedText | `AttributedTextNodeRec` | Rich text (multiple styled runs). | +| Image | `ImageNodeRec` | Raster image (embedded or referenced). | +| Error | `ErrorNodeRec` | Placeholder for failed imports. | + +## Common Properties + +Fields shared across most node types: + +| Field | Type | Description | +| ------------ | ----------------------- | ------------------------------------------ | +| `active` | `bool` | Whether node is visible/active | +| `opacity` | `f32` | 0.0 (transparent) to 1.0 (opaque) | +| `blend_mode` | `LayerBlendMode` | PassThrough or Blend(BlendMode) | +| `transform` | `AffineTransform` | 2D affine (3x2 matrix) | +| `mask` | `Option` | Alpha or luminance mask | +| `effects` | `LayerEffects` | Blur, backdrop blur, shadows, glass, noise | + +## Geometry + +| Field | Type | Applies to | +| ------------------ | ------------------------- | --------------------------------- | +| `size` | `Size { width, height }` | Rectangle, Line, Image, etc. | +| `corner_radius` | `RectangularCornerRadius` | Rectangle, Container (per-corner) | +| `corner_smoothing` | `CornerSmoothing` | Rectangle, Container (iOS-style) | + +## Paint + +### Fills & Strokes + +Both use `Paints` (ordered list of `Paint`): + +| Paint Variant | Description | +| ---------------- | ------------------------------------------ | +| `Solid` | `SolidPaint { color, blend_mode, active }` | +| `LinearGradient` | Start/end points, color stops | +| `RadialGradient` | Center, radius, color stops | +| `SweepGradient` | Center, start/end angle, color stops | +| `Image` | `ImagePaint { src, fit, tile }` | + +### Stroke Properties + +| Field | Type | +| --------------------------------- | ----------------------------------------------------- | +| `stroke_width` | `StrokeWidth` (Uniform `f32` or Rectangular per-side) | +| `stroke_style.stroke_align` | `StrokeAlign` (Inside, Outside, Center) | +| `stroke_style.stroke_cap` | `StrokeCap` (Butt, Round, Square) | +| `stroke_style.stroke_join` | `StrokeJoin` (Miter, Round, Bevel) | +| `stroke_style.stroke_miter_limit` | `StrokeMiterLimit` | +| `stroke_style.stroke_dash_array` | `Option` | + +## Layout + +### Container Layout (`LayoutContainerStyle`) + +| Field | Type | Description | +| ----------------------------- | ---------------------------- | ------------------------------------------------------------------- | +| `layout_mode` | `LayoutMode` | `Normal` or `Flex` | +| `layout_direction` | `Axis` | `Horizontal` or `Vertical` | +| `layout_wrap` | `Option` | `NoWrap` or `Wrap` | +| `layout_main_axis_alignment` | `Option` | Start, Center, End, SpaceBetween, SpaceAround, SpaceEvenly, Stretch | +| `layout_cross_axis_alignment` | `Option` | Start, Center, End, Stretch | +| `layout_padding` | `Option` | top, right, bottom, left | +| `layout_gap` | `Option` | main_axis_gap, cross_axis_gap | + +### Child Layout (`LayoutChildStyle`) + +| Field | Type | Description | +| -------------------- | ------------------- | -------------------------------- | +| `layout_grow` | `f32` | Flex grow factor (0.0 = no grow) | +| `layout_positioning` | `LayoutPositioning` | `Auto` or `Absolute` | + +**Not yet in schema:** flex-shrink, margin, align-self, order. + +### Dimensions (`LayoutDimensionStyle`) + +| Field | Type | +| ---------------------------- | -------------------- | +| `layout_target_width` | `Option` | +| `layout_target_height` | `Option` | +| `layout_min_width` | `Option` | +| `layout_max_width` | `Option` | +| `layout_min_height` | `Option` | +| `layout_max_height` | `Option` | +| `layout_target_aspect_ratio` | `Option<(f32, f32)>` | + +## Effects (`LayerEffects`) + +| Field | Type | Description | +| --------------- | ------------------------- | ------------------------------------ | +| `blur` | `Option` | Layer blur (Gaussian or Progressive) | +| `backdrop_blur` | `Option` | Backdrop blur | +| `shadows` | `Vec` | DropShadow or InnerShadow | +| `glass` | `Option` | Liquid glass effect | +| `noises` | `Vec` | Noise grain effects | + +### Shadow (`FeShadow`) + +``` +FeShadow { dx, dy, blur, spread, color, active } +``` + +Wrapped in `FilterShadowEffect::DropShadow(FeShadow)` or `FilterShadowEffect::InnerShadow(FeShadow)`. + +## Blend Modes (`BlendMode`) + +Normal, Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn, HardLight, SoftLight, Difference, Exclusion, Hue, Saturation, Color, Luminosity. + +`LayerBlendMode::PassThrough` = non-isolated (children blend with backdrop). +`LayerBlendMode::Blend(mode)` = isolated compositing with specified mode. + +## Text + +### `TextSpanNodeRec` + +| Field | Type | +| --------------------- | ------------------------------------------------------- | +| `text` | `String` | +| `text_style` | `TextStyleRec` | +| `text_align` | `TextAlign` (Left, Right, Center, Justify) | +| `text_align_vertical` | `TextAlignVertical` (Top, Center, Bottom) | +| `width` | `Option` (wrapping width) | +| `height` | `Option` (container height for vertical alignment) | + +### `TextStyleRec` + +| Field | Type | +| ------------------- | -------------------------------------------------------- | +| `font_size` | `f32` | +| `font_weight` | `FontWeight(u32)` | +| `font_family` | `String` | +| `font_style_italic` | `bool` | +| `line_height` | `TextLineHeight` (Factor or Fixed) | +| `letter_spacing` | `TextLetterSpacing` (Fixed or Percentage) | +| `word_spacing` | `TextWordSpacing` (Fixed or Percentage) | +| `text_transform` | `TextTransform` (None, Uppercase, Lowercase, Capitalize) | +| `text_decoration` | `Option` | + +### `TextDecorationRec` + +| Field | Type | +| --------------------------- | ------------------------------------------------------------------- | +| `text_decoration_line` | `TextDecorationLine` (None, Underline, Overline, LineThrough) | +| `text_decoration_color` | `Option` | +| `text_decoration_style` | `Option` (Solid, Double, Dotted, Dashed, Wavy) | +| `text_decoration_skip_ink` | `Option` | +| `text_decoration_thickness` | `Option` | + +## Clip / Overflow + +`ContainerNodeRec.clip: bool` -- when true, children are clipped to the container's rounded-rect bounds. The container's own stroke and outer effects are not clipped. + +## Transform (`AffineTransform`) + +2D affine transform as a 3x2 matrix: + +``` +| m00 m01 m02 | | scaleX skewX translateX | +| m10 m11 m12 | = | skewY scaleY translateY | +| 0 0 1 | | 0 0 1 | +``` + +Identity = `[[1,0,0],[0,1,0]]`. Every node with geometry has a transform field. diff --git a/docs/wg/format/html.md b/docs/wg/format/html.md new file mode 100644 index 0000000000..5673311c38 --- /dev/null +++ b/docs/wg/format/html.md @@ -0,0 +1,106 @@ +--- +title: "HTML Element Mapping" +format: md +tags: + - internal + - wg + - format + - html +--- + +# HTML Element Mapping + +How HTML elements map to Grida IR nodes. For CSS property mapping, see [css.md](./css.md). + +## Structural / Sectioning + +| Element | Grida IR Node | Status | Notes | +| ----------- | --------------------- | ------ | -------------------------------------------- | +| `` | Container | ✅ | Always emitted as root container | +| `` | Container | ✅ | Always emitted as root container | +| `
` | Container / Rectangle | ✅ | Container if has children, Rectangle if leaf | +| `
` | Container / Rectangle | ✅ | Same as `
` | +| `
` | Container / Rectangle | ✅ | Same as `
` | +| `