render: honor text and background intrinsic metrics

This commit is contained in:
SGavrl committed 2026-07-26 16:04:55 +00:00
1 parent 1dc4bf1b63
commit eea960dd32
4 files changed
+302 -50

No files matched your search

+51 -7
View File
@@ -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<usize>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
) -> Vec<taffy::NodeId> {
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<taffy::NodeId> {
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#"<div style="display:flex;font:16px/32px monospace">code</div>"#,
);
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(
+6 -6
View File
@@ -263,14 +263,14 @@ pub struct LayoutStyle {
pub background_image: Option<String>,
/// `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<ObjectFit>,
/// `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
+217 -36
View File
@@ -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<crate::Rect>) {
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<crate::Rect>,
) {
// 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<crate::ObjectFit>,
position: (f32, f32),
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> Option<crate::Rect> {
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 `<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.
@@ -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#"<html><body style="margin:0">
<div style="width:100px;height:100px;background-color:red;
background-image:url(&quot;data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='20'%20height='10'%3E%3Crect%20width='20'%20height='10'%20fill='blue'/%3E%3C/svg%3E&quot;);
background-position:right bottom;background-repeat:no-repeat"></div>
</body></html>"#,
);
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
+28 -1
View File
@@ -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<crate::ObjectFit> {
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]