//! 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, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform};
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
use crate::layout_dom_with_images;
/// 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 (` `) 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 {
let (w, h) = (viewport.0 as u32, viewport.1 as u32);
let mut pixmap = Pixmap::new(w, h)?;
pixmap.fill(Color::WHITE);
// 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>> = std::collections::HashMap::new();
// Fetch bytes up front to learn intrinsic sizes for layout (a
// CSS-sized image with no width/height attribute would otherwise be 0x0
// and never paint). This seeds the same cache the paint pass reads, so
// each URL is still fetched at most once.
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
// Nodes that live inside an inline `` we rasterized as one document;
// their painting is owned by that raster, so they are skipped in both the
// box/text loop below and the inline-formatting loop after it (an svg
// `` element must not also paint its glyphs on top of the raster).
let mut svg_subtree_skip: std::collections::HashSet = std::collections::HashSet::new();
// External sprite symbols, keyed by "url#id", extracted from a fetched
// sprite file so a `` resolves. One sprite backs many
// icons (a whole logo/icon band), so cache the parsed symbol across every
// inline svg on the page rather than re-parsing the sprite per icon.
let mut sprite_cache: std::collections::HashMap> = std::collections::HashMap::new();
// Whether any element carries a `transform: translate()`. When none does
// (the overwhelmingly common case), every node's accumulated offset is
// zero, so skip the per-node ancestor walk entirely and keep the paint
// path free of any added cost.
// Paint order: tree order for the normal flow (later elements paint over
// earlier ones), except that a positioned element with a non-zero
// z-index lifts its whole subtree into a separate layer: negative layers
// paint under the normal flow, positive ones above it, each sorted by
// z-index ascending (stable, so equal z keeps tree order). This is the
// pragmatic core of CSS stacking contexts: dropdowns/overlays/badges
// (z>0) stop losing to later siblings, and z:-1 decorative backdrops
// stop covering their content. Nested z roots paint inside their
// ancestor root's subtree in tree order.
let mut neg_layers: Vec<(i32, Vec)> = Vec::new();
let mut pos_layers: Vec<(i32, Vec)> = Vec::new();
let mut normal: Vec = Vec::new();
let mut consumed: std::collections::HashSet = std::collections::HashSet::new();
for nid in tree.descendants(tree.document()) {
if consumed.contains(&nid) {
continue;
}
let z = laid
.styles
.get(&nid)
.filter(|s| s.position.is_some())
.and_then(|s| s.z_index)
.filter(|&z| z != 0);
if let Some(z) = z {
let mut sub = vec![nid];
sub.extend(tree.descendants(nid));
for &m in &sub {
consumed.insert(m);
}
if z < 0 {
neg_layers.push((z, sub));
} else {
pos_layers.push((z, sub));
}
} else {
normal.push(nid);
}
}
neg_layers.sort_by_key(|(z, _)| *z);
pos_layers.sort_by_key(|(z, _)| *z);
let paint_order: Vec = neg_layers
.into_iter()
.flat_map(|(_, sub)| sub)
.chain(normal)
.chain(pos_layers.into_iter().flat_map(|(_, sub)| sub))
.collect();
for nid in paint_order {
if svg_subtree_skip.contains(&nid) {
continue;
}
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,
};
if style.effectively_invisible {
continue;
}
// A `transform: translate()` on this element or any ancestor offsets
// this element's whole painted box (and, applied per node, its whole
// subtree). The box shifts into screen space; the inherited clip is
// ALREADY in screen space (shifted by its own owner's translate at
// `resolve_clip_rects`), so it must not move with this descendant:
// that is what lets a clip cull a slide the carousel track has
// translated out of its viewport.
let (ox, oy) = laid.translates.get(&nid).copied().unwrap_or((0.0, 0.0));
let rect = crate::Rect { x: rect.x + ox, y: rect.y + oy, width: rect.width, height: rect.height };
// 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,
};
// Outset box-shadow paints behind this element's own background/border.
// Geometry comes from the full (translate-adjusted) border box; the
// ancestor overflow clip is reapplied inside so the shadow is clipped by
// an ancestor exactly as the box itself is.
if let Some(shadow) = style.box_shadow {
paint_box_shadow(&mut pixmap, &shadow, &rect, style.border_radius, clip);
}
// Box path (rounded if border-radius), reused for gradient/color fill.
let r = style.border_radius;
let bg_path = || if r > 0.5 {
rounded_rect_path(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height, r)
} else {
let mut pb = PathBuilder::new();
pb.push_rect(box_rect);
pb.finish()
};
// A linear-gradient background (heavily used by modern hero sections);
// without this it paints white. Takes precedence over a solid color.
// `background-clip: text` clips the background to the glyphs, so it must
// not paint as a box here; the text paint path fills the glyphs instead.
if style.mask_image.is_none() && !style.background_clip_text {
if let Some((angle, stops)) = &style.background_gradient {
if let Some(path) = bg_path() {
paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops);
}
} else if let Some(bg) = style.background_color {
// A masked element's background-color is the mask's fill color,
// not an ordinary box background (handled below), so this only
// runs for unmasked boxes.
if let Some(path) = bg_path() {
let mut paint = Paint::default();
paint.set_color(Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
paint.anti_alias = r > 0.5;
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 {
// object-fit does not apply to background images (that is
// background-size, handled above); paint the layer as-is.
// img_rect is already clip-intersected, so it doubles as the
// visible bound.
paint_image(bg_url, base_url, &img_rect, &img_rect, crate::ObjectFit::Fill, &mut pixmap, &mut image_cache);
}
}
// Rounded, uniform border: stroke the rounded-rect outline instead of
// four sharp edge rects.
let uniform_border = style.border.top == style.border.right
&& style.border.right == style.border.bottom
&& style.border.bottom == style.border.left
&& style.border.top > 0.0;
if style.border_radius > 0.5 && uniform_border {
let bc = style.border_color.or(style.color).unwrap_or([0, 0, 0, 255]);
let w = style.border.top;
if let Some(path) = rounded_rect_path(rect.x + w / 2.0, rect.y + w / 2.0, rect.width - w, rect.height - w, (style.border_radius - w / 2.0).max(0.0)) {
let mut paint = Paint::default();
paint.set_color(Color::from_rgba8(bc[0], bc[1], bc[2], bc[3]));
paint.anti_alias = true;
let stroke = tiny_skia::Stroke { width: w, ..Default::default() };
pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
}
} else 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, _density)) = resolve_img_url(tree, nid) {
// `visible_rect` is the border box already intersected with the
// ancestor overflow clip: the raster must not paint past it (a
// half-scrolled carousel slide's image otherwise bleeds over
// the viewport edge).
let painted =
paint_image(&src, base_url, &rect, &visible_rect, style.object_fit, &mut pixmap, &mut image_cache);
// Fall back when the image itself did not paint, following
// what browsers show for a broken image: a non-empty alt
// renders as text in place of the image (no placeholder box),
// alt="" renders nothing at all (the author declared the
// image decorative), and only a MISSING alt keeps the neutral
// grey placeholder. box_rect/visible_rect are already
// clip-intersected, so none of this paints outside an
// overflow:hidden clip.
if !painted {
match node.get_attribute("alt") {
Some(alt) if !alt.trim().is_empty() => {
draw_text(&mut pixmap, &alt, rect.x, rect.y, [0, 0, 0, 255], 12.0, false, clip);
}
Some(_) => {}
None => {
if visible_rect.width >= 4.0 && visible_rect.height >= 4.0 {
let mut ph = Paint::default();
ph.set_color(Color::from_rgba8(0xE9, 0xEA, 0xEC, 0xFF));
pixmap.fill_rect(box_rect, &ph, Transform::identity(), None);
}
}
}
}
}
}
// Inline `... `: serialize the whole subtree back to one
// standalone SVG document and rasterize it as a unit, so a
// `` resolves against the ``/`` in the
// same svg. The raster owns the subtree, so its DOM children are not
// painted individually (they are added to `svg_subtree_skip`). The svg
// is drawn at its full border-box size (undistorted) and clipped to the
// overflow-visible region.
if name.local.as_ref() == "svg" {
let mut markup = serialize_svg(tree, nid);
// resvg parses the serialized subtree as a standalone SVG
// document, outside the page's author stylesheet. Preserve the
// host element's computed `color` so paths using `currentColor`
// (the standard framework-logo/icon pattern) do not fall back to
// black.
if let Some(color) = style.color {
inject_svg_current_color(&mut markup, color);
}
// `` pointing at an EXTERNAL sprite file resolves
// to nothing in resvg (the symbol lives in another document). Fetch
// the sprite, splice the referenced `` into a local ``,
// and rewrite the href to a same-document `#id`. Same-document
// `` (empty url) is untouched.
inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache);
if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) {
let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect));
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
content.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
mask.as_ref(),
);
}
for child in tree.descendants(nid) {
svg_subtree_skip.insert(child);
}
}
// List-item marker (bullet or number), drawn in the indent to the left
// of the item's content box. `list_style` is inherited and resolved,
// so `None` (e.g. a nav ``) suppresses it.
if name.local.as_ref() == "li" {
if let Some(marker) = list_marker_text(tree, nid, style.list_style) {
let fsize = style.font_size.unwrap_or(16.0);
let color = style.color.unwrap_or([0, 0, 0, 255]);
let mw = measure_text(&marker, fsize, false);
let mx = rect.x + style.padding.left - mw - 6.0;
let my = rect.y + style.border.top + style.padding.top;
draw_text(&mut pixmap, &marker, mx, my, color, fsize, false, clip);
}
}
// `::before`/`::after` generated text (see `dom::build_pseudo_content`)
// has no DOM text node of its own; its word runs are registered under
// the host element's own id instead, so paint them here rather than
// through `paint_text_node` (which only runs for real text nodes).
if let Some(runs) = laid.text_runs.get(&nid) {
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");
for (word_rect, word) in runs {
draw_text(&mut pixmap, word, word_rect.x + ox, word_rect.y + oy, color, fsize, is_bold, clip);
}
}
// An empty text ` `/`