//! 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, RadialGradient, Rect, SpreadMode, Transform}; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static FONT_BOLD_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); use crate::dom::layout_dom_with_web_fonts; /// 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 { paint_dom_scrolled(tree, viewport, base_url, (0.0, 0.0)) } /// Render the visible viewport after root scrolling. Normal document content /// is translated by the clamped scroll offset while viewport-fixed subtrees /// remain anchored to the initial containing block. pub fn paint_dom_scrolled( tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>, scroll: (f32, f32), ) -> 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 mut intrinsic = collect_image_intrinsics(tree, viewport, base_url, &mut image_cache); let fonts = collect_web_fonts(tree, base_url); // Most framework pages use web fonts and many decorative SVG icons, but // only SVG text needs the page font faces. Avoid cloning/loading the page // font database for ordinary icons and HTML-only text. let svg_fonts = if has_inline_svg_text(tree) { svg_font_database_with_web_fonts(&fonts) } else { svg_font_database() }; let mut laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); // `content:url(...)` is computed by the author cascade, whereas ordinary // HTML image sources are available before layout. Pay for a second layout // only on the uncommon pages that actually use a CSS image as replaced // content: its metadata then enters the same intrinsic-size map as `src`. if collect_content_image_intrinsics( tree, &laid.styles, base_url, &mut image_cache, &mut intrinsic, ) { laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts); } paint_laid_dom_scrolled( tree, viewport, base_url, scroll, pixmap, image_cache, svg_fonts, &mut laid, ) } /// Paint an already prepared layout without changing its document-space /// geometry. Root scrolling and sticky positioning are per-shot visual state, /// so alternating captures can safely reuse the same layout. fn paint_laid_dom_scrolled( tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>, scroll: (f32, f32), mut pixmap: Pixmap, mut image_cache: std::collections::HashMap>>, svg_fonts: std::sync::Arc, laid: &mut crate::DomLayout, ) -> Option { let scroll_state = ScrollPaintState::new(tree, laid, viewport, scroll); // Only raster images inside an actually rotated/scaled subtree receive an // affine entry. Ordinary pages pay one fast style scan and allocate no // matrix map; transformed pages do matrix work only along those subtrees. let projected_images = collect_projected_image_transforms(tree, laid, &scroll_state); let root_font_size = tree .query_selector("html") .ok() .flatten() .and_then(|root| laid.styles.get(&root)) .and_then(|style| style.font_size) .unwrap_or(16.0); // 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(); // Generated boxes are anonymous layout children. ::before paints directly // after its host's own box; ::after paints after the host's last DOM // descendant in this paint order. Build the latter schedule only on pages // that actually materialized a generated box. A reverse DOM-preorder pass // propagates each node's paint index to its parent once, deriving subtree // endpoints in O(nodes + generated boxes), including reordered z layers. let mut generated_before: std::collections::HashMap< obscura_dom::tree::NodeId, Vec, > = std::collections::HashMap::new(); let mut generated_after_at: Vec> = vec![Vec::new(); paint_order.len()]; if !laid.generated_boxes.is_empty() { let paint_indices: std::collections::HashMap = paint_order .iter() .enumerate() .map(|(index, &nid)| (nid, index)) .collect(); let mut last_index: std::collections::HashMap = paint_indices.clone(); let dom_preorder = tree.descendants(tree.document()); for nid in dom_preorder.into_iter().rev() { let Some(index) = last_index.get(&nid).copied() else { continue; }; if let Some(parent) = tree.get_node(nid).and_then(|node| node.parent) { last_index .entry(parent) .and_modify(|last| *last = (*last).max(index)) .or_insert(index); } } for generated in laid.generated_boxes.iter().copied() { match generated.kind { crate::dom::GeneratedBoxKind::Before => { generated_before .entry(generated.host) .or_default() .push(generated); } crate::dom::GeneratedBoxKind::After => { if let Some(index) = last_index.get(&generated.host) { generated_after_at[*index].push(generated); } } } } } for (paint_index, nid) in paint_order.into_iter().enumerate() { 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, &scroll_state, &mut pixmap); for generated in &generated_after_at[paint_index] { paint_in_flow_generated_box( &mut pixmap, generated, laid, &scroll_state, viewport, root_font_size, base_url, &mut image_cache, ); } 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 // owner-shifted by layout and root-scroll/sticky-adjusted by the visual // state, but it must not move with this descendant: that is what lets a // clip cull a slide the carousel track translated out of its viewport. let (ox, oy) = scroll_state.translation_for(laid, nid); 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 = scroll_state.clip_for(laid, nid); let projected_image = projected_images.get(&nid).copied(); let cull_rect = projected_image .map(|transform| transform.map_rect(rect)) .unwrap_or(rect); let visible_rect = match clip { Some(c) => match cull_rect.intersect(&c) { Some(r) => r, None => continue, }, None => cull_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(bg) = style.background_color { 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((center, stops)) = &style.background_radial_gradient { if let Some(path) = bg_path() { paint_radial_gradient( &mut pixmap, &path, &visible_rect, *center, stops, ); } } if let Some((angle, center, stops)) = &style.background_conic_gradient { paint_conic_gradient(&mut pixmap, &visible_rect, *angle, *center, stops); } if let Some((angle, stops)) = &style.background_gradient { if let Some(path) = bg_path() { paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops); } } } 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, style.background_gradient.as_ref(), style.background_conic_gradient.as_ref(), style.mask_size, style.mask_repeat, &mut pixmap, &mut image_cache, ); } else if let Some(bg_url) = &style.background_image { if let Some(img_rect) = background_image_rect( bg_url, base_url, &rect, style.background_size, style.background_size_expression.as_deref(), style.background_size_fit, style.background_position, style.font_size.unwrap_or(16.0), root_font_size, viewport, &mut image_cache, ) { // A background layer is always clipped to its owner's border // box and then to inherited overflow. Keep its full destination // rect separate from that clip: intersecting first and then // scaling would resize a partially clipped image. let visible = match clip { Some(c) => rect.intersect(&c), None => Some(rect), }; if let Some(visible) = visible { paint_image( bg_url, base_url, &img_rect, &visible, crate::ObjectFit::Fill, &mut pixmap, &mut image_cache, None, ); } } } for pseudo in [style.before_pseudo.as_deref(), style.after_pseudo.as_deref()] .into_iter() .flatten() { paint_positioned_pseudo( &mut laid.text_engine, &mut pixmap, pseudo, &rect, viewport, root_font_size, clip, base_url, &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" { let source = style .content_image .as_ref() .map(|url| (url.clone(), 1.0)) .or_else(|| resolve_img_url(tree, nid, viewport)); if let Some((src, _density)) = source { // `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, projected_image, ); // 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, None, 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_styled(tree, nid, &laid.styles); // Resolve referenced symbols before carrying the host color into // the standalone document. A document-level/external symbol may // itself contain `currentColor`, and therefore has to be present // when the root color is established. inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache); // 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. if let Some(content) = render_svg_with_font_database( markup.as_bytes(), rect.width as u32, rect.height as u32, &svg_fonts, ) { 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); } } if let Some(generated) = generated_before.get(&nid) { for generated in generated { paint_in_flow_generated_box( &mut pixmap, generated, laid, &scroll_state, viewport, root_font_size, base_url, &mut image_cache, ); } } // 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, style.font_family.as_deref()); 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, style.font_family.as_deref(), 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 = crate::style::used_font_weight(style) >= 600; for (word_rect, word) in runs { draw_text( &mut pixmap, word, word_rect.x + ox, word_rect.y + oy, color, fsize, is_bold, style.font_family.as_deref(), clip, ); } } // A closed native ``/`