render: honor text and background intrinsic metrics
This commit is contained in:
1 parent
1dc4bf1b63
commit
eea960dd32
4 files changed
+302
-50
No files matched your search
@@ -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("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");
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user