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:
SGavrl committed 2026-07-03 12:04:03 +00:00
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);
}
+258
View File
@@ -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
View File
@@ -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
+141 -3
View File
@@ -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),
+200 -87
View File
@@ -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 {
+398 -23
View File
@@ -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> {