From eea960dd32e19293429ec2a2144030f37d76648c Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:04:55 +0000 Subject: [PATCH] render: honor text and background intrinsic metrics --- crates/obscura-render/src/dom.rs | 58 ++++++- crates/obscura-render/src/lib.rs | 12 +- crates/obscura-render/src/paint.rs | 253 +++++++++++++++++++++++++---- crates/obscura-render/src/style.rs | 29 +++- 4 files changed, 302 insertions(+), 50 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index ecb39c9..c06541b 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -21,12 +21,12 @@ use crate::{Rect, to_taffy_style}; /// (lightest-weight) feature set, matching RENDER.md's documented "layout /// (default)" mode. #[cfg(feature = "paint")] -fn text_width(text: &str, size: f32, is_bold: bool) -> f32 { - crate::paint::measure_text(text, size, is_bold) +fn text_width(text: &str, size: f32, is_bold: bool, family: Option<&str>) -> f32 { + crate::paint::measure_text(text, size, is_bold, family) } #[cfg(not(feature = "paint"))] -fn text_width(text: &str, size: f32, is_bold: bool) -> f32 { +fn text_width(text: &str, size: f32, is_bold: bool, _family: Option<&str>) -> f32 { const AVG_CHAR_WIDTH_EM: f32 = 0.55; let chars = text.chars().filter(|c| !c.is_control()).count() as f32; let width = chars * size * AVG_CHAR_WIDTH_EM; @@ -2727,11 +2727,15 @@ fn build_text_words( let mut fsize = 16.0; let mut is_bold = false; + let mut family = None; + let mut line_height = fsize * 1.2; let mut transform = crate::TextTransform::None; if let Some(parent_id) = node.parent { if let Some(p_style) = styles.get(&parent_id) { fsize = p_style.font_size.unwrap_or(16.0); is_bold = p_style.font_weight.as_deref() == Some("bold"); + family = p_style.font_family.as_deref(); + line_height = crate::inline::used_line_height(p_style); transform = p_style .text_transform .unwrap_or(crate::TextTransform::None); @@ -2739,7 +2743,16 @@ fn build_text_words( } display_text = transform_word_leaf_text(&display_text, transform); - build_word_leaves(id, &display_text, fsize, is_bold, taffy_tree, words) + build_word_leaves( + id, + &display_text, + fsize, + line_height, + is_bold, + family, + taffy_tree, + words, + ) } fn transform_word_leaf_text(text: &str, transform: crate::TextTransform) -> String { @@ -2774,21 +2787,27 @@ fn build_word_leaves( source_id: NodeId, text: &str, fsize: f32, + line_height: f32, is_bold: bool, + family: Option<&str>, taffy_tree: &mut TaffyTree, words: &mut HashMap, ) -> Vec { tokenize_with_spaces(text) .into_iter() .filter_map(|token| { - let width = text_width(&token, fsize, is_bold); + let width = text_width(&token, fsize, is_bold, family); // A pure-whitespace token is HTML source formatting or a bare // inter-element space; it keeps its (small) width so adjacent // inline content stays visually separated, but contributes no // height, so it never adds a spurious blank row when it lands // between block-level siblings (e.g. formatting whitespace // around a run of now-collapsed, display:none list items). - let height = if token.trim().is_empty() { 0.0 } else { fsize * 1.2 }; + let height = if token.trim().is_empty() { + 0.0 + } else { + line_height.max(0.0) + }; let taffy_style = taffy::Style { size: taffy::Size { width: taffy::Dimension::length(width), height: taffy::Dimension::length(height) }, ..Default::default() @@ -2814,7 +2833,16 @@ fn build_pseudo_content( ) -> Vec { let fsize = style.font_size.unwrap_or(16.0); let is_bold = style.font_weight.as_deref() == Some("bold"); - build_word_leaves(id, content, fsize, is_bold, taffy_tree, words) + build_word_leaves( + id, + content, + fsize, + crate::inline::used_line_height(style), + is_bold, + style.font_family.as_deref(), + taffy_tree, + words, + ) } /// Split already whitespace-collapsed text into tokens, each carrying at most @@ -4838,6 +4866,22 @@ mod tests { ); } + #[test] + fn word_leaves_use_the_computed_line_height() { + let tree = parse_html( + r#"
code
"#, + ); + let text = tree + .descendants(tree.document()) + .into_iter() + .find(|id| tree.get_node(*id).is_some_and(|node| node.is_text())) + .unwrap(); + let laid = layout_dom(&tree, (1280.0, 720.0)); + let runs = laid.text_runs.get(&text).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].0.height, 32.0); + } + #[test] fn table_cell_content_box_width_includes_padding_and_border_in_track() { let tree = parse_html( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 0761fa6..800a5ae 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -263,14 +263,14 @@ pub struct LayoutStyle { pub background_image: Option, /// `background-size`, in px, when given as explicit length(s) (a bare /// `10px` applies to both axes, matching how small square icons are - /// almost always sized). `None` means "fill the whole box" (our fallback - /// when the value is a keyword we don't evaluate, `cover`/`contain`, or - /// absent with an already icon-sized box like the HN vote arrow). + /// almost always sized). pub background_size: Option<(f32, f32)>, + /// Keyword `background-size` behavior. `None` is CSS `auto`, which uses + /// the image's intrinsic dimensions rather than stretching it to the box. + pub background_size_fit: Option, /// `background-position` as a 0.0-1.0 fraction per axis (0,0 = default - /// top-left; 1,0.5 = "right center"). Only meaningful alongside - /// `background_size`: without a known image size there is no leftover - /// box space to position within. + /// top-left; 1,0.5 = "right center"). The fraction applies to the leftover + /// space after resolving explicit, intrinsic, cover, or contain size. pub background_position: (f32, f32), /// `background-clip: text` / `-webkit-background-clip: text`: the background /// paints only through the element's glyphs, not as a filled box. Combined diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index b920307..e86d7fe 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -10,6 +10,8 @@ use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilde 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"); use crate::layout_dom_with_resources; @@ -220,33 +222,34 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - &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, - } + if let Some(img_rect) = background_image_rect( + bg_url, + base_url, + &rect, + style.background_size, + style.background_size_fit, + style.background_position, + &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 => 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); } } @@ -318,7 +321,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - 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); + draw_text( + &mut pixmap, + &alt, + rect.x, + rect.y, + [0, 0, 0, 255], + 12.0, + false, + None, + clip, + ); } Some(_) => {} None => { @@ -379,10 +392,20 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - 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 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, clip); + draw_text( + &mut pixmap, + &marker, + mx, + my, + color, + fsize, + false, + style.font_family.as_deref(), + clip, + ); } } @@ -395,7 +418,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - 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); + draw_text( + &mut pixmap, + word, + word_rect.x + ox, + word_rect.y + oy, + color, + fsize, + is_bold, + style.font_family.as_deref(), + clip, + ); } } @@ -411,7 +444,17 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - 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); + draw_text( + &mut pixmap, + placeholder, + text_x, + text_y, + [117, 117, 117, 255], + fsize, + false, + style.font_family.as_deref(), + clip, + ); } } } @@ -653,13 +696,70 @@ fn paint_text_node( let clip = laid.clip_rects.get(&nid).copied().flatten(); for (rect, word) in runs { - draw_text(pixmap, word, rect.x + ox, rect.y + oy, color, fsize, is_bold, clip); + draw_text( + pixmap, + word, + rect.x + ox, + rect.y + oy, + color, + fsize, + is_bold, + style.font_family.as_deref(), + clip, + ); } Some(()) } -pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 { - let font = FontRef::try_from_slice(FONT_BYTES).unwrap(); +fn fallback_font_bytes(family: Option<&str>) -> &'static [u8] { + let Some(family) = family else { return FONT_BYTES }; + for token in family.split(',') { + let token = token + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_ascii_lowercase(); + if token == "monospace" + || token.contains("mono") + || token.contains("courier") + || token.contains("consol") + || token == "menlo" + || token == "monaco" + || token == "code" + { + return MONO_FONT_BYTES; + } + if token == "serif" + || token == "georgia" + || token.contains("times") + || token == "cambria" + || token.contains("garamond") + || token.contains("liberation serif") + || token == "roman" + { + return SERIF_FONT_BYTES; + } + if token == "sans-serif" + || token.contains("sans") + || token == "arial" + || token == "helvetica" + || token == "helvetica neue" + || token == "system-ui" + || token == "-apple-system" + || token == "roboto" + || token == "segoe ui" + || token == "inter" + || token == "verdana" + || token == "tahoma" + || token == "ui-sans-serif" + { + return FONT_BYTES; + } + } + FONT_BYTES +} + +pub fn measure_text(text: &str, size: f32, is_bold: bool, family: Option<&str>) -> f32 { + let font = FontRef::try_from_slice(fallback_font_bytes(family)).unwrap(); let scale = PxScale::from(size); let scaled_font = font.as_scaled(scale); let mut width = 0.0; @@ -671,7 +771,17 @@ pub fn measure_text(text: &str, size: f32, is_bold: bool) -> f32 { width } -fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], size: f32, is_bold: bool, clip: Option) { +fn draw_text( + pixmap: &mut Pixmap, + text: &str, + x: f32, + y: f32, + color: [u8; 4], + size: f32, + is_bold: bool, + family: Option<&str>, + clip: Option, +) { // A fully clipped-away run (the common "visually hidden" accessibility // pattern: a 1x1 box with overflow: hidden) paints nothing at all. if let Some(c) = clip { @@ -679,7 +789,7 @@ fn draw_text(pixmap: &mut Pixmap, text: &str, x: f32, y: f32, color: [u8; 4], si return; } } - let font = FontRef::try_from_slice(FONT_BYTES).unwrap(); + let font = FontRef::try_from_slice(fallback_font_bytes(family)).unwrap(); let scale = PxScale::from(size); let scaled_font = font.as_scaled(scale); let mut caret = ab_glyph::point(x, y + scaled_font.ascent()); @@ -1357,6 +1467,45 @@ fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { .ok() } +fn background_image_rect( + src: &str, + base_url: Option<&str>, + box_rect: &crate::Rect, + explicit_size: Option<(f32, f32)>, + fit: Option, + position: (f32, f32), + cache: &mut std::collections::HashMap>>, +) -> Option { + let bytes = fetch_bytes(src, base_url, cache)?; + let intrinsic = if is_svg(&bytes) { + svg_intrinsic(&bytes) + } else { + image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32)) + }; + let (width, height) = if let Some(size) = explicit_size { + size + } else if let Some(fit) = fit { + let (iw, ih) = intrinsic?; + let scale = match fit { + crate::ObjectFit::Cover => (box_rect.width / iw).max(box_rect.height / ih), + crate::ObjectFit::Contain => (box_rect.width / iw).min(box_rect.height / ih), + _ => 1.0, + }; + (iw * scale, ih * scale) + } else { + intrinsic.unwrap_or((box_rect.width, box_rect.height)) + }; + if width <= 0.0 || height <= 0.0 { + return None; + } + Some(crate::Rect { + x: box_rect.x + (box_rect.width - width) * position.0, + y: box_rect.y + (box_rect.height - height) * position.1, + width, + height, + }) +} + /// Fetch every `` 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 ``'s NodeId. @@ -2338,6 +2487,28 @@ mod tests { assert_eq!(outside.blue(), 255); } + #[test] + fn auto_background_size_uses_intrinsic_dimensions_and_position() { + let tree = parse_html( + r#" +
+ "#, + ); + let pixmap = paint_dom(&tree, (120.0, 120.0), None).expect("pixmap"); + let background = pixmap.pixel(10, 10).expect("pixel"); + assert!( + background.red() > 200 && background.blue() < 60, + "the intrinsic image must not stretch across the owner" + ); + let image = pixmap.pixel(90, 95).expect("pixel"); + assert!( + image.blue() > 200 && image.red() < 60, + "the 20x10 intrinsic image must anchor at bottom right" + ); + } + #[test] fn later_element_paints_over_earlier() { // A blue div nested inside a red one: both cover the origin, and blue @@ -2461,6 +2632,16 @@ mod tests { assert!(found_green, "expected green text to be painted"); } + #[test] + fn word_measurement_honors_generic_font_family() { + let sans = measure_text("iiiiiiii", 16.0, false, Some("sans-serif")); + let mono = measure_text("iiiiiiii", 16.0, false, Some("monospace")); + assert!( + mono > sans * 1.5, + "monospace advances must be used for code text: sans={sans}, mono={mono}" + ); + } + #[test] fn paints_vendor_gradient_on_inline_text_span() { // Vue and many other framework sites put the gradient on an inline diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index aac4dbf..00eb58a 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -442,6 +442,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } style.background_image = parse_url(value); style.background_size = None; + style.background_size_fit = parse_background_size_fit(value); style.background_position = (0.0, 0.0); style.background_clip_text = false; } @@ -451,7 +452,10 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.background_conic_gradient = parse_conic_gradient(value); style.background_image = parse_url(value); } - "background-size" => style.background_size = parse_background_size(value), + "background-size" => { + style.background_size = parse_background_size(value); + style.background_size_fit = parse_background_size_fit(value); + } "background-position" => style.background_position = parse_background_position(value), "mask-image" | "-webkit-mask-image" => style.mask_image = parse_url(value), "mask-size" | "-webkit-mask-size" => { @@ -3158,6 +3162,17 @@ fn parse_background_size(value: &str) -> Option<(f32, f32)> { } } +fn parse_background_size_fit(value: &str) -> Option { + let size = value.rsplit_once('/').map_or(value, |(_, size)| size); + if size.split_whitespace().any(|token| token == "cover") { + Some(crate::ObjectFit::Cover) + } else if size.split_whitespace().any(|token| token == "contain") { + Some(crate::ObjectFit::Contain) + } else { + None + } +} + /// `background-position` keywords/lengths -> a 0.0-1.0 fraction per axis (the /// fraction of the box's leftover space, after the image's own size, to /// offset by: 0 = start edge, 1 = end edge, 0.5 = centered). @@ -3669,8 +3684,20 @@ mod tests { assert_eq!(s.background_conic_gradient, None); assert_eq!(s.background_image, None); assert_eq!(s.background_size, None); + assert_eq!(s.background_size_fit, None); assert_eq!(s.background_position, (0.0, 0.0)); assert!(!s.background_clip_text); + + let cover = compute_style( + "div", + Some("background:url(hero.svg) center/cover no-repeat"), + ); + assert_eq!(cover.background_size_fit, Some(crate::ObjectFit::Cover)); + let contain = compute_style("div", Some("background-size:contain")); + assert_eq!( + contain.background_size_fit, + Some(crate::ObjectFit::Contain) + ); } #[test]