Files
obscura/crates/obscura-render/src/paint.rs
T
SGavrl 88313178b2 Split text nodes into per-word layout leaves so text actually wraps
A whole DOM text node was one indivisible taffy leaf. Flex-wrap only
  breaks between items, never inside one, so a long run of plain text
  with no inline element breaking it up (an ordinary sentence with no
  link in it) either fit on one line or ran straight past the
  container's edge instead of wrapping. Short runs interrupted by links
  every few words (extremely common in Wikipedia's markup) happened to
  be narrow enough to wrap on their own, which is why paragraphs looked
  fine but any long plain-text stretch overflowed straight over the
  infobox and the History-section image.

  Text nodes now split into one taffy leaf per word, flattened into the
  same child list as sibling elements so words from different text nodes
  and inline elements interleave and wrap together like real inline
  flow. This changes the geometry data model: a text node's content is
  now a list of (box, word) pairs (DomLayout::text_runs) instead of a
  single box, since it can span several lines. Updated painting to draw
  each word at its own position, and fixed a bold-text measurement
  mismatch (layout width and drawn width disagreed by 1px/char, invisible
  before as one blob, visible as gaps between every word once each word
  is independently positioned).

  Added a regression test that fails against the old single-leaf
  behavior.
2026-07-03 12:59:40 +00:00

562 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()
}
/// Paint every word of a text node at its own laid-out position. A text node
/// lays out as one taffy leaf per word (see `dom::build_text_words`), each
/// wrapping independently, so its content is a list of (box, word) pairs
/// rather than one box for the whole node; color/font/clip come from the
/// parent element and are the same for every word.
fn paint_text_node(
tree: &DomTree,
nid: obscura_dom::tree::NodeId,
laid: &crate::DomLayout,
pixmap: &mut Pixmap,
) -> Option<()> {
let runs = laid.text_runs.get(&nid)?;
let node = tree.get_node(nid)?;
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");
let clip = laid.clip_rects.get(&nid).copied().flatten();
for (rect, word) in runs {
draw_text(pixmap, word, 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());
}
}
}
}
});
// Matches measure_text's +1px-per-character bold compensation:
// without it, a word's reserved layout width (from measure_text)
// is wider than what draw_text actually advances through, and
// the difference shows up as a visible gap after every word once
// each word is its own independently-positioned box.
caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 };
} else {
caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 };
}
}
}
/// 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");
}
}