//! 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 `