Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.40% |
165 / 166 |
|
95.00% |
19 / 20 |
CRAP | |
0.00% |
0 / 1 |
| ColorConverter | |
99.40% |
165 / 166 |
|
95.00% |
19 / 20 |
80 | |
0.00% |
0 / 1 |
| toSrgb | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
18 | |||
| labToSrgb | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| lchToSrgbGamutMapped | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| oklchToSrgbGamutMapped | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| gamutMapByChroma | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
9 | |||
| fromHwb | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| 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 | |||
| 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::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 | } |