render: paint generated boxes with contextual backgrounds
This commit is contained in:
1 parent
eea960dd32
commit
37f6c93af9
5 files changed
+556
-63
No files matched your search
@@ -6,7 +6,7 @@
|
||||
//! dependencies, so a screenshot is reproducible across hosts.
|
||||
|
||||
use obscura_dom::tree::DomTree;
|
||||
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform};
|
||||
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, RadialGradient, Rect, SpreadMode, Transform};
|
||||
use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
|
||||
|
||||
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
|
||||
@@ -37,6 +37,13 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
|
||||
let fonts = collect_web_fonts(tree, base_url);
|
||||
let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts);
|
||||
let root_font_size = tree
|
||||
.query_selector("html")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|root| laid.styles.get(&root))
|
||||
.and_then(|style| style.font_size)
|
||||
.unwrap_or(16.0);
|
||||
// Nodes that live inside an inline `<svg>` we rasterized as one document;
|
||||
// their painting is owned by that raster, so they are skipped in both the
|
||||
// box/text loop below and the inline-formatting loop after it (an svg
|
||||
@@ -180,29 +187,37 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// `background-clip: text` clips the background to the glyphs, so it must
|
||||
// not paint as a box here; the text paint path fills the glyphs instead.
|
||||
if style.mask_image.is_none() && !style.background_clip_text {
|
||||
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((angle, center, stops)) =
|
||||
&style.background_conic_gradient
|
||||
{
|
||||
paint_conic_gradient(
|
||||
&mut pixmap,
|
||||
&visible_rect,
|
||||
*angle,
|
||||
*center,
|
||||
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(bg) = style.background_color {
|
||||
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);
|
||||
pixmap.fill_path(
|
||||
&path,
|
||||
&paint,
|
||||
FillRule::Winding,
|
||||
Transform::identity(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some((center, stops)) = &style.background_radial_gradient {
|
||||
if let Some(path) = bg_path() {
|
||||
paint_radial_gradient(
|
||||
&mut pixmap,
|
||||
&path,
|
||||
&visible_rect,
|
||||
*center,
|
||||
stops,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some((angle, center, stops)) = &style.background_conic_gradient {
|
||||
paint_conic_gradient(&mut pixmap, &visible_rect, *angle, *center, stops);
|
||||
}
|
||||
if let Some((angle, stops)) = &style.background_gradient {
|
||||
if let Some(path) = bg_path() {
|
||||
paint_linear_gradient(&mut pixmap, &path, &visible_rect, *angle, stops);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,8 +242,12 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
base_url,
|
||||
&rect,
|
||||
style.background_size,
|
||||
style.background_size_expression.as_deref(),
|
||||
style.background_size_fit,
|
||||
style.background_position,
|
||||
style.font_size.unwrap_or(16.0),
|
||||
root_font_size,
|
||||
viewport,
|
||||
&mut image_cache,
|
||||
) {
|
||||
// A background layer is always clipped to its owner's border
|
||||
@@ -252,6 +271,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(pseudo) = style.before_pseudo.as_deref() {
|
||||
paint_positioned_pseudo(
|
||||
&mut pixmap,
|
||||
pseudo,
|
||||
&rect,
|
||||
viewport,
|
||||
root_font_size,
|
||||
clip,
|
||||
);
|
||||
}
|
||||
|
||||
// Rounded, uniform border: stroke the rounded-rect outline instead of
|
||||
// four sharp edge rects.
|
||||
@@ -355,6 +384,11 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// overflow-visible region.
|
||||
if name.local.as_ref() == "svg" {
|
||||
let mut markup = serialize_svg(tree, nid);
|
||||
// Resolve referenced symbols before carrying the host color into
|
||||
// the standalone document. A document-level/external symbol may
|
||||
// itself contain `currentColor`, and therefore has to be present
|
||||
// when the root color is established.
|
||||
inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache);
|
||||
// resvg parses the serialized subtree as a standalone SVG
|
||||
// document, outside the page's author stylesheet. Preserve the
|
||||
// host element's computed `color` so paths using `currentColor`
|
||||
@@ -368,7 +402,6 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
|
||||
// the sprite, splice the referenced `<symbol>` into a local `<defs>`,
|
||||
// and rewrite the href to a same-document `#id`. Same-document
|
||||
// `<use href="#id">` (empty url) is untouched.
|
||||
inject_external_sprites(tree, nid, base_url, &mut markup, &mut image_cache, &mut sprite_cache);
|
||||
if let Some(content) = render_svg(markup.as_bytes(), rect.width as u32, rect.height as u32) {
|
||||
let mask = clip.and_then(|_| rect_clip_mask(pixmap.width(), pixmap.height(), &visible_rect));
|
||||
pixmap.draw_pixmap(
|
||||
@@ -1296,7 +1329,8 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra
|
||||
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() {
|
||||
for (i, (_, pos)) in stops.iter().enumerate() {
|
||||
let c = gradient_stop_color(stops, i);
|
||||
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])));
|
||||
@@ -1309,6 +1343,54 @@ fn paint_linear_gradient(pixmap: &mut Pixmap, path: &tiny_skia::Path, rect: &cra
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_radial_gradient(
|
||||
pixmap: &mut Pixmap,
|
||||
path: &tiny_skia::Path,
|
||||
rect: &crate::Rect,
|
||||
center: (f32, f32),
|
||||
stops: &[([u8; 4], Option<f32>)],
|
||||
) {
|
||||
if stops.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let center = Point::from_xy(
|
||||
rect.x + rect.width * center.0,
|
||||
rect.y + rect.height * center.1,
|
||||
);
|
||||
let radius = [
|
||||
(rect.x - center.x).hypot(rect.y - center.y),
|
||||
(rect.x + rect.width - center.x).hypot(rect.y - center.y),
|
||||
(rect.x - center.x).hypot(rect.y + rect.height - center.y),
|
||||
(rect.x + rect.width - center.x).hypot(rect.y + rect.height - center.y),
|
||||
]
|
||||
.into_iter()
|
||||
.fold(0.0, f32::max);
|
||||
let normalized = normalized_stops(stops);
|
||||
let gradient_stops = normalized
|
||||
.into_iter()
|
||||
.map(|(position, color)| {
|
||||
GradientStop::new(
|
||||
position,
|
||||
Color::from_rgba8(color[0], color[1], color[2], color[3]),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if let Some(shader) = RadialGradient::new(
|
||||
center,
|
||||
0.0,
|
||||
center,
|
||||
radius,
|
||||
gradient_stops,
|
||||
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 paint_conic_gradient(
|
||||
pixmap: &mut Pixmap,
|
||||
rect: &crate::Rect,
|
||||
@@ -1354,7 +1436,8 @@ fn normalized_stops(
|
||||
let count = stops.len();
|
||||
let mut normalized = Vec::with_capacity(count);
|
||||
let mut last = 0.0f32;
|
||||
for (index, (color, position)) in stops.iter().enumerate() {
|
||||
for (index, (_, position)) in stops.iter().enumerate() {
|
||||
let color = gradient_stop_color(stops, index);
|
||||
let position = position
|
||||
.unwrap_or_else(|| {
|
||||
if count <= 1 {
|
||||
@@ -1366,11 +1449,28 @@ fn normalized_stops(
|
||||
.clamp(0.0, 1.0)
|
||||
.max(last);
|
||||
last = position;
|
||||
normalized.push((position, *color));
|
||||
normalized.push((position, color));
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn gradient_stop_color(
|
||||
stops: &[([u8; 4], Option<f32>)],
|
||||
index: usize,
|
||||
) -> [u8; 4] {
|
||||
let color = stops[index].0;
|
||||
if color[3] != 0 {
|
||||
return color;
|
||||
}
|
||||
let neighbor = stops[index + 1..]
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate[3] != 0)
|
||||
.or_else(|| stops[..index].iter().rev().find(|(candidate, _)| candidate[3] != 0));
|
||||
neighbor
|
||||
.map(|(neighbor, _)| [neighbor[0], neighbor[1], neighbor[2], 0])
|
||||
.unwrap_or(color)
|
||||
}
|
||||
|
||||
fn sample_normalized_stops(stops: &[(f32, [u8; 4])], t: f32) -> [u8; 4] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let Some(&(first_position, first_color)) = stops.first() else {
|
||||
@@ -1472,8 +1572,12 @@ fn background_image_rect(
|
||||
base_url: Option<&str>,
|
||||
box_rect: &crate::Rect,
|
||||
explicit_size: Option<(f32, f32)>,
|
||||
size_expression: Option<&str>,
|
||||
fit: Option<crate::ObjectFit>,
|
||||
position: (f32, f32),
|
||||
em: f32,
|
||||
rem: f32,
|
||||
viewport: (f32, f32),
|
||||
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
|
||||
) -> Option<crate::Rect> {
|
||||
let bytes = fetch_bytes(src, base_url, cache)?;
|
||||
@@ -1482,7 +1586,43 @@ fn background_image_rect(
|
||||
} else {
|
||||
image_dimensions(&bytes).map(|(width, height)| (width as f32, height as f32))
|
||||
};
|
||||
let (width, height) = if let Some(size) = explicit_size {
|
||||
let expression_size = size_expression.and_then(|expression| {
|
||||
let components = split_background_size_components(expression);
|
||||
let width = components.first().and_then(|value| {
|
||||
(!value.eq_ignore_ascii_case("auto")).then(|| {
|
||||
crate::style::resolve_contextual_length(
|
||||
value,
|
||||
em,
|
||||
rem,
|
||||
viewport.0 / 100.0,
|
||||
viewport.1 / 100.0,
|
||||
box_rect.width,
|
||||
)
|
||||
})?
|
||||
});
|
||||
let height = components.get(1).and_then(|value| {
|
||||
(!value.eq_ignore_ascii_case("auto")).then(|| {
|
||||
crate::style::resolve_contextual_length(
|
||||
value,
|
||||
em,
|
||||
rem,
|
||||
viewport.0 / 100.0,
|
||||
viewport.1 / 100.0,
|
||||
box_rect.height,
|
||||
)
|
||||
})?
|
||||
});
|
||||
match (width, height, intrinsic) {
|
||||
(Some(width), Some(height), _) => Some((width, height)),
|
||||
(Some(width), None, Some((iw, ih))) => Some((width, width * ih / iw)),
|
||||
(None, Some(height), Some((iw, ih))) => Some((height * iw / ih, height)),
|
||||
(None, None, Some(intrinsic)) => Some(intrinsic),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let (width, height) = if let Some(size) = expression_size {
|
||||
size
|
||||
} else if let Some(size) = explicit_size {
|
||||
size
|
||||
} else if let Some(fit) = fit {
|
||||
let (iw, ih) = intrinsic?;
|
||||
@@ -1506,6 +1646,102 @@ fn background_image_rect(
|
||||
})
|
||||
}
|
||||
|
||||
fn split_background_size_components(value: &str) -> Vec<&str> {
|
||||
let mut components = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut start = None;
|
||||
for (index, ch) in value.char_indices() {
|
||||
match ch {
|
||||
'(' => {
|
||||
depth += 1;
|
||||
start.get_or_insert(index);
|
||||
}
|
||||
')' => depth = (depth - 1).max(0),
|
||||
ch if ch.is_whitespace() && depth == 0 => {
|
||||
if let Some(start) = start.take() {
|
||||
components.push(value[start..index].trim());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
start.get_or_insert(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(start) = start {
|
||||
components.push(value[start..].trim());
|
||||
}
|
||||
components
|
||||
}
|
||||
|
||||
fn paint_positioned_pseudo(
|
||||
pixmap: &mut Pixmap,
|
||||
style: &crate::LayoutStyle,
|
||||
containing_block: &crate::Rect,
|
||||
viewport: (f32, f32),
|
||||
root_font_size: f32,
|
||||
ancestor_clip: Option<crate::Rect>,
|
||||
) {
|
||||
if style.position != Some(taffy::Position::Absolute) {
|
||||
return;
|
||||
}
|
||||
let em = style.font_size.unwrap_or(16.0);
|
||||
let resolve = |dimension: crate::Dimension, basis: f32| {
|
||||
match dimension.resolve(em, root_font_size, viewport.0 / 100.0, viewport.1 / 100.0) {
|
||||
crate::Dimension::Px(value) => Some(value),
|
||||
crate::Dimension::Percent(value) => Some(value * basis),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let top = style.inset[0].and_then(|value| resolve(value, containing_block.height));
|
||||
let right = style.inset[1].and_then(|value| resolve(value, containing_block.width));
|
||||
let bottom = style.inset[2].and_then(|value| resolve(value, containing_block.height));
|
||||
let left = style.inset[3].and_then(|value| resolve(value, containing_block.width));
|
||||
let width = resolve(style.width, containing_block.width).or_else(|| {
|
||||
Some(containing_block.width - left? - right?)
|
||||
});
|
||||
let height = resolve(style.height, containing_block.height).or_else(|| {
|
||||
Some(containing_block.height - top? - bottom?)
|
||||
});
|
||||
let (Some(width), Some(height)) = (width, height) else { return };
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let x = left
|
||||
.map(|value| containing_block.x + value)
|
||||
.or_else(|| right.map(|value| containing_block.x + containing_block.width - value - width))
|
||||
.unwrap_or(containing_block.x);
|
||||
let y = top
|
||||
.map(|value| containing_block.y + value)
|
||||
.or_else(|| bottom.map(|value| containing_block.y + containing_block.height - value - height))
|
||||
.unwrap_or(containing_block.y);
|
||||
let rect = crate::Rect { x, y, width, height };
|
||||
let visible = match ancestor_clip {
|
||||
Some(clip) => rect.intersect(&clip),
|
||||
None => Some(rect),
|
||||
};
|
||||
let Some(visible) = visible else { return };
|
||||
let Some(sk_rect) = Rect::from_xywh(visible.x, visible.y, visible.width, visible.height) else {
|
||||
return;
|
||||
};
|
||||
let mut builder = PathBuilder::new();
|
||||
builder.push_rect(sk_rect);
|
||||
let Some(path) = builder.finish() else { return };
|
||||
if let Some(color) = style.background_color {
|
||||
let mut paint = Paint::default();
|
||||
paint.set_color(Color::from_rgba8(color[0], color[1], color[2], color[3]));
|
||||
pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
|
||||
}
|
||||
if let Some((center, stops)) = &style.background_radial_gradient {
|
||||
paint_radial_gradient(pixmap, &path, &rect, *center, stops);
|
||||
}
|
||||
if let Some((angle, center, stops)) = &style.background_conic_gradient {
|
||||
paint_conic_gradient(pixmap, &rect, *angle, *center, stops);
|
||||
}
|
||||
if let Some((angle, stops)) = &style.background_gradient {
|
||||
paint_linear_gradient(pixmap, &path, &rect, *angle, stops);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -2509,6 +2745,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_background_size_preserves_auto_axis_ratio() {
|
||||
let source = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='200'%20height='50'%3E%3C/svg%3E";
|
||||
let owner = crate::Rect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 132.0,
|
||||
height: 60.0,
|
||||
};
|
||||
let mut cache = std::collections::HashMap::new();
|
||||
let image = background_image_rect(
|
||||
source,
|
||||
None,
|
||||
&owner,
|
||||
None,
|
||||
Some("calc(100% - 2rem) auto"),
|
||||
None,
|
||||
(0.0, 0.5),
|
||||
10.0,
|
||||
10.0,
|
||||
(1280.0, 720.0),
|
||||
&mut cache,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(image.width, 112.0);
|
||||
assert_eq!(image.height, 28.0);
|
||||
assert_eq!(image.y, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_positioned_empty_pseudo_background_box() {
|
||||
let tree = parse_html(
|
||||
r#"<html><head><style>
|
||||
body { margin:0 }
|
||||
#host { position:relative; width:100px; height:50px }
|
||||
#host::before {
|
||||
content:"";
|
||||
position:absolute;
|
||||
top:10px;
|
||||
left:20px;
|
||||
width:40px;
|
||||
height:30px;
|
||||
background:
|
||||
linear-gradient(to bottom, transparent, #ffffff),
|
||||
radial-gradient(circle at 50% 50%, #ebf3f9, #d6dee4);
|
||||
}
|
||||
</style></head><body><div id="host"></div></body></html>"#,
|
||||
);
|
||||
let pixmap = paint_dom(&tree, (120.0, 80.0), None).expect("pixmap");
|
||||
let center = pixmap.pixel(40, 25).expect("pixel");
|
||||
assert!(
|
||||
center.red() >= 214 && center.green() >= 222 && center.blue() >= 228,
|
||||
"transparent-to-white over a light radial layer must not darken it: {center:?}"
|
||||
);
|
||||
let outside = pixmap.pixel(5, 5).expect("pixel");
|
||||
assert_eq!(
|
||||
(outside.red(), outside.green(), outside.blue()),
|
||||
(255, 255, 255)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_element_paints_over_earlier() {
|
||||
// A blue div nested inside a red one: both cover the origin, and blue
|
||||
@@ -2856,6 +3153,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_document_symbol_inherits_target_current_color() {
|
||||
let tree = parse_html(
|
||||
r##"<html><body>
|
||||
<svg style="display:none"><symbol id="arrow" viewBox="0 0 10 10"><rect width="10" height="10" fill="currentColor"/></symbol></svg>
|
||||
<svg id="icon" viewBox="0 0 10 10"><use href="#arrow"/></svg>
|
||||
</body></html>"##,
|
||||
);
|
||||
let svg = tree.query_selector("#icon").unwrap().unwrap();
|
||||
let mut markup = serialize_svg(&tree, svg);
|
||||
let mut cache = std::collections::HashMap::new();
|
||||
let mut sprite_cache = std::collections::HashMap::new();
|
||||
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
|
||||
inject_svg_current_color(&mut markup, [220, 20, 60, 255]);
|
||||
let pixmap = render_svg(markup.as_bytes(), 20, 20).expect("injected svg renders");
|
||||
assert!(
|
||||
pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.any(|pixel| pixel.red() > 180 && pixel.green() < 60 && pixel.blue() < 100),
|
||||
"injected currentColor symbol should inherit target SVG color: {markup}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() {
|
||||
let css = r#"
|
||||
|
||||
Reference in new issue
Block a user