diff --git a/float-pigment-css/src/parser/property_value/calc.rs b/float-pigment-css/src/parser/property_value/calc.rs index ce4d9c2..d8e752d 100644 --- a/float-pigment-css/src/parser/property_value/calc.rs +++ b/float-pigment-css/src/parser/property_value/calc.rs @@ -176,6 +176,34 @@ impl ComputeCalcExpr { } } CalcExpr::Length(Length::Ratio(ratio)) => Some(Angle::from_ratio(*ratio)), + CalcExpr::Min(args) => { + let mut iter = args.iter(); + let mut ret = Self::try_compute(iter.next()?)?.to_f32(); + for arg in iter { + let v = Self::try_compute(arg)?.to_f32(); + if v < ret { + ret = v; + } + } + Some(Angle::Rad(ret)) + } + CalcExpr::Max(args) => { + let mut iter = args.iter(); + let mut ret = Self::try_compute(iter.next()?)?.to_f32(); + for arg in iter { + let v = Self::try_compute(arg)?.to_f32(); + if v > ret { + ret = v; + } + } + Some(Angle::Rad(ret)) + } + CalcExpr::Clamp(min, val, max) => { + let mn = Self::try_compute(min)?.to_f32(); + let v = Self::try_compute(val)?.to_f32(); + let mx = Self::try_compute(max)?.to_f32(); + Some(Angle::Rad(v.max(mn).min(mx))) + } _ => None, } } @@ -199,6 +227,34 @@ impl ComputeCalcExpr { _ => None, } } + CalcExpr::Min(args) => { + let mut iter = args.iter(); + let mut ret = Self::try_compute(iter.next()?)?.to_f32(); + for arg in iter { + let v = Self::try_compute(arg)?.to_f32(); + if v < ret { + ret = v; + } + } + Some(Number::F32(ret)) + } + CalcExpr::Max(args) => { + let mut iter = args.iter(); + let mut ret = Self::try_compute(iter.next()?)?.to_f32(); + for arg in iter { + let v = Self::try_compute(arg)?.to_f32(); + if v > ret { + ret = v; + } + } + Some(Number::F32(ret)) + } + CalcExpr::Clamp(min, val, max) => { + let mn = Self::try_compute(min)?.to_f32(); + let v = Self::try_compute(val)?.to_f32(); + let mx = Self::try_compute(max)?.to_f32(); + Some(Number::F32(v.max(mn).min(mx))) + } _ => None, } } @@ -269,6 +325,55 @@ impl ComputeCalcExpr { // None } + CalcExpr::Min(args) => { + let mut iter = args.iter(); + let first = Self::try_compute(iter.next()?)?; + let (unit, mut val) = Self::length_unit_value(&first); + if !unit.is_specified_unit() { + return None; + } + for arg in iter { + let (u, v) = Self::length_unit_value(&Self::try_compute(arg)?); + if u != unit { + return None; + } + if v < val { + val = v; + } + } + Some(LengthUnit::to_length(unit, val)) + } + CalcExpr::Max(args) => { + let mut iter = args.iter(); + let first = Self::try_compute(iter.next()?)?; + let (unit, mut val) = Self::length_unit_value(&first); + if !unit.is_specified_unit() { + return None; + } + for arg in iter { + let (u, v) = Self::length_unit_value(&Self::try_compute(arg)?); + if u != unit { + return None; + } + if v > val { + val = v; + } + } + Some(LengthUnit::to_length(unit, val)) + } + CalcExpr::Clamp(min, val, max) => { + let min = Self::try_compute(min)?; + let val = Self::try_compute(val)?; + let max = Self::try_compute(max)?; + let (u, mn) = Self::length_unit_value(&min); + let (uu, vv) = Self::length_unit_value(&val); + let (uuu, mx) = Self::length_unit_value(&max); + if u == uu && uu == uuu && u.is_specified_unit() { + Some(LengthUnit::to_length(u, vv.max(mn).min(mx))) + } else { + None + } + } _ => None, } } @@ -358,6 +463,52 @@ pub(crate) fn parse_calc_inner<'a, 't: 'a, 'i: 't>( }) } +// Parse the body of a math function (min/max/clamp) whose `Function` token has already +// been consumed by the caller. `name` is matched ASCII case-insensitively. +#[inline(never)] +pub(crate) fn parse_math_function_body<'a, 't: 'a, 'i: 't>( + parser: &'a mut Parser<'i, 't>, + properties: &mut Vec, + st: &mut ParseState, + expect_type: ExpectValueType, + name: &str, +) -> Result> { + match name.to_ascii_lowercase().as_str() { + "min" | "max" => { + let is_min = name.eq_ignore_ascii_case("min"); + let args: Vec = parser.parse_nested_block(|parser| { + parse_comma_separated_without_important(parser, |p| { + parse_calc_sum_expr(p, properties, st, expect_type) + }) + })?; + let arr: Array = args.into(); + Ok(if is_min { + CalcExpr::Min(arr) + } else { + CalcExpr::Max(arr) + }) + } + "clamp" => { + let args: Vec = parser.parse_nested_block(|parser| { + parse_comma_separated_without_important(parser, |p| { + parse_calc_sum_expr(p, properties, st, expect_type) + }) + })?; + if args.len() != 3 { + return Err(parser.new_custom_error(CustomError::Reason( + "clamp() requires exactly 3 arguments".to_string(), + ))); + } + let mut it = args.into_iter(); + let min = it.next().unwrap(); + let val = it.next().unwrap(); + let max = it.next().unwrap(); + Ok(CalcExpr::Clamp(Box::new(min), Box::new(val), Box::new(max))) + } + _ => Err(parser.new_custom_error(CustomError::Unsupported)), + } +} + #[inline(never)] fn parse_calc_sum_expr<'a, 't: 'a, 'i: 't>( parser: &'a mut Parser<'i, 't>, @@ -504,5 +655,23 @@ fn parse_calc_value<'a, 't: 'a, 'i: 't>( return Ok(CalcExpr::Angle(Box::new(angle))); } } + // match min() / max() / clamp() + let func = parser.try_parse::<_, CalcExpr, ParseError<'_, CustomError>>(|parser| { + let next = parser.next()?.clone(); + let name = match &next { + Token::Function(n) => n.to_string(), + _ => return Err(parser.new_unexpected_token_error(next)), + }; + if !(name.eq_ignore_ascii_case("min") + || name.eq_ignore_ascii_case("max") + || name.eq_ignore_ascii_case("clamp")) + { + return Err(parser.new_unexpected_token_error(next)); + } + parse_math_function_body(parser, properties, st, expect_type, &name) + }); + if let Ok(expr) = func { + return Ok(expr); + } Err(parser.new_custom_error(CustomError::Unmatched)) } diff --git a/float-pigment-css/src/parser/property_value/mod.rs b/float-pigment-css/src/parser/property_value/mod.rs index 53decad..028ac7b 100644 --- a/float-pigment-css/src/parser/property_value/mod.rs +++ b/float-pigment-css/src/parser/property_value/mod.rs @@ -197,16 +197,17 @@ fn parse_length_inner<'a, 't: 'a, 'i: 't>( if !allow_negative && *value < 0. { return Err(parser.new_unexpected_token_error(next)); } + // CSS dimension units are ASCII case-insensitive. let unit: &str = unit; match unit { - "px" => return Ok(Length::Px(*value)), - "vw" => return Ok(Length::Vw(*value)), - "vh" => return Ok(Length::Vh(*value)), - "rem" => return Ok(Length::Rem(*value)), - "rpx" => return Ok(Length::Rpx(*value)), - "em" => return Ok(Length::Em(*value)), - "vmin" => return Ok(Length::Vmin(*value)), - "vmax" => return Ok(Length::Vmax(*value)), + u if u.eq_ignore_ascii_case("px") => return Ok(Length::Px(*value)), + u if u.eq_ignore_ascii_case("vw") => return Ok(Length::Vw(*value)), + u if u.eq_ignore_ascii_case("vh") => return Ok(Length::Vh(*value)), + u if u.eq_ignore_ascii_case("rem") => return Ok(Length::Rem(*value)), + u if u.eq_ignore_ascii_case("rpx") => return Ok(Length::Rpx(*value)), + u if u.eq_ignore_ascii_case("em") => return Ok(Length::Em(*value)), + u if u.eq_ignore_ascii_case("vmin") => return Ok(Length::Vmin(*value)), + u if u.eq_ignore_ascii_case("vmax") => return Ok(Length::Vmax(*value)), _ => {} } } @@ -237,6 +238,24 @@ fn parse_length_inner<'a, 't: 'a, 'i: 't>( Length::Expr(Box::new(LengthExpr::Calc(Box::new(ret)))) }); } + n if n.eq_ignore_ascii_case("min") + || n.eq_ignore_ascii_case("max") + || n.eq_ignore_ascii_case("clamp") => + { + return parse_math_function_body( + parser, + properties, + st, + ExpectValueType::NumberAndLength, + n, + ) + .map(|ret| { + if let Some(r) = ComputeCalcExpr::::try_compute(&ret) { + return r; + } + Length::Expr(Box::new(LengthExpr::Calc(Box::new(ret)))) + }); + } _ => {} }, _ => {} @@ -317,20 +336,21 @@ pub(crate) fn angle<'a, 't: 'a, 'i: 't>( properties: &mut Vec, st: &mut ParseState, ) -> Result> { - let next = parser.next()?; - match next { + let next = parser.next()?.clone(); + match &next { Token::Number { value, .. } => { if *value == 0. { return Ok(Angle::Deg(0.)); } } Token::Dimension { value, unit, .. } => { + // CSS dimension units are ASCII case-insensitive. let unit: &str = unit; match unit { - "deg" => return Ok(Angle::Deg(*value)), - "grad" => return Ok(Angle::Grad(*value)), - "rad" => return Ok(Angle::Rad(*value)), - "turn" => return Ok(Angle::Turn(*value)), + u if u.eq_ignore_ascii_case("deg") => return Ok(Angle::Deg(*value)), + u if u.eq_ignore_ascii_case("grad") => return Ok(Angle::Grad(*value)), + u if u.eq_ignore_ascii_case("rad") => return Ok(Angle::Rad(*value)), + u if u.eq_ignore_ascii_case("turn") => return Ok(Angle::Turn(*value)), _ => {} } } @@ -344,6 +364,25 @@ pub(crate) fn angle<'a, 't: 'a, 'i: 't>( Angle::Calc(Box::new(ret)) }); } + let n = &**name; + if n.eq_ignore_ascii_case("min") + || n.eq_ignore_ascii_case("max") + || n.eq_ignore_ascii_case("clamp") + { + return parse_math_function_body( + parser, + properties, + st, + ExpectValueType::AngleAndLength, + n, + ) + .map(|ret| { + if let Some(r) = ComputeCalcExpr::::try_compute(&ret) { + return r; + } + Angle::Calc(Box::new(ret)) + }); + } } _ => {} } @@ -443,8 +482,8 @@ pub(crate) fn number<'a, 't: 'a, 'i: 't>( properties: &mut Vec, st: &mut ParseState, ) -> Result> { - let next = parser.next()?; - match next { + let next = parser.next()?.clone(); + match &next { Token::Number { value, .. } => { return Ok(Number::F32(*value)); } @@ -459,6 +498,19 @@ pub(crate) fn number<'a, 't: 'a, 'i: 't>( }, ); } + let n = &**name; + if n.eq_ignore_ascii_case("min") + || n.eq_ignore_ascii_case("max") + || n.eq_ignore_ascii_case("clamp") + { + return parse_math_function_body(parser, properties, st, ExpectValueType::Number, n) + .map(|ret| { + if let Some(r) = ComputeCalcExpr::::try_compute(&ret) { + return r; + } + Number::Calc(Box::new(ret)) + }); + } } _ => {} } diff --git a/float-pigment-css/src/typing.rs b/float-pigment-css/src/typing.rs index 2f34e7f..8a42c40 100644 --- a/float-pigment-css/src/typing.rs +++ b/float-pigment-css/src/typing.rs @@ -54,6 +54,12 @@ pub enum CalcExpr { Mul(Box, Box), /// `/` expression. Div(Box, Box), + /// `min(...)` expression; comma-separated calc-sums. + Min(Array), + /// `max(...)` expression; comma-separated calc-sums. + Max(Array), + /// `clamp(MIN, VAL, MAX)` expression. + Clamp(Box, Box, Box), } impl Default for CalcExpr { @@ -392,6 +398,62 @@ impl CalcExpr { )?; x / y } + CalcExpr::Min(args) => { + let mut iter = args.iter(); + let mut ret = iter.next()?.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + for arg in iter { + let v = arg.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + if v < ret { + ret = v; + } + } + ret + } + CalcExpr::Max(args) => { + let mut iter = args.iter(); + let mut ret = iter.next()?.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + for arg in iter { + let v = arg.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + if v > ret { + ret = v; + } + } + ret + } + CalcExpr::Clamp(min, val, max) => { + let mn = min.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + let v = val.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + let mx = max.resolve_to_f32( + media_query_status, + relative_length, + length_as_parent_font_size, + )?; + v.max(mn).min(mx) + } }; Some(ret) } diff --git a/float-pigment-css/src/typing_stringify.rs b/float-pigment-css/src/typing_stringify.rs index a6a1164..ced11a6 100644 --- a/float-pigment-css/src/typing_stringify.rs +++ b/float-pigment-css/src/typing_stringify.rs @@ -29,6 +29,9 @@ impl fmt::Display for CalcExpr { Self::Mul(lhs, rhs) => write!(f, "{lhs}*{rhs}"), Self::Plus(lhs, rhs) => write!(f, "{lhs} + {rhs}"), Self::Sub(lhs, rhs) => write!(f, "{lhs} - {rhs}"), + Self::Min(arr) => write!(f, "min({})", generate_array_str(arr)), + Self::Max(arr) => write!(f, "max({})", generate_array_str(arr)), + Self::Clamp(min, val, max) => write!(f, "clamp({min}, {val}, {max})"), } } } diff --git a/float-pigment-css/tests/calc.rs b/float-pigment-css/tests/calc.rs index b3e4548..5606c2b 100644 --- a/float-pigment-css/tests/calc.rs +++ b/float-pigment-css/tests/calc.rs @@ -514,3 +514,105 @@ pub fn calc_operator_whitespace() { test_parse_property!(width, "width", "calc(3px /1)", Length::Px(3.)); test_parse_property!(width, "width", "calc(3px/1)", Length::Px(3.)); } + +#[test] +pub fn calc_unit_case_insensitive() { + // CSS dimension units are ASCII case-insensitive (CSS Syntax L3 / CSS Values L4). + test_parse_property!(width, "width", "calc(10PX + 10px)", Length::Px(20.)); + test_parse_property!(width, "width", "calc(10Px * 2)", Length::Px(20.)); + test_parse_property!( + transform, + "transform", + "rotate(calc(45DEG + 45deg))", + Transform::Series(vec![TransformItem::Rotate2D(Angle::Rad(1.570_796_4))].into()) + ); +} + +#[test] +pub fn calc_min_max_length() { + // parse-time fold (same unit, literal) + test_parse_property!(width, "width", "calc(max(10px, 20px))", Length::Px(20.)); + test_parse_property!(width, "width", "calc(min(10px, 20px))", Length::Px(10.)); + // runtime resolve (contains viewport unit) + test_parse_property!( + width, + "width", + "calc(min(10px, 5vw))", + Length::new_calc_expr(Box::new(CalcExpr::Min(vec![ + CalcExpr::Length(Length::Px(10.)), + CalcExpr::Length(Length::Vw(5.)), + ] + .into()))) + ); +} + +#[test] +pub fn calc_min_max_number_angle() { + // number, parse-time fold + test_parse_property!(flex_grow, "flex-grow", "calc(max(1, 2))", Number::F32(2.)); + test_parse_property!(flex_grow, "flex-grow", "calc(min(3, 2, 1))", Number::F32(1.)); + // angle, parse-time fold + test_parse_property!( + transform, + "transform", + "rotate(calc(max(45deg, 90deg)))", + Transform::Series(vec![TransformItem::Rotate2D(Angle::Rad(1.570_796_4))].into()) + ); +} + +#[test] +pub fn calc_clamp() { + // parse-time fold (all literals, same unit) + test_parse_property!(width, "width", "calc(clamp(10px, 50px, 100px))", Length::Px(50.)); + test_parse_property!(width, "width", "calc(clamp(10px, 5px, 100px))", Length::Px(10.)); + test_parse_property!(width, "width", "calc(clamp(10px, 500px, 100px))", Length::Px(100.)); + // number + test_parse_property!(flex_grow, "flex-grow", "calc(clamp(0, 2, 1))", Number::F32(1.)); + // runtime resolve (val contains vw) + test_parse_property!( + width, + "width", + "calc(clamp(10px, 50vw, 100px))", + Length::new_calc_expr(Box::new(CalcExpr::Clamp( + Box::new(CalcExpr::Length(Length::Px(10.))), + Box::new(CalcExpr::Length(Length::Vw(50.))), + Box::new(CalcExpr::Length(Length::Px(100.))), + ))) + ); +} + +#[test] +pub fn math_function_standalone() { + // standalone length (independent of calc()) + test_parse_property!( + width, + "width", + "min(10px, 5vw)", + Length::new_calc_expr(Box::new(CalcExpr::Min(vec![ + CalcExpr::Length(Length::Px(10.)), + CalcExpr::Length(Length::Vw(5.)), + ] + .into()))) + ); + test_parse_property!(width, "width", "max(10px, 20px)", Length::Px(20.)); + // standalone number + test_parse_property!(flex_grow, "flex-grow", "max(1, 2)", Number::F32(2.)); + // standalone angle + test_parse_property!( + transform, + "transform", + "rotate(max(45deg, 90deg))", + Transform::Series(vec![TransformItem::Rotate2D(Angle::Rad(1.570_796_4))].into()) + ); + // case-insensitive function name + test_parse_property!(width, "width", "MIN(10px, 20px)", Length::Px(10.)); + test_parse_property!(width, "width", "Clamp(10px, 50px, 100px)", Length::Px(50.)); +} + +#[test] +pub fn math_function_errors() { + // type mismatch: angle inside length min -> parse fails -> Length falls back + test_parse_property!(width, "width", "min(10px, 45deg)", Length::Auto); + // clamp arity != 3 -> parse fails + test_parse_property!(width, "width", "clamp(10px, 20px)", Length::Auto); +}