render: object-fit, transform translate/scale, inline-svg rasterization

object-fit: contain/cover/scale-down/none for <img> so an image whose
aspect ratio differs from its box is fit (letterbox/crop) instead of
stretched.
transform: parse translate()/translateX/translateY (and scale); apply
translate as a paint-time offset to the element and its subtree, so
translate(-50%,-50%) centering and off-screen skip-links land correctly.
inline-svg: serialize and rasterize inline <svg> subtrees via resvg so
icon sprites and brand logos paint instead of showing blank.
This commit is contained in:
SGavrl
2026-07-11 20:29:57 +00:00
parent 2652ee64a4
commit e27a3bc5b0
4 changed files with 721 additions and 30 deletions

View File

@@ -416,12 +416,16 @@ pub fn content_width(rect: &Rect, style: &LayoutStyle) -> f32 {
impl TextEngine {
/// Rasterize inline context `idx` into `pixmap`, honoring its clip. Uses
/// cosmic-text's swash-backed rasterizer (anti-aliased, per-glyph color
/// from the span attributes).
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap) {
/// from the span attributes). `offset` is the accumulated `transform:
/// translate()` of the container's ancestors, shifting both the glyph
/// origin and the clip so shaped text moves with a transformed box.
pub fn paint_item(&mut self, idx: usize, pixmap: &mut tiny_skia::Pixmap, offset: (f32, f32)) {
let TextEngine { font_system, swash, items } = self;
let Some(item) = items.get_mut(idx) else { return };
let (ox, oy) = item.origin;
let clip = item.clip;
let (ox, oy) = (item.origin.0 + offset.0, item.origin.1 + offset.1);
let clip = item
.clip
.map(|c| Rect { x: c.x + offset.0, y: c.y + offset.1, width: c.width, height: c.height });
let pw = pixmap.width() as i32;
let ph = pixmap.height() as i32;
let clip_bounds = clip.map(|c| (c.x, c.y, c.x + c.width, c.y + c.height));

View File

@@ -345,6 +345,27 @@ pub struct LayoutStyle {
/// shaping (we embed the DejaVu Sans oblique/bold-oblique faces). `None`
/// means inherit.
pub font_style_italic: Option<bool>,
/// `object-fit` for a replaced element (`<img>`). Controls how the decoded
/// image is scaled into the element's box when their aspect ratios differ;
/// `Fill` (default) stretches to the box, the rest preserve aspect ratio.
/// Only consulted in the image paint path.
pub object_fit: ObjectFit,
/// `transform: translate(x[, y])` / `translateX` / `translateY`, stored
/// unresolved as (dx, dy). Applied at paint time as an offset to the
/// element's own box AND its whole descendant subtree; percentages resolve
/// against the element's own border-box size then. This is what makes the
/// canonical `translate(-50%,-50%)` centering of an absolutely-positioned
/// box land in the right place, and moves a `translate(-9999px,0)`
/// off-screen skip-link out of view instead of painting it on-screen. Not
/// inherited (transform is a non-inherited property).
pub transform_translate: Option<(Dimension, Dimension)>,
/// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy).
/// Parsed and stored so a value that mixes scale with translate still yields
/// its translate part; scale is not yet folded into paint geometry (doing so
/// correctly would require scaling every descendant's size and text, outside
/// the paint-time translate-offset model used here).
pub transform_scale: Option<(f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -353,6 +374,22 @@ pub enum Float {
Right,
}
/// `object-fit` for replaced elements (`<img>`): how the image's intrinsic
/// content is scaled into its box when their aspect ratios differ. `Fill` (the
/// default) stretches to the whole box; the others preserve the image's aspect
/// ratio, either letterboxing inside the box (`Contain`), cropping to cover it
/// (`Cover`), or using the intrinsic size (`None`, or `ScaleDown` which is
/// `Contain` capped at the intrinsic size so it never upscales).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ObjectFit {
#[default]
Fill,
Contain,
Cover,
ScaleDown,
None,
}
/// `line-height`: `normal` (a font-relative default), a unitless multiple of
/// font-size, or an absolute pixel length.
#[derive(Debug, Clone, Copy, PartialEq)]

View File

@@ -34,15 +34,28 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
// each URL is still fetched at most once.
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
// 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
// `<text>` element must not also paint its glyphs on top of the raster).
let mut svg_subtree_skip: std::collections::HashSet<obscura_dom::tree::NodeId> = std::collections::HashSet::new();
// Whether any element carries a `transform: translate()`. When none does
// (the overwhelmingly common case), every node's accumulated offset is
// zero, so skip the per-node ancestor walk entirely and keep the paint
// path free of any added cost.
let any_transform = laid.styles.values().any(|s| s.transform_translate.is_some());
// Tree order so later elements paint over earlier ones (normal flow).
for nid in tree.descendants(tree.document()) {
if svg_subtree_skip.contains(&nid) {
continue;
}
let node = match tree.get_node(nid) {
Some(n) => n,
None => continue,
};
if node.is_text() {
paint_text_node(tree, nid, &laid, &mut pixmap);
paint_text_node(tree, nid, &laid, &mut pixmap, any_transform);
continue;
}
@@ -51,7 +64,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
None => continue,
};
let rect = match laid.rects.get(&nid) {
Some(r) => r,
Some(r) => *r,
None => continue,
};
@@ -64,17 +77,33 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
continue;
}
// A `transform: translate()` on this element or any ancestor offsets
// this element's whole painted box (and, applied per node, its whole
// subtree). Shift the border box and the inherited clip by the same
// accumulated vector so everything painted for this node moves together.
let (ox, oy) = if any_transform {
accumulated_translate(tree, nid, &laid)
} else {
(0.0, 0.0)
};
let rect = crate::Rect { x: rect.x + ox, y: rect.y + oy, width: rect.width, height: rect.height };
// Ancestor `overflow: hidden` clip, if any. Skip painting entirely
// once the box has no visible overlap with it (this is what makes the
// ubiquitous 1x1 clipped "visually hidden" accessibility pattern
// actually invisible instead of painting text wherever it lands).
let clip = laid.clip_rects.get(&nid).copied().flatten();
let clip = laid
.clip_rects
.get(&nid)
.copied()
.flatten()
.map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height });
let visible_rect = match clip {
Some(c) => match rect.intersect(&c) {
Some(r) => r,
None => continue,
},
None => *rect,
None => rect,
};
let box_rect = match Rect::from_xywh(visible_rect.x, visible_rect.y, visible_rect.width, visible_rect.height) {
Some(r) => r,
@@ -129,14 +158,16 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
height: ih,
}
}
None => *rect,
None => rect,
};
let img_rect = match clip {
Some(c) => img_rect.intersect(&c),
None => Some(img_rect),
};
if let Some(img_rect) = img_rect {
paint_image(bg_url, base_url, &img_rect, &mut pixmap, &mut image_cache);
// object-fit does not apply to background images (that is
// background-size, handled above); paint the layer as-is.
paint_image(bg_url, base_url, &img_rect, crate::ObjectFit::Fill, &mut pixmap, &mut image_cache);
}
}
@@ -191,7 +222,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
if name.local.as_ref() == "img" {
if let Some(src) = resolve_img_url(tree, nid) {
let painted = paint_image(&src, base_url, &rect, &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.
if !painted {
// A fetch/decode failure paints a neutral grey box (like a
@@ -213,6 +244,31 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
}
}
// Inline `<svg>...</svg>`: serialize the whole subtree back to one
// standalone SVG document and rasterize it as a unit, so a
// `<use href="#id">` resolves against the `<symbol>`/`<defs>` in the
// same svg. The raster owns the subtree, so its DOM children are not
// painted individually (they are added to `svg_subtree_skip`). The svg
// is drawn at its full border-box size (undistorted) and clipped to the
// overflow-visible region.
if name.local.as_ref() == "svg" {
let markup = serialize_svg(tree, nid);
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(
rect.x as i32,
rect.y as i32,
content.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
mask.as_ref(),
);
}
for child in tree.descendants(nid) {
svg_subtree_skip.insert(child);
}
}
// List-item marker (bullet or number), drawn in the indent to the left
// of the item's content box. `list_style` is inherited and resolved,
// so `None` (e.g. a nav `<ul style="list-style:none">`) suppresses it.
@@ -236,7 +292,7 @@ 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, word_rect.y, color, fsize, is_bold, clip);
draw_text(&mut pixmap, word, word_rect.x + ox, word_rect.y + oy, color, fsize, is_bold, clip);
}
}
@@ -264,17 +320,71 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
// box backgrounds/borders painted in the loop above. Each item already
// carries its final origin and clip from `TextEngine::finalize`.
for nid in tree.descendants(tree.document()) {
if svg_subtree_skip.contains(&nid) {
continue;
}
if let Some(&idx) = laid.ifc_items.get(&nid) {
if laid.styles.get(&nid).map(|s| s.effectively_invisible).unwrap_or(false) {
continue;
}
laid.text_engine.paint_item(idx, &mut pixmap);
// Shift the shaped glyphs by the same accumulated translate as the
// container's box so text under a transformed ancestor moves with
// it. Computed before the mutable `paint_item` borrow.
let off = if any_transform {
accumulated_translate(tree, nid, &laid)
} else {
(0.0, 0.0)
};
laid.text_engine.paint_item(idx, &mut pixmap, off);
}
}
Some(pixmap)
}
/// Sum of `transform: translate()` offsets of `nid` and every ancestor, in CSS
/// px. A translate shifts an element's own box and its entire subtree by the
/// same vector, so a node's painted position is its normal-flow position plus
/// every translate at or above it. Percentage components resolve against each
/// contributing element's own border box (read from the laid-out rects), per
/// CSS. Nodes with no style/rect entry (e.g. text nodes) contribute nothing but
/// are still walked through so element ancestors above them count.
fn accumulated_translate(
tree: &DomTree,
nid: obscura_dom::tree::NodeId,
laid: &crate::DomLayout,
) -> (f32, f32) {
let (mut ox, mut oy) = (0.0f32, 0.0f32);
let mut cur = Some(nid);
while let Some(id) = cur {
if let (Some(style), Some(rect)) = (laid.styles.get(&id), laid.rects.get(&id)) {
if let Some((dx, dy)) = style.transform_translate {
ox += resolve_translate(dx, rect.width);
oy += resolve_translate(dy, rect.height);
}
}
cur = tree.get_node(id).and_then(|n| n.parent);
}
(ox, oy)
}
/// Resolve one translate component to px: a length passes through, a percentage
/// is taken against `basis` (the element's own border-box extent on that axis).
/// Font/viewport-relative leftovers fall back to a coarse px value rather than
/// panicking (translate rarely uses them; px/% are the required forms).
fn resolve_translate(d: crate::Dimension, basis: f32) -> f32 {
match d {
crate::Dimension::Px(px) => px,
crate::Dimension::Percent(p) => p * basis,
crate::Dimension::Em(v) | crate::Dimension::Rem(v) => v * 16.0,
crate::Dimension::Vw(v)
| crate::Dimension::Vh(v)
| crate::Dimension::Vmin(v)
| crate::Dimension::Vmax(v) => v,
crate::Dimension::Auto => 0.0,
}
}
/// A closed rounded-rectangle path, corners approximated by quadratic curves
/// (visually indistinguishable from true arcs at typical UI radii). `r` is
/// clamped so it never exceeds half the shorter side.
@@ -336,6 +446,7 @@ fn paint_text_node(
nid: obscura_dom::tree::NodeId,
laid: &crate::DomLayout,
pixmap: &mut Pixmap,
any_transform: bool,
) -> Option<()> {
let runs = laid.text_runs.get(&nid)?;
let node = tree.get_node(nid)?;
@@ -347,10 +458,22 @@ fn paint_text_node(
let color = style.color.unwrap_or([0, 0, 0, 255]);
let fsize = style.font_size.unwrap_or(16.0);
let is_bold = style.font_weight.as_deref() == Some("bold");
let clip = laid.clip_rects.get(&nid).copied().flatten();
// A text node has no transform of its own, but any transformed element
// ancestor offsets it; the ancestor walk from the text node picks those up.
let (ox, oy) = if any_transform {
accumulated_translate(tree, nid, laid)
} else {
(0.0, 0.0)
};
let clip = laid
.clip_rects
.get(&nid)
.copied()
.flatten()
.map(|c| crate::Rect { x: c.x + ox, y: c.y + oy, width: c.width, height: c.height });
for (rect, word) in runs {
draw_text(pixmap, word, rect.x, rect.y, color, fsize, is_bold, clip);
draw_text(pixmap, word, rect.x + ox, rect.y + oy, color, fsize, is_bold, clip);
}
Some(())
}
@@ -897,6 +1020,7 @@ fn paint_image(
src: &str,
base_url: Option<&str>,
rect: &crate::Rect,
object_fit: crate::ObjectFit,
pixmap: &mut Pixmap,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> bool {
@@ -904,24 +1028,113 @@ fn paint_image(
return false;
}
let Some(bytes) = fetch_bytes(src, base_url, cache) else { return false };
let svg = is_svg(&bytes);
let content = if is_svg(&bytes) {
render_svg(&bytes, rect.width as u32, rect.height as u32)
// Destination sub-rect within the element box. `Fill` keeps the historical
// behavior (stretch the image to the whole box); the other modes need the
// image's intrinsic size to preserve its aspect ratio, and fall back to
// fill when it cannot be read.
let dest = if object_fit == crate::ObjectFit::Fill {
*rect
} else {
raster_to_pixmap(&bytes, rect.width as u32, rect.height as u32)
let intrinsic = if svg {
svg_intrinsic(&bytes)
} else {
image_dimensions(&bytes).map(|(w, h)| (w as f32, h as f32))
};
match intrinsic {
Some((iw, ih)) => object_fit_dest(rect, iw, ih, object_fit),
None => *rect,
}
};
if let Some(content) = content {
pixmap.draw_pixmap(
rect.x as i32,
rect.y as i32,
content.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
None,
);
return true;
let (dw, dh) = (dest.width.round().max(1.0) as u32, dest.height.round().max(1.0) as u32);
let content = if svg {
render_svg(&bytes, dw, dh)
} else {
raster_to_pixmap(&bytes, dw, dh)
};
let Some(content) = content else { return false };
// `Cover`/`None` can size the image past the box; clip it to the box so it
// does not paint over neighboring content (CSS clips object-fit to the
// content box). `Fill`/`Contain`/`scale-down` always fit inside the box, so
// they take the faster unclipped path.
let clip = if dest.width > rect.width + 0.5 || dest.height > rect.height + 0.5 {
box_clip_mask(pixmap.width(), pixmap.height(), rect)
} else {
None
};
pixmap.draw_pixmap(
dest.x as i32,
dest.y as i32,
content.as_ref(),
&tiny_skia::PixmapPaint::default(),
Transform::identity(),
clip.as_ref(),
);
true
}
/// The destination sub-rect for a replaced element's image within its box,
/// given the image's intrinsic `(iw, ih)` size and `object-fit`. Centered in
/// the box; for `Cover`/`None` it can extend past the box edges (the caller
/// clips it). Aspect ratio is preserved for every mode except `Fill`.
fn object_fit_dest(box_rect: &crate::Rect, iw: f32, ih: f32, fit: crate::ObjectFit) -> crate::Rect {
let (bw, bh) = (box_rect.width, box_rect.height);
if iw <= 0.0 || ih <= 0.0 {
return *box_rect;
}
false
let (dw, dh) = match fit {
crate::ObjectFit::Fill => (bw, bh),
crate::ObjectFit::Contain => {
let s = (bw / iw).min(bh / ih);
(iw * s, ih * s)
}
crate::ObjectFit::Cover => {
let s = (bw / iw).max(bh / ih);
(iw * s, ih * s)
}
crate::ObjectFit::None => (iw, ih),
crate::ObjectFit::ScaleDown => {
// min(Contain-size, intrinsic-size): the Contain fit, but never
// scaled up past the image's own pixels.
let s = (bw / iw).min(bh / ih).min(1.0);
(iw * s, ih * s)
}
};
crate::Rect {
x: box_rect.x + (bw - dw) / 2.0,
y: box_rect.y + (bh - dh) / 2.0,
width: dw,
height: dh,
}
}
/// The intrinsic `(width, height)` of an SVG image from its size/`viewBox`,
/// used to preserve aspect ratio under `object-fit`. Parses the SVG once; the
/// eventual raster re-parses in `render_svg` (only reached for a non-`fill`
/// object-fit on an SVG image, which is rare).
fn svg_intrinsic(bytes: &[u8]) -> Option<(f32, f32)> {
let tree = usvg::Tree::from_data(bytes, &usvg::Options::default()).ok()?;
let size = tree.size();
if size.width() > 0.0 && size.height() > 0.0 {
Some((size.width(), size.height()))
} else {
None
}
}
/// A full-pixmap clip mask admitting only the pixels inside `rect`, used to
/// crop an `object-fit: cover|none` image to its element box.
fn box_clip_mask(pw: u32, ph: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
let mut mask = tiny_skia::Mask::new(pw, ph)?;
let r = Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)?;
let mut pb = PathBuilder::new();
pb.push_rect(r);
let path = pb.finish()?;
mask.fill_path(&path, FillRule::Winding, true, Transform::identity());
Some(mask)
}
/// Sniff SVG content: either an XML/SVG prolog, or a bare `<svg` root tag
@@ -951,6 +1164,106 @@ fn render_svg(bytes: &[u8], width: u32, height: u32) -> Option<Pixmap> {
Some(svg_pixmap)
}
/// Serialize an inline `<svg>` subtree (rooted at `root`) back to a standalone
/// SVG document string. Emits `<tag attr="v">children</tag>` for the element
/// and every descendant, preserving the root's `viewBox`/`width`/`height` and
/// all `<defs>`/`<symbol>`/`<use>`/`<path>` structure so resvg can rasterize it
/// as a self-contained document. SVG is XML-clean, so there are no HTML
/// void-element or optional-close rules to apply; every element gets an
/// explicit closing tag. The root gains an `xmlns` declaration when it lacks
/// one (common for inline svg, whose namespace is implied by the HTML parser
/// but required for usvg to parse the string on its own).
fn serialize_svg(tree: &DomTree, root: obscura_dom::tree::NodeId) -> String {
let mut buf = String::new();
serialize_svg_node(tree, root, true, &mut buf);
buf
}
fn serialize_svg_node(tree: &DomTree, nid: obscura_dom::tree::NodeId, is_root: bool, buf: &mut String) {
let node = match tree.get_node(nid) {
Some(n) => n,
None => return,
};
if let Some(text) = node.text_content_of_text_node() {
svg_escape_text(text, buf);
return;
}
let name = match node.as_element() {
Some(n) => n,
// Document/comment/PI: no tag of its own, emit only element children.
None => {
for child in tree.children(nid) {
serialize_svg_node(tree, child, false, buf);
}
return;
}
};
let tag = name.local.as_ref();
buf.push('<');
buf.push_str(tag);
let mut has_xmlns = false;
if let Some(attrs) = node.attrs() {
for attr in attrs {
// Emit the local name only, dropping any prefix (`xlink:href` ->
// `href`): resvg reads both, and a bare local avoids needing an
// `xmlns:xlink` declaration in the standalone document.
let aname = attr.name.local.as_ref();
if aname == "xmlns" {
has_xmlns = true;
}
buf.push(' ');
buf.push_str(aname);
buf.push_str("=\"");
svg_escape_attr(&attr.value, buf);
buf.push('"');
}
}
if is_root && !has_xmlns {
buf.push_str(" xmlns=\"http://www.w3.org/2000/svg\"");
}
buf.push('>');
for child in tree.children(nid) {
serialize_svg_node(tree, child, false, buf);
}
buf.push_str("</");
buf.push_str(tag);
buf.push('>');
}
fn svg_escape_text(s: &str, buf: &mut String) {
for c in s.chars() {
match c {
'&' => buf.push_str("&amp;"),
'<' => buf.push_str("&lt;"),
'>' => buf.push_str("&gt;"),
_ => buf.push(c),
}
}
}
fn svg_escape_attr(s: &str, buf: &mut String) {
for c in s.chars() {
match c {
'&' => buf.push_str("&amp;"),
'<' => buf.push_str("&lt;"),
'"' => buf.push_str("&quot;"),
_ => buf.push(c),
}
}
}
/// A full-pixmap alpha mask that is opaque only inside `rect`, used to clip a
/// blit (e.g. an inline svg raster) to an ancestor's overflow-hidden region.
fn rect_clip_mask(width: u32, height: u32, rect: &crate::Rect) -> Option<tiny_skia::Mask> {
let mut mask = tiny_skia::Mask::new(width, height)?;
let r = Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)?;
let mut pb = PathBuilder::new();
pb.push_rect(r);
let path = pb.finish()?;
mask.fill_path(&path, FillRule::Winding, true, Transform::identity());
Some(mask)
}
/// Paint a `mask-image`: the ubiquitous "colored, scalable icon" pattern,
/// where an SVG shape is used purely as a stencil and tinted by
/// `background-color`/`color` rather than carrying its own colors. Fetches
@@ -1011,6 +1324,55 @@ mod tests {
use super::*;
use obscura_dom::tree_sink::parse_html;
#[test]
fn object_fit_contain_and_cover_center_and_preserve_aspect() {
// A 200x100 box (2:1) with a square 100x100 image, offset so centering
// is checked against the box origin, not (0,0).
let box_rect = crate::Rect { x: 10.0, y: 20.0, width: 200.0, height: 100.0 };
let (iw, ih) = (100.0f32, 100.0f32);
// Contain: the largest square fitting inside 200x100 is 100x100,
// letterboxed horizontally and centered.
let c = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Contain);
assert!((c.width - 100.0).abs() < 0.01 && (c.height - 100.0).abs() < 0.01, "contain size {:?}", c);
assert!((c.width / c.height - iw / ih).abs() < 1e-3, "contain preserves aspect: {:?}", c);
assert!((c.x - 60.0).abs() < 0.01, "contain centered x (10 + (200-100)/2): {}", c.x);
assert!((c.y - 20.0).abs() < 0.01, "contain centered y (20 + (100-100)/2): {}", c.y);
// Contain always fits inside the box.
assert!(c.x >= box_rect.x - 0.01 && c.x + c.width <= box_rect.x + box_rect.width + 0.01);
assert!(c.y >= box_rect.y - 0.01 && c.y + c.height <= box_rect.y + box_rect.height + 0.01);
// Cover: the smallest square covering 200x100 is 200x200, centered so
// it overflows the box vertically (the paint path clips it).
let v = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Cover);
assert!((v.width - 200.0).abs() < 0.01 && (v.height - 200.0).abs() < 0.01, "cover size {:?}", v);
assert!((v.width / v.height - iw / ih).abs() < 1e-3, "cover preserves aspect: {:?}", v);
assert!((v.x - 10.0).abs() < 0.01, "cover centered x (10 + (200-200)/2): {}", v.x);
assert!((v.y + 30.0).abs() < 0.01, "cover centered y (20 + (100-200)/2 = -30): {}", v.y);
// Cover fully covers the box on both axes.
assert!(v.x <= box_rect.x + 0.01 && v.x + v.width >= box_rect.x + box_rect.width - 0.01);
assert!(v.y <= box_rect.y + 0.01 && v.y + v.height >= box_rect.y + box_rect.height - 0.01);
// scale-down never upscales: a 100x100 image in a 200x200 box stays
// 100x100 (Contain would grow it to 200x200), centered.
let box2 = crate::Rect { x: 0.0, y: 0.0, width: 200.0, height: 200.0 };
let sd = object_fit_dest(&box2, iw, ih, crate::ObjectFit::ScaleDown);
assert!((sd.width - 100.0).abs() < 0.01 && (sd.height - 100.0).abs() < 0.01, "scale-down no upscale: {:?}", sd);
assert!((sd.x - 50.0).abs() < 0.01 && (sd.y - 50.0).abs() < 0.01, "scale-down centered: {:?}", sd);
let cn = object_fit_dest(&box2, iw, ih, crate::ObjectFit::Contain);
assert!((cn.width - 200.0).abs() < 0.01, "contain upscales into the box: {:?}", cn);
// None uses the intrinsic size regardless of box, centered.
let n = object_fit_dest(&box2, iw, ih, crate::ObjectFit::None);
assert!((n.width - 100.0).abs() < 0.01 && (n.height - 100.0).abs() < 0.01, "none intrinsic size: {:?}", n);
assert!((n.x - 50.0).abs() < 0.01 && (n.y - 50.0).abs() < 0.01, "none centered: {:?}", n);
// Fill stretches to exactly the box.
let f = object_fit_dest(&box_rect, iw, ih, crate::ObjectFit::Fill);
assert!((f.width - box_rect.width).abs() < 0.01 && (f.height - box_rect.height).abs() < 0.01, "fill: {:?}", f);
assert!((f.x - box_rect.x).abs() < 0.01 && (f.y - box_rect.y).abs() < 0.01, "fill origin: {:?}", f);
}
#[test]
fn paints_background_color() {
let tree = parse_html(
@@ -1046,6 +1408,93 @@ mod tests {
assert!(p.blue() > 200, "expected blue to paint over red, got {:?}", p);
}
#[test]
fn nested_translate_accumulates_through_subtree() {
// Parent red box (position:absolute at 0,0, 20x20) translated by
// (50,60). Child blue box (10x10, in-flow at the red box's origin)
// translated by an additional (30,0). The child's painted position must
// be the SUM of both translates, (50+30, 60+0) = (80,60), proving an
// ancestor's translate offsets the whole subtree on top of the node's
// own translate.
let tree = parse_html(
"<html><body style=\"margin:0\">\
<div style=\"position:relative; width:200px; height:200px\">\
<div style=\"position:absolute; top:0; left:0; width:20px; height:20px; \
background:#ff0000; transform:translate(50px,60px)\">\
<div style=\"width:10px; height:10px; background:#0000ff; \
transform:translate(30px,0)\"></div>\
</div>\
</div>\
</body></html>",
);
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
// Child blue lands at (80..90, 60..70).
let blue = pixmap.pixel(85, 65).expect("pixel");
assert!(
blue.blue() > 200 && blue.red() < 60,
"expected blue child at accumulated offset (80,60), got {:?}",
blue
);
// Parent red lands at (50..70, 60..80); sample where the blue child does
// not cover.
let red = pixmap.pixel(55, 75).expect("pixel");
assert!(
red.red() > 200 && red.blue() < 60,
"expected red parent at its own translate (50,60), got {:?}",
red
);
// Nothing painted at the pre-transform origin: both boxes moved away.
let origin = pixmap.pixel(5, 5).expect("pixel");
assert_eq!((origin.red(), origin.green(), origin.blue()), (255, 255, 255));
}
#[test]
fn translate_offscreen_box_is_not_painted() {
// translate(-10000px,0) shoves the box far off the left edge (the old
// hidden skip-link idiom); it must not paint anywhere on the canvas.
let tree = parse_html(
"<html><body>\
<div style=\"position:absolute; top:0; left:0; width:50px; height:50px; \
background:#ff0000; transform:translate(-10000px,0)\"></div>\
</body></html>",
);
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
let mut any_red = false;
'scan: for y in 0..200 {
for x in 0..200 {
let p = pixmap.pixel(x, y).expect("pixel");
if p.red() > 200 && p.green() < 60 && p.blue() < 60 {
any_red = true;
break 'scan;
}
}
}
assert!(!any_red, "translate(-10000px,0) box should be off-screen and unpainted");
}
#[test]
fn translate_percent_centers_absolute_box() {
// The canonical centering idiom: an absolutely-positioned box at
// top:50%/left:50% of its containing block pulled back by
// translate(-50%,-50%) of its own size centers within it. In a 200x200
// container a 40x40 box centers at (100,100), so its border box (with
// top-left at 100,100 before the transform) becomes (80..120, 80..120).
let tree = parse_html(
"<html><body style=\"margin:0\">\
<div style=\"position:relative; width:200px; height:200px\">\
<div style=\"position:absolute; top:50%; left:50%; width:40px; height:40px; \
background:#ff0000; transform:translate(-50%,-50%)\"></div>\
</div>\
</body></html>",
);
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
let center = pixmap.pixel(100, 100).expect("pixel");
assert!(center.red() > 200 && center.blue() < 60, "expected centered red box, got {:?}", center);
// Just outside the centered box stays white.
let outside = pixmap.pixel(70, 70).expect("pixel");
assert_eq!((outside.red(), outside.green(), outside.blue()), (255, 255, 255));
}
#[test]
fn paints_text_color() {
let tree = parse_html(
@@ -1065,4 +1514,80 @@ mod tests {
}
assert!(found_green, "expected green text to be painted");
}
#[test]
fn serializes_inline_svg_subtree() {
// A sprite-style svg: a <use> that references a <symbol> in the same
// document must survive serialization so resvg can resolve it.
let tree = parse_html(
r##"<html><body><svg viewBox="0 0 10 10"><use href="#a"/><symbol id="a"><path d="M0 0h10v10z"/></symbol></svg></body></html>"##,
);
let svg = tree.query_selector("svg").unwrap().unwrap();
let out = serialize_svg(&tree, svg);
assert!(out.starts_with("<svg"), "root svg tag: {out}");
assert!(out.contains(r#"viewBox="0 0 10 10""#), "viewBox preserved: {out}");
assert!(out.contains(r#"xmlns="http://www.w3.org/2000/svg""#), "xmlns injected: {out}");
assert!(out.contains("<use") && out.contains(r##"href="#a""##), "use + href: {out}");
assert!(out.contains("<symbol") && out.contains(r#"id="a""#), "symbol id: {out}");
assert!(out.contains("<path") && out.contains("</path>"), "path opened + closed: {out}");
assert!(out.trim_end().ends_with("</svg>"), "root closed: {out}");
// The serialized string parses as a standalone SVG document.
let opts = usvg::Options::default();
assert!(
usvg::Tree::from_data(out.as_bytes(), &opts).is_ok(),
"usvg should parse serialized svg: {out}",
);
}
#[test]
fn injects_xmlns_only_when_absent() {
let tree = parse_html(
r#"<html><body><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><rect width="4" height="4"/></svg></body></html>"#,
);
let svg = tree.query_selector("svg").unwrap().unwrap();
let out = serialize_svg(&tree, svg);
assert_eq!(out.matches("xmlns=").count(), 1, "no duplicate xmlns: {out}");
}
#[test]
fn paints_inline_svg() {
// The <rect> inside an inline svg must rasterize (it is not an <img>).
let tree = parse_html(
r##"<html><body><svg width="40" height="40" viewBox="0 0 40 40"><rect x="0" y="0" width="40" height="40" fill="#ff0000"/></svg></body></html>"##,
);
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
let mut found_red = false;
'outer: for y in 0..80 {
for x in 0..80 {
let p = pixmap.pixel(x, y).expect("pixel");
if p.red() > 200 && p.green() < 60 && p.blue() < 60 {
found_red = true;
break 'outer;
}
}
}
assert!(found_red, "expected inline svg <rect> to paint red");
}
#[test]
fn paints_inline_svg_use_reference() {
// The icon-sprite pattern: <use href="#id"> resolves against a <defs>
// element in the same svg only because the whole subtree is serialized
// and handed to resvg as one document.
let tree = parse_html(
r##"<html><body><svg width="40" height="40" viewBox="0 0 40 40"><defs><rect id="a" width="40" height="40" fill="#0000ff"/></defs><use href="#a"/></svg></body></html>"##,
);
let pixmap = paint_dom(&tree, (200.0, 200.0), None).expect("pixmap");
let mut found_blue = false;
'outer: for y in 0..80 {
for x in 0..80 {
let p = pixmap.pixel(x, y).expect("pixel");
if p.blue() > 200 && p.red() < 60 && p.green() < 60 {
found_blue = true;
break 'outer;
}
}
}
assert!(found_blue, "expected <use> to instantiate the referenced <rect>");
}
}

View File

@@ -395,6 +395,16 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
_ => {}
}
},
"object-fit" => {
match value.trim().to_ascii_lowercase().as_str() {
"fill" => style.object_fit = crate::ObjectFit::Fill,
"contain" => style.object_fit = crate::ObjectFit::Contain,
"cover" => style.object_fit = crate::ObjectFit::Cover,
"scale-down" => style.object_fit = crate::ObjectFit::ScaleDown,
"none" => style.object_fit = crate::ObjectFit::None,
_ => {}
}
},
"top" => style.inset[0] = inset_dim(value),
"right" => style.inset[1] = inset_dim(value),
"bottom" => style.inset[2] = inset_dim(value),
@@ -499,10 +509,125 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
"grid-column" => set_grid_placement(style, value, true),
"grid-row" => set_grid_placement(style, value, false),
"transform" => parse_transform(style, value),
_ => {}
}
}
/// Parse the subset of `transform` obscura applies at paint time: `translate`,
/// `translateX`, `translateY` (px and %, stored unresolved so % can resolve
/// against the element's own border box later) and `scale`/`scaleX`/`scaleY`.
/// `rotate`, `skew`, `matrix`, and `perspective` are ignored (left unhandled)
/// rather than erroring, so a value that mixes them still contributes its
/// translate and scale parts.
fn parse_transform(style: &mut LayoutStyle, value: &str) {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("none") {
return;
}
let zero = crate::Dimension::Px(0.0);
let (mut tx, mut ty): (Option<crate::Dimension>, Option<crate::Dimension>) = (None, None);
let (mut sx, mut sy): (Option<f32>, Option<f32>) = (None, None);
for (func, args) in transform_functions(v) {
let parts: Vec<&str> = args.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
match func.to_ascii_lowercase().as_str() {
"translate" => {
if let Some(a) = parts.first() {
tx = Some(dimension_value(a));
}
// A missing second component is 0 (translateY), not inherited.
ty = Some(parts.get(1).map(|a| dimension_value(a)).unwrap_or(zero));
}
"translatex" => {
if let Some(a) = parts.first() {
tx = Some(dimension_value(a));
}
}
"translatey" => {
if let Some(a) = parts.first() {
ty = Some(dimension_value(a));
}
}
"scale" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
// scale(s) is uniform; scale(sx, sy) sets each axis.
sy = Some(parts.get(1).and_then(|s| scale_number(s)).unwrap_or(a));
sx = Some(a);
}
}
"scalex" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
sx = Some(a);
}
}
"scaley" => {
if let Some(a) = parts.first().and_then(|s| scale_number(s)) {
sy = Some(a);
}
}
// rotate / skew / matrix / perspective: not modeled, skip.
_ => {}
}
}
if tx.is_some() || ty.is_some() {
style.transform_translate = Some((tx.unwrap_or(zero), ty.unwrap_or(zero)));
}
if sx.is_some() || sy.is_some() {
style.transform_scale = Some((sx.unwrap_or(1.0), sy.unwrap_or(1.0)));
}
}
/// Split a `transform` value into its `name(args)` functions, in source order.
/// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an
/// argument stays intact); the function name is the trailing identifier run
/// just before each `(`.
fn transform_functions(value: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut rest = value;
while let Some(open) = rest.find('(') {
let name: String = rest[..open]
.chars()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let after = &rest[open + 1..];
let mut depth = 1i32;
let mut end = None;
for (i, c) in after.char_indices() {
match c {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = Some(i);
break;
}
}
_ => {}
}
}
let Some(e) = end else { break };
if !name.is_empty() {
out.push((name, after[..e].to_string()));
}
rest = &after[e + 1..];
}
out
}
/// Parse a unitless scale factor. A trailing `%` (`scale(50%)`) is accepted and
/// divided by 100, matching the individual `scale` property's percentage form.
fn scale_number(s: &str) -> Option<f32> {
let t = s.trim();
if let Some(p) = t.strip_suffix('%') {
return p.trim().parse::<f32>().ok().map(|v| v / 100.0);
}
t.parse::<f32>().ok()
}
/// Parse a CSS grid track list (`min-content 1fr min-content`, `12.25rem
/// minmax(0,1fr)`) into taffy sizing functions. Tokenizes respecting the
/// parentheses in `minmax(...)` / `fit-content(...)`.