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

+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> {