render: scale image intrinsic size by the selected srcset density

The srcset picker already normalized every candidate to a pixel density
(x descriptor, or w / source-size) to choose one, then threw the density
away, so layout sized the image from the file's raw pixels. Every 2x
responsive asset occupied twice its design size.

Return the picked candidate's density alongside its URL (img srcset,
data-srcset, and picture source paths alike) and divide the fetched
intrinsic dimensions by it, so layout sees CSS px.

Verified: srcset "x.png 2x" of a 648px file lays out at 324 CSS px and
"648w" with sizes=300px at 300, while a bare src stays at raw size; no
regressions on the image-heavy spot set (a bbc delta traced to homepage
content drift against a days-old baseline, 8.0 vs 7.8 percent when
recaptured same-time); obstacle course 33/33.
This commit is contained in:
SGavrl committed 2026-07-16 12:08:19 +00:00
1 parent 35cbbff9b2
commit c29c64fc57
1 file changed
+25 -15
+25 -15
View File
@@ -230,7 +230,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
} }
if name.local.as_ref() == "img" { if name.local.as_ref() == "img" {
if let Some(src) = resolve_img_url(tree, nid) { if let Some((src, _density)) = resolve_img_url(tree, nid) {
let painted = paint_image(&src, base_url, &rect, style.object_fit, &mut pixmap, &mut image_cache); let painted = paint_image(&src, base_url, &rect, style.object_fit, &mut pixmap, &mut image_cache);
// Fall back to alt text only when the image itself did not paint. // Fall back to alt text only when the image itself did not paint.
if !painted { if !painted {
@@ -883,11 +883,14 @@ fn collect_image_intrinsics(
if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) { if node.as_element().map(|e| e.local.as_ref() != "img").unwrap_or(true) {
continue; continue;
} }
let Some(url) = resolve_img_url(tree, nid) else { continue }; let Some((url, density)) = resolve_img_url(tree, nid) else { continue };
let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue }; let Some(bytes) = fetch_bytes(&url, base_url, cache) else { continue };
if let Some((w, h)) = image_dimensions(&bytes) { if let Some((w, h)) = image_dimensions(&bytes) {
if w > 0 && h > 0 { if w > 0 && h > 0 {
out.insert(nid, (w as f32, h as f32)); // A 2x (or w-descriptor) candidate's raw pixels are density
// times its CSS size; divide so layout sees CSS px, or every
// responsive image occupies twice its design size.
out.insert(nid, (w as f32 / density, h as f32 / density));
} }
} }
} }
@@ -902,18 +905,18 @@ fn collect_image_intrinsics(
/// resolve the same URL the browser would end up with: a matching `<picture>` /// 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 /// source first, then a real candidate from `srcset`/`data-srcset`, then a
/// non-inline `src`/`data-*` URL, then any `src` (an inlined data: image). /// 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> { fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<(String, f32)> {
let node = tree.get_node(nid)?; let node = tree.get_node(nid)?;
// A <picture>'s preceding, type/media-matching <source> wins over the // A <picture>'s preceding, type/media-matching <source> wins over the
// <img>'s own attributes (HTML "update the source set"). // <img>'s own attributes (HTML "update the source set").
if let Some(url) = picture_source_url(tree, nid) { if let Some(pick) = picture_source_url(tree, nid) {
return Some(url); return Some(pick);
} }
let sizes = node.get_attribute("sizes"); let sizes = node.get_attribute("sizes");
for a in ["srcset", "data-srcset"] { for a in ["srcset", "data-srcset"] {
if let Some(v) = node.get_attribute(a) { if let Some(v) = node.get_attribute(a) {
if let Some(u) = best_srcset_candidate(v, sizes) { if let Some(pick) = best_srcset_candidate(v, sizes) {
return Some(u); return Some(pick);
} }
} }
} }
@@ -923,7 +926,7 @@ fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<Str
if let Some(v) = node.get_attribute(a) { if let Some(v) = node.get_attribute(a) {
let v = v.trim(); let v = v.trim();
if !v.is_empty() && !v.starts_with("data:") { if !v.is_empty() && !v.starts_with("data:") {
return Some(v.to_string()); return Some((v.to_string(), 1.0));
} }
} }
} }
@@ -932,7 +935,7 @@ fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<Str
if let Some(v) = node.get_attribute(a) { if let Some(v) = node.get_attribute(a) {
let v = v.trim(); let v = v.trim();
if !v.is_empty() { if !v.is_empty() {
return Some(v.to_string()); return Some((v.to_string(), 1.0));
} }
} }
} }
@@ -944,7 +947,7 @@ fn resolve_img_url(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<Str
/// first supported one (matching `type` and `media`), per WebKit's /// first supported one (matching `type` and `media`), per WebKit's
/// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source /// `HTMLImageElement::bestFitSourceFromPictureElement`. `None` means no source
/// applied and the caller should fall back to the `<img>`'s own attributes. /// 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> { fn picture_source_url(tree: &DomTree, img_nid: obscura_dom::tree::NodeId) -> Option<(String, f32)> {
let img = tree.get_node(img_nid)?; let img = tree.get_node(img_nid)?;
let parent = img.parent?; let parent = img.parent?;
let is_picture = tree let is_picture = tree
@@ -1007,7 +1010,11 @@ const SRCSET_VIEWPORT_W: f32 = 1280.0;
/// width), treat `x` descriptors as-is and a bare candidate as `1x`, then pick /// 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 /// the smallest density at least the device pixel ratio (1 at DPR 1), else the
/// largest available. /// largest available.
fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<String> { /// Returns the picked candidate URL and its pixel density. The density is the
/// x-descriptor (or, for w-descriptors, width / source-size): the factor the
/// file's raw pixels must be divided by to get CSS px. Laying out with raw
/// pixels made every 2x responsive image occupy twice its design size.
fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<(String, f32)> {
const DPR: f32 = 1.0; const DPR: f32 = 1.0;
let source_size = source_size_px(sizes); let source_size = source_size_px(sizes);
let mut cands: Vec<(f32, String)> = Vec::new(); let mut cands: Vec<(f32, String)> = Vec::new();
@@ -1055,9 +1062,12 @@ fn best_srcset_candidate(srcset: &str, sizes: Option<&str>) -> Option<String> {
let pick = cands let pick = cands
.iter() .iter()
.find(|(d, _)| *d >= DPR) .find(|(d, _)| *d >= DPR)
.map(|(_, u)| u.clone()) .map(|(d, u)| (u.clone(), *d))
.unwrap_or_else(|| cands.last().unwrap().1.clone()); .unwrap_or_else(|| {
Some(pick) let (d, u) = cands.last().unwrap();
(u.clone(), *d)
});
Some((pick.0, pick.1.max(0.01)))
} }
/// Approximate the CSS px size an image will be displayed at, from its `sizes` /// Approximate the CSS px size an image will be displayed at, from its `sizes`