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 committed 2026-07-11 20:29:57 +00:00
1 parent 2652ee64a4
commit e27a3bc5b0
4 files changed
+721 -30

No files matched your search

+125
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(...)`.