Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.04% covered (success)
94.04%
284 / 302
86.11% covered (warning)
86.11%
31 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
ColorConverter
94.04% covered (success)
94.04%
284 / 302
86.11% covered (warning)
86.11%
31 / 36
143.09
0.00% covered (danger)
0.00%
0 / 1
 toSrgb
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
19
 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%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 oklchToSrgbGamutMapped
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 fromHwb
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 fromHsl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
 interpolate
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 interpolateSrgb
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 toSpaceCoords
97.44% covered (success)
97.44%
38 / 39
0.00% covered (danger)
0.00%
0 / 1
14
 hueChannel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 hueIsPowerless
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 adjustHuePair
53.57% covered (warning)
53.57%
15 / 28
0.00% covered (danger)
0.00%
0 / 1
37.52
 srgbToXyzD65
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 srgbToDisplayP3
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 srgbToOklab
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 srgbToLab
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 xyzD50ToLab
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 labToLch
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 srgbToHsl
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 srgbToHwb
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cbrt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 invert3x3
100.00% covered (success)
100.00%
13 / 13
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::HSL => self::fromHsl($color->r, $color->g, $color->b, $color->a),
89            ColorSpace::Lab => self::labToSrgb($color->r, $color->g, $color->b, $color->a),
90            ColorSpace::Lch => self::lchToSrgbGamutMapped($color->r, $color->g, $color->b, $color->a),
91            ColorSpace::OKLab => self::fromOklab($color->r, $color->g, $color->b, $color->a),
92            ColorSpace::OKLCH => self::oklchToSrgbGamutMapped($color->r, $color->g, $color->b, $color->a),
93            ColorSpace::XYZ, ColorSpace::XYZD65
94                => self::fromXyzD65($color->r, $color->g, $color->b, $color->a),
95            ColorSpace::XYZD50 => self::fromXyzD50($color->r, $color->g, $color->b, $color->a),
96            ColorSpace::DisplayP3 => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_DISPLAYP3_TO_XYZD65, isD50: false),
97            ColorSpace::DisplayP3Linear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_DISPLAYP3_TO_XYZD65, isD50: false, skipDegamma: true),
98            ColorSpace::A98RGB => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_A98RGB_TO_XYZD65, isD50: false),
99            ColorSpace::A98RGBLinear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_A98RGB_TO_XYZD65, isD50: false, skipDegamma: true),
100            ColorSpace::Rec2020 => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_REC2020_TO_XYZD65, isD50: false),
101            ColorSpace::Rec2020Linear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_REC2020_TO_XYZD65, isD50: false, skipDegamma: true),
102            ColorSpace::ProPhotoRGB => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_PROPHOTORGB_TO_XYZD50, isD50: true),
103            ColorSpace::ProPhotoRGBLinear => self::fromWideRgb($color->r, $color->g, $color->b, $color->a, self::M_LINEAR_PROPHOTORGB_TO_XYZD50, isD50: true, skipDegamma: true),
104        };
105    }
106
107    private static function labToSrgb(float $l, float $a, float $b, float $alpha): Color
108    {
109        [$x, $y, $z] = self::labToXyzD50($l, $a, $b);
110        return self::fromXyzD50($x, $y, $z, $alpha);
111    }
112
113    /**
114     * CSS Color 4 §13 gamut-mapping for LCH: binary-search chroma down to
115     * the largest value that produces an in-sRGB result. The previous
116     * implementation relied on `fromLinearSrgb`'s per-channel `clip01` to
117     * fit out-of-gamut samples into sRGB, but clipping produces colours
118     * that don't preserve lightness or hue — `lch(0% 110 60)` clipped to
119     * `(0.295, 0, 0)` (a dark red) instead of black. Pre-search short-
120     * circuits at the lightness extremes since L=0/L=100 collapse to
121     * pure black/white regardless of chroma.
122     */
123    private static function lchToSrgbGamutMapped(float $l, float $c, float $h, float $alpha): Color
124    {
125        if ($l <= 0.0) {
126            return new Color(0.0, 0.0, 0.0, $alpha);
127        }
128        if ($l >= 100.0) {
129            return new Color(1.0, 1.0, 1.0, $alpha);
130        }
131        // CSS Color 4 — the WPT wide-gamut families (e.g. P3-green expressed
132        // as lab/lch/oklab/oklch/color(display-p3 …), all matching
133        // greensquare-display-p3-ref) are authored to CLIP an out-of-gamut
134        // sample per-channel, the same as labToSrgb / fromOklab and the
135        // wide-gamut RGB paths. §13 chroma-reduction (used previously) left
136        // lch/oklch desaturated relative to their lab/oklab siblings. Convert
137        // to Lab and clip through the shared labToSrgb path.
138        [$ll, $a, $b] = self::lchToLab($l, $c, $h);
139        return self::labToSrgb($ll, $a, $b, $alpha);
140    }
141
142    private static function oklchToSrgbGamutMapped(float $l, float $c, float $h, float $alpha): Color
143    {
144        // OKLCH lightness is 0–1, not 0–100. A lightness within a hair of
145        // the extremes collapses to pure black / white regardless of chroma
146        // (oklch(0.0001% …) is black, oklch(99.9999% …) is white) — clipping
147        // such a sample per-channel would leave a saturated non-black/white
148        // colour, so short-circuit the near-extreme range too.
149        if ($l <= 1e-4) {
150            return new Color(0.0, 0.0, 0.0, $alpha);
151        }
152        if ($l >= 1.0 - 1e-4) {
153            return new Color(1.0, 1.0, 1.0, $alpha);
154        }
155        // As above — clip (via fromOklab) instead of chroma-reduction so
156        // out-of-gamut OKLCH converges with its OKLab / display-p3 siblings.
157        [$lab_l, $a, $b] = self::lchToLab($l, $c, $h);
158        return self::fromOklab($lab_l, $a, $b, $alpha);
159    }
160
161    /**
162     * CSS Color 4 §6 — HWB(h, w, b) = mix(hue(h), white, w) ⊕ mix(…, black, b),
163     * where hue(h) is a pure-saturation sRGB colour at the given hue.
164     * Components stored as `r=h(deg)`, `g=w(0–1)`, `b=b(0–1)`.
165     */
166    private static function fromHwb(float $h, float $w, float $b, float $alpha): Color
167    {
168        if ($w + $b >= 1.0) {
169            $gray = $w / ($w + $b);
170            return new Color($gray, $gray, $gray, $alpha);
171        }
172        [$rh, $gh, $bh] = self::hueToSrgb($h);
173        $r = $rh * (1.0 - $w - $b) + $w;
174        $g = $gh * (1.0 - $w - $b) + $w;
175        $b2 = $bh * (1.0 - $w - $b) + $w;
176        return new Color(self::clip01($r), self::clip01($g), self::clip01($b2), $alpha);
177    }
178
179    /**
180     * CSS Color 4 §7 — HSL(h, s, l) → sRGB. Components stored as
181     * `r=h(deg)`, `g=s(0–1)`, `b=l(0–1)`.
182     */
183    private static function fromHsl(float $h, float $s, float $l, float $alpha): Color
184    {
185        [$r, $g, $b] = self::hslToSrgb($h, $s, $l);
186        return new Color(self::clip01($r), self::clip01($g), self::clip01($b), $alpha);
187    }
188
189    /**
190     * @return array{float, float, float}
191     */
192    private static function hueToSrgb(float $h): array
193    {
194        $h = fmod($h, 360.0);
195        if ($h < 0.0) {
196            $h += 360.0;
197        }
198        // sRGB hue at saturation=1, lightness=0.5 → standard hue-wheel formula.
199        return self::hslToSrgb($h, 1.0, 0.5);
200    }
201
202    /**
203     * @return array{float, float, float}
204     */
205    private static function hslToSrgb(float $h, float $s, float $l): array
206    {
207        $h = fmod($h, 360.0);
208        if ($h < 0.0) {
209            $h += 360.0;
210        }
211        $c = (1.0 - abs(2.0 * $l - 1.0)) * $s;
212        $hp = $h / 60.0;
213        $x = $c * (1.0 - abs(fmod($hp, 2.0) - 1.0));
214        $m = $l - $c / 2.0;
215        [$r, $g, $b] = match (true) {
216            $hp < 1.0 => [$c, $x, 0.0],
217            $hp < 2.0 => [$x, $c, 0.0],
218            $hp < 3.0 => [0.0, $c, $x],
219            $hp < 4.0 => [0.0, $x, $c],
220            $hp < 5.0 => [$x, 0.0, $c],
221            default => [$c, 0.0, $x],
222        };
223        return [$r + $m, $g + $m, $b + $m];
224    }
225
226    /**
227     * Lab → XYZ-D50 per CSS Color 4 §10.4. `l` is 0–100, `a` and `b` are
228     * unbounded (typical range ±125). The reference white D50 is (0.96422,
229     * 1.0, 0.82521).
230     *
231     * @return array{float, float, float}
232     */
233    private static function labToXyzD50(float $l, float $a, float $b): array
234    {
235        $epsilon = 216.0 / 24389.0;
236        $kappa = 24389.0 / 27.0;
237        $fy = ($l + 16.0) / 116.0;
238        $fx = $a / 500.0 + $fy;
239        $fz = $fy - $b / 200.0;
240        $x3 = $fx ** 3;
241        $y3 = $fy ** 3;
242        $z3 = $fz ** 3;
243        $xN = $x3 > $epsilon ? $x3 : (116.0 * $fx - 16.0) / $kappa;
244        $yN = $l > $kappa * $epsilon ? $y3 : $l / $kappa;
245        $zN = $z3 > $epsilon ? $z3 : (116.0 * $fz - 16.0) / $kappa;
246        return [$xN * 0.96422, $yN * 1.0, $zN * 0.82521];
247    }
248
249    /**
250     * LCH → Lab (in radians-from-degrees). Works for both Lab/LCH and
251     * OKLab/OKLCH because the polar transform is space-agnostic.
252     *
253     * @return array{float, float, float}
254     */
255    private static function lchToLab(float $l, float $c, float $h): array
256    {
257        $rad = $h * M_PI / 180.0;
258        return [$l, $c * cos($rad), $c * sin($rad)];
259    }
260
261    private static function fromOklab(float $l, float $a, float $b, float $alpha): Color
262    {
263        // OKLab → LMS_cbrt → LMS (cube) → linear-sRGB via published matrices
264        // (CSS Color 4 §10).
265        [$l1, $m1, $s1] = self::mul3(self::M_OKLAB_LAB_TO_LMS_CBRT, [$l, $a, $b]);
266        $l3 = $l1 ** 3;
267        $m3 = $m1 ** 3;
268        $s3 = $s1 ** 3;
269        [$lr, $lg, $lb] = self::mul3(self::M_OKLAB_LMS_TO_LINEAR_SRGB, [$l3, $m3, $s3]);
270        return self::fromLinearSrgb($lr, $lg, $lb, $alpha);
271    }
272
273    private static function fromXyzD65(float $x, float $y, float $z, float $alpha): Color
274    {
275        [$lr, $lg, $lb] = self::mul3(self::M_XYZD65_TO_LINEAR_SRGB, [$x, $y, $z]);
276        return self::fromLinearSrgb($lr, $lg, $lb, $alpha);
277    }
278
279    private static function fromXyzD50(float $x, float $y, float $z, float $alpha): Color
280    {
281        [$x65, $y65, $z65] = self::mul3(self::M_D50_TO_D65_BRADFORD, [$x, $y, $z]);
282        return self::fromXyzD65($x65, $y65, $z65, $alpha);
283    }
284
285    /**
286     * Convert a wide-gamut RGB Color (Display-P3 / A98RGB / Rec2020 / ProPhotoRGB).
287     * Each space has its own gamma; we use sRGB-style gamma for sRGB-like
288     * spaces and 1.8 for ProPhoto (per CSS Color 4 §10.7). `$matrix` maps
289     * the LINEAR components to XYZ.
290     *
291     * @param array<int, array<int, float>> $matrix
292     */
293    private static function fromWideRgb(
294        float $r,
295        float $g,
296        float $b,
297        float $alpha,
298        array $matrix,
299        bool $isD50,
300        bool $skipDegamma = false,
301    ): Color {
302        // CSS Color 4 §10 — the `*-linear` variants of wide-gamut spaces
303        // share the encoded-variant's matrix but skip its transfer curve
304        // (the input components are already linear-light).
305        if ($skipDegamma) {
306            $lr = $r;
307            $lg = $g;
308            $lb = $b;
309        } elseif ($matrix === self::M_LINEAR_PROPHOTORGB_TO_XYZD50) {
310            // ProPhoto RGB — CSS Color 4 §10.6 transfer is a simple
311            // 1.8 power curve in both directions; the sub-threshold
312            // linear segment is handled by the same formula in CSS
313            // (no piecewise split).
314            $lr = $r >= 0.0 ? $r ** 1.8 : -((-$r) ** 1.8);
315            $lg = $g >= 0.0 ? $g ** 1.8 : -((-$g) ** 1.8);
316            $lb = $b >= 0.0 ? $b ** 1.8 : -((-$b) ** 1.8);
317        } elseif ($matrix === self::M_LINEAR_A98RGB_TO_XYZD65) {
318            // Adobe RGB (1998) — CSS Color 4 §10.4: gamma is 563/256
319            // = 2.19921875 (the spec rounds to "approximately 2.2"
320            // but mandates the exact rational). No piecewise split.
321            // The previous shared-with-sRGB transfer pulled the
322            // converted samples 6-7% off green/red (WPT a98rgb-001,
323            // predefined-007/-008).
324            $lr = $r >= 0.0 ? $r ** 2.19921875 : -((-$r) ** 2.19921875);
325            $lg = $g >= 0.0 ? $g ** 2.19921875 : -((-$g) ** 2.19921875);
326            $lb = $b >= 0.0 ? $b ** 2.19921875 : -((-$b) ** 2.19921875);
327        } elseif ($matrix === self::M_LINEAR_REC2020_TO_XYZD65) {
328            // BT.2020 — CSS Color 4 §10.5 piecewise transfer:
329            //   c < β   → c / 4.5
330            //   c ≥ β   → ((|c| + α - 1) / α) ^ (1 / 0.45),  α = 1.09929682680944, β = 0.018053968510807
331            // (Cross-rate signs through.)
332            $lr = self::rec2020Degamma($r);
333            $lg = self::rec2020Degamma($g);
334            $lb = self::rec2020Degamma($b);
335        } else {
336            $lr = self::srgbDegamma($r);
337            $lg = self::srgbDegamma($g);
338            $lb = self::srgbDegamma($b);
339        }
340        [$x, $y, $z] = self::mul3($matrix, [$lr, $lg, $lb]);
341        if ($isD50) {
342            [$x, $y, $z] = self::mul3(self::M_D50_TO_D65_BRADFORD, [$x, $y, $z]);
343        }
344        return self::fromXyzD65($x, $y, $z, $alpha);
345    }
346
347    private static function fromLinearSrgb(float $r, float $g, float $b, float $alpha): Color
348    {
349        return new Color(
350            self::clip01(self::srgbGamma($r)),
351            self::clip01(self::srgbGamma($g)),
352            self::clip01(self::srgbGamma($b)),
353            $alpha,
354        );
355    }
356
357    private static function srgbGamma(float $v): float
358    {
359        $sign = $v < 0.0 ? -1.0 : 1.0;
360        $a = abs($v);
361        if ($a <= 0.0031308) {
362            return $sign * 12.92 * $a;
363        }
364        return $sign * (1.055 * ($a ** (1.0 / 2.4)) - 0.055);
365    }
366
367    private static function srgbDegamma(float $v): float
368    {
369        $sign = $v < 0.0 ? -1.0 : 1.0;
370        $a = abs($v);
371        if ($a <= 0.04045) {
372            return $sign * $a / 12.92;
373        }
374        return $sign * ((($a + 0.055) / 1.055) ** 2.4);
375    }
376
377    private static function rec2020Degamma(float $v): float
378    {
379        // BT.2020 transfer (CSS Color 4 §10.5): α = 1.09929682680944,
380        // β linear cutoff 4.5β = 0.081242858298635 (so β ≈ 0.018053).
381        $sign = $v < 0.0 ? -1.0 : 1.0;
382        $a = abs($v);
383        if ($a < 0.08124285829863) {
384            return $sign * $a / 4.5;
385        }
386        return $sign * ((($a + 0.09929682680944) / 1.09929682680944) ** (1.0 / 0.45));
387    }
388
389    private static function clip01(float $v): float
390    {
391        if ($v < 0.0) {
392            return 0.0;
393        }
394        if ($v > 1.0) {
395            return 1.0;
396        }
397        return $v;
398    }
399
400    /**
401     * @param array<int, array<int, float>> $m
402     * @param array{0:float,1:float,2:float} $v
403     * @return array{float, float, float}
404     */
405    private static function mul3(array $m, array $v): array
406    {
407        return [
408            $m[0][0] * $v[0] + $m[0][1] * $v[1] + $m[0][2] * $v[2],
409            $m[1][0] * $v[0] + $m[1][1] * $v[1] + $m[1][2] * $v[2],
410            $m[2][0] * $v[0] + $m[2][1] * $v[1] + $m[2][2] * $v[2],
411        ];
412    }
413
414    // ------------------------------------------------------------------
415    // Color-space interpolation (CSS Color 4 §12 / CSS Images 4 §3.1)
416    //
417    // A gradient with `in <space> [<hue> hue]` interpolates its stop
418    // colours in <space>, not sRGB. PDF colour shadings only interpolate
419    // linearly in DeviceRGB, so the painter pre-samples each segment via
420    // {@see interpolate()} and emits the samples as sRGB — the shading's
421    // linear DeviceRGB interpolation between dense samples then tracks the
422    // true curve. The forward (sRGB → space) matrices are computed once as
423    // exact runtime inverses of the published backward matrices above, so
424    // a round-trip is identity to float precision.
425    // ------------------------------------------------------------------
426
427    /** @var array<int, array<int, float>>|null */
428    private static ?array $mLinSrgbToXyzD65 = null;
429    /** @var array<int, array<int, float>>|null */
430    private static ?array $mXyzD65ToLinDisplayP3 = null;
431    /** @var array<int, array<int, float>>|null */
432    private static ?array $mXyzD65ToD50 = null;
433    /** @var array<int, array<int, float>>|null */
434    private static ?array $mLinSrgbToLms = null;
435    /** @var array<int, array<int, float>>|null */
436    private static ?array $mLmsCbrtToOklab = null;
437
438    /**
439     * Interpolate two colours at fraction `$t` in `$space`, returning an
440     * sRGB Color. For polar spaces (OKLCH, LCH, HSL, HWB) the hue channel
441     * follows `$hue` (default: shorter) per CSS Color 4 §12.4, with a
442     * powerless (achromatic) endpoint adopting its neighbour's hue. Spaces
443     * without a forward conversion fall back to plain sRGB interpolation,
444     * i.e. the previous behaviour.
445     */
446    public static function interpolate(Color $a, Color $b, float $t, ColorSpace $space, ?HueInterpolation $hue): Color
447    {
448        $ca = self::toSpaceCoords($a, $space);
449        $cb = self::toSpaceCoords($b, $space);
450        if ($ca === null || $cb === null) {
451            return self::interpolateSrgb($a, $b, $t);
452        }
453        $hueIndex = self::hueChannel($space);
454        if ($hueIndex !== null) {
455            $aPowerless = self::hueIsPowerless($space, $ca);
456            $bPowerless = self::hueIsPowerless($space, $cb);
457            if ($aPowerless && !$bPowerless) {
458                $ca[$hueIndex] = $cb[$hueIndex];
459            } elseif ($bPowerless && !$aPowerless) {
460                $cb[$hueIndex] = $ca[$hueIndex];
461            }
462            [$h0, $h1] = self::adjustHuePair($ca[$hueIndex], $cb[$hueIndex], $hue ?? HueInterpolation::Shorter);
463            $ca[$hueIndex] = $h0;
464            $cb[$hueIndex] = $h1;
465        }
466        $mixed = new Color(
467            $ca[0] + ($cb[0] - $ca[0]) * $t,
468            $ca[1] + ($cb[1] - $ca[1]) * $t,
469            $ca[2] + ($cb[2] - $ca[2]) * $t,
470            $ca[3] + ($cb[3] - $ca[3]) * $t,
471            $space,
472        );
473        return self::toSrgb($mixed);
474    }
475
476    private static function interpolateSrgb(Color $a, Color $b, float $t): Color
477    {
478        $sa = self::toSrgb($a);
479        $sb = self::toSrgb($b);
480        return new Color(
481            $sa->r + ($sb->r - $sa->r) * $t,
482            $sa->g + ($sb->g - $sa->g) * $t,
483            $sa->b + ($sb->b - $sa->b) * $t,
484            $sa->a + ($sb->a - $sa->a) * $t,
485        );
486    }
487
488    /**
489     * Convert a colour into the channel coordinates of `$space`
490     * (`[c0, c1, c2, alpha]`), or null when `$space` has no forward
491     * conversion. Colours already stored in `$space` are returned
492     * verbatim (exact); everything else goes via sRGB.
493     *
494     * @return array{float, float, float, float}|null
495     */
496    private static function toSpaceCoords(Color $c, ColorSpace $space): ?array
497    {
498        if ($c->space === $space) {
499            return [$c->r, $c->g, $c->b, $c->a];
500        }
501        $s = self::toSrgb($c);
502        $r = $s->r;
503        $g = $s->g;
504        $b = $s->b;
505        $al = $s->a;
506        switch ($space) {
507            case ColorSpace::sRGB:
508                return [$r, $g, $b, $al];
509            case ColorSpace::sRGBLinear:
510                return [self::srgbDegamma($r), self::srgbDegamma($g), self::srgbDegamma($b), $al];
511            case ColorSpace::HSL:
512                [$h, $sat, $l] = self::srgbToHsl($r, $g, $b);
513                return [$h, $sat, $l, $al];
514            case ColorSpace::HWB:
515                [$h, $w, $bl] = self::srgbToHwb($r, $g, $b);
516                return [$h, $w, $bl, $al];
517            case ColorSpace::OKLab:
518                [$L, $A, $B] = self::srgbToOklab($r, $g, $b);
519                return [$L, $A, $B, $al];
520            case ColorSpace::OKLCH:
521                [$L, $A, $B] = self::srgbToOklab($r, $g, $b);
522                [$Lc, $C, $H] = self::labToLch($L, $A, $B);
523                return [$Lc, $C, $H, $al];
524            case ColorSpace::Lab:
525                [$L, $A, $B] = self::srgbToLab($r, $g, $b);
526                return [$L, $A, $B, $al];
527            case ColorSpace::Lch:
528                [$L, $A, $B] = self::srgbToLab($r, $g, $b);
529                [$Lc, $C, $H] = self::labToLch($L, $A, $B);
530                return [$Lc, $C, $H, $al];
531            case ColorSpace::XYZ:
532            case ColorSpace::XYZD65:
533                [$x, $y, $z] = self::srgbToXyzD65($r, $g, $b);
534                return [$x, $y, $z, $al];
535            case ColorSpace::DisplayP3:
536                [$pr, $pg, $pb] = self::srgbToDisplayP3($r, $g, $b);
537                return [$pr, $pg, $pb, $al];
538            default:
539                // sRGB-linear-family wide gamuts, ProPhoto etc. — no
540                // forward path yet; caller falls back to sRGB interp.
541                return null;
542        }
543    }
544
545    private static function hueChannel(ColorSpace $space): ?int
546    {
547        return match ($space) {
548            ColorSpace::OKLCH, ColorSpace::Lch => 2,
549            ColorSpace::HSL, ColorSpace::HWB => 0,
550            default => null,
551        };
552    }
553
554    /**
555     * A hue is "powerless" (undefined, to be adopted from the neighbour)
556     * when the colour is achromatic: zero chroma/saturation, or in HWB
557     * when whiteness + blackness saturate. CSS Color 4 §4.4 / §12.4.
558     *
559     * @param array{float, float, float, float} $c
560     */
561    private static function hueIsPowerless(ColorSpace $space, array $c): bool
562    {
563        return match ($space) {
564            ColorSpace::OKLCH, ColorSpace::Lch, ColorSpace::HSL => abs($c[1]) < 1e-6,
565            ColorSpace::HWB => ($c[1] + $c[2]) >= 1.0 - 1e-6,
566            default => false,
567        };
568    }
569
570    /**
571     * Adjust a hue pair (degrees) so a linear interpolation between them
572     * travels the arc the method selects. CSS Color 4 §12.4.
573     *
574     * @return array{float, float}
575     */
576    private static function adjustHuePair(float $h0, float $h1, HueInterpolation $method): array
577    {
578        $h0 = fmod($h0, 360.0);
579        if ($h0 < 0.0) {
580            $h0 += 360.0;
581        }
582        $h1 = fmod($h1, 360.0);
583        if ($h1 < 0.0) {
584            $h1 += 360.0;
585        }
586        $d = $h1 - $h0;
587        switch ($method) {
588            case HueInterpolation::Shorter:
589                if ($d > 180.0) {
590                    $h0 += 360.0;
591                } elseif ($d < -180.0) {
592                    $h1 += 360.0;
593                }
594                break;
595            case HueInterpolation::Longer:
596                if ($d > 0.0 && $d < 180.0) {
597                    $h0 += 360.0;
598                } elseif ($d > -180.0 && $d <= 0.0) {
599                    $h1 += 360.0;
600                }
601                break;
602            case HueInterpolation::Increasing:
603                if ($d < 0.0) {
604                    $h1 += 360.0;
605                }
606                break;
607            case HueInterpolation::Decreasing:
608                if ($d > 0.0) {
609                    $h0 += 360.0;
610                }
611                break;
612        }
613        return [$h0, $h1];
614    }
615
616    /**
617     * @return array{float, float, float}
618     */
619    private static function srgbToXyzD65(float $r, float $g, float $b): array
620    {
621        self::$mLinSrgbToXyzD65 ??= self::invert3x3(self::M_XYZD65_TO_LINEAR_SRGB);
622        return self::mul3(self::$mLinSrgbToXyzD65, [self::srgbDegamma($r), self::srgbDegamma($g), self::srgbDegamma($b)]);
623    }
624
625    /**
626     * @return array{float, float, float}
627     */
628    private static function srgbToDisplayP3(float $r, float $g, float $b): array
629    {
630        self::$mXyzD65ToLinDisplayP3 ??= self::invert3x3(self::M_LINEAR_DISPLAYP3_TO_XYZD65);
631        [$x, $y, $z] = self::srgbToXyzD65($r, $g, $b);
632        [$lr, $lg, $lb] = self::mul3(self::$mXyzD65ToLinDisplayP3, [$x, $y, $z]);
633        // Display-P3 shares sRGB's transfer curve (CSS Color 4 §10.2).
634        return [self::srgbGamma($lr), self::srgbGamma($lg), self::srgbGamma($lb)];
635    }
636
637    /**
638     * @return array{float, float, float}
639     */
640    private static function srgbToOklab(float $r, float $g, float $b): array
641    {
642        self::$mLinSrgbToLms ??= self::invert3x3(self::M_OKLAB_LMS_TO_LINEAR_SRGB);
643        self::$mLmsCbrtToOklab ??= self::invert3x3(self::M_OKLAB_LAB_TO_LMS_CBRT);
644        $lin = [self::srgbDegamma($r), self::srgbDegamma($g), self::srgbDegamma($b)];
645        [$l, $m, $s] = self::mul3(self::$mLinSrgbToLms, $lin);
646        return self::mul3(self::$mLmsCbrtToOklab, [self::cbrt($l), self::cbrt($m), self::cbrt($s)]);
647    }
648
649    /**
650     * @return array{float, float, float}
651     */
652    private static function srgbToLab(float $r, float $g, float $b): array
653    {
654        self::$mXyzD65ToD50 ??= self::invert3x3(self::M_D50_TO_D65_BRADFORD);
655        [$x, $y, $z] = self::srgbToXyzD65($r, $g, $b);
656        [$x50, $y50, $z50] = self::mul3(self::$mXyzD65ToD50, [$x, $y, $z]);
657        return self::xyzD50ToLab($x50, $y50, $z50);
658    }
659
660    /**
661     * XYZ-D50 → Lab (CSS Color 4 §10.4, inverse of {@see labToXyzD50}).
662     *
663     * @return array{float, float, float}
664     */
665    private static function xyzD50ToLab(float $x, float $y, float $z): array
666    {
667        $epsilon = 216.0 / 24389.0;
668        $kappa = 24389.0 / 27.0;
669        $xr = $x / 0.96422;
670        $yr = $y / 1.0;
671        $zr = $z / 0.82521;
672        $fx = $xr > $epsilon ? self::cbrt($xr) : ($kappa * $xr + 16.0) / 116.0;
673        $fy = $yr > $epsilon ? self::cbrt($yr) : ($kappa * $yr + 16.0) / 116.0;
674        $fz = $zr > $epsilon ? self::cbrt($zr) : ($kappa * $zr + 16.0) / 116.0;
675        return [116.0 * $fy - 16.0, 500.0 * ($fx - $fy), 200.0 * ($fy - $fz)];
676    }
677
678    /**
679     * Rectangular Lab/OKLab → polar LCH/OKLCH. Space-agnostic, mirroring
680     * {@see lchToLab}.
681     *
682     * @return array{float, float, float}
683     */
684    private static function labToLch(float $l, float $a, float $b): array
685    {
686        $c = sqrt($a * $a + $b * $b);
687        $h = atan2($b, $a) * 180.0 / M_PI;
688        if ($h < 0.0) {
689            $h += 360.0;
690        }
691        return [$l, $c, $h];
692    }
693
694    /**
695     * sRGB → HSL (h in degrees, s/l in [0, 1]). Achromatic colours return
696     * hue 0 / saturation 0 (the hue is powerless).
697     *
698     * @return array{float, float, float}
699     */
700    private static function srgbToHsl(float $r, float $g, float $b): array
701    {
702        $max = max($r, $g, $b);
703        $min = min($r, $g, $b);
704        $l = ($max + $min) / 2.0;
705        $d = $max - $min;
706        if ($d < 1e-9) {
707            return [0.0, 0.0, $l];
708        }
709        $s = $l > 0.5 ? $d / (2.0 - $max - $min) : $d / ($max + $min);
710        if ($max === $r) {
711            $h = ($g - $b) / $d + ($g < $b ? 6.0 : 0.0);
712        } elseif ($max === $g) {
713            $h = ($b - $r) / $d + 2.0;
714        } else {
715            $h = ($r - $g) / $d + 4.0;
716        }
717        return [$h * 60.0, $s, $l];
718    }
719
720    /**
721     * sRGB → HWB (h in degrees, w/b in [0, 1]). CSS Color 4 §6.
722     *
723     * @return array{float, float, float}
724     */
725    private static function srgbToHwb(float $r, float $g, float $b): array
726    {
727        [$h] = self::srgbToHsl($r, $g, $b);
728        return [$h, min($r, $g, $b), 1.0 - max($r, $g, $b)];
729    }
730
731    private static function cbrt(float $v): float
732    {
733        return $v < 0.0 ? -((-$v) ** (1.0 / 3.0)) : $v ** (1.0 / 3.0);
734    }
735
736    /**
737     * Invert a 3×3 matrix (adjugate / determinant). Used to derive the
738     * sRGB → space forward matrices as exact inverses of the published
739     * backward matrices, avoiding transcription drift.
740     *
741     * @param array<int, array<int, float>> $m
742     * @return array<int, array<int, float>>
743     */
744    private static function invert3x3(array $m): array
745    {
746        [$a, $b, $c] = [$m[0][0], $m[0][1], $m[0][2]];
747        [$d, $e, $f] = [$m[1][0], $m[1][1], $m[1][2]];
748        [$g, $h, $i] = [$m[2][0], $m[2][1], $m[2][2]];
749        $A = $e * $i - $f * $h;
750        $B = -($d * $i - $f * $g);
751        $C = $d * $h - $e * $g;
752        $det = $a * $A + $b * $B + $c * $C;
753        $inv = 1.0 / $det;
754        return [
755            [$A * $inv, ($c * $h - $b * $i) * $inv, ($b * $f - $c * $e) * $inv],
756            [$B * $inv, ($a * $i - $c * $g) * $inv, ($c * $d - $a * $f) * $inv],
757            [$C * $inv, ($b * $g - $a * $h) * $inv, ($a * $e - $b * $d) * $inv],
758        ];
759    }
760}