Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.04% |
284 / 302 |
|
86.11% |
31 / 36 |
CRAP | |
0.00% |
0 / 1 |
| ColorConverter | |
94.04% |
284 / 302 |
|
86.11% |
31 / 36 |
143.09 | |
0.00% |
0 / 1 |
| toSrgb | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
19 | |||
| labToSrgb | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| lchToSrgbGamutMapped | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| oklchToSrgbGamutMapped | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| fromHwb | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| fromHsl | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| hueToSrgb | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| hslToSrgb | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
8.02 | |||
| labToXyzD50 | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| lchToLab | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| fromOklab | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| fromXyzD65 | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| fromXyzD50 | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| fromWideRgb | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
12 | |||
| fromLinearSrgb | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| srgbGamma | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| srgbDegamma | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| rec2020Degamma | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| clip01 | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| mul3 | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| interpolate | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
8 | |||
| interpolateSrgb | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| toSpaceCoords | |
97.44% |
38 / 39 |
|
0.00% |
0 / 1 |
14 | |||
| hueChannel | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| hueIsPowerless | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| adjustHuePair | |
53.57% |
15 / 28 |
|
0.00% |
0 / 1 |
37.52 | |||
| srgbToXyzD65 | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| srgbToDisplayP3 | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| srgbToOklab | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| srgbToLab | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| xyzD50ToLab | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| labToLch | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| srgbToHsl | |
84.62% |
11 / 13 |
|
0.00% |
0 / 1 |
6.13 | |||
| srgbToHwb | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| cbrt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| invert3x3 | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 31 | final 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 | } |