Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.57% covered (success)
98.57%
69 / 70
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
CalcEvaluator
98.57% covered (success)
98.57%
69 / 70
80.00% covered (warning)
80.00%
4 / 5
67
0.00% covered (danger)
0.00%
0 / 1
 evaluate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 eval
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
12.02
 resolveLeaf
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 callFunc
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
43
 resolveValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Cascade;
6
7use Phpdftk\Css\Value\Calc;
8use Phpdftk\Css\Value\CalcBinary;
9use Phpdftk\Css\Value\CalcExpression;
10use Phpdftk\Css\Value\CalcFunc;
11use Phpdftk\Css\Value\CalcFunction;
12use Phpdftk\Css\Value\CalcLeaf;
13use Phpdftk\Css\Value\CalcOp;
14use Phpdftk\Css\Value\Integer;
15use Phpdftk\Css\Value\Length;
16use Phpdftk\Css\Value\LengthUnit;
17use Phpdftk\Css\Value\Number;
18use Phpdftk\Css\Value\Percentage;
19use Phpdftk\Css\Value\Value;
20
21/**
22 * Reduce a {@see Calc} tree (or any {@see CalcExpression} subtree) to a
23 * concrete numeric value in CSS pixels against a {@see LengthContext}.
24 *
25 * Scope (CSS Values 4 §10):
26 *  - `calc()` — recursive arithmetic over Length / Percentage / Number leaves.
27 *  - `min()` / `max()` — variadic min / max over evaluated args.
28 *  - `clamp(min, val, max)` — clamps the middle arg.
29 *  - `abs()` / `sign()` / `hypot()` — straightforward unary / variadic helpers.
30 *  - Trig + power functions are out of v1 scope — they evaluate to NAN so
31 *    callers can detect and route to legacy length parsing.
32 *
33 * Leaf resolution:
34 *  - {@see Length} → {@see LengthResolver::toPx} (handles px / em / rem /
35 *    vw / vh / etc).
36 *  - {@see Percentage} → resolved against `LengthContext::percentageBasis`
37 *    if non-zero, otherwise returns NAN so the caller can defer.
38 *  - {@see Number} / {@see Integer} → the unitless value (callers use this
39 *    in multiplications inside the tree; a bare `calc(2)` outside a length
40 *    context degenerates to a unitless 2).
41 *
42 * Returns NAN whenever the tree contains an unresolvable percentage,
43 * an unsupported function, or a leaf that isn't a numeric primitive.
44 * Callers should treat NAN as "leave the Calc as-is, defer to the
45 * legacy Length parser, or skip evaluation".
46 */
47final class CalcEvaluator
48{
49    /** Evaluate a top-level `Calc` value to a pixel quantity. */
50    public static function evaluate(Calc $calc, LengthContext $ctx): float
51    {
52        return self::eval($calc->expression, $ctx);
53    }
54
55    public static function eval(CalcExpression $expr, LengthContext $ctx): float
56    {
57        if ($expr instanceof CalcLeaf) {
58            return self::resolveLeaf($expr->value, $ctx);
59        }
60        if ($expr instanceof CalcBinary) {
61            $left = self::eval($expr->left, $ctx);
62            $right = self::eval($expr->right, $ctx);
63            if (is_nan($left) || is_nan($right)) {
64                return NAN;
65            }
66            return match ($expr->op) {
67                CalcOp::Add => $left + $right,
68                CalcOp::Sub => $left - $right,
69                CalcOp::Mul => $left * $right,
70                CalcOp::Div => $right == 0.0 ? NAN : $left / $right,
71            };
72        }
73        if ($expr instanceof CalcFunc) {
74            $args = [];
75            foreach ($expr->args as $arg) {
76                $args[] = self::eval($arg, $ctx);
77            }
78            return self::callFunc($expr->func, $args);
79        }
80        return NAN;
81    }
82
83    private static function resolveLeaf(Value $value, LengthContext $ctx): float
84    {
85        if ($value instanceof Length) {
86            return LengthResolver::toPx($value, $ctx);
87        }
88        if ($value instanceof Percentage) {
89            // CSS Sizing 3 §5.1 — in an intrinsic-size contribution a
90            // percentage resolves to zero (keeping any sibling fixed terms
91            // of the calc intact, e.g. `calc(10% + 100px)` → 100px).
92            if ($ctx->percentagesAsZero) {
93                return 0.0;
94            }
95            // Percentage requires a basis. Zero basis = unknown to us;
96            // defer by returning NAN so the caller can leave the Calc
97            // untouched (e.g. background-position-percent resolves at
98            // paint time, not cascade time).
99            if ($ctx->percentageBasis === 0.0) {
100                return NAN;
101            }
102            return $value->value / 100.0 * $ctx->percentageBasis;
103        }
104        if ($value instanceof Number) {
105            return $value->value;
106        }
107        if ($value instanceof Integer) {
108            return (float) $value->value;
109        }
110        if ($value instanceof Calc) {
111            return self::evaluate($value, $ctx);
112        }
113        return NAN;
114    }
115
116    /**
117     * @param list<float> $args
118     */
119    private static function callFunc(CalcFunction $func, array $args): float
120    {
121        foreach ($args as $a) {
122            if (is_nan($a)) {
123                return NAN;
124            }
125        }
126        return match ($func) {
127            CalcFunction::Calc => $args[0] ?? NAN,
128            // PHP's `min()` / `max()` require an array or at least
129            // two arguments — the single-element case (`max(50%)`)
130            // would throw "must be of type array, float given".
131            CalcFunction::Min => count($args) === 1
132                ? $args[0]
133                : ($args === [] ? NAN : min(...$args)),
134            CalcFunction::Max => count($args) === 1
135                ? $args[0]
136                : ($args === [] ? NAN : max(...$args)),
137            CalcFunction::Clamp => count($args) === 3
138                ? max($args[0], min($args[1], $args[2]))
139                : NAN,
140            CalcFunction::Abs => count($args) === 1 ? abs($args[0]) : NAN,
141            CalcFunction::Sign => count($args) === 1
142                ? ($args[0] <=> 0.0)
143                : NAN,
144            CalcFunction::Hypot => $args === [] ? NAN : sqrt(array_sum(array_map(static fn(float $a): float => $a * $a, $args))),
145            CalcFunction::Sqrt => count($args) === 1 && $args[0] >= 0.0 ? sqrt($args[0]) : NAN,
146            CalcFunction::Pow => count($args) === 2 ? $args[0] ** $args[1] : NAN,
147            CalcFunction::Exp => count($args) === 1 ? exp($args[0]) : NAN,
148            CalcFunction::Log => count($args) === 1 ? log($args[0]) : (count($args) === 2 ? log($args[0], $args[1]) : NAN),
149            // Trig functions take radians per CSS Values 4 §10.8.
150            // Phase-1: only honour them when callers pre-converted any
151            // `deg` / `grad` / `turn` to radians at parse time.
152            CalcFunction::Sin => count($args) === 1 ? sin($args[0]) : NAN,
153            CalcFunction::Cos => count($args) === 1 ? cos($args[0]) : NAN,
154            CalcFunction::Tan => count($args) === 1 ? tan($args[0]) : NAN,
155            CalcFunction::Asin => count($args) === 1 ? asin($args[0]) : NAN,
156            CalcFunction::Acos => count($args) === 1 ? acos($args[0]) : NAN,
157            CalcFunction::Atan => count($args) === 1 ? atan($args[0]) : NAN,
158            CalcFunction::Atan2 => count($args) === 2 ? atan2($args[0], $args[1]) : NAN,
159            // round / mod / rem — out of v1 scope; defer.
160            CalcFunction::Round, CalcFunction::Mod, CalcFunction::Rem => NAN,
161        };
162    }
163
164    /**
165     * Reduce a {@see Calc} value to a {@see Length} when possible.
166     * Returns the original Calc untouched when evaluation hits a NAN
167     * (unresolvable percentage, unsupported function, etc.) so the
168     * caller can defer to later resolution.
169     */
170    public static function resolveValue(Value $value, LengthContext $ctx): Value
171    {
172        if (!$value instanceof Calc) {
173            return $value;
174        }
175        $px = self::evaluate($value, $ctx);
176        if (is_nan($px)) {
177            return $value;
178        }
179        return new Length($px, LengthUnit::Px);
180    }
181}