render: real-world CSS rendering parity
Colors: oklch()/oklab() and color-mix(); linear-gradient backgrounds. Grid: named-line placement, repeat() expansion, drop whitespace-only grid items; upgrade taffy 0.7 -> 0.12 so aspect-ratio images in auto-height rows no longer collapse to slivers. CSS: @layer, nesting, @property, :is()/:where(), clamp(), @media not all; source/track/param/area and svg desc/metadata as display:none. Images: webp + intrinsic sizing, srcset/picture, browser UA + Accept for CDN fetches, grey placeholder on failure; width/height HTML attributes rank below author CSS with aspect-ratio preserved for auto axes.
This commit is contained in:
1 parent
88313178b2
commit
2652ee64a4
17 files changed
+4482
-368
No files matched your search
@@ -6,12 +6,12 @@
|
||||
//! dependencies, so a screenshot is reproducible across hosts.
|
||||
|
||||
use obscura_dom::tree::DomTree;
|
||||
use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Rect, Transform};
|
||||
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/dejavu-sans.ttf");
|
||||
|
||||
use crate::layout_dom;
|
||||
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
|
||||
@@ -23,12 +23,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
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();
|
||||
// Fetch <img> 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);
|
||||
// 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) {
|
||||
@@ -55,6 +60,10 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if style.effectively_invisible {
|
||||
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
|
||||
@@ -72,17 +81,32 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
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);
|
||||
// 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.
|
||||
if style.mask_image.is_none() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +140,23 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
}
|
||||
}
|
||||
|
||||
if style.border.top > 0.0 || style.border.right > 0.0 || style.border.bottom > 0.0 || style.border.left > 0.0 {
|
||||
// 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]));
|
||||
@@ -150,10 +190,20 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
}
|
||||
|
||||
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);
|
||||
if let Some(src) = resolve_img_url(tree, nid) {
|
||||
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 {
|
||||
// A fetch/decode failure paints a neutral grey box (like a
|
||||
// browser's lazy/broken-image placeholder) so a missing
|
||||
// image reads as "not loaded", not as a broken render.
|
||||
// box_rect/visible_rect are already clip-intersected, so
|
||||
// this never paints outside an overflow:hidden clip.
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -162,10 +212,114 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 `<ul style="list-style:none">`) 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, word_rect.y, color, fsize, is_bold, clip);
|
||||
}
|
||||
}
|
||||
|
||||
// An empty text `<input>`/`<textarea>` shows its `placeholder`
|
||||
// attribute as muted text; there is no DOM text node for it (it is
|
||||
// not real content), so paint it directly from the attribute instead
|
||||
// of going through `paint_text_node`.
|
||||
if name.local.as_ref() == "input" || name.local.as_ref() == "textarea" {
|
||||
let has_value = node.get_attribute("value").map(|v| !v.is_empty()).unwrap_or(false);
|
||||
if !has_value {
|
||||
if let Some(placeholder) = node.get_attribute("placeholder") {
|
||||
if !placeholder.is_empty() {
|
||||
let fsize = style.font_size.unwrap_or(16.0);
|
||||
let text_x = rect.x + style.padding.left + style.border.left;
|
||||
let text_y = rect.y + style.padding.top + style.border.top;
|
||||
draw_text(&mut pixmap, placeholder, text_x, text_y, [117, 117, 117, 255], fsize, false, clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline formatting contexts shaped by cosmic-text (paragraphs, headings,
|
||||
// cells, labels) draw last, in tree order, so their glyphs sit above the
|
||||
// box backgrounds/borders painted in the loop above. Each item already
|
||||
// carries its final origin and clip from `TextEngine::finalize`.
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
if let Some(&idx) = laid.ifc_items.get(&nid) {
|
||||
if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
laid.text_engine.paint_item(idx, &mut pixmap);
|
||||
}
|
||||
}
|
||||
|
||||
Some(pixmap)
|
||||
}
|
||||
|
||||
/// A closed rounded-rectangle path, corners approximated by quadratic curves
|
||||
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
|
||||
/// clamped so it never exceeds half the shorter side.
|
||||
fn rounded_rect_path(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option<tiny_skia::Path> {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let r = r.min(w / 2.0).min(h / 2.0).max(0.0);
|
||||
let mut pb = PathBuilder::new();
|
||||
pb.move_to(x + r, y);
|
||||
pb.line_to(x + w - r, y);
|
||||
pb.quad_to(x + w, y, x + w, y + r);
|
||||
pb.line_to(x + w, y + h - r);
|
||||
pb.quad_to(x + w, y + h, x + w - r, y + h);
|
||||
pb.line_to(x + r, y + h);
|
||||
pb.quad_to(x, y + h, x, y + h - r);
|
||||
pb.line_to(x, y + r);
|
||||
pb.quad_to(x, y, x + r, y);
|
||||
pb.close();
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
/// The marker text for a list item, or `None` when markers are suppressed
|
||||
/// (`list-style: none`). `Decimal` numbers the item by its position among
|
||||
/// sibling list items so `<ol>`s count 1, 2, 3.
|
||||
fn list_marker_text(tree: &DomTree, nid: obscura_dom::tree::NodeId, style: Option<crate::ListStyle>) -> Option<String> {
|
||||
match style {
|
||||
Some(crate::ListStyle::Disc) => Some("\u{2022}".to_string()),
|
||||
Some(crate::ListStyle::Circle) => Some("\u{25E6}".to_string()),
|
||||
Some(crate::ListStyle::Square) => Some("\u{25AA}".to_string()),
|
||||
Some(crate::ListStyle::Decimal) => {
|
||||
let mut n = 1usize;
|
||||
let mut cur = tree.get_node(nid).and_then(|node| node.prev_sibling);
|
||||
while let Some(sib) = cur {
|
||||
if tree.get_node(sib).and_then(|s| s.as_element().map(|e| e.local.to_string())).as_deref() == Some("li") {
|
||||
n += 1;
|
||||
}
|
||||
cur = tree.get_node(sib).and_then(|s| s.prev_sibling);
|
||||
}
|
||||
Some(format!("{}.", n))
|
||||
}
|
||||
Some(crate::ListStyle::None) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -187,6 +341,9 @@ fn paint_text_node(
|
||||
let node = tree.get_node(nid)?;
|
||||
let parent = node.parent?;
|
||||
let style = laid.styles.get(&parent)?;
|
||||
if style.effectively_invisible {
|
||||
return Some(());
|
||||
}
|
||||
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");
|
||||
@@ -331,15 +488,68 @@ fn fetch_bytes(
|
||||
let url = resolved?;
|
||||
cache
|
||||
.entry(url.clone())
|
||||
.or_insert_with(|| {
|
||||
ureq::get(&url).call().ok().and_then(|resp| {
|
||||
.or_insert_with(|| http_get_bytes(&url))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Fetch `url` with a descriptive User-Agent and a bounded timeout, retrying on
|
||||
/// rate-limit / transient errors with backoff. Real pages pull dozens of images
|
||||
/// from one CDN in a burst (a Wikipedia article references ~60); hosts like
|
||||
/// Wikimedia answer a rapid burst with HTTP 429 after ~10 requests. Without a
|
||||
/// retry the rate-limited images (e.g. an infobox photo montage fetched late in
|
||||
/// the burst) came back blank, and the failure was cached permanently. The
|
||||
/// backoff both recovers them and paces the burst back under the limit.
|
||||
fn http_get_bytes(url: &str) -> Option<Vec<u8>> {
|
||||
let mut backoff = std::time::Duration::from_millis(200);
|
||||
for attempt in 0..3 {
|
||||
// A browser-like Accept advertises the modern image formats and is what
|
||||
// content-negotiating CDNs expect; some UA-gated hosts also reject a
|
||||
// request with no Accept header outright.
|
||||
let res = image_agent()
|
||||
.get(url)
|
||||
.set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
.call();
|
||||
match res {
|
||||
Ok(resp) => {
|
||||
let mut buf = Vec::new();
|
||||
use std::io::Read;
|
||||
resp.into_reader().read_to_end(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
})
|
||||
.clone()
|
||||
return resp.into_reader().read_to_end(&mut buf).ok().map(|_| buf);
|
||||
}
|
||||
// 429 (rate limit) and 5xx are transient: back off and retry.
|
||||
Err(ureq::Error::Status(code, _)) if matches!(code, 429 | 500 | 502 | 503 | 504) && attempt < 2 => {
|
||||
std::thread::sleep(backoff);
|
||||
backoff *= 2;
|
||||
}
|
||||
// A network/transport error is also worth one more try.
|
||||
Err(ureq::Error::Transport(_)) if attempt < 2 => {
|
||||
std::thread::sleep(backoff);
|
||||
backoff *= 2;
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One shared HTTP agent for all image fetches in the process, with a browser
|
||||
/// User-Agent and keep-alive connection pooling. A CDN's bot rate-limiter keys
|
||||
/// on connection churn as much as on rate: a fresh TLS handshake per image (the
|
||||
/// old per-call `ureq::get`) reads as a burst and gets 429'd, whereas reusing
|
||||
/// one pooled connection to the same host (as a browser does) both avoids most
|
||||
/// throttling and is much faster on an image-heavy page.
|
||||
fn image_agent() -> &'static ureq::Agent {
|
||||
static AGENT: std::sync::OnceLock<ureq::Agent> = std::sync::OnceLock::new();
|
||||
AGENT.get_or_init(|| {
|
||||
ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
// Present the same normal browser identity the engine uses for the
|
||||
// document. A bot-identifying UA got image requests filtered by CDNs
|
||||
// that gate on User-Agent (Akamai/Cloudflare image endpoints on
|
||||
// cnbc, techcrunch, arstechnica), so the images Chrome loads came
|
||||
// back blank; a real browser UA loads the same bytes Chrome does.
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode a percent-escaped data: URI payload (`%23` -> `#`, etc). Bytes that
|
||||
@@ -386,6 +596,303 @@ fn raster_to_pixmap(bytes: &[u8], w: u32, h: u32) -> Option<Pixmap> {
|
||||
Pixmap::from_vec(raw, size)
|
||||
}
|
||||
|
||||
/// Read an image's intrinsic pixel dimensions from its header only, without
|
||||
/// decoding the whole thing. Returns None for formats the raster decoder does
|
||||
/// not recognize (e.g. SVG, which is sized elsewhere).
|
||||
/// Fill `path` with a CSS `linear-gradient`. `angle` is degrees clockwise from
|
||||
/// 12 o'clock (0 = to top). The gradient line length uses the CSS formula so
|
||||
/// the stops land where a browser puts them. Positionless stops are spread
|
||||
/// evenly; positions are clamped monotonic (tiny-skia requires ascending).
|
||||
fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &crate::Rect, angle: f32, stops: &[([u8; 4], Option<f32>)]) {
|
||||
if stops.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let rad = angle.to_radians();
|
||||
let dx = rad.sin();
|
||||
let dy = -rad.cos();
|
||||
let (cx, cy) = (rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
|
||||
let half = (dx.abs() * rect.width + dy.abs() * rect.height) / 2.0;
|
||||
let start = Point::from_xy(cx - dx * half, cy - dy * half);
|
||||
let end = Point::from_xy(cx + dx * half, cy + dy * half);
|
||||
let n = stops.len();
|
||||
let mut gs: Vec<GradientStop> = Vec::with_capacity(n);
|
||||
let mut last = 0.0f32;
|
||||
for (i, (c, pos)) in stops.iter().enumerate() {
|
||||
let p = pos.unwrap_or(i as f32 / (n - 1) as f32).clamp(0.0, 1.0).max(last);
|
||||
last = p;
|
||||
gs.push(GradientStop::new(p, Color::from_rgba8(c[0], c[1], c[2], c[3])));
|
||||
}
|
||||
if let Some(shader) = LinearGradient::new(start, end, gs, SpreadMode::Pad, Transform::identity()) {
|
||||
let mut paint = Paint::default();
|
||||
paint.shader = shader;
|
||||
paint.anti_alias = true;
|
||||
pixmap.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?
|
||||
.into_dimensions()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Fetch every `<img>` once (seeding `cache` for the paint pass) and record its
|
||||
/// intrinsic (width, height) so layout can size replaced elements that have no
|
||||
/// explicit dimensions. Keyed by the `<img>`'s NodeId.
|
||||
fn collect_image_intrinsics(
|
||||
tree: &DomTree,
|
||||
base_url: Option<&str>,
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> std::collections::HashMap<obscura_dom::tree::NodeId, (f32, f32)> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for nid in tree.descendants(tree.document()) {
|
||||
let Some(node) = tree.get_node(nid) else { continue };
|
||||
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(url) = resolve_img_url(tree, nid) else { continue };
|
||||
let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue };
|
||||
if let Some((w, h)) = image_dimensions(&bytes) {
|
||||
if w > 0 && h > 0 {
|
||||
out.insert(nid, (w as f32, h as f32));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Choose the URL to paint for an `<img>`. Browsers do not use `src` alone:
|
||||
/// a wrapping `<picture>`'s `<source>`s, `srcset`, and `sizes` select by
|
||||
/// type/media/viewport/density, and lazy-loaded images keep the real URL in
|
||||
/// `data-src`/`data-srcset` with `src` holding a 1x1 placeholder until script
|
||||
/// swaps it in. Since obscura may not have run the site's lazy-load script,
|
||||
/// resolve the same URL the browser would end up with: a matching `<picture>`
|
||||
/// source first, then a real candidate from `srcset`/`data-srcset`, then a
|
||||
/// non-inline `src`/`data-*` URL, then any `src` (an inlined data: image).
|
||||
fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<String> {
|
||||
let node = tree.get_node(nid)?;
|
||||
// A <picture>'s preceding, type/media-matching <source> wins over the
|
||||
// <img>'s own attributes (HTML "update the source set").
|
||||
if let Some(url) = picture_source_url(tree, nid) {
|
||||
return Some(url);
|
||||
}
|
||||
let sizes = node.get_attribute("sizes");
|
||||
for a in ["srcset", "data-srcset"] {
|
||||
if let Some(v) = node.get_attribute(a) {
|
||||
if let Some(u) = best_srcset_candidate(v, sizes) {
|
||||
return Some(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
let url_attrs = ["src", "data-src", "data-lazy-src", "data-original", "data-fallback-src", "data-lazy"];
|
||||
// A non-inline URL first (a data: src is usually the lazy-load placeholder).
|
||||
for a in url_attrs {
|
||||
if let Some(v) = node.get_attribute(a) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() && !v.starts_with("data:") {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Otherwise fall back to whatever is there (an inlined data: image).
|
||||
for a in url_attrs {
|
||||
if let Some(v) = node.get_attribute(a) {
|
||||
let v = v.trim();
|
||||
if !v.is_empty() {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// When `img_nid` is an `<img>` inside a `<picture>`, walk its preceding
|
||||
/// `<source>` siblings in document order and return the selected URL of the
|
||||
/// first supported one (matching `type` and `media`), per WebKit's
|
||||
/// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source
|
||||
/// applied and the caller should fall back to the `<img>`'s own attributes.
|
||||
fn picture_source_url(tree: &DomTree, img_nid: obscura_dom::tree::NodeId) -> Option<String> {
|
||||
let img = tree.get_node(img_nid)?;
|
||||
let parent = img.parent?;
|
||||
let is_picture = tree
|
||||
.get_node(parent)
|
||||
.and_then(|p| p.as_element().map(|e| e.local.as_ref() == "picture"))
|
||||
.unwrap_or(false);
|
||||
if !is_picture {
|
||||
return None;
|
||||
}
|
||||
for cid in tree.children(parent) {
|
||||
// Only sources that precede the <img> contribute.
|
||||
if cid == img_nid {
|
||||
break;
|
||||
}
|
||||
let Some(child) = tree.get_node(cid) else { continue };
|
||||
if child.as_element().map(|e| e.local.as_ref() != "source").unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(srcset) = child.get_attribute("srcset") else { continue };
|
||||
if srcset.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = child.get_attribute("type") {
|
||||
if !source_type_supported(t) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(m) = child.get_attribute("media") {
|
||||
if !m.trim().is_empty() && !crate::css::media_query_applies(m) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let sizes = child.get_attribute("sizes");
|
||||
if let Some(u) = best_srcset_candidate(srcset, sizes) {
|
||||
return Some(u);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether a `<source type=...>` names an image format this build can decode.
|
||||
/// AVIF/JPEG-XL are intentionally excluded: the `image` crate cannot decode
|
||||
/// them here, so a decodable `<img>` fallback must win over such a source.
|
||||
fn source_type_supported(t: &str) -> bool {
|
||||
matches!(
|
||||
t.trim().to_ascii_lowercase().as_str(),
|
||||
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
|
||||
| "image/bmp" | "image/svg+xml" | "image/x-icon" | "image/vnd.microsoft.icon"
|
||||
)
|
||||
}
|
||||
|
||||
/// Assumed layout viewport width (matches the desktop width the `@media`
|
||||
/// cascade evaluates against in `css.rs`). Used to turn `w` descriptors and
|
||||
/// `vw`/`%` source sizes into effective pixel densities.
|
||||
const SRCSET_VIEWPORT_W: f32 = 1280.0;
|
||||
|
||||
/// Pick one URL from a `srcset` list, matching the WebKit/Blink selection:
|
||||
/// normalize each `w` descriptor to an effective density (`w / source-size`,
|
||||
/// with the source-size taken from `sizes` or falling back to the viewport
|
||||
/// width), treat `x` descriptors as-is and a bare candidate as `1x`, then pick
|
||||
/// the smallest density at least the device pixel ratio (1 at DPR 1), else the
|
||||
/// largest available.
|
||||
fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<String> {
|
||||
const DPR: f32 = 1.0;
|
||||
let source_size = source_size_px(sizes);
|
||||
let mut cands: Vec<(f32, String)> = Vec::new();
|
||||
// Parse candidates WHATWG-style: a URL is a run of non-whitespace (so a
|
||||
// data: URI's internal commas stay part of it, unlike a naive split on
|
||||
// ','), optionally followed by a descriptor up to the next comma.
|
||||
let is_ws = |c: char| c.is_whitespace();
|
||||
let mut rest = srcset.trim_start_matches(|c: char| is_ws(c) || c == ',');
|
||||
while !rest.is_empty() {
|
||||
let url_end = rest.find(is_ws).unwrap_or(rest.len());
|
||||
let raw_url = &rest[..url_end];
|
||||
rest = &rest[url_end..];
|
||||
// Trailing commas on the URL mean the candidate had no descriptor.
|
||||
let url = raw_url.trim_end_matches(',');
|
||||
let no_desc = url.len() != raw_url.len();
|
||||
rest = rest.trim_start_matches(is_ws);
|
||||
let desc = if no_desc {
|
||||
""
|
||||
} else {
|
||||
let d_end = rest.find(',').unwrap_or(rest.len());
|
||||
let d = rest[..d_end].trim();
|
||||
rest = &rest[d_end..];
|
||||
d
|
||||
};
|
||||
rest = rest.trim_start_matches(|c: char| c == ',' || is_ws(c));
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let density = if desc.is_empty() {
|
||||
1.0
|
||||
} else if let Some(w) = desc.strip_suffix('w').and_then(|s| s.parse::<f32>().ok()) {
|
||||
if source_size > 0.0 { w / source_size } else { continue }
|
||||
} else if let Some(x) = desc.strip_suffix('x').and_then(|s| s.parse::<f32>().ok()) {
|
||||
x
|
||||
} else {
|
||||
// An `h` (height) descriptor or malformed token: skip the candidate.
|
||||
continue;
|
||||
};
|
||||
cands.push((density, url.to_string()));
|
||||
}
|
||||
if cands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let pick = cands
|
||||
.iter()
|
||||
.find(|(d, _)| *d >= DPR)
|
||||
.map(|(_, u)| u.clone())
|
||||
.unwrap_or_else(|| cands.last().unwrap().1.clone());
|
||||
Some(pick)
|
||||
}
|
||||
|
||||
/// Approximate the CSS px size an image will be displayed at, from its `sizes`
|
||||
/// attribute: the first entry whose media condition holds at our assumed
|
||||
/// desktop viewport (a bare entry always holds), else the viewport width. Used
|
||||
/// only to convert `w` descriptors to densities, so a coarse value is fine.
|
||||
fn source_size_px(sizes: Option<&str>) -> f32 {
|
||||
let Some(sizes) = sizes else { return SRCSET_VIEWPORT_W };
|
||||
for entry in sizes.split(',') {
|
||||
let entry = entry.trim();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (cond, len) = split_size_entry(entry);
|
||||
if let Some(cond) = cond {
|
||||
if !crate::css::media_query_applies(&cond) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(px) = length_to_px(&len) {
|
||||
return px;
|
||||
}
|
||||
}
|
||||
SRCSET_VIEWPORT_W
|
||||
}
|
||||
|
||||
/// Split one `sizes` entry into its optional leading media condition and its
|
||||
/// trailing `<length>`. Tokenizes on whitespace at paren depth 0 so a
|
||||
/// `calc(...)` length or a parenthesized condition stays intact; the last
|
||||
/// token is the length, anything before it is the condition.
|
||||
fn split_size_entry(entry: &str) -> (Option<String>, String) {
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut depth = 0i32;
|
||||
for c in entry.chars() {
|
||||
match c {
|
||||
'(' => { depth += 1; cur.push(c); }
|
||||
')' => { depth -= 1; cur.push(c); }
|
||||
c if c.is_whitespace() && depth == 0 => {
|
||||
if !cur.is_empty() { tokens.push(std::mem::take(&mut cur)); }
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
tokens.push(cur);
|
||||
}
|
||||
let len = tokens.pop().unwrap_or_default();
|
||||
let cond = if tokens.is_empty() { None } else { Some(tokens.join(" ")) };
|
||||
(cond, len)
|
||||
}
|
||||
|
||||
/// Resolve a `sizes` length to px against the assumed viewport. `vw`/`%` scale
|
||||
/// by the viewport width; `px` is literal; `em`/`rem` use the 16px root.
|
||||
/// `calc()` and other forms return `None` (the caller tries the next entry).
|
||||
fn length_to_px(len: &str) -> Option<f32> {
|
||||
let t = len.trim().to_ascii_lowercase();
|
||||
let num = |s: &str| s.trim().parse::<f32>().ok();
|
||||
if let Some(v) = t.strip_suffix("vw").and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); }
|
||||
if let Some(v) = t.strip_suffix('%').and_then(num) { return Some(v / 100.0 * SRCSET_VIEWPORT_W); }
|
||||
if let Some(v) = t.strip_suffix("px").and_then(num) { return Some(v); }
|
||||
if let Some(v) = t.strip_suffix("rem").and_then(num) { return Some(v * 16.0); }
|
||||
if let Some(v) = t.strip_suffix("em").and_then(num) { return Some(v * 16.0); }
|
||||
num(&t)
|
||||
}
|
||||
|
||||
fn paint_image(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
|
||||
Reference in new issue
Block a user