Files
obscura/crates/obscura-render/src/paint.rs
T
SGavrl ca4a4ef135 Fix icon rendering, calc()/var() length resolution, and approximate CSS float
Icons were rendering as either overlapping solid squares or not at all,
  text ran under the infobox instead of beside it, and general.css parsing
  had two more silent-corruption bugs on top of the ones fixed earlier:

  - A CSS declaration list was split on every literal semicolon, so any
    value containing one inside a quoted string or url() (a data: URI
    with a MIME parameter, e.g. url(data:image/svg+xml;utf8,<svg...>),
    extremely common for small inline icon SVGs) got torn into two
    malformed halves and silently dropped. This is why mask-image never
    applied to the TOC toggle icons at all.
  - data: URIs were only decoded as base64. Inline SVGs are just as
    commonly written as data:image/svg+xml;utf8,<percent-escaped text>,
    which needs percent-decoding instead; added that path.
  - top/left/right/bottom/width/height only handled bare lengths and a
    flat calc(a +/- b). Real values nest var(--x, fallback), calc(), and
    min()/max() inside each other (Wikipedia's icon offset math is
    literally calc(-1 * 22px / 2), and its icon sizing is
    calc(max(calc(var(--font-size-medium,1rem) + 4px),10px))). Replaced
    the flat evaluator with a small recursive resolver that handles all
    three, nested arbitrarily. This is what was placing the TOC toggle
    button's icon on top of the section text instead of in the left
    margin where it belongs.

  Added actual feature support once the parsing was correct:

  - mask-image/-webkit-mask-image: the pattern behind most scalable
    colored icons on the modern web (an SVG or image used purely as an
    alpha stencil, tinted by background-color/color). Previously every
    masked icon rendered as an unmasked solid-color square.
  - float: left/right, approximated without a full per-line reflow
    engine (which taffy's block/flex/grid modes don't provide): a
    floated element is grouped into a row alongside the flow siblings
    that follow it, up to whichever comes first of the next heading or
    another float, then everything from there on reverts to normal
    full-width flow. This isn't general CSS float, but it directly
    targets the overwhelmingly common real shape (an infobox or image
    floated near the top of an article, with the rest of the content
    starting at the next section heading), and is what makes article
    text actually wrap beside an infobox instead of running underneath
    it.

  Refactored paint.rs's image fetch/decode/cache logic into shared
  helpers (fetch_bytes, raster_to_pixmap) reused by plain <img> painting,
  background-image, and the new mask-image path.
2026-07-03 12:34:27 +00:00

580 lines
23 KiB
Rust

//! Paint: rasterize the laid-out DOM into a [`tiny_skia::Pixmap`].
//!
//! Phase 5a. Fills each element's border box with its background color over a
//! white page. Text rendering arrives with the text step; borders and images
//! are later enhancements. Pure Rust (tiny-skia, CPU), deterministic, no system
//! dependencies, so a screenshot is reproducible across hosts.
use obscura_dom::tree::DomTree;
use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Transform};
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
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. `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) {
Some(n) => n,
None => continue,
};
if node.is_text() {
paint_text_node(tree, nid, &laid, &mut pixmap);
continue;
}
let name = match node.as_element() {
Some(name) => name,
None => continue,
};
let rect = match laid.rects.get(&nid) {
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,
};
// A masked element's background-color is the mask's fill color, not
// an ordinary box background: it must only show through the mask
// shape (painted below), not as a solid rect behind it.
if let (Some(bg), None) = (style.background_color, &style.mask_image) {
let mut path = PathBuilder::new();
path.push_rect(box_rect);
if let Some(path) = path.finish() {
let mut paint = Paint::default();
paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
paint.anti_alias = false;
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
}
}
if let Some(mask_url) = &style.mask_image {
let fill = style.background_color.or(style.color).unwrap_or([0, 0, 0, 255]);
paint_mask(mask_url, base_url, &visible_rect, fill, &mut pixmap, &mut image_cache);
} else 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();
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 {
push_clipped(rect.x + rect.width - style.border.right, rect.y, style.border.right, rect.height);
}
if style.border.bottom > 0.0 {
push_clipped(rect.x, rect.y + rect.height - style.border.bottom, rect.width, style.border.bottom);
}
if style.border.left > 0.0 {
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" {
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);
}
}
}
}
}
}
Some(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), base_url: Option<&str>) -> Option<Vec<u8>> {
paint_dom(tree, viewport, base_url)?.encode_png().ok()
}
fn paint_text_node(
tree: &DomTree,
nid: obscura_dom::tree::NodeId,
laid: &crate::DomLayout,
pixmap: &mut Pixmap,
) -> Option<()> {
let node = tree.get_node(nid)?;
let mut text_buf = String::new();
let text = match &node.data {
obscura_dom::tree::NodeData::Text { contents } => {
let mut in_space = false;
for c in contents.chars() {
if c.is_whitespace() {
if !in_space {
text_buf.push(' ');
in_space = true;
}
} else {
text_buf.push(c);
in_space = false;
}
}
if text_buf.is_empty() {
" "
} else {
&text_buf
}
},
_ => return None,
};
let parent = node.parent?;
let style = laid.styles.get(&parent)?;
let color = style.color.unwrap_or([0, 0, 0, 255]);
let fsize = style.font_size.unwrap_or(16.0);
let is_bold = style.font_weight.as_deref() == Some("bold");
// Get geometry of the text node itself!
let rect = match laid.rects.get(&nid) {
Some(r) => r,
None => return None,
};
let clip = laid.clip_rects.get(&nid).copied().flatten();
draw_text(pixmap, text, rect.x, rect.y, color, fsize, is_bold, clip);
Some(())
}
pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 {
let font = FontRef::try_from_slice(FONT_BYTES).unwrap();
let scale = PxScale::from(size);
let scaled_font = font.as_scaled(scale);
let mut width = 0.0;
for c in text.chars() {
if c.is_control() { continue; }
width += scaled_font.h_advance(font.glyph_id(c));
}
if is_bold { width += text.chars().filter(|c| !c.is_control()).count() as f32; }
width
}
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);
let mut caret = ab_glyph::point(x, y + scaled_font.ascent());
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]);
for c in text.chars() {
if c.is_control() {
continue;
}
let glyph_id = font.glyph_id(c);
let id = glyph_id;
let glyph = glyph_id.with_scale_and_position(scale, caret);
if let Some(outlined) = font.outline_glyph(glyph) {
let bounds = outlined.px_bounds();
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 {
let mut px_indices = vec![(py * width + px) as usize];
if is_bold && px + 1 < width {
px_indices.push((py * width + px + 1) as usize);
}
for idx in px_indices {
let dst = pixels[idx];
let src_a = alpha as u32;
let src_r = (r as u32 * src_a) / 255;
let src_g = (g as u32 * src_a) / 255;
let src_b = (b as u32 * src_a) / 255;
let dst_a = dst.alpha() as u32;
let out_a = src_a + (dst_a * (255 - src_a) / 255);
if out_a > 0 {
let out_r = src_r + (dst.red() as u32 * (255 - src_a) / 255);
let out_g = src_g + (dst.green() as u32 * (255 - src_a) / 255);
let out_b = src_b + (dst.blue() as u32 * (255 - src_a) / 255);
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(
out_r as u8, out_g as u8, out_b as u8, out_a as u8
).unwrap_or_else(|| tiny_skia::PremultipliedColorU8::from_rgba(0,0,0,0).unwrap());
}
}
}
}
});
caret.x += scaled_font.h_advance(id);
} else {
caret.x += scaled_font.h_advance(id);
}
}
}
/// Resolve `src` (a `data:` URI, or an absolute/relative URL against
/// `base_url`) to raw bytes, fetching over the network at most once per
/// distinct URL per screenshot via `cache`.
fn fetch_bytes(
src: &str,
base_url: Option<&str>,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> Option<Vec<u8>> {
if let Some(rest) = src.strip_prefix("data:image/") {
let comma_idx = rest.find(',')?;
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
// Inline SVGs are very commonly authored as data:image/svg+xml;utf8,
// (or with no encoding label at all, which is equivalent): plain,
// percent-escaped text, not base64. Only decode as base64 when the
// URI actually says so.
return if meta.contains("base64") {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(data).ok()
} else {
Some(percent_decode(data))
};
}
// 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>
// or mask/background reference silently fails to fetch.
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())
};
// 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.
let url = resolved?;
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()
}
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
/// are not part of a `%XX` escape pass through unchanged, which is exactly
/// right for the inline-SVG case: only the characters that would otherwise be
/// ambiguous in a URI (`#`, `"`, ...) get escaped, everything else is literal
/// UTF-8 text.
fn percent_decode(s: &str) -> Vec<u8> {
// Operates on raw bytes throughout (never slices `s` as a string): a
// stray '%' followed by non-hex bytes could otherwise land a string
// slice in the middle of a multi-byte UTF-8 character and panic.
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
out
}
/// Decode raster image bytes (jpeg/png/webp) to a premultiplied-alpha pixmap
/// resized to `w`x`h`.
fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option<Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
let resized = image::imageops::resize(&img, w, h, 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;
}
let size = tiny_skia::IntSize::from_wh(w, h)?;
Pixmap::from_vec(raw, size)
}
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 false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
let content = if is_svg(&bytes) {
render_svg(&bytes, rect.width as u32, rect.height as u32)
} else {
raster_to_pixmap(&bytes, rect.width as u32, rect.height as u32)
};
if let Some(content) = content {
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
content.as_ref(),
&tiny_skia::PixmapPaint::default(),
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)
}
/// Paint a `mask-image`: the ubiquitous "colored, scalable icon" pattern,
/// where an SVG shape is used purely as a stencil and tinted by
/// `background-color`/`color` rather than carrying its own colors. Fetches
/// and rasterizes the mask the same way as an ordinary image, then repaints
/// every pixel it covers as `fill`, weighted by the mask's own alpha there
/// (its "coverage"), instead of drawing the mask's own pixel colors.
fn paint_mask(
src: &str,
base_url: Option<&str>,
rect: &crate::Rect,
fill: [u8; 4],
pixmap: &mut Pixmap,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> bool {
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
let (w, h) = (rect.width as u32, rect.height as u32);
let mask = if is_svg(&bytes) { render_svg(&bytes, w, h) } else { raster_to_pixmap(&bytes, w, h) };
let Some(mask) = mask else { return false };
let recolored = recolor_by_alpha(&mask, fill);
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
recolored.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
None,
);
true
}
/// Replace every pixel's color with `fill`, scaling `fill`'s alpha by the
/// source pixel's own alpha (its mask coverage at that point).
fn recolor_by_alpha(src: &Pixmap, fill: [u8; 4]) -> Pixmap {
let (w, h) = (src.width(), src.height());
let mut out = Pixmap::new(w, h).expect("non-zero size, already validated by caller");
let dst = out.pixels_mut();
for (i, p) in src.pixels().iter().enumerate() {
let coverage = p.alpha() as u32;
if coverage == 0 {
continue;
}
let a = (fill[3] as u32 * coverage) / 255;
let r = (fill[0] as u32 * a) / 255;
let g = (fill[1] as u32 * a) / 255;
let b = (fill[2] as u32 * a) / 255;
dst[i] = tiny_skia::PremultipliedColorU8::from_rgba(r as u8, g as u8, b as u8, a as u8)
.unwrap_or_else(|| tiny_skia::PremultipliedColorU8::from_rgba(0, 0, 0, 0).unwrap());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use obscura_dom::tree_sink::parse_html;
#[test]
fn paints_background_color() {
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), 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");
assert!(inside.red() > 200, "expected red bg, got {:?}", inside);
assert!(inside.green() < 60);
assert!(inside.blue() < 60);
// Outside the 100x80 div the page background is white.
let outside = pixmap.pixel(150, 150).expect("pixel");
assert_eq!(outside.red(), 255);
assert_eq!(outside.green(), 255);
assert_eq!(outside.blue(), 255);
}
#[test]
fn later_element_paints_over_earlier() {
// A blue div nested inside a red one: both cover the origin, and blue
// (a descendant, later in tree order) paints over red.
let tree = parse_html(
"<html><body>\
<div style=\"background-color:red; width:100px; height:100px\">\
<div style=\"background-color:blue; width:50px; height:50px\"></div>\
</div>\
</body></html>",
);
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);
}
#[test]
fn paints_text_color() {
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), None).expect("pixmap");
let mut found_green = false;
for y in 0..200 {
for x in 0..200 {
let p = pixmap.pixel(x, y).expect("pixel");
if p.green() > 200 && p.red() < 50 && p.blue() < 50 {
found_green = true;
break;
}
}
if found_green { break; }
}
assert!(found_green, "expected green text to be painted");
}
}