render: resolve contextual CSS math

This commit is contained in:
SGavrl
2026-07-23 12:53:11 +00:00
parent acc4422f48
commit 29628df548
3 changed files with 336 additions and 5 deletions

View File

@@ -422,6 +422,36 @@ pub fn layout_dom_with_images(
// em in non-font-size properties is relative to this element's
// OWN computed font-size; resolve every relative length now.
let em_px = style.font_size.unwrap_or(parent_fs);
let cb_w = inh.cb_width;
for index in 0..6 {
let Some(expression) = style.size_expressions[index].as_deref() else {
continue;
};
let percent_base = if matches!(index, 1 | 3 | 5) {
viewport.1
} else {
cb_w
};
let Some(px) = crate::style::resolve_contextual_length(
expression,
em_px,
root_fs,
vw,
vh,
percent_base,
) else {
continue;
};
let resolved = crate::Dimension::Px(px);
match index {
0 => style.width = resolved,
1 => style.height = resolved,
2 => style.min_width = resolved,
3 => style.min_height = resolved,
4 => style.max_width = resolved,
_ => style.max_height = resolved,
}
}
style.width = style.width.resolve(em_px, root_fs, vw, vh);
style.height = style.height.resolve(em_px, root_fs, vw, vh);
style.min_width = style.min_width.resolve(em_px, root_fs, vw, vh);
@@ -454,8 +484,41 @@ pub fn layout_dom_with_images(
// (per CSS: `padding-top:56.25%` in a 1000px block is 562.5px,
// not a fraction of the height). Bake the resolved px back into
// `padding`/`margin`, which then feed taffy.
let cb_w = inh.cb_width;
for i in 0..4 {
if let Some(expression) = style.padding_expressions[i].as_deref() {
if let Some(px) = crate::style::resolve_contextual_length(
expression,
em_px,
root_fs,
vw,
vh,
cb_w,
) {
match i {
0 => style.padding.top = px.max(0.0),
1 => style.padding.right = px.max(0.0),
2 => style.padding.bottom = px.max(0.0),
_ => style.padding.left = px.max(0.0),
}
}
}
if let Some(expression) = style.margin_expressions[i].as_deref() {
if let Some(px) = crate::style::resolve_contextual_length(
expression,
em_px,
root_fs,
vw,
vh,
cb_w,
) {
match i {
0 => style.margin.top = px,
1 => style.margin.right = px,
2 => style.margin.bottom = px,
_ => style.margin.left = px,
}
}
}
if let Some(relative) = style.padding_relative[i] {
if let crate::Dimension::Px(px) =
relative.resolve(em_px, root_fs, vw, vh)

View File

@@ -181,6 +181,11 @@ pub struct LayoutStyle {
pub min_height: Dimension,
pub max_width: Dimension,
pub max_height: Dimension,
/// Deferred CSS math expressions for width, height, min-width, min-height,
/// max-width, and max-height. Functional lengths can depend on the actual
/// viewport, containing block, or computed font size and cannot be safely
/// collapsed to pixels during stylesheet parsing.
pub size_expressions: [Option<String>; 6],
/// `aspect-ratio` as width/height, or an image's intrinsic ratio resolved
/// at layout. Lets a replaced element (or a padding-box card) derive the
/// missing dimension from the given one, so a `width:100%` image gets a
@@ -201,6 +206,8 @@ pub struct LayoutStyle {
/// These retain their unit until the top-down pass knows the element font
/// size, root font size, and viewport dimensions.
pub margin_relative: [Option<Dimension>; 4],
/// Deferred `calc()`/`min()`/`max()`/`clamp()` margin expressions.
pub margin_expressions: [Option<String>; 4],
pub padding: Edges,
/// Percentage padding per side (top, right, bottom, left) as a 0..1
/// fraction, `None` when the side is a fixed length. All four sides resolve
@@ -213,6 +220,8 @@ pub struct LayoutStyle {
/// Font- and viewport-relative padding lengths (top, right, bottom, left),
/// resolved alongside `margin_relative` during the top-down pass.
pub padding_relative: [Option<Dimension>; 4],
/// Deferred `calc()`/`min()`/`max()`/`clamp()` padding expressions.
pub padding_expressions: [Option<String>; 4],
pub border: Edges,
/// `border-radius` (uniform; the first value of the shorthand). Rounds the
/// background fill and border. In px after resolution.

View File

@@ -259,10 +259,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
}
"width" => {
style.width = dimension_value(value);
style.size_expressions[0] = deferred_length_expression(value);
style.width_set = true;
}
"height" => {
style.height = dimension_value(value);
style.size_expressions[1] = deferred_length_expression(value);
style.height_set = true;
}
"box-sizing" => {
@@ -276,10 +278,22 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.box_sizing
};
}
"min-width" => style.min_width = dimension_value(value),
"min-height" => style.min_height = dimension_value(value),
"max-width" => style.max_width = dimension_value(value),
"max-height" => style.max_height = dimension_value(value),
"min-width" => {
style.min_width = dimension_value(value);
style.size_expressions[2] = deferred_length_expression(value);
}
"min-height" => {
style.min_height = dimension_value(value);
style.size_expressions[3] = deferred_length_expression(value);
}
"max-width" => {
style.max_width = dimension_value(value);
style.size_expressions[4] = deferred_length_expression(value);
}
"max-height" => {
style.max_height = dimension_value(value);
style.size_expressions[5] = deferred_length_expression(value);
}
"aspect-ratio" => style.aspect_ratio = parse_aspect_ratio(value),
"margin" => apply_margin_shorthand(style, value),
"margin-top" => set_margin_side(style, 0, value),
@@ -1494,6 +1508,196 @@ fn px(value: &str) -> Option<f32> {
token(value).and_then(px_value)
}
fn deferred_length_expression(value: &str) -> Option<String> {
value.trim().contains('(').then(|| value.trim().to_string())
}
/// Resolve functional CSS lengths once the actual layout context is known.
/// `vw`/`vh` are one-percent viewport units and `percent_base` is the relevant
/// containing-block dimension. Custom properties have already been substituted
/// by the cascade before this function sees the expression.
pub(crate) fn resolve_contextual_length(
value: &str,
em_px: f32,
rem_px: f32,
vw: f32,
vh: f32,
percent_base: f32,
) -> Option<f32> {
let context = LengthContext {
em_px,
rem_px,
vw,
vh,
percent_base,
};
resolve_contextual(value, &context)
}
#[derive(Clone, Copy)]
struct LengthContext {
em_px: f32,
rem_px: f32,
vw: f32,
vh: f32,
percent_base: f32,
}
fn resolve_contextual(value: &str, context: &LengthContext) -> Option<f32> {
let value = value.trim();
if let Some(rest) = value.strip_prefix("var(") {
let end = find_matching_paren(rest)?;
let inner = &rest[..end];
let (_, fallback) = inner.split_once(',')?;
return resolve_contextual(fallback.trim(), context);
}
if let Some(rest) = value.strip_prefix("calc(") {
let end = find_matching_paren(rest)?;
return eval_contextual_calc(&rest[..end], context);
}
if let Some(rest) = value
.strip_prefix("max(")
.or_else(|| value.strip_prefix("min("))
{
let is_max = value.starts_with("max(");
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
let mut values = args
.iter()
.filter_map(|arg| resolve_contextual(arg, context));
let mut best = values.next()?;
for candidate in values {
if (is_max && candidate > best) || (!is_max && candidate < best) {
best = candidate;
}
}
return Some(best);
}
if let Some(rest) = value.strip_prefix("clamp(") {
let end = find_matching_paren(rest)?;
let args = split_top_level(&rest[..end], ',');
if args.len() == 3 {
let low = resolve_contextual(args[0], context)?;
let preferred = resolve_contextual(args[1], context)?;
let high = resolve_contextual(args[2], context)?;
return Some(preferred.min(high).max(low));
}
}
contextual_atom(value, context)
}
fn contextual_atom(value: &str, context: &LengthContext) -> Option<f32> {
let lower = value.trim().to_ascii_lowercase();
let parse = |number: &str| number.trim().parse::<f32>().ok();
if let Some(value) = lower.strip_suffix("rem").and_then(parse) {
return Some(value * context.rem_px);
}
if let Some(value) = lower.strip_suffix("em").and_then(parse) {
return Some(value * context.em_px);
}
if let Some(value) = lower.strip_suffix("vmin").and_then(parse) {
return Some(value * context.vw.min(context.vh));
}
if let Some(value) = lower.strip_suffix("vmax").and_then(parse) {
return Some(value * context.vw.max(context.vh));
}
if let Some(value) = lower.strip_suffix("vw").and_then(parse) {
return Some(value * context.vw);
}
if let Some(value) = lower.strip_suffix("vh").and_then(parse) {
return Some(value * context.vh);
}
if let Some(value) = lower.strip_suffix('%').and_then(parse) {
return Some(value * context.percent_base / 100.0);
}
if let Some(value) = lower.strip_suffix("px").and_then(parse) {
return Some(value);
}
if let Some(value) = lower.strip_suffix("pt").and_then(parse) {
return Some(value * 1.333);
}
parse(&lower)
}
fn eval_contextual_calc(expr: &str, context: &LengthContext) -> Option<f32> {
let mut terms: Vec<(f32, String)> = Vec::new();
let mut sign = 1.0;
let mut current = String::new();
let mut depth = 0i32;
for character in expr.chars() {
match character {
'(' => {
depth += 1;
current.push(character);
}
')' => {
depth -= 1;
current.push(character);
}
'+' | '-' if depth == 0 => {
if !current.trim().is_empty() {
terms.push((sign, std::mem::take(&mut current)));
}
sign = if character == '-' { -1.0 } else { 1.0 };
}
_ => current.push(character),
}
}
if !current.trim().is_empty() {
terms.push((sign, current));
}
if terms.is_empty() {
return None;
}
let mut total = 0.0;
for (term_sign, term) in terms {
total += term_sign * eval_contextual_product(term.trim(), context)?;
}
Some(total)
}
fn eval_contextual_product(term: &str, context: &LengthContext) -> Option<f32> {
let mut result = None;
let mut operator = '*';
let mut depth = 0i32;
let mut current = String::new();
let mut factors = Vec::new();
for character in term.chars() {
match character {
'(' => {
depth += 1;
current.push(character);
}
')' => {
depth -= 1;
current.push(character);
}
' ' if depth == 0 => {
if !current.is_empty() {
factors.push(std::mem::take(&mut current));
}
}
_ => current.push(character),
}
}
if !current.is_empty() {
factors.push(current);
}
for factor in &factors {
if factor == "*" || factor == "/" {
operator = factor.chars().next()?;
continue;
}
let value = resolve_contextual(factor, context)?;
result = Some(match result {
None => value,
Some(previous) if operator == '/' => previous / value,
Some(previous) => previous * value,
});
}
result
}
/// Resolve a CSS length expression to px, recursively handling the small set
/// of functional forms real stylesheets actually nest in practice:
/// `var(--x, fallback)` (substitute the fallback; we track no custom
@@ -1743,6 +1947,15 @@ fn two(value: &str) -> (&str, &str) {
fn set_margin_side(style: &mut LayoutStyle, idx: usize, value: &str) {
let v = value.trim();
let is_auto = v.eq_ignore_ascii_case("auto");
if let Some(expression) = deferred_length_expression(v) {
style.margin_expressions[idx] = Some(expression);
style.margin_percent[idx] = None;
style.margin_relative[idx] = None;
style.margin_auto[idx] = false;
set_margin_px(&mut style.margin, idx, 0.0);
return;
}
style.margin_expressions[idx] = None;
if let Some(frac) = percent_fraction(v) {
style.margin_percent[idx] = Some(frac);
style.margin_relative[idx] = None;
@@ -1789,6 +2002,14 @@ fn set_margin_px(margin: &mut Edges, idx: usize, px: f32) {
/// width during the top-down pass; a length is stored directly.
fn set_padding_side(style: &mut LayoutStyle, idx: usize, value: &str) {
let value = value.trim();
if let Some(expression) = deferred_length_expression(value) {
style.padding_expressions[idx] = Some(expression);
style.padding_percent[idx] = None;
style.padding_relative[idx] = None;
set_padding_px(&mut style.padding, idx, 0.0);
return;
}
style.padding_expressions[idx] = None;
if let Some(frac) = percent_fraction(value) {
style.padding_percent[idx] = Some(frac);
style.padding_relative[idx] = None;
@@ -2349,6 +2570,44 @@ mod tests {
assert_eq!(shorthand.grid_template_columns.len(), 3);
}
#[test]
fn contextual_css_math_uses_runtime_geometry() {
let context = (20.0, 16.0, 9.0, 10.0, 900.0);
assert_eq!(
resolve_contextual_length(
"min(25vw,350px)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(225.0)
);
assert_eq!(
resolve_contextual_length(
"clamp(200px,30vw,320px)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(270.0)
);
assert_eq!(
resolve_contextual_length(
"calc(10vw + 2rem)",
context.0,
context.1,
context.2,
context.3,
context.4,
),
Some(122.0)
);
}
#[test]
fn ua_defaults_and_ignore_unknown() {
let s = compute_style("span", Some("color: red; ; bogus: ; display: none"));