Fix render engine: CSS grid, cascade correctness, SVG, inline flow
The render layer had no CSS Grid support, so Vector 2022's page layout
(three nested grids) collapsed to a vertical stack with the article
landing tens of thousands of pixels down the page. Wikipedia rendered
as a near-blank page and Hacker News only worked through per-site
hardcoded CSS overrides.
Removed the HN-specific hardcoding and fixed the general engine instead:
- CSS Grid layout (grid-template-areas/columns/rows via taffy's grid mode)
- Indexed, specificity-correct cascade in a new css.rs, replacing the
old test-every-selector-against-every-node scan. Cascade time on
Wikipedia went from 9.5s to under 200ms.
- Ancestor bloom-filter fast-rejection for selector matching in
obscura-dom, needed to make the indexed cascade actually fast on
descendant combinators.
- :link/:visited pseudo-classes were previously unparseable, so any
rule using them (a:link{color:...}, extremely common) silently
dropped the whole rule from the cascade.
- overflow:hidden clipping, fixing the pervasive visually-hidden-but-
accessible pattern used for skip links and screen-reader-only labels.
- @media parser robustness: it did a literal substring search for
max-width: and silently dropped the constraint on max-width : 750px
(space before colon) or max-width:calc(750px - 1px), treating
mobile-only or narrow-viewport rules as always active. The calc()
case was hiding Wikipedia's TOC sidebar via a rule meant only for
narrow screens.
- Table auto-layout approximation: the trailing auto-width td in a row
absorbs leftover space, so a width:100% table's unsized last column
reaches the right edge instead of shrinking to content.
- Inline formatting context approximation: a block holding inline-level
children (a paragraph or list item with links) is promoted to a
wrapping flex row so text and links read as continuous prose instead
of one fragment per line.
- Whitespace-only text nodes no longer get a full line-height box. They
used to stack up as blank rows, turning Wikipedia's collapsed TOC
subsections and the page header into hundreds of pixels of dead space.
- SVG rendering via resvg/usvg, plus resolving relative image URLs
against the page's base URL.
- background-image support including background-size/background-position
for correctly sized and positioned icons.
- Per-screenshot image fetch cache.
- Fixed the layout-only (no paint feature) build, which didn't compile
due to an unconditional call into paint-gated code.
This commit is contained in:
1 parent
cd1ee2dcc5
commit
920977776c
12 files changed
+1949
-225
No files matched your search
@@ -0,0 +1,20 @@
|
||||
// Offline paint harness: read an HTML file, paint it at a viewport, write PNG.
|
||||
// Usage: paint_file <input.html> <output.png> [width] [height] [base_url]
|
||||
use std::fs;
|
||||
use obscura_dom::tree_sink::parse_html;
|
||||
use obscura_render::screenshot_png;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = args.get(1).map(|s| s.as_str()).unwrap_or("wiki_prepared.html");
|
||||
let output = args.get(2).map(|s| s.as_str()).unwrap_or("wiki_out.png");
|
||||
let w: f32 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(1280.0);
|
||||
let h: f32 = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(720.0);
|
||||
let base_url = args.get(5).map(|s| s.as_str());
|
||||
|
||||
let html = fs::read_to_string(input).expect("read input html");
|
||||
let tree = parse_html(&html);
|
||||
let png = screenshot_png(&tree, (w, h), base_url).expect("screenshot");
|
||||
fs::write(output, &png).expect("write png");
|
||||
eprintln!("wrote {} ({} bytes) at {}x{}", output, png.len(), w, h);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Stylesheet cascade: parse `<style>` text into rules once, index each rule by
|
||||
//! its subject key (id / class / tag), and resolve the matching declarations for
|
||||
//! an element by testing only the handful of candidate rules that share a key.
|
||||
//!
|
||||
//! This replaces the naive "run every selector against the whole tree" approach,
|
||||
//! which is O(rules x nodes) and dominated render time on large pages (thousands
|
||||
//! of rules). The indexed cascade is closer to how real browsers match: bucket
|
||||
//! rules, gather candidates per element, then match and sort by specificity.
|
||||
|
||||
use obscura_dom::selector::{CompiledSelector, Matcher, SelectorKey};
|
||||
use obscura_dom::tree::{DomTree, NodeId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::LayoutStyle;
|
||||
|
||||
struct Rule {
|
||||
sel: CompiledSelector,
|
||||
decls: String,
|
||||
/// Source order, for breaking specificity ties (later wins).
|
||||
order: usize,
|
||||
}
|
||||
|
||||
/// An indexed set of author rules ready for fast per-element matching.
|
||||
pub struct Stylesheet {
|
||||
rules: Vec<Rule>,
|
||||
by_id: HashMap<String, Vec<usize>>,
|
||||
by_class: HashMap<String, Vec<usize>>,
|
||||
by_local: HashMap<String, Vec<usize>>,
|
||||
universal: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Stylesheet {
|
||||
/// Parse and index a set of raw CSS sources (the text of each `<style>`
|
||||
/// block, in document order). Selectors that fail to parse are dropped.
|
||||
pub fn parse(tree: &DomTree, sources: &[String]) -> Self {
|
||||
let mut sheet = Stylesheet {
|
||||
rules: Vec::new(),
|
||||
by_id: HashMap::new(),
|
||||
by_class: HashMap::new(),
|
||||
by_local: HashMap::new(),
|
||||
universal: Vec::new(),
|
||||
};
|
||||
let mut order = 0usize;
|
||||
for src in sources {
|
||||
for (selector, decls) in parse_stylesheet(src) {
|
||||
let Some(sel) = tree.compile_rule_selector(&selector) else { continue };
|
||||
let idx = sheet.rules.len();
|
||||
match sel.key() {
|
||||
SelectorKey::Id(v) => sheet.by_id.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Class(v) => sheet.by_class.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Local(v) => sheet.by_local.entry(v.clone()).or_default().push(idx),
|
||||
SelectorKey::Universal => sheet.universal.push(idx),
|
||||
}
|
||||
sheet.rules.push(Rule { sel, decls, order });
|
||||
order += 1;
|
||||
}
|
||||
}
|
||||
sheet
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn debug_stats(&self) -> (usize, usize, usize, usize, usize) {
|
||||
(self.rules.len(), self.by_id.len(), self.by_class.len(), self.by_local.len(), self.universal.len())
|
||||
}
|
||||
|
||||
/// Apply every author rule that matches `nid` to `style`, in cascade order
|
||||
/// (ascending specificity, then source order, so the winner is applied last).
|
||||
/// `id`, `classes`, and `local` are the element's precomputed keys.
|
||||
pub fn apply(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
id: Option<&str>,
|
||||
classes: &[String],
|
||||
local: &str,
|
||||
style: &mut LayoutStyle,
|
||||
) {
|
||||
// (specificity, order, rule index) for each matching rule.
|
||||
let mut matched: Vec<(u32, usize, usize)> = Vec::new();
|
||||
let mut consider = |bucket: Option<&Vec<usize>>, matched: &mut Vec<(u32, usize, usize)>| {
|
||||
if let Some(idxs) = bucket {
|
||||
for &i in idxs {
|
||||
let rule = &self.rules[i];
|
||||
if matcher.matches(tree, nid, &rule.sel) {
|
||||
matched.push((rule.sel.specificity(), rule.order, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
consider(self.by_local.get(local), &mut matched);
|
||||
if let Some(id) = id {
|
||||
consider(self.by_id.get(id), &mut matched);
|
||||
}
|
||||
for c in classes {
|
||||
consider(self.by_class.get(c), &mut matched);
|
||||
}
|
||||
if !self.universal.is_empty() {
|
||||
consider(Some(&self.universal), &mut matched);
|
||||
}
|
||||
|
||||
if matched.is_empty() {
|
||||
return;
|
||||
}
|
||||
matched.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
for (_, _, i) in matched {
|
||||
crate::style::apply_inline(style, &self.rules[i].decls);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a stylesheet into `(selector, declarations)` rules. Handles nested
|
||||
/// braces, `/* comments */`, and the at-rules that carry ordinary rules inside
|
||||
/// (`@media`, `@supports`); other at-rules (`@font-face`, `@keyframes`, ...) are
|
||||
/// skipped since they do not contribute layout-relevant declarations here.
|
||||
pub fn parse_stylesheet(css: &str) -> Vec<(String, String)> {
|
||||
let mut rules = Vec::new();
|
||||
let mut current_selector = String::new();
|
||||
let mut current_decls = String::new();
|
||||
let mut block_depth = 0;
|
||||
let mut in_comment = false;
|
||||
let mut chars = css.chars().peekable();
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if in_comment {
|
||||
if c == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
in_comment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
in_comment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '{' {
|
||||
if block_depth != 0 {
|
||||
current_decls.push(c);
|
||||
}
|
||||
block_depth += 1;
|
||||
} else if c == '}' {
|
||||
block_depth -= 1;
|
||||
if block_depth == 0 {
|
||||
let sel = current_selector.trim();
|
||||
let decls = current_decls.trim();
|
||||
if let Some(at) = sel.strip_prefix('@') {
|
||||
flush_at_rule(at, sel, decls, &mut rules);
|
||||
} else {
|
||||
for s in sel.split(',') {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
rules.push((s.to_string(), decls.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
current_selector.clear();
|
||||
current_decls.clear();
|
||||
} else {
|
||||
current_decls.push(c);
|
||||
}
|
||||
} else if block_depth > 0 {
|
||||
current_decls.push(c);
|
||||
} else {
|
||||
current_selector.push(c);
|
||||
}
|
||||
}
|
||||
rules
|
||||
}
|
||||
|
||||
/// Handle the at-rules whose bodies contain ordinary rules. For `@media`, apply
|
||||
/// the inner rules only when the query holds for a desktop 1280px viewport.
|
||||
fn flush_at_rule(at: &str, sel: &str, inner: &str, rules: &mut Vec<(String, String)>) {
|
||||
if at.starts_with("media") {
|
||||
if media_query_applies(sel) {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
}
|
||||
} else if at.starts_with("supports") {
|
||||
rules.extend(parse_stylesheet(inner));
|
||||
}
|
||||
// Other at-rules (@font-face, @keyframes, @import, ...) carry no
|
||||
// layout-relevant rules for us, so drop them.
|
||||
}
|
||||
|
||||
/// Coarse `@media` evaluation against an assumed 1280px-wide desktop viewport.
|
||||
///
|
||||
/// Real stylesheets format media features inconsistently
|
||||
/// (`max-width:750px`, `max-width: 750px`, even `max-width : 750px`), so this
|
||||
/// strips whitespace before scanning: CSS gives no semantic meaning to spaces
|
||||
/// inside `(feature: value)`, so it's safe to discard them wholesale rather
|
||||
/// than special-case every formatting variant a site might use.
|
||||
fn media_query_applies(query: &str) -> bool {
|
||||
const VIEWPORT_W: f32 = 1280.0;
|
||||
if query.contains("print") {
|
||||
return false;
|
||||
}
|
||||
let compact: String = query.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
if let Some(px) = extract_px(&compact, "max-width:") {
|
||||
if VIEWPORT_W > px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(px) = extract_px(&compact, "min-width:") {
|
||||
if VIEWPORT_W < px {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Read the number immediately following `prop` in `s`. Callers pass a
|
||||
/// whitespace-stripped `s`, so the first non-digit character always ends the
|
||||
/// number. Handles a bare px value (`750px`) and the extremely common
|
||||
/// responsive-breakpoint idiom `calc(750px - 1px)` (real stylesheets use this
|
||||
/// constantly to express "one pixel narrower than the next breakpoint"):
|
||||
/// evaluated as a simple left-to-right sum of every px term, since css media
|
||||
/// feature calc() expressions in practice are always plain +/- of px values,
|
||||
/// never nested, multiplied, or mixed-unit.
|
||||
fn extract_px(s: &str, prop: &str) -> Option<f32> {
|
||||
let start = s.find(prop)? + prop.len();
|
||||
let rest = &s[start..];
|
||||
if let Some(inner) = rest.strip_prefix("calc(") {
|
||||
let end = inner.find(')')?;
|
||||
return Some(eval_px_sum(&inner[..end]));
|
||||
}
|
||||
let num: String = rest.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();
|
||||
num.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// Sum a sequence of signed px terms like `1120px-1px` (whitespace already
|
||||
/// stripped by the caller) left to right.
|
||||
fn eval_px_sum(expr: &str) -> f32 {
|
||||
let mut total = 0.0;
|
||||
let mut sign = 1.0;
|
||||
let mut num = String::new();
|
||||
let flush = |num: &mut String, sign: f32, total: &mut f32| {
|
||||
if let Ok(v) = num.parse::<f32>() {
|
||||
*total += sign * v;
|
||||
}
|
||||
num.clear();
|
||||
};
|
||||
for c in expr.chars() {
|
||||
match c {
|
||||
'+' => { flush(&mut num, sign, &mut total); sign = 1.0; }
|
||||
'-' => { flush(&mut num, sign, &mut total); sign = -1.0; }
|
||||
c if c.is_ascii_digit() || c == '.' => num.push(c),
|
||||
_ => {} // unit suffix (px) or anything else: not part of the number
|
||||
}
|
||||
}
|
||||
flush(&mut num, sign, &mut total);
|
||||
total
|
||||
}
|
||||
+265
-108
@@ -13,53 +13,134 @@ use taffy::prelude::*;
|
||||
|
||||
use crate::{Rect, to_taffy_style};
|
||||
|
||||
/// Text width for layout. With the `paint` feature this is exact (real glyph
|
||||
/// metrics from the embedded font, shared with rasterization). Without it
|
||||
/// (layout-only builds, e.g. for `getBoundingClientRect`), fall back to a
|
||||
/// standard average-character-width heuristic for proportional fonts so
|
||||
/// `obscura-render` compiles and gives reasonable geometry with its default
|
||||
/// (lightest-weight) feature set, matching RENDER.md's documented "layout
|
||||
/// (default)" mode.
|
||||
#[cfg(feature = "paint")]
|
||||
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
|
||||
crate::paint::measure_text(text, size, is_bold)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "paint"))]
|
||||
fn text_width(text: &str, size: f32, is_bold: bool) -> f32 {
|
||||
const AVG_CHAR_WIDTH_EM: f32 = 0.55;
|
||||
let chars = text.chars().filter(|c| !c.is_control()).count() as f32;
|
||||
let width = chars * size * AVG_CHAR_WIDTH_EM;
|
||||
if is_bold { width * 1.08 } else { width }
|
||||
}
|
||||
|
||||
/// Per-element border boxes after layout, in viewport coordinates.
|
||||
pub struct DomLayout {
|
||||
pub rects: HashMap<NodeId, Rect>,
|
||||
pub styles: HashMap<NodeId, crate::LayoutStyle>,
|
||||
/// The clip rect inherited from ancestor `overflow: hidden` boxes, keyed
|
||||
/// per node. `None` means unclipped. Does not include the node's own
|
||||
/// overflow (that only clips its children, not itself).
|
||||
pub clip_rects: HashMap<NodeId, Option<Rect>>,
|
||||
}
|
||||
|
||||
/// Walk the tree top-down accumulating the clip rect imposed by ancestor
|
||||
/// `overflow: hidden` boxes. Must run after layout, since it needs border
|
||||
/// boxes. This is what makes `overflow: hidden` (used pervasively for the
|
||||
/// "visually hidden but accessible" pattern: a 1x1 absolutely-positioned box
|
||||
/// with clipped overflow) actually hide its contents instead of letting text
|
||||
/// paint wherever the box's static position happens to land.
|
||||
fn resolve_clip_rects(
|
||||
tree: &DomTree,
|
||||
id: NodeId,
|
||||
inherited: Option<Rect>,
|
||||
rects: &HashMap<NodeId, Rect>,
|
||||
styles: &HashMap<NodeId, crate::LayoutStyle>,
|
||||
clip_rects: &mut HashMap<NodeId, Option<Rect>>,
|
||||
) {
|
||||
clip_rects.insert(id, inherited);
|
||||
let next = match (styles.get(&id), rects.get(&id)) {
|
||||
(Some(style), Some(rect)) if style.overflow_hidden => {
|
||||
Some(match inherited {
|
||||
Some(clip) => clip.intersect(rect).unwrap_or(Rect::default()),
|
||||
None => *rect,
|
||||
})
|
||||
}
|
||||
_ => inherited,
|
||||
};
|
||||
for cid in tree.children(id) {
|
||||
resolve_clip_rects(tree, cid, next, rects, styles, clip_rects);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the UA + author style for every element in preorder, maintaining
|
||||
/// `matcher`'s ancestor filter as we descend so descendant-combinator rules
|
||||
/// fast-reject correctly. Non-element nodes (text, comments) are skipped but
|
||||
/// still walked through, since an element may be their descendant.
|
||||
fn cascade_walk(
|
||||
tree: &DomTree,
|
||||
id: NodeId,
|
||||
sheet: &crate::css::Stylesheet,
|
||||
matcher: &mut obscura_dom::selector::Matcher,
|
||||
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
||||
) {
|
||||
let Some(node) = tree.get_node(id) else { return };
|
||||
let is_element = node.as_element().map(|elem| {
|
||||
let mut style = crate::style::ua_style(elem.local.as_ref());
|
||||
if !sheet.is_empty() {
|
||||
let node_id = node.get_attribute("id");
|
||||
let classes: Vec<String> = node
|
||||
.get_attribute("class")
|
||||
.map(|c| c.split_whitespace().map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
sheet.apply(tree, matcher, id, node_id, &classes, elem.local.as_ref(), &mut style);
|
||||
}
|
||||
styles.insert(id, style);
|
||||
});
|
||||
|
||||
if is_element.is_some() {
|
||||
matcher.push_ancestor(tree, id);
|
||||
}
|
||||
for cid in tree.children(id) {
|
||||
cascade_walk(tree, cid, sheet, matcher, styles);
|
||||
}
|
||||
if is_element.is_some() {
|
||||
matcher.pop_ancestor();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out a DOM tree within `viewport` (width, height) in CSS pixels.
|
||||
pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
|
||||
let mut css_rules = Vec::new();
|
||||
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
|
||||
|
||||
// Collect the text of every <style> block in document order.
|
||||
let mut css_sources = Vec::new();
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
if let Some(elem) = node.as_element() {
|
||||
if elem.local.as_ref() == "style" {
|
||||
let css = tree.text_content(nid);
|
||||
css_rules.extend(parse_simple_css(&css));
|
||||
css_sources.push(tree.text_content(nid));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let sheet = crate::css::Stylesheet::parse(tree, &css_sources);
|
||||
let t_parse = t0.elapsed();
|
||||
|
||||
let t1 = std::time::Instant::now();
|
||||
let mut matcher = tree.matcher();
|
||||
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
if let Some(elem) = node.as_element() {
|
||||
styles.insert(nid, crate::style::ua_style(elem.local.as_ref()));
|
||||
}
|
||||
}
|
||||
// A real preorder walk (not a flat descendants() scan) so the matcher's
|
||||
// ancestor bloom filter tracks the current path: push before recursing
|
||||
// into children, pop on the way back out. This is what lets descendant
|
||||
// combinators (".mw-body .firstHeading") fast-reject via the filter
|
||||
// instead of falling back to the always-true "can't reject" case.
|
||||
cascade_walk(tree, tree.document(), &sheet, &mut matcher, &mut styles);
|
||||
if timing {
|
||||
let (r, i, c, l, u) = sheet.debug_stats();
|
||||
eprintln!("[timing] parse+index={:?} cascade={:?} rules={} id_keys={} class_keys={} local_keys={} universal={}", t_parse, t1.elapsed(), r, i, c, l, u);
|
||||
}
|
||||
css_rules.push((".subtext".into(), "color: #828282; font-size: 7pt".into()));
|
||||
css_rules.push((".subline".into(), "color: #828282; font-size: 7pt".into()));
|
||||
css_rules.push((".hnname".into(), "margin-right: 10px; margin-left: 2px".into()));
|
||||
css_rules.push((".pagetop".into(), "align-items: center".into()));
|
||||
css_rules.push((".votearrow".into(), "width: 10px; height: 10px; margin-top: 2px".into()));
|
||||
css_rules.push((".rank".into(), "color: #828282".into()));
|
||||
css_rules.push((".comhead".into(), "color: #828282; font-size: 7pt".into()));
|
||||
css_rules.push(("a".into(), "color: #000000".into()));
|
||||
|
||||
for (selector, decls) in &css_rules {
|
||||
if let Ok(matched) = tree.query_selector_all(selector) {
|
||||
for nid in matched {
|
||||
if let Some(style) = styles.get_mut(&nid) {
|
||||
crate::style::apply_inline(style, decls);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if let Some(node) = tree.get_node(nid) {
|
||||
if node.is_element() {
|
||||
@@ -90,21 +171,13 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
|
||||
crate::style::apply_inline(style, &format!("height: {}", height));
|
||||
}
|
||||
}
|
||||
// For table cells with right alignment, make them grow to push content to the right
|
||||
if let Some(elem) = node.as_element() {
|
||||
let local = elem.local.as_ref();
|
||||
if local == "td" || local == "th" {
|
||||
let is_title = node.get_attribute("class").unwrap_or_default().contains("title");
|
||||
if !is_title && style.justify_content == Some(taffy::JustifyContent::FlexEnd) {
|
||||
style.flex_grow = Some(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
grow_trailing_auto_cells(tree, &mut styles);
|
||||
|
||||
let mut taffy_tree: TaffyTree = TaffyTree::new();
|
||||
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
|
||||
|
||||
@@ -117,25 +190,27 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
|
||||
|
||||
let mut rects = HashMap::new();
|
||||
if let Some(root_id) = root {
|
||||
let mut queue = vec![(root_id, None, None)];
|
||||
while let Some((id, mut parent_color, mut parent_size)) = queue.pop() {
|
||||
// Top-down inheritance of the properties CSS inherits by default.
|
||||
#[derive(Clone, Default)]
|
||||
struct Inherited {
|
||||
color: Option<[u8; 4]>,
|
||||
font_size: Option<f32>,
|
||||
font_weight: Option<String>,
|
||||
}
|
||||
let mut queue = vec![(root_id, Inherited::default())];
|
||||
while let Some((id, mut inh)) = queue.pop() {
|
||||
if let Some(style) = styles.get_mut(&id) {
|
||||
if style.color.is_some() {
|
||||
parent_color = style.color;
|
||||
} else if parent_color.is_some() {
|
||||
style.color = parent_color;
|
||||
}
|
||||
if style.font_size.is_some() {
|
||||
parent_size = style.font_size;
|
||||
} else if parent_size.is_some() {
|
||||
style.font_size = parent_size;
|
||||
}
|
||||
match style.color { Some(c) => inh.color = Some(c), None => style.color = inh.color }
|
||||
match style.font_size { Some(s) => inh.font_size = Some(s), None => style.font_size = inh.font_size }
|
||||
match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() }
|
||||
}
|
||||
for cid in tree.children(id).into_iter().rev() {
|
||||
queue.push((cid, parent_color, parent_size));
|
||||
queue.push((cid, inh.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
resolve_grid_areas(tree, root_id, &mut styles);
|
||||
|
||||
if let Some(taffy_root) = build(tree, root_id, &mut taffy_tree, &mut id_map, &styles) {
|
||||
let _ = taffy_tree.compute_layout(
|
||||
taffy_root,
|
||||
@@ -147,7 +222,105 @@ pub fn layout_dom(tree: &DomTree, viewport: (f32, f32)) -> DomLayout {
|
||||
compute_absolute_rects(&taffy_tree, taffy_root, 0.0, 0.0, &id_map, &mut rects);
|
||||
}
|
||||
}
|
||||
DomLayout { rects, styles }
|
||||
|
||||
let mut clip_rects = HashMap::new();
|
||||
if let Some(root_id) = root {
|
||||
resolve_clip_rects(tree, root_id, None, &rects, &styles, &mut clip_rects);
|
||||
}
|
||||
|
||||
DomLayout { rects, styles, clip_rects }
|
||||
}
|
||||
|
||||
/// HTML table auto-layout approximation: for each `<tr>`, the last `<td>`/
|
||||
/// `<th>` child with no explicit width absorbs the row's leftover space. Real
|
||||
/// browsers run a genuine column-width-negotiation algorithm across every row
|
||||
/// in a table; giving `flex_grow` to *every* auto-width cell approximates that
|
||||
/// badly; multiple growing siblings compete and the split becomes sensitive to
|
||||
/// each cell's own content size, which looks like near-random drift row to
|
||||
/// row. Growing only the trailing auto cell matches the extremely common
|
||||
/// layout intent (fixed-size leading cells, one expanding trailing cell) and
|
||||
/// leaves the others shrink-to-fit, matching a shrink-to-fit table exactly
|
||||
/// when there is no surplus width to distribute in the first place.
|
||||
fn grow_trailing_auto_cells(tree: &DomTree, styles: &mut HashMap<NodeId, crate::LayoutStyle>) {
|
||||
let is_tag = |id: NodeId, tags: &[&str]| -> bool {
|
||||
match tree.get_node(id).and_then(|n| n.as_element().map(|e| e.local.to_string())) {
|
||||
Some(local) => tags.contains(&local.as_str()),
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
for tr in tree.descendants(tree.document()) {
|
||||
if !is_tag(tr, &["tr"]) {
|
||||
continue;
|
||||
}
|
||||
let last_auto_cell = tree.children(tr).into_iter().rev().find(|&cid| {
|
||||
is_tag(cid, &["td", "th"])
|
||||
&& styles.get(&cid).map(|s| s.width == crate::Dimension::Auto && s.flex_grow.is_none()).unwrap_or(false)
|
||||
});
|
||||
if let Some(cid) = last_auto_cell {
|
||||
if let Some(style) = styles.get_mut(&cid) {
|
||||
style.flex_grow = Some(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the tree; for each `display: grid` element that declares
|
||||
/// `grid-template-areas`, resolve each direct child's `grid-area` name to a
|
||||
/// taffy line placement. This is how Vector 2022 (and most grid layouts) place
|
||||
/// their header/sidebar/content/footer regions.
|
||||
fn resolve_grid_areas(
|
||||
tree: &DomTree,
|
||||
root: NodeId,
|
||||
styles: &mut HashMap<NodeId, crate::LayoutStyle>,
|
||||
) {
|
||||
let mut stack = vec![root];
|
||||
while let Some(id) = stack.pop() {
|
||||
for cid in tree.children(id) {
|
||||
stack.push(cid);
|
||||
}
|
||||
let areas = match styles.get(&id) {
|
||||
Some(s) if s.display == crate::Display::Grid => match &s.grid_areas {
|
||||
Some(a) if !a.is_empty() => a.clone(),
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// name -> (row_start, row_end, col_start, col_end) in 0-based track indices.
|
||||
let mut spans: HashMap<String, (usize, usize, usize, usize)> = HashMap::new();
|
||||
for (r, row) in areas.iter().enumerate() {
|
||||
for (c, name) in row.iter().enumerate() {
|
||||
if name == "." {
|
||||
continue;
|
||||
}
|
||||
spans
|
||||
.entry(name.clone())
|
||||
.and_modify(|s| {
|
||||
s.0 = s.0.min(r);
|
||||
s.1 = s.1.max(r);
|
||||
s.2 = s.2.min(c);
|
||||
s.3 = s.3.max(c);
|
||||
})
|
||||
.or_insert((r, r, c, c));
|
||||
}
|
||||
}
|
||||
|
||||
for cid in tree.children(id) {
|
||||
let Some(cstyle) = styles.get_mut(&cid) else { continue };
|
||||
let Some(name) = cstyle.grid_area_name.clone() else { continue };
|
||||
if let Some(&(r0, r1, c0, c1)) = spans.get(&name) {
|
||||
use taffy::style_helpers::line;
|
||||
cstyle.grid_row = Some(taffy::Line {
|
||||
start: line((r0 + 1) as i16),
|
||||
end: line((r1 + 2) as i16),
|
||||
});
|
||||
cstyle.grid_column = Some(taffy::Line {
|
||||
start: line((c0 + 1) as i16),
|
||||
end: line((c1 + 2) as i16),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_absolute_rects(
|
||||
@@ -182,36 +355,18 @@ fn compute_absolute_rects(
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_simple_css(css: &str) -> Vec<(String, String)> {
|
||||
let mut rules = Vec::new();
|
||||
let mut current_selector = String::new();
|
||||
let mut current_decls = String::new();
|
||||
let mut in_block = false;
|
||||
|
||||
for c in css.chars() {
|
||||
if c == '{' && !in_block {
|
||||
in_block = true;
|
||||
} else if c == '}' && in_block {
|
||||
in_block = false;
|
||||
let sel = current_selector.trim();
|
||||
let decls = current_decls.trim();
|
||||
for s in sel.split(',') {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
rules.push((s.to_string(), decls.to_string()));
|
||||
}
|
||||
}
|
||||
current_selector.clear();
|
||||
current_decls.clear();
|
||||
} else {
|
||||
if in_block {
|
||||
current_decls.push(c);
|
||||
} else {
|
||||
current_selector.push(c);
|
||||
}
|
||||
/// Does `id` have any direct child that is inline-level (a non-whitespace
|
||||
/// text node, or an element whose resolved display is `Inline`)? Used to
|
||||
/// decide whether a block container needs the flex-row-wrap approximation of
|
||||
/// an inline formatting context.
|
||||
fn has_inline_content(tree: &DomTree, id: NodeId, styles: &HashMap<NodeId, crate::LayoutStyle>) -> bool {
|
||||
tree.children(id).into_iter().any(|cid| {
|
||||
let Some(node) = tree.get_node(cid) else { return false };
|
||||
match &node.data {
|
||||
obscura_dom::tree::NodeData::Text { contents } => !contents.trim().is_empty(),
|
||||
_ => styles.get(&cid).map(|s| s.display == crate::Display::Inline).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
rules
|
||||
})
|
||||
}
|
||||
|
||||
fn build(
|
||||
@@ -237,10 +392,8 @@ fn build(
|
||||
in_space = false;
|
||||
}
|
||||
}
|
||||
if display_text == " " {
|
||||
display_text = " ".to_string(); // Keep single space
|
||||
}
|
||||
|
||||
|
||||
|
||||
let mut fsize = 16.0;
|
||||
let mut is_bold = false;
|
||||
if let Some(parent_id) = node.parent {
|
||||
@@ -250,9 +403,18 @@ fn build(
|
||||
}
|
||||
}
|
||||
|
||||
let width = crate::paint::measure_text(&display_text, fsize, is_bold);
|
||||
let height = fsize * 1.2;
|
||||
|
||||
let width = text_width(&display_text, fsize, is_bold);
|
||||
// A text node that is pure whitespace is almost always HTML source
|
||||
// formatting (a newline and indentation between sibling tags), not
|
||||
// meaningful content: real browsers collapse it away entirely. We
|
||||
// still give it its natural (small) width, since a bare space
|
||||
// between two inline elements ("<a>Foo</a> <a>Bar</a>") does need to
|
||||
// keep them visually separated, but zero height, so it never adds a
|
||||
// spurious blank row when it lands between block-level siblings
|
||||
// (e.g. formatting whitespace around a run of now-collapsed,
|
||||
// display:none list items).
|
||||
let height = if contents.trim().is_empty() { 0.0 } else { fsize * 1.2 };
|
||||
|
||||
let taffy_style = taffy::Style {
|
||||
size: taffy::Size {
|
||||
width: taffy::Dimension::Length(width),
|
||||
@@ -271,26 +433,21 @@ fn build(
|
||||
return None;
|
||||
}
|
||||
let mut taffy_style = to_taffy_style(style);
|
||||
|
||||
// Emulate HTML table layout by giving the last td in a tr all remaining space
|
||||
if _name.local.as_ref() == "td" || _name.local.as_ref() == "th" {
|
||||
if let Some(parent_id) = node.parent {
|
||||
if let Some(parent) = tree.get_node(parent_id) {
|
||||
if parent.as_element().map_or(false, |e| e.local.as_ref() == "tr") {
|
||||
let mut last_td = None;
|
||||
for cid in tree.children(parent_id) {
|
||||
if let Some(child) = tree.get_node(cid) {
|
||||
if child.as_element().map_or(false, |e| e.local.as_ref() == "td" || e.local.as_ref() == "th") {
|
||||
last_td = Some(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_td == Some(id) {
|
||||
taffy_style.flex_grow = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Taffy has no real inline formatting context: its native Block layout
|
||||
// treats every direct child as its own full-width block box, stacked
|
||||
// vertically. That is correct when a block's children are other block
|
||||
// elements (a div full of divs), but wrong the moment a block holds
|
||||
// inline-level content (running text with <a>/<span>/<b> mixed in, i.e.
|
||||
// any ordinary paragraph or list item), where real CSS instead lays
|
||||
// consecutive inline boxes out left-to-right, wrapping at the container's
|
||||
// width. Approximate that inline formatting context by promoting such
|
||||
// blocks to a wrapping flex row, so text and inline elements actually
|
||||
// flow together on shared lines instead of each getting its own row.
|
||||
if style.display == crate::Display::Block && has_inline_content(tree, id, styles) {
|
||||
taffy_style.display = taffy::style::Display::Flex;
|
||||
taffy_style.flex_direction = taffy::FlexDirection::Row;
|
||||
taffy_style.flex_wrap = taffy::FlexWrap::Wrap;
|
||||
}
|
||||
|
||||
let child_ids: Vec<taffy::NodeId> = tree
|
||||
|
||||
@@ -13,10 +13,13 @@
|
||||
|
||||
use taffy::prelude::*;
|
||||
|
||||
mod style;
|
||||
pub mod css;
|
||||
pub use css::Stylesheet;
|
||||
|
||||
pub mod style;
|
||||
pub use style::compute_style;
|
||||
|
||||
mod dom;
|
||||
pub mod dom;
|
||||
pub use dom::{layout_dom, DomLayout};
|
||||
|
||||
#[cfg(feature = "paint")]
|
||||
@@ -33,6 +36,23 @@ pub struct Rect {
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
/// The overlap of two rects, or `None` if they do not intersect (or the
|
||||
/// overlap is degenerate). Used to accumulate an ancestor clip chain for
|
||||
/// `overflow: hidden`.
|
||||
pub fn intersect(&self, other: &Rect) -> Option<Rect> {
|
||||
let x0 = self.x.max(other.x);
|
||||
let y0 = self.y.max(other.y);
|
||||
let x1 = (self.x + self.width).min(other.x + other.width);
|
||||
let y1 = (self.y + self.height).min(other.y + other.height);
|
||||
if x1 > x0 && y1 > y0 {
|
||||
Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-edge box values (margin / padding / border) in CSS pixels.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct Edges {
|
||||
@@ -49,6 +69,7 @@ pub enum Display {
|
||||
#[default]
|
||||
Block,
|
||||
Flex,
|
||||
Grid,
|
||||
Inline,
|
||||
#[allow(dead_code)]
|
||||
None,
|
||||
@@ -68,11 +89,30 @@ pub struct LayoutStyle {
|
||||
pub display: Display,
|
||||
pub width: Dimension,
|
||||
pub height: Dimension,
|
||||
pub min_width: Dimension,
|
||||
pub min_height: Dimension,
|
||||
pub max_width: Dimension,
|
||||
pub max_height: Dimension,
|
||||
pub margin: Edges,
|
||||
pub padding: Edges,
|
||||
pub border: Edges,
|
||||
/// RGBA for the paint step. Parsed always (cheap), used only with `paint`.
|
||||
pub background_color: Option<[u8; 4]>,
|
||||
/// The first `url(...)` reference from `background`/`background-image`
|
||||
/// (gradients and repeat keywords in the same shorthand are ignored: we
|
||||
/// paint the referenced image, not the gradient layer).
|
||||
pub background_image: Option<String>,
|
||||
/// `background-size`, in px, when given as explicit length(s) (a bare
|
||||
/// `10px` applies to both axes, matching how small square icons are
|
||||
/// almost always sized). `None` means "fill the whole box" (our fallback
|
||||
/// when the value is a keyword we don't evaluate, `cover`/`contain`, or
|
||||
/// absent with an already icon-sized box like the HN vote arrow).
|
||||
pub background_size: Option<(f32, f32)>,
|
||||
/// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default
|
||||
/// top-left; 1,0.5 = "right center"). Only meaningful alongside
|
||||
/// `background_size`: without a known image size there is no leftover
|
||||
/// box space to position within.
|
||||
pub background_position: (f32, f32),
|
||||
/// Foreground (text) color for the paint step.
|
||||
pub color: Option<[u8; 4]>,
|
||||
pub border_color: Option<[u8; 4]>,
|
||||
@@ -80,8 +120,33 @@ pub struct LayoutStyle {
|
||||
pub font_weight: Option<String>,
|
||||
pub align_items: Option<taffy::AlignItems>,
|
||||
pub flex_direction: Option<taffy::FlexDirection>,
|
||||
pub flex_wrap: Option<taffy::FlexWrap>,
|
||||
pub justify_content: Option<taffy::JustifyContent>,
|
||||
pub flex_grow: Option<f32>,
|
||||
pub flex_shrink: Option<f32>,
|
||||
|
||||
// CSS Grid. Tracks are stored as taffy sizing functions; `grid_areas` is the
|
||||
// parsed `grid-template-areas` matrix (one Vec per row, `.` for a null cell),
|
||||
// resolved to line placements on children in a later pass.
|
||||
pub grid_template_columns: Vec<taffy::TrackSizingFunction>,
|
||||
pub grid_template_rows: Vec<taffy::TrackSizingFunction>,
|
||||
pub grid_areas: Option<Vec<Vec<String>>>,
|
||||
pub grid_area_name: Option<String>,
|
||||
pub grid_column: Option<taffy::Line<taffy::GridPlacement>>,
|
||||
pub grid_row: Option<taffy::Line<taffy::GridPlacement>>,
|
||||
pub column_gap: Option<f32>,
|
||||
pub row_gap: Option<f32>,
|
||||
|
||||
// Positioning. `position: absolute|fixed` takes the box out of normal flow.
|
||||
pub position: Option<taffy::Position>,
|
||||
pub inset: [Option<f32>; 4], // top, right, bottom, left
|
||||
|
||||
/// `overflow`/-x/-y other than `visible`: clips this element's descendants
|
||||
/// to its border box during paint. This is what makes the ubiquitous
|
||||
/// "visually-hidden but accessible" pattern (a 1x1 absolutely-positioned,
|
||||
/// clipped box used for skip-links and screen-reader-only labels) actually
|
||||
/// invisible instead of painting its text wherever it lands.
|
||||
pub overflow_hidden: bool,
|
||||
}
|
||||
|
||||
/// A node in the input layout tree. `text` is carried for the paint phase; it
|
||||
@@ -154,15 +219,34 @@ fn read_node(tree: &TaffyTree, id: NodeId) -> NodeRect {
|
||||
|
||||
pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
let mut s = Style::DEFAULT;
|
||||
|
||||
// A block box that wants non-default cross-axis alignment (from
|
||||
// text-align: center/right, the only way our engine currently sets
|
||||
// align_items on a block-level element) needs a flex column to have any
|
||||
// effect at all: taffy's native block algorithm (used for plain
|
||||
// Display::Block) has no align-items concept whatsoever, it only ever
|
||||
// places block children at the start edge. Promote such boxes to a flex
|
||||
// column, matching the same column-flex approximation already used for
|
||||
// elements like <td> and <center>.
|
||||
let promote_for_alignment = style.display == Display::Block
|
||||
&& matches!(style.align_items, Some(taffy::AlignItems::Center) | Some(taffy::AlignItems::FlexEnd));
|
||||
|
||||
s.display = match style.display {
|
||||
Display::Block if promote_for_alignment => taffy::style::Display::Flex,
|
||||
Display::Block => taffy::style::Display::Block,
|
||||
Display::Flex => taffy::style::Display::Flex,
|
||||
Display::Grid => taffy::style::Display::Grid,
|
||||
Display::Inline => taffy::style::Display::Flex,
|
||||
Display::None => taffy::style::Display::None,
|
||||
};
|
||||
if promote_for_alignment {
|
||||
s.flex_direction = taffy::FlexDirection::Column;
|
||||
}
|
||||
if let Some(fd) = style.flex_direction {
|
||||
s.flex_direction = fd;
|
||||
s.flex_wrap = taffy::FlexWrap::NoWrap;
|
||||
}
|
||||
if let Some(fw) = style.flex_wrap {
|
||||
s.flex_wrap = fw;
|
||||
} else if style.display == Display::Inline {
|
||||
s.flex_direction = taffy::FlexDirection::Row;
|
||||
s.flex_wrap = taffy::FlexWrap::Wrap;
|
||||
@@ -173,6 +257,14 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
width: dimension(style.width),
|
||||
height: dimension(style.height),
|
||||
};
|
||||
s.min_size = taffy::Size {
|
||||
width: dimension(style.min_width),
|
||||
height: dimension(style.min_height),
|
||||
};
|
||||
s.max_size = taffy::Size {
|
||||
width: dimension(style.max_width),
|
||||
height: dimension(style.max_height),
|
||||
};
|
||||
if let Some(ai) = style.align_items {
|
||||
s.align_items = Some(ai);
|
||||
}
|
||||
@@ -182,12 +274,58 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
|
||||
if let Some(fg) = style.flex_grow {
|
||||
s.flex_grow = fg;
|
||||
}
|
||||
if let Some(fs) = style.flex_shrink {
|
||||
s.flex_shrink = fs;
|
||||
}
|
||||
|
||||
// Grid container tracks and gaps.
|
||||
if style.display == Display::Grid {
|
||||
if !style.grid_template_columns.is_empty() {
|
||||
s.grid_template_columns = style.grid_template_columns.clone();
|
||||
}
|
||||
if !style.grid_template_rows.is_empty() {
|
||||
s.grid_template_rows = style.grid_template_rows.clone();
|
||||
}
|
||||
}
|
||||
let cg = style.column_gap.unwrap_or(0.0);
|
||||
let rg = style.row_gap.unwrap_or(0.0);
|
||||
s.gap = taffy::Size {
|
||||
width: taffy::style::LengthPercentage::Length(cg),
|
||||
height: taffy::style::LengthPercentage::Length(rg),
|
||||
};
|
||||
|
||||
// Grid item placement (resolved from grid-area names or explicit lines).
|
||||
if let Some(gc) = style.grid_column {
|
||||
s.grid_column = gc;
|
||||
}
|
||||
if let Some(gr) = style.grid_row {
|
||||
s.grid_row = gr;
|
||||
}
|
||||
|
||||
// Positioning. Absolute/fixed take the box out of flow.
|
||||
if let Some(pos) = style.position {
|
||||
s.position = pos;
|
||||
s.inset = taffy::Rect {
|
||||
top: inset_lpa(style.inset[0]),
|
||||
right: inset_lpa(style.inset[1]),
|
||||
bottom: inset_lpa(style.inset[2]),
|
||||
left: inset_lpa(style.inset[3]),
|
||||
};
|
||||
}
|
||||
|
||||
s.margin = rect_auto(style.margin);
|
||||
s.padding = rect_lp(style.padding);
|
||||
s.border = rect_lp(style.border);
|
||||
s
|
||||
}
|
||||
|
||||
fn inset_lpa(v: Option<f32>) -> taffy::style::LengthPercentageAuto {
|
||||
match v {
|
||||
Some(px) => taffy::style::LengthPercentageAuto::Length(px),
|
||||
None => taffy::style::LengthPercentageAuto::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension(v: Dimension) -> taffy::style::Dimension {
|
||||
match v {
|
||||
Dimension::Px(px) => taffy::style::Dimension::Length(px),
|
||||
|
||||
@@ -14,13 +14,21 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf");
|
||||
use crate::layout_dom;
|
||||
|
||||
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
|
||||
/// None if the viewport is zero-sized.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
|
||||
/// relative image URLs (`<img src="logo.svg">`) that make up the overwhelming
|
||||
/// majority of real-world markup; without it only absolute and `data:` URLs
|
||||
/// can be fetched.
|
||||
pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Pixmap> {
|
||||
let (w, h) = (viewport.0 as u32, viewport.1 as u32);
|
||||
let mut pixmap = Pixmap::new(w, h)?;
|
||||
pixmap.fill(Color::WHITE);
|
||||
|
||||
let laid = layout_dom(tree, viewport);
|
||||
// The same URL (an icon sprite, a repeated background image) commonly
|
||||
// backs many elements on one page; fetch each distinct URL at most once
|
||||
// per screenshot. `None` caches a failed fetch too, so a broken image
|
||||
// reference does not retry on every element that references it.
|
||||
let mut image_cache: std::collections::HashMap<String, Option<Vec<u8>>> = std::collections::HashMap::new();
|
||||
// Tree order so later elements paint over earlier ones (normal flow).
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let node = match tree.get_node(nid) {
|
||||
@@ -41,16 +49,29 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(rect.x, rect.y, rect.width, rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
let style = match laid.styles.get(&nid) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
|
||||
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
|
||||
// once the box has no visible overlap with it (this is what makes the
|
||||
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
|
||||
// actually invisible instead of painting text wherever it lands).
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
let visible_rect = match clip {
|
||||
Some(c) => match rect.intersect(&c) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
},
|
||||
None => *rect,
|
||||
};
|
||||
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if let Some(bg) = style.background_color {
|
||||
let mut path = PathBuilder::new();
|
||||
path.push_rect(box_rect);
|
||||
@@ -61,69 +82,78 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if let Some(bg_url) = &style.background_image {
|
||||
// With an explicit background-size, paint the image at that size
|
||||
// positioned within the box per background-position, instead of
|
||||
// stretching it to fill the whole element: a small icon
|
||||
// (background-size: 0.857em) on a wide text link must stay small
|
||||
// and corner-positioned, not balloon to the link's full width.
|
||||
let img_rect = match style.background_size {
|
||||
Some((iw, ih)) => {
|
||||
let (px, py) = style.background_position;
|
||||
crate::Rect {
|
||||
x: rect.x + (rect.width - iw).max(0.0) * px,
|
||||
y: rect.y + (rect.height - ih).max(0.0) * py,
|
||||
width: iw,
|
||||
height: ih,
|
||||
}
|
||||
}
|
||||
None => *rect,
|
||||
};
|
||||
let img_rect = match clip {
|
||||
Some(c) => img_rect.intersect(&c),
|
||||
None => Some(img_rect),
|
||||
};
|
||||
if let Some(img_rect) = img_rect {
|
||||
paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache);
|
||||
}
|
||||
}
|
||||
|
||||
if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
||||
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
|
||||
paint.anti_alias = false;
|
||||
|
||||
|
||||
let mut path = PathBuilder::new();
|
||||
if style.border.top > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width, style.border.top) {
|
||||
path.push_rect(r);
|
||||
let mut push_clipped = |x: f32, y: f32, w: f32, h: f32| {
|
||||
let edge = crate::Rect { x, y, width: w, height: h };
|
||||
let edge = match clip { Some(c) => edge.intersect(&c), None => Some(edge) };
|
||||
if let Some(e) = edge {
|
||||
if let Some(r) = Rect::from_xywh(e.x, e.y, e.width, e.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
}
|
||||
};
|
||||
if style.border.top > 0.0 {
|
||||
push_clipped(rect.x, rect.y, rect.width, style.border.top);
|
||||
}
|
||||
if style.border.right > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height);
|
||||
}
|
||||
if style.border.bottom > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom);
|
||||
}
|
||||
if style.border.left > 0.0 {
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, style.border.left, rect.height) {
|
||||
path.push_rect(r);
|
||||
}
|
||||
push_clipped(rect.x, rect.y, style.border.left, rect.height);
|
||||
}
|
||||
if let Some(path) = path.finish() {
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if name.local.as_ref() == "img" {
|
||||
let src = node.get_attribute("src").unwrap_or("");
|
||||
if src.contains("y18") {
|
||||
let mut paint = tiny_skia::Paint {
|
||||
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 255, 255, 255)),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(r) = Rect::from_xywh(rect.x - 1.0, rect.y - 1.0, rect.width.max(18.0) + 2.0, rect.height.max(18.0) + 2.0) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
if let Some(src) = node.get_attribute("src") {
|
||||
let painted = paint_image(src, base_url, &rect, &mut pixmap, &mut image_cache);
|
||||
// Fall back to alt text only when the image itself did not paint.
|
||||
if !painted {
|
||||
if let Some(alt) = node.get_attribute("alt") {
|
||||
if !alt.is_empty() {
|
||||
draw_text(&mut pixmap, alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
paint.shader = tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba8(255, 102, 0, 255));
|
||||
if let Some(r) = Rect::from_xywh(rect.x, rect.y, rect.width.max(18.0), rect.height.max(18.0)) {
|
||||
pixmap.fill_rect(r, &paint, Transform::identity(), None);
|
||||
draw_text(&mut pixmap, "Y", rect.x + 4.0, rect.y + 2.0, [255, 255, 255, 255], 14.0, false);
|
||||
}
|
||||
} else if let Some(src) = node.get_attribute("src") {
|
||||
paint_image(src, &rect, &mut pixmap);
|
||||
}
|
||||
}
|
||||
|
||||
if node.get_attribute("class").unwrap_or_default() == "votearrow" {
|
||||
let mut pb = tiny_skia::PathBuilder::new();
|
||||
pb.move_to(rect.x + rect.width * 0.5, rect.y);
|
||||
pb.line_to(rect.x + rect.width, rect.y + rect.height);
|
||||
pb.line_to(rect.x, rect.y + rect.height);
|
||||
pb.close();
|
||||
if let Some(path) = pb.finish() {
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(130, 130, 130, 255);
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,8 +162,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32)) -> Option<Pixmap> {
|
||||
|
||||
/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the
|
||||
/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`.
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32)) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport)?.encode_png().ok()
|
||||
pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -> Option<Vec<u8>> {
|
||||
paint_dom(tree, viewport, base_url)?.encode_png().ok()
|
||||
}
|
||||
|
||||
fn paint_text_node(
|
||||
@@ -179,13 +209,9 @@ fn paint_text_node(
|
||||
Some(r) => r,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
if text.contains("zachtronics") || text.contains("Exapunks") {
|
||||
println!("DEBUG text: {:?} rect: {:?}", text, rect);
|
||||
}
|
||||
|
||||
// Paint text at rect.x, rect.y
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold);
|
||||
|
||||
let clip = laid.clip_rects.get(&nid).copied().flatten();
|
||||
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip);
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -202,7 +228,14 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 {
|
||||
width
|
||||
}
|
||||
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool) {
|
||||
fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool, clip: Option<crate::Rect>) {
|
||||
// A fully clipped-away run (the common "visually hidden" accessibility
|
||||
// pattern: a 1x1 box with overflow: hidden) paints nothing at all.
|
||||
if let Some(c) = clip {
|
||||
if c.width <= 0.0 || c.height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let font = FontRef::try_from_slice(FONT_BYTES).unwrap();
|
||||
let scale = PxScale::from(size);
|
||||
let scaled_font = font.as_scaled(scale);
|
||||
@@ -210,6 +243,7 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
|
||||
let width = pixmap.width() as i32;
|
||||
let height = pixmap.height() as i32;
|
||||
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));
|
||||
let pixels = pixmap.pixels_mut();
|
||||
let (r, g, b, a_full) = (color[0], color[1], color[2], color[3]);
|
||||
|
||||
@@ -225,6 +259,11 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
outlined.draw(|gx, gy, c| {
|
||||
let px = (bounds.min.x + gx as f32) as i32;
|
||||
let py = (bounds.min.y + gy as f32) as i32;
|
||||
if let Some((cx0, cy0, cx1, cy1)) = clip_bounds {
|
||||
if (px as f32) < cx0 || (px as f32) >= cx1 || (py as f32) < cy0 || (py as f32) >= cy1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if px >= 0 && px < width && py >= 0 && py < height {
|
||||
let alpha = (a_full as f32 * c) as u8;
|
||||
if alpha > 0 {
|
||||
@@ -263,9 +302,15 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
fn paint_image(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
rect: &crate::Rect,
|
||||
pixmap: &mut Pixmap,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> bool {
|
||||
if rect.width <= 0.0 || rect.height <= 0.0 {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let bytes = if src.starts_with("data:image/") {
|
||||
@@ -273,30 +318,69 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(&src[comma_idx + 1..]).ok()
|
||||
} else { None }
|
||||
} else if src.starts_with("http://") || src.starts_with("https://") {
|
||||
ureq::get(src).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
// Resolve relative to the document's base URL: the overwhelming
|
||||
// majority of real markup uses relative image paths ("logo.svg", not
|
||||
// "https://example.com/logo.svg"), so without this every relative
|
||||
// <img> silently fails to fetch, not just SVGs.
|
||||
let resolved = if src.starts_with("http://") || src.starts_with("https://") {
|
||||
Some(src.to_string())
|
||||
} else {
|
||||
base_url
|
||||
.and_then(|b| url::Url::parse(b).ok())
|
||||
.and_then(|base| base.join(src).ok())
|
||||
.map(|u| u.to_string())
|
||||
};
|
||||
match resolved {
|
||||
// The same icon/sprite/background is routinely referenced by
|
||||
// dozens of elements on one page (every story's vote arrow, every
|
||||
// repeated logo); fetch each distinct URL over the network once
|
||||
// per screenshot rather than once per element.
|
||||
Some(url) => cache
|
||||
.entry(url.clone())
|
||||
.or_insert_with(|| {
|
||||
ureq::get(&url).call().ok().and_then(|resp| {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
})
|
||||
.clone(),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(bytes) = bytes {
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32).unwrap()) {
|
||||
let Some(bytes) = bytes else { return false };
|
||||
|
||||
if is_svg(&bytes) {
|
||||
if let Some(svg_pixmap) = render_svg(&bytes, rect.width as u32, rect.height as u32) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
svg_pixmap.as_ref(),
|
||||
&tiny_skia::PixmapPaint::default(),
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(img) = image::load_from_memory(&bytes) {
|
||||
let img = img.to_rgba8();
|
||||
let resized = image::imageops::resize(&img, rect.width as u32, rect.height as u32, image::imageops::FilterType::Triangle);
|
||||
|
||||
let mut raw = resized.into_raw();
|
||||
for pixel in raw.chunks_exact_mut(4) {
|
||||
let a = pixel[3] as u32;
|
||||
pixel[0] = ((pixel[0] as u32 * a) / 255) as u8;
|
||||
pixel[1] = ((pixel[1] as u32 * a) / 255) as u8;
|
||||
pixel[2] = ((pixel[2] as u32 * a) / 255) as u8;
|
||||
}
|
||||
if let Some(size) = tiny_skia::IntSize::from_wh(rect.width as u32, rect.height as u32) {
|
||||
if let Some(img_pixmap) = Pixmap::from_vec(raw, size) {
|
||||
pixmap.draw_pixmap(
|
||||
rect.x as i32,
|
||||
rect.y as i32,
|
||||
@@ -305,9 +389,38 @@ fn paint_image(src: &str, rect: &crate::Rect, pixmap: &mut Pixmap) {
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Sniff SVG content: either an XML/SVG prolog, or a bare `<svg` root tag
|
||||
/// (both are valid, and image responses commonly omit the XML declaration).
|
||||
fn is_svg(bytes: &[u8]) -> bool {
|
||||
let head = &bytes[..bytes.len().min(256)];
|
||||
let text = String::from_utf8_lossy(head);
|
||||
let trimmed = text.trim_start_matches('\u{feff}').trim_start();
|
||||
trimmed.starts_with("<?xml") || trimmed.starts_with("<svg")
|
||||
}
|
||||
|
||||
/// Rasterize SVG bytes to a `width` x `height` pixmap, scaled to fit (matching
|
||||
/// how a replaced element like `<img>` sizes its intrinsic content).
|
||||
fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let opts = usvg::Options::default();
|
||||
let tree = usvg::Tree::from_data(bytes, &opts).ok()?;
|
||||
let size = tree.size();
|
||||
if size.width() <= 0.0 || size.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut svg_pixmap = Pixmap::new(width, height)?;
|
||||
let transform = Transform::from_scale(width as f32 / size.width(), height as f32 / size.height());
|
||||
resvg::render(&tree, transform, &mut svg_pixmap.as_mut());
|
||||
Some(svg_pixmap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -320,7 +433,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"background-color: #ff0000; width: 100px; height: 80px\"></div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
assert_eq!(pixmap.width(), 200);
|
||||
// The red div is laid out at the origin; sample inside it.
|
||||
let inside = pixmap.pixel(10, 10).expect("pixel");
|
||||
@@ -345,7 +458,7 @@ mod tests {
|
||||
</div>\
|
||||
</body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let p = pixmap.pixel(5, 5).expect("pixel");
|
||||
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
|
||||
}
|
||||
@@ -355,7 +468,7 @@ mod tests {
|
||||
let tree = parse_html(
|
||||
"<html><body><div style=\"color: #00ff00; width: 100px; height: 100px\">Hello</div></body></html>",
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0)).expect("pixmap");
|
||||
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
|
||||
let mut found_green = false;
|
||||
for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
|
||||
@@ -38,9 +38,33 @@ pub fn ua_style(tag: &str) -> LayoutStyle {
|
||||
style.flex_direction = Some(taffy::FlexDirection::Column);
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
} else if tag == "head" || tag == "script" || tag == "style" || tag == "title" || tag == "meta" || tag == "link" {
|
||||
style.display = Display::None;
|
||||
style.display = crate::Display::None;
|
||||
} else if tag == "body" {
|
||||
style.margin = Edges { top: 8.0, right: 8.0, bottom: 8.0, left: 8.0 };
|
||||
} else if tag == "h1" {
|
||||
style.font_size = Some(32.0);
|
||||
style.font_weight = Some("bold".to_string());
|
||||
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
|
||||
} else if tag == "h2" {
|
||||
style.font_size = Some(24.0);
|
||||
style.font_weight = Some("bold".to_string());
|
||||
style.margin = Edges { top: 19.0, bottom: 19.0, left: 0.0, right: 0.0 };
|
||||
} else if tag == "h3" {
|
||||
style.font_size = Some(18.7);
|
||||
style.font_weight = Some("bold".to_string());
|
||||
style.margin = Edges { top: 18.0, bottom: 18.0, left: 0.0, right: 0.0 };
|
||||
} else if tag == "h4" || tag == "h5" || tag == "h6" {
|
||||
style.font_size = Some(16.0);
|
||||
style.font_weight = Some("bold".to_string());
|
||||
style.margin = Edges { top: 21.0, bottom: 21.0, left: 0.0, right: 0.0 };
|
||||
} else if tag == "p" || tag == "ul" || tag == "ol" {
|
||||
style.margin = Edges { top: 16.0, bottom: 16.0, left: 0.0, right: 0.0 };
|
||||
} else if tag == "li" {
|
||||
style.margin = Edges { top: 0.0, bottom: 0.0, left: 40.0, right: 0.0 };
|
||||
} else if tag == "b" || tag == "strong" || tag == "th" {
|
||||
style.font_weight = Some("bold".to_string());
|
||||
} else if tag == "a" {
|
||||
style.color = Some([0, 0, 238, 255]); // blue
|
||||
} else if tag == "table" || tag == "tbody" {
|
||||
style.display = Display::Flex;
|
||||
style.flex_direction = Some(taffy::FlexDirection::Column);
|
||||
@@ -76,14 +100,22 @@ pub fn apply_inline(style: &mut LayoutStyle, css: &str) {
|
||||
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
match name {
|
||||
"display" => match value {
|
||||
"block" => style.display = Display::Block,
|
||||
"flex" => style.display = Display::Flex,
|
||||
"inline" => style.display = Display::Inline,
|
||||
"none" => style.display = Display::None,
|
||||
"none" => style.display = crate::Display::None,
|
||||
"flex" => style.display = crate::Display::Flex,
|
||||
"inline-flex" => style.display = crate::Display::Flex,
|
||||
"inline" => style.display = crate::Display::Inline,
|
||||
"inline-block" => style.display = crate::Display::Inline,
|
||||
"grid" => style.display = crate::Display::Grid,
|
||||
"inline-grid" => style.display = crate::Display::Grid,
|
||||
"block" => style.display = crate::Display::Block,
|
||||
_ => {}
|
||||
},
|
||||
"width" => style.width = dimension_value(value),
|
||||
"height" => style.height = dimension_value(value),
|
||||
"min-width" => style.min_width = dimension_value(value),
|
||||
"min-height" => style.min_height = dimension_value(value),
|
||||
"max-width" => style.max_width = dimension_value(value),
|
||||
"max-height" => style.max_height = dimension_value(value),
|
||||
"margin" => { if let Some(e) = edges(value) { style.margin = e; } }
|
||||
"margin-top" => set_edge(&mut style.margin, Side::Top, px(value)),
|
||||
"margin-right" => set_edge(&mut style.margin, Side::Right, px(value)),
|
||||
@@ -108,36 +140,304 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
|
||||
"border-right-width" | "border-right" => set_edge(&mut style.border, Side::Right, px(value)),
|
||||
"border-bottom-width" | "border-bottom" => set_edge(&mut style.border, Side::Bottom, px(value)),
|
||||
"border-left-width" | "border-left" => set_edge(&mut style.border, Side::Left, px(value)),
|
||||
"background-color" | "background" => style.background_color = parse_color(value),
|
||||
"background-color" => style.background_color = parse_color(value),
|
||||
"background" => {
|
||||
style.background_color = parse_color(value);
|
||||
style.background_image = parse_url(value);
|
||||
}
|
||||
"background-image" => style.background_image = parse_url(value),
|
||||
"background-size" => style.background_size = parse_background_size(value),
|
||||
"background-position" => style.background_position = parse_background_position(value),
|
||||
"color" => style.color = parse_color(value),
|
||||
"border-color" => style.border_color = parse_color(value),
|
||||
"font-size" => style.font_size = px(value),
|
||||
"font-weight" => style.font_weight = Some(value.to_string()),
|
||||
"text-align" => {
|
||||
if value == "right" {
|
||||
style.justify_content = Some(taffy::JustifyContent::FlexEnd);
|
||||
style.align_items = Some(taffy::AlignItems::FlexEnd);
|
||||
} else if value == "center" {
|
||||
style.justify_content = Some(taffy::JustifyContent::Center);
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
}
|
||||
// Our engine has no real inline formatting context, so text-align is
|
||||
// approximated the same way as align-items: it positions a block's
|
||||
// (or column-flex's) children along the cross axis. See
|
||||
// `to_taffy_style`'s block-to-flex-column promotion, which is what
|
||||
// makes this actually take effect on plain block elements.
|
||||
"text-align" => match value {
|
||||
"right" => style.align_items = Some(taffy::AlignItems::FlexEnd),
|
||||
"center" => style.align_items = Some(taffy::AlignItems::Center),
|
||||
"left" | "start" | "justify" => style.align_items = Some(taffy::AlignItems::FlexStart),
|
||||
_ => {}
|
||||
},
|
||||
"align-items" => {
|
||||
if value == "center" {
|
||||
style.align_items = Some(taffy::AlignItems::Center);
|
||||
} else if value == "flex-start" || value == "start" {
|
||||
style.align_items = Some(taffy::AlignItems::FlexStart);
|
||||
} else if value == "flex-end" || value == "end" {
|
||||
style.align_items = Some(taffy::AlignItems::FlexEnd);
|
||||
match value {
|
||||
"center" => style.align_items = Some(taffy::AlignItems::Center),
|
||||
"flex-start" | "start" => style.align_items = Some(taffy::AlignItems::FlexStart),
|
||||
"flex-end" | "end" => style.align_items = Some(taffy::AlignItems::FlexEnd),
|
||||
"stretch" => style.align_items = Some(taffy::AlignItems::Stretch),
|
||||
"baseline" => style.align_items = Some(taffy::AlignItems::Baseline),
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"justify-content" => {
|
||||
match value {
|
||||
"center" => style.justify_content = Some(taffy::JustifyContent::Center),
|
||||
"flex-start" | "start" | "left" => style.justify_content = Some(taffy::JustifyContent::FlexStart),
|
||||
"flex-end" | "end" | "right" => style.justify_content = Some(taffy::JustifyContent::FlexEnd),
|
||||
"space-between" => style.justify_content = Some(taffy::JustifyContent::SpaceBetween),
|
||||
"space-around" => style.justify_content = Some(taffy::JustifyContent::SpaceAround),
|
||||
"space-evenly" => style.justify_content = Some(taffy::JustifyContent::SpaceEvenly),
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"flex-direction" => {
|
||||
match value {
|
||||
"row" => style.flex_direction = Some(taffy::FlexDirection::Row),
|
||||
"row-reverse" => style.flex_direction = Some(taffy::FlexDirection::RowReverse),
|
||||
"column" => style.flex_direction = Some(taffy::FlexDirection::Column),
|
||||
"column-reverse" => style.flex_direction = Some(taffy::FlexDirection::ColumnReverse),
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"flex-wrap" => {
|
||||
match value {
|
||||
"wrap" => style.flex_wrap = Some(taffy::FlexWrap::Wrap),
|
||||
"nowrap" => style.flex_wrap = Some(taffy::FlexWrap::NoWrap),
|
||||
"wrap-reverse" => style.flex_wrap = Some(taffy::FlexWrap::WrapReverse),
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"flex-grow" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_grow = Some(v); } }
|
||||
"flex-shrink" => { if let Some(v) = token(value).and_then(|t| t.parse::<f32>().ok()) { style.flex_shrink = Some(v); } }
|
||||
"position" => {
|
||||
match value {
|
||||
"absolute" | "fixed" => style.position = Some(taffy::Position::Absolute),
|
||||
"relative" | "sticky" | "static" => style.position = Some(taffy::Position::Relative),
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
"top" => style.inset[0] = px(value),
|
||||
"right" => style.inset[1] = px(value),
|
||||
"bottom" => style.inset[2] = px(value),
|
||||
"left" => style.inset[3] = px(value),
|
||||
"overflow" | "overflow-x" | "overflow-y" => {
|
||||
style.overflow_hidden = value != "visible";
|
||||
}
|
||||
"gap" | "grid-gap" => {
|
||||
let dims: Vec<f32> = value.split_whitespace().filter_map(px_value).collect();
|
||||
if let Some(&r) = dims.first() { style.row_gap = Some(r); style.column_gap = Some(*dims.get(1).unwrap_or(&r)); }
|
||||
}
|
||||
"row-gap" | "grid-row-gap" => style.row_gap = px(value),
|
||||
"column-gap" | "grid-column-gap" => style.column_gap = px(value),
|
||||
"grid-template-columns" => style.grid_template_columns = parse_track_list(value),
|
||||
"grid-template-rows" => style.grid_template_rows = parse_track_list(value),
|
||||
"grid-template-areas" => style.grid_areas = Some(parse_grid_areas(value)),
|
||||
"grid-template" => parse_grid_template(style, value),
|
||||
"grid-area" => {
|
||||
// Named area (single ident) or line form `r/c/r/c`. We only resolve
|
||||
// the named-area case here; line forms are handled by grid-row/column.
|
||||
let v = value.trim();
|
||||
if !v.contains('/') && !v.is_empty() {
|
||||
style.grid_area_name = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
"grid-column" => style.grid_column = parse_grid_line(value),
|
||||
"grid-row" => style.grid_row = parse_grid_line(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex and a
|
||||
/// small set of named colors. Returns None for anything else (transparent).
|
||||
fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
|
||||
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
|
||||
/// parentheses in `minmax(...)` / `fit-content(...)`.
|
||||
pub(crate) fn parse_track_list(value: &str) -> Vec<taffy::TrackSizingFunction> {
|
||||
tokenize_tracks(value).iter().map(|t| track(t)).collect()
|
||||
}
|
||||
|
||||
/// Split a track list on whitespace while keeping `func(a, b)` groups intact.
|
||||
fn tokenize_tracks(value: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut depth = 0i32;
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'(' => { depth += 1; cur.push(c); }
|
||||
')' => { depth -= 1; cur.push(c); }
|
||||
c if c.is_whitespace() && depth == 0 => {
|
||||
if !cur.is_empty() { out.push(std::mem::take(&mut cur)); }
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() { out.push(cur); }
|
||||
out
|
||||
}
|
||||
|
||||
fn track(tok: &str) -> taffy::TrackSizingFunction {
|
||||
use taffy::MinMax;
|
||||
let t = tok.trim();
|
||||
let lower = t.to_ascii_lowercase();
|
||||
if let Some(inner) = lower.strip_prefix("minmax(").and_then(|s| s.strip_suffix(')')) {
|
||||
if let Some((a, b)) = inner.split_once(',') {
|
||||
return taffy::TrackSizingFunction::Single(MinMax {
|
||||
min: min_track(a.trim()),
|
||||
max: max_track(b.trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(inner) = lower.strip_prefix("fit-content(").and_then(|s| s.strip_suffix(')')) {
|
||||
let lp = px_value(inner.trim())
|
||||
.map(taffy::style::LengthPercentage::Length)
|
||||
.unwrap_or(taffy::style::LengthPercentage::Length(0.0));
|
||||
return taffy::TrackSizingFunction::Single(MinMax {
|
||||
min: taffy::MinTrackSizingFunction::Auto,
|
||||
max: taffy::MaxTrackSizingFunction::FitContent(lp),
|
||||
});
|
||||
}
|
||||
taffy::TrackSizingFunction::Single(MinMax { min: min_track(t), max: max_track(t) })
|
||||
}
|
||||
|
||||
fn min_track(tok: &str) -> taffy::MinTrackSizingFunction {
|
||||
use taffy::MinTrackSizingFunction as M;
|
||||
match tok.to_ascii_lowercase().as_str() {
|
||||
"min-content" => M::MinContent,
|
||||
"max-content" => M::MaxContent,
|
||||
"auto" => M::Auto,
|
||||
other => {
|
||||
if other.ends_with("fr") {
|
||||
// Flexible tracks have an automatic minimum.
|
||||
M::Auto
|
||||
} else if let Some(px) = dim_lp(other) {
|
||||
M::Fixed(px)
|
||||
} else {
|
||||
M::Auto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn max_track(tok: &str) -> taffy::MaxTrackSizingFunction {
|
||||
use taffy::MaxTrackSizingFunction as M;
|
||||
let lower = tok.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"min-content" => M::MinContent,
|
||||
"max-content" => M::MaxContent,
|
||||
"auto" => M::Auto,
|
||||
other => {
|
||||
if let Some(fr) = other.strip_suffix("fr").and_then(|n| n.trim().parse::<f32>().ok()) {
|
||||
M::Fraction(fr)
|
||||
} else if let Some(px) = dim_lp(other) {
|
||||
M::Fixed(px)
|
||||
} else {
|
||||
M::Auto
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A length or percentage as a taffy `LengthPercentage`.
|
||||
fn dim_lp(tok: &str) -> Option<taffy::style::LengthPercentage> {
|
||||
let t = tok.trim();
|
||||
if let Some(p) = t.strip_suffix('%') {
|
||||
return p.parse::<f32>().ok().map(|v| taffy::style::LengthPercentage::Percent(v / 100.0));
|
||||
}
|
||||
px_value(t).map(taffy::style::LengthPercentage::Length)
|
||||
}
|
||||
|
||||
/// Parse `grid-template-areas: 'a a' 'b c'` into a matrix of cell names.
|
||||
fn parse_grid_areas(value: &str) -> Vec<Vec<String>> {
|
||||
let mut rows = Vec::new();
|
||||
let mut in_str = false;
|
||||
let mut cur = String::new();
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'\'' | '"' => {
|
||||
if in_str {
|
||||
rows.push(cur.split_whitespace().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
cur.clear();
|
||||
in_str = false;
|
||||
} else {
|
||||
in_str = true;
|
||||
}
|
||||
}
|
||||
_ if in_str => cur.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Parse the `grid-template: <rows> / <cols>` shorthand. The rows side may embed
|
||||
/// area strings; we support the common `tracks / tracks` form and, when area
|
||||
/// strings are present, extract them too.
|
||||
fn parse_grid_template(style: &mut LayoutStyle, value: &str) {
|
||||
let (rows_part, cols_part) = match value.split_once('/') {
|
||||
Some((r, c)) => (r.trim(), Some(c.trim())),
|
||||
None => (value.trim(), None),
|
||||
};
|
||||
if rows_part.contains('\'') || rows_part.contains('"') {
|
||||
// Rows side carries area strings interleaved with row track sizes.
|
||||
style.grid_areas = Some(parse_grid_areas(rows_part));
|
||||
} else if !rows_part.is_empty() {
|
||||
style.grid_template_rows = parse_track_list(rows_part);
|
||||
}
|
||||
if let Some(cols) = cols_part {
|
||||
style.grid_template_columns = parse_track_list(cols);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `grid-column`/`grid-row` values: `2`, `1 / 3`, `span 2`.
|
||||
fn parse_grid_line(value: &str) -> Option<taffy::Line<taffy::GridPlacement>> {
|
||||
let place = |tok: &str| -> taffy::GridPlacement {
|
||||
let tok = tok.trim();
|
||||
if let Some(n) = tok.strip_prefix("span").map(|s| s.trim()) {
|
||||
if let Ok(s) = n.parse::<u16>() { return taffy::style_helpers::span(s); }
|
||||
}
|
||||
if let Ok(i) = tok.parse::<i16>() { return taffy::style_helpers::line(i); }
|
||||
taffy::GridPlacement::Auto
|
||||
};
|
||||
let (a, b) = match value.split_once('/') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (value, None),
|
||||
};
|
||||
let start = place(a);
|
||||
let end = b.map(place).unwrap_or(taffy::GridPlacement::Auto);
|
||||
Some(taffy::Line { start, end })
|
||||
}
|
||||
|
||||
/// Parse a CSS color to RGBA. Handles #rgb, #rgba, #rrggbb, #rrggbbaa hex,
|
||||
/// rgb()/rgba(), `var(--x, fallback)` (uses the fallback), and a set of named
|
||||
/// colors. Returns None for anything else (transparent).
|
||||
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
let raw = value.trim();
|
||||
// CSS custom property with a fallback: var(--name, <fallback>). We cannot
|
||||
// resolve the variable, but the fallback after the comma is a real color.
|
||||
if let Some(rest) = raw.strip_prefix("var(") {
|
||||
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
||||
if let Some((_, fallback)) = inner.split_once(',') {
|
||||
return parse_color(fallback.trim());
|
||||
}
|
||||
return None;
|
||||
}
|
||||
// rgb()/rgba() functional notation.
|
||||
let lower_full = raw.to_ascii_lowercase();
|
||||
if let Some(rest) = lower_full.strip_prefix("rgb(").or_else(|| lower_full.strip_prefix("rgba(")) {
|
||||
let inner = rest.strip_suffix(')').unwrap_or(rest);
|
||||
let parts: Vec<&str> = inner.split([',', '/', ' ']).filter(|p| !p.trim().is_empty()).collect();
|
||||
if parts.len() >= 3 {
|
||||
let c = |s: &str| -> Option<u8> {
|
||||
let s = s.trim();
|
||||
if let Some(pct) = s.strip_suffix('%') {
|
||||
pct.parse::<f32>().ok().map(|v| (v * 2.55).round().clamp(0.0, 255.0) as u8)
|
||||
} else {
|
||||
s.parse::<f32>().ok().map(|v| v.round().clamp(0.0, 255.0) as u8)
|
||||
}
|
||||
};
|
||||
let r = c(parts[0])?;
|
||||
let g = c(parts[1])?;
|
||||
let b = c(parts[2])?;
|
||||
let a = parts.get(3)
|
||||
.and_then(|s| s.trim().parse::<f32>().ok())
|
||||
.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8)
|
||||
.unwrap_or(255);
|
||||
return Some([r, g, b, a]);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let v = value.split_whitespace().next()?.to_ascii_lowercase();
|
||||
if let Some(h) = v.strip_prefix('#') {
|
||||
let (r, g, b, a) = match h.len() {
|
||||
@@ -173,10 +473,24 @@ fn parse_color(value: &str) -> Option<[u8; 4]> {
|
||||
"white" => Some([255, 255, 255, 255]),
|
||||
"black" => Some([0, 0, 0, 255]),
|
||||
"gray" | "grey" => Some([128, 128, 128, 255]),
|
||||
"silver" => Some([192, 192, 192, 255]),
|
||||
"lightgray" | "lightgrey" => Some([211, 211, 211, 255]),
|
||||
"darkgray" | "darkgrey" => Some([169, 169, 169, 255]),
|
||||
"whitesmoke" => Some([245, 245, 245, 255]),
|
||||
"gainsboro" => Some([220, 220, 220, 255]),
|
||||
"red" => Some([255, 0, 0, 255]),
|
||||
"green" => Some([0, 128, 0, 255]),
|
||||
"lime" => Some([0, 255, 0, 255]),
|
||||
"blue" => Some([0, 0, 255, 255]),
|
||||
"navy" => Some([0, 0, 128, 255]),
|
||||
"yellow" => Some([255, 255, 0, 255]),
|
||||
"orange" => Some([255, 165, 0, 255]),
|
||||
"purple" => Some([128, 0, 128, 255]),
|
||||
"maroon" => Some([128, 0, 0, 255]),
|
||||
"teal" => Some([0, 128, 128, 255]),
|
||||
"aqua" | "cyan" => Some([0, 255, 255, 255]),
|
||||
"fuchsia" | "magenta" => Some([255, 0, 255, 255]),
|
||||
"olive" => Some([128, 128, 0, 255]),
|
||||
"transparent" => Some([0, 0, 0, 0]),
|
||||
_ => None,
|
||||
}
|
||||
@@ -247,6 +561,67 @@ fn token(value: &str) -> Option<&str> {
|
||||
value.split_whitespace().next()
|
||||
}
|
||||
|
||||
/// Extract the first `url(...)` reference from a `background`/`background-image`
|
||||
/// value, unquoted. Ignores any other layers in the same shorthand (gradients,
|
||||
/// `no-repeat`, etc.): we paint the referenced image, not the gradient.
|
||||
fn parse_url(value: &str) -> Option<String> {
|
||||
let start = value.find("url(")? + 4;
|
||||
let end = value[start..].find(')')?;
|
||||
let inner = value[start..start + end].trim();
|
||||
let unquoted = inner.trim_matches(|c| c == '"' || c == '\'');
|
||||
if unquoted.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(unquoted.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `background-size: 10px` / `0.857em` / `10px 20px` -> explicit px pair.
|
||||
/// Keyword values (`cover`, `contain`, `auto`) are left unhandled (`None`, the
|
||||
/// "stretch to fill the box" fallback) since evaluating them needs the
|
||||
/// image's own intrinsic aspect ratio, which is not known until it is
|
||||
/// fetched, well after style resolution.
|
||||
fn parse_background_size(value: &str) -> Option<(f32, f32)> {
|
||||
let tokens: Vec<&str> = value.split_whitespace().collect();
|
||||
match tokens.as_slice() {
|
||||
[one] => px_value(one).map(|v| (v, v)),
|
||||
[w, h] => Some((px_value(w)?, px_value(h)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the
|
||||
/// fraction of the box's leftover space, after the image's own size, to
|
||||
/// offset by: 0 = start edge, 1 = end edge, 0.5 = centered).
|
||||
///
|
||||
/// `left`/`right` always set the x axis and `top`/`bottom` always set y,
|
||||
/// regardless of order (CSS allows `center right` and `right center`
|
||||
/// interchangeably for keyword-only values). Any axis left unset by an
|
||||
/// explicit keyword defaults to centered, which correctly handles a bare
|
||||
/// `center` (both axes), a single directional keyword (`right` -> the other
|
||||
/// axis centers), and explicit percentages (first fills x, second fills y,
|
||||
/// per CSS shorthand order).
|
||||
fn parse_background_position(value: &str) -> (f32, f32) {
|
||||
let mut x = None;
|
||||
let mut y = None;
|
||||
for tok in value.split_whitespace() {
|
||||
match tok {
|
||||
"left" => x = Some(0.0),
|
||||
"right" => x = Some(1.0),
|
||||
"top" => y = Some(0.0),
|
||||
"bottom" => y = Some(1.0),
|
||||
t if t.ends_with('%') => {
|
||||
if let Ok(pct) = t.trim_end_matches('%').parse::<f32>() {
|
||||
let v = pct / 100.0;
|
||||
if x.is_none() { x = Some(v); } else { y = Some(v); }
|
||||
}
|
||||
}
|
||||
_ => {} // "center" (or an unhandled bare length): leaves this axis to default below
|
||||
}
|
||||
}
|
||||
(x.unwrap_or(0.5), y.unwrap_or(0.5))
|
||||
}
|
||||
|
||||
/// CSS box shorthand: 1 value applies to all sides; 2 values are (v, h);
|
||||
/// 3 values are (top, h, bottom); 4 values are (top, right, bottom, left).
|
||||
fn edges_from(dims: Vec<f32>) -> Option<Edges> {
|
||||
|
||||
Reference in new issue
Block a user