Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.40% covered (success)
99.40%
165 / 166
95.00% covered (success)
95.00%
19 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ColorConverter
99.40% covered (success)
99.40%
165 / 166
95.00% covered (success)
95.00%
19 / 20
80
0.00% covered (danger)
0.00%
0 / 1
 toSrgb
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
18
 labToSrgb
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lchToSrgbGamutMapped
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 oklchToSrgbGamutMapped
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 gamutMapByChroma
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 fromHwb
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 hueToSrgb
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hslToSrgb
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 labToXyzD50
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 lchToLab
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fromOklab
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 fromXyzD65
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fromXyzD50
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fromWideRgb
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
12
 fromLinearSrgb
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 srgbGamma
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 srgbDegamma
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 rec2020Degamma
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 clip01
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 mul3
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Value;
6
7/**
8 * Convert a {@see Color} from any of CSS Color 4 / 5's storage spaces into
9 * sRGB so the painter can emit the components through PDF's DeviceRGB
10 * operators. All math is single-precision-float per CSS Color 4 §17, with
11 * standard published matrices.
12 *
13 * Conversion chains, by source space:
14 *  - sRGB                         — already sRGB, no-op.
15 *  - HWB                          — straight CSS Color 4 §6 formula.
16 *  - Lab / LCH                    — Lab ↔ XYZ-D50 (Lab is D50-referenced),
17 *                                   chromatic adaptation D50 → D65 via
18 *                                   Bradford, then linear-sRGB → sRGB.
19 *  - OKLab / OKLCH                — straight matrix to linear-sRGB
20 *                                   (CSS Color 4 §10), no whitepoint chain.
21 *  - sRGB-linear                  — gamma encode.
22 *  - XYZ / XYZD65                 — XYZ-D65 → linear-sRGB → sRGB.
23 *  - XYZD50                       — D50 → D65 → linear-sRGB → sRGB.
24 *  - DisplayP3 / Rec2020 / A98RGB / ProPhotoRGB
25 *                                 — space-specific degamma → linear-space
26 *                                   → space-specific matrix → XYZ → sRGB.
27 *
28 * Components outside [0, 1] after the chain are clipped — proper gamut
29 * mapping (CSS Color 4 §13) is a follow-up.
30 */
31final class ColorConverter
32{
33    private const M_XYZD65_TO_LINEAR_SRGB = [
34        [ 3.2409699419045226, -1.5373831775700935, -0.4986107602930034],
35        [-0.9692436362808796,  1.8759675015077202,  0.0415550574071756],
36        [ 0.0556300796969936, -0.2039769588889765,  1.0569715142428784],
37    ];
38
39    private const M_D50_TO_D65_BRADFORD = [
40        [ 0.9554734527042182, -0.0230985368742614,  0.0632593086610217],
41        [-0.0283697069632081,  1.0099954580058226,  0.0210413821411275],
42        [ 0.0123140016883199, -0.0205076964334779,  1.3303659366080753],
43    ];
44
45    private const M_LINEAR_DISPLAYP3_TO_XYZD65 = [
46        [0.4865709486482162, 0.2656676931690927, 0.1982172852343625],
47        [0.2289745640697488, 0.6917385218365064, 0.0792869140937449],
48        [0.0000000000000000, 0.0451133818589026, 1.0439443689009760],
49    ];
50
51    private const M_LINEAR_A98RGB_TO_XYZD65 = [
52        [0.5766690429101305, 0.1855582379065463, 0.1882286462349947],
53        [0.2973449340834686, 0.6273635662554663, 0.0752914996610652],
54        [0.0270313613864123, 0.0706888525358272, 0.9913375368376388],
55    ];
56
57    private const M_LINEAR_REC2020_TO_XYZD65 = [
58        [0.6369580483012911, 0.1446169035862080, 0.1688809751641320],
59        [0.2627002120112671, 0.6779980715188708, 0.0593017164698621],
60        [0.0000000000000000, 0.0280726930490874, 1.0609850577107909],
61    ];
62
63    private const M_LINEAR_PROPHOTORGB_TO_XYZD50 = [
64        [0.7977666449006423, 0.1351812974005331, 0.0313477341283742],
65        [0.2880748288194013, 0.7118352342418857, 0.0000899369387123],
66        [0.0000000000000000, 0.0000000000000000, 0.8251046025104602],
67    ];
68
69    private const M_OKLAB_LMS_TO_LINEAR_SRGB = [
70        [ 4.0767416621, -3.3077115913,  0.2309699292],
71        [-1.2684380046,  2.6097574011, -0.3413193965],
72        [-0.0041960863, -0.7034186147,  1.7076147010],
73    ];
74
75    private const M_OKLAB_LAB_TO_LMS_CBRT = [
76        [1.0, 0.3963377774, 0.2158037573],
77        [1.0, -0.1055613458, -0.0638541728],
78        [1.0, -0.0894841775, -1.2914855480],
79    ];
80
81    /** Convert any Color to an sRGB-space Color in `[0, 1]`. */
82    public static function toSrgb(Color $color): Color
83    {
84        return match ($color->space) {
85            ColorSpace::sRGB => $color,
86            ColorSpace::sRGBLinear => self::fromLinearSrgb($color->r, $color->g, $color->b, $color->a),
87            ColorSpace::HWB => self::fromHwb($color->r, $color->g, $color->b, $color->a),
88            ColorSpace::Lab => self::labToSrgb($color->r, $color->g, $color->b, $color->a),
89            ColorSpace::Lch => self::lchToSrgbGamutMapped($color->r, $color->g, $color->b, $color->a),
90            ColorSpace::OKLab => self::fromOklab($color->r, $color->g, $color->b, $color->a),
91            ColorSpace::OKLCH => self::oklchToSrgbGamutMapped($color->r, $color->g, $color->b, $color->a),
92            ColorSpace::XYZ, ColorSpace::XYZD65
93                => self::fromXyzD65($color->r, $color->g, $color->b, $color->a),
94            ColorSpace::XYZD50 => self::fromXyzD50($color->r, $color->g, $color->b, $color->a),
95            ColorSpace::DisplayP3 => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_DISPLAYP3_TO_XYZD65, isD50: false),
96            ColorSpace::DisplayP3Linear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_DISPLAYP3_TO_XYZD65, isD50: false, skipDegamma: true),
97            ColorSpace::A98RGB => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_A98RGB_TO_XYZD65, isD50: false),
98            ColorSpace::A98RGBLinear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_A98RGB_TO_XYZD65, isD50: false, skipDegamma: true),
99            ColorSpace::Rec2020 => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_REC2020_TO_XYZD65, isD50: false),
100            ColorSpace::Rec2020Linear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_REC2020_TO_XYZD65, isD50: false, skipDegamma: true),
101            ColorSpace::ProPhotoRGB => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_PROPHOTORGB_TO_XYZD50, isD50: true),
102            ColorSpace::ProPhotoRGBLinear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_PROPHOTORGB_TO_XYZD50, isD50: true, skipDegamma: true),
103        };
104    }
105
106    private static function labToSrgb(float $l, float $a, float $b, float $alpha): Color
107    {
108        [$x, $y, $z] = self::labToXyzD50($l, $a, $b);
109        return self::fromXyzD50($x, $y, $z, $alpha);
110    }
111
112    /**
113     * CSS Color 4 §13 gamut-mapping for LCH: binary-search chroma down to
114     * the largest value that produces an in-sRGB result. The previous
115     * implementation relied on `fromLinearSrgb`'s per-channel `clip01` to
116     * fit out-of-gamut samples into sRGB, but clipping produces colours
117     * that don't preserve lightness or hue — `lch(0% 110 60)` clipped to
118     * `(0.295, 0, 0)` (a dark red) instead of black. Pre-search short-
119     * circuits at the lightness extremes since L=0/L=100 collapse to
120     * pure black/white regardless of chroma.
121     */
122    private static function lchToSrgbGamutMapped(float $l, float $c, float $h, float $alpha): Color
123    {
124        if ($l <= 0.0) {
125            return new Color(0.0, 0.0, 0.0, $alpha);
126        }
127        if ($l >= 100.0) {
128            return new Color(1.0, 1.0, 1.0, $alpha);
129        }
130        $buildLinear = static function (float $chroma) use ($l, $h): array {
131            [$ll, $a, $b] = self::lchToLab($l, $chroma, $h);
132            [$x, $y, $z] = self::labToXyzD50($ll, $a, $b);
133            [$xn, $yn, $zn] = self::mul3(self::M_D50_TO_D65_BRADFORD, [$x, $y, $z]);
134            return self::mul3(self::M_XYZD65_TO_LINEAR_SRGB, [$xn, $yn, $zn]);
135        };
136        return self::gamutMapByChroma($buildLinear, $c, $alpha);
137    }
138
139    private static function oklchToSrgbGamutMapped(float $l, float $c, float $h, float $alpha): Color
140    {
141        // OKLCH lightness is 0–1, not 0–100.
142        if ($l <= 0.0) {
143            return new Color(0.0, 0.0, 0.0, $alpha);
144        }
145        if ($l >= 1.0) {
146            return new Color(1.0, 1.0, 1.0, $alpha);
147        }
148        $buildLinear = static function (float $chroma) use ($l, $h): array {
149            [$lab_l, $a, $b] = self::lchToLab($l, $chroma, $h);
150            [$l1, $m1, $s1] = self::mul3(self::M_OKLAB_LAB_TO_LMS_CBRT, [$lab_l, $a, $b]);
151            return self::mul3(self::M_OKLAB_LMS_TO_LINEAR_SRGB, [$l1 ** 3, $m1 ** 3, $s1 ** 3]);
152        };
153        return self::gamutMapByChroma($buildLinear, $c, $alpha);
154    }
155
156    /**
157     * Binary-search chroma in [0, maxChroma] for the largest value whose
158     * converted sRGB sample is in-gamut (no channel clamped by
159     * `fromLinearSrgb`). `$build(chroma)` returns the converted Color;
160     * the helper compares its output to a parallel uncoloured probe
161     * (`fromLinearSrgb` is the only path that clamps, so re-running with
162     * a known channel value isn't needed — the clipped result equals the
163     * unclipped only when the linear samples already sit in `[0, 1]`).
164     */
165    /**
166     * Binary-search chroma in [0, maxChroma] for the largest value
167     * whose linear-sRGB sample is in `[0, 1]` per channel (i.e.
168     * in-gamut). `$buildLinear(chroma)` returns the UNCLIPPED
169     * linear sRGB triple from the relevant color-space conversion.
170     * The final sample is gamma-encoded and clipped via the regular
171     * `fromLinearSrgb` path before returning.
172     *
173     * @param \Closure(float): array{0:float,1:float,2:float} $buildLinear
174     */
175    private static function gamutMapByChroma(\Closure $buildLinear, float $maxChroma, float $alpha): Color
176    {
177        $TOL = 1e-4;
178        $fitsInGamut = static function (array $linear) use ($TOL): bool {
179            [$r, $g, $b] = $linear;
180            return $r >= -$TOL && $r <= 1.0 + $TOL
181                && $g >= -$TOL && $g <= 1.0 + $TOL
182                && $b >= -$TOL && $b <= 1.0 + $TOL;
183        };
184        $best = $buildLinear($maxChroma);
185        if ($fitsInGamut($best)) {
186            return self::fromLinearSrgb($best[0], $best[1], $best[2], $alpha);
187        }
188        $lo = 0.0;
189        $hi = $maxChroma;
190        for ($i = 0; $i < 25; $i++) {
191            $mid = ($lo + $hi) / 2.0;
192            $candidate = $buildLinear($mid);
193            if ($fitsInGamut($candidate)) {
194                $best = $candidate;
195                $lo = $mid;
196            } else {
197                $hi = $mid;
198            }
199        }
200        return self::fromLinearSrgb($best[0], $best[1], $best[2], $alpha);
201    }
202
203    /**
204     * CSS Color 4 §6 — HWB(h, w, b) = mix(hue(h), white, w) ⊕ mix(…, black, b),
205     * where hue(h) is a pure-saturation sRGB colour at the given hue.
206     * Components stored as `r=h(deg)`, `g=w(0–1)`, `b=b(0–1)`.
207     */
208    private static function fromHwb(float $h, float $w, float $b, float $alpha): Color
209    {
210        if ($w + $b >= 1.0) {
211            $gray = $w / ($w + $b);
212            return new Color($gray, $gray, $gray, $alpha);
213        }
214        [$rh, $gh, $bh] = self::hueToSrgb($h);
215        $r = $rh * (1.0 - $w - $b) + $w;
216        $g = $gh * (1.0 - $w - $b) + $w;
217        $b2 = $bh * (1.0 - $w - $b) + $w;
218        return new Color(self::clip01($r), self::clip01($g), self::clip01($b2), $alpha);
219    }
220
221    /**
222     * @return array{float, float, float}
223     */
224    private static function hueToSrgb(float $h): array
225    {
226        $h = fmod($h, 360.0);
227        if ($h < 0.0) {
228            $h += 360.0;
229        }
230        // sRGB hue at saturation=1, lightness=0.5 → standard hue-wheel formula.
231        return self::hslToSrgb($h, 1.0, 0.5);
232    }
233
234    /**
235     * @return array{float, float, float}
236     */
237    private static function hslToSrgb(float $h, float $s, float $l): array
238    {
239        $h = fmod($h, 360.0);
240        if ($h < 0.0) {
241            $h += 360.0;
242        }
243        $c = (1.0 - abs(2.0 * $l - 1.0)) * $s;
244        $hp = $h / 60.0;
245        $x = $c * (1.0 - abs(fmod($hp, 2.0) - 1.0));
246        $m = $l - $c / 2.0;
247        [$r, $g, $b] = match (true) {
248            $hp < 1.0 => [$c, $x, 0.0],
249            $hp < 2.0 => [$x, $c, 0.0],
250            $hp < 3.0 => [0.0, $c, $x],
251            $hp < 4.0 => [0.0, $x, $c],
252            $hp < 5.0 => [$x, 0.0, $c],
253            default => [$c, 0.0, $x],
254        };
255        return [$r + $m, $g + $m, $b + $m];
256    }
257
258    /**
259     * Lab → XYZ-D50 per CSS Color 4 §10.4. `l` is 0–100, `a` and `b` are
260     * unbounded (typical range ±125). The reference white D50 is (0.96422,
261     * 1.0, 0.82521).
262     *
263     * @return array{float, float, float}
264     */
265    private static function labToXyzD50(float $l, float $a, float $b): array
266    {
267        $epsilon = 216.0 / 24389.0;
268        $kappa = 24389.0 / 27.0;
269        $fy = ($l + 16.0) / 116.0;
270        $fx = $a / 500.0 + $fy;
271        $fz = $fy - $b / 200.0;
272        $x3 = $fx ** 3;
273        $y3 = $fy ** 3;
274        $z3 = $fz ** 3;
275        $xN = $x3 > $epsilon ? $x3 : (116.0 * $fx - 16.0) / $kappa;
276        $yN = $l > $kappa * $epsilon ? $y3 : $l / $kappa;
277        $zN = $z3 > $epsilon ? $z3 : (116.0 * $fz - 16.0) / $kappa;
278        return [$xN * 0.96422, $yN * 1.0, $zN * 0.82521];
279    }
280
281    /**
282     * LCH → Lab (in radians-from-degrees). Works for both Lab/LCH and
283     * OKLab/OKLCH because the polar transform is space-agnostic.
284     *
285     * @return array{float, float, float}
286     */
287    private static function lchToLab(float $l, float $c, float $h): array
288    {
289        $rad = $h * M_PI / 180.0;
290        return [$l, $c * cos($rad), $c * sin($rad)];
291    }
292
293    private static function fromOklab(float $l, float $a, float $b, float $alpha): Color
294    {
295        // OKLab → LMS_cbrt → LMS (cube) → linear-sRGB via published matrices
296        // (CSS Color 4 §10).
297        [$l1, $m1, $s1] = self::mul3(self::M_OKLAB_LAB_TO_LMS_CBRT, [$l, $a, $b]);
298        $l3 = $l1 ** 3;
299        $m3 = $m1 ** 3;
300        $s3 = $s1 ** 3;
301        [$lr, $lg, $lb] = self::mul3(self::M_OKLAB_LMS_TO_LINEAR_SRGB, [$l3, $m3, $s3]);
302        return self::fromLinearSrgb($lr, $lg, $lb, $alpha);
303    }
304
305    private static function fromXyzD65(float $x, float $y, float $z, float $alpha): Color
306    {
307        [$lr, $lg, $lb] = self::mul3(self::M_XYZD65_TO_LINEAR_SRGB, [$x, $y, $z]);
308        return self::fromLinearSrgb($lr, $lg, $lb, $alpha);
309    }
310
311    private static function fromXyzD50(float $x, float $y, float $z, float $alpha): Color
312    {
313        [$x65, $y65, $z65] = self::mul3(self::M_D50_TO_D65_BRADFORD, [$x, $y, $z]);
314        return self::fromXyzD65($x65, $y65, $z65, $alpha);
315    }
316
317    /**
318     * Convert a wide-gamut RGB Color (Display-P3 / A98RGB / Rec2020 / ProPhotoRGB).
319     * Each space has its own gamma; we use sRGB-style gamma for sRGB-like
320     * spaces and 1.8 for ProPhoto (per CSS Color 4 §10.7). `$matrix` maps
321     * the LINEAR components to XYZ.
322     *
323     * @param array<int, array<int, float>> $matrix
324     */
325    private static function fromWideRgb(
326        float $r,
327        float $g,
328        float $b,
329        float $alpha,
330        array $matrix,
331        bool $isD50,
332        bool $skipDegamma = false,
333    ): Color {
334        // CSS Color 4 §10 — the `*-linear` variants of wide-gamut spaces
335        // share the encoded-variant's matrix but skip its transfer curve
336        // (the input components are already linear-light).
337        if ($skipDegamma) {
338            $lr = $r;
339            $lg = $g;
340            $lb = $b;
341        } elseif ($matrix === self::M_LINEAR_PROPHOTORGB_TO_XYZD50) {
342            // ProPhoto RGB — CSS Color 4 §10.6 transfer is a simple
343            // 1.8 power curve in both directions; the sub-threshold
344            // linear segment is handled by the same formula in CSS
345            // (no piecewise split).
346            $lr = $r >= 0.0 ? $r ** 1.8 : -((-$r) ** 1.8);
347            $lg = $g >= 0.0 ? $g ** 1.8 : -((-$g) ** 1.8);
348            $lb = $b >= 0.0 ? $b ** 1.8 : -((-$b) ** 1.8);
349        } elseif ($matrix === self::M_LINEAR_A98RGB_TO_XYZD65) {
350            // Adobe RGB (1998) — CSS Color 4 §10.4: gamma is 563/256
351            // = 2.19921875 (the spec rounds to "approximately 2.2"
352            // but mandates the exact rational). No piecewise split.
353            // The previous shared-with-sRGB transfer pulled the
354            // converted samples 6-7% off green/red (WPT a98rgb-001,
355            // predefined-007/-008).
356            $lr = $r >= 0.0 ? $r ** 2.19921875 : -((-$r) ** 2.19921875);
357            $lg = $g >= 0.0 ? $g ** 2.19921875 : -((-$g) ** 2.19921875);
358            $lb = $b >= 0.0 ? $b ** 2.19921875 : -((-$b) ** 2.19921875);
359        } elseif ($matrix === self::M_LINEAR_REC2020_TO_XYZD65) {
360            // BT.2020 — CSS Color 4 §10.5 piecewise transfer:
361            //   c < β   → c / 4.5
362            //   c ≥ β   → ((|c| + α - 1) / α) ^ (1 / 0.45),  α = 1.09929682680944, β = 0.018053968510807
363            // (Cross-rate signs through.)
364            $lr = self::rec2020Degamma($r);
365            $lg = self::rec2020Degamma($g);
366            $lb = self::rec2020Degamma($b);
367        } else {
368            $lr = self::srgbDegamma($r);
369            $lg = self::srgbDegamma($g);
370            $lb = self::srgbDegamma($b);
371        }
372        [$x, $y, $z] = self::mul3($matrix, [$lr, $lg, $lb]);
373        if ($isD50) {
374            [$x, $y, $z] = self::mul3(self::M_D50_TO_D65_BRADFORD, [$x, $y, $z]);
375        }
376        return self::fromXyzD65($x, $y, $z, $alpha);
377    }
378
379    private static function fromLinearSrgb(float $r, float $g, float $b, float $alpha): Color
380    {
381        return new Color(
382            self::clip01(self::srgbGamma($r)),
383            self::clip01(self::srgbGamma($g)),
384            self::clip01(self::srgbGamma($b)),
385            $alpha,
386        );
387    }
388
389    private static function srgbGamma(float $v): float
390    {
391        $sign = $v < 0.0 ? -1.0 : 1.0;
392        $a = abs($v);
393        if ($a <= 0.0031308) {
394            return $sign * 12.92 * $a;
395        }
396        return $sign * (1.055 * ($a ** (1.0 / 2.4)) - 0.055);
397    }
398
399    private static function srgbDegamma(float $v): float
400    {
401        $sign = $v < 0.0 ? -1.0 : 1.0;
402        $a = abs($v);
403        if ($a <= 0.04045) {
404            return $sign * $a / 12.92;
405        }
406        return $sign * ((($a + 0.055) / 1.055) ** 2.4);
407    }
408
409    private static function rec2020Degamma(float $v): float
410    {
411        // BT.2020 transfer (CSS Color 4 §10.5): α = 1.09929682680944,
412        // β linear cutoff 4.5β = 0.081242858298635 (so β ≈ 0.018053).
413        $sign = $v < 0.0 ? -1.0 : 1.0;
414        $a = abs($v);
415        if ($a < 0.08124285829863) {
416            return $sign * $a / 4.5;
417        }
418        return $sign * ((($a + 0.09929682680944) / 1.09929682680944) ** (1.0 / 0.45));
419    }
420
421    private static function clip01(float $v): float
422    {
423        if ($v < 0.0) {
424            return 0.0;
425        }
426        if ($v > 1.0) {
427            return 1.0;
428        }
429        return $v;
430    }
431
432    /**
433     * @param array<int, array<int, float>> $m
434     * @param array{0:float,1:float,2:float} $v
435     * @return array{float, float, float}
436     */
437    private static function mul3(array $m, array $v): array
438    {
439        return [
440            $m[0][0] * $v[0] + $m[0][1] * $v[1] + $m[0][2] * $v[2],
441            $m[1][0] * $v[0] + $m[1][1] * $v[1] + $m[1][2] * $v[2],
442            $m[2][0] * $v[0] + $m[2][1] * $v[1] + $m[2][2] * $v[2],
443        ];
444    }
445}