Search by

bugo / iris

Bugo

Color spaces, any-to-any conversions and serialization for legacy and modern CSS colors

0.4 2026-09-14 02:34 UTC

This package is auto-updated.

Last update: 2026-09-14 02:53:38 UTC


README

PHP Coverage Status

По-русски

Color space conversions, serialization, and operations for legacy and modern CSS colors.

Named after Iris, the goddess of the rainbow in Greek mythology.

Installation

composer require bugo/iris

Color spaces

Iris supports the legacy CSS spaces and the modern color spaces used by lab(), lch(), oklab(), oklch(), and color(...).

Class Space Channels
RgbColor rgb r, g, b (0-1), a (0-1)
HslColor hsl h (0-360), s (0-100), l (0-100), a (0-1)
HwbColor hwb h (0-360), w (0-100), b (0-100), a (0-1)
LabColor lab l (0-100), a, b, alpha (0-1)
LchColor lch l (0-100), c, h (0-360), alpha (0-1)
OklabColor oklab l (0-100), a, b, alpha (0-1)
OklchColor oklch l (0-100), c (0-0.4), h (0-360), a (0-1)
XyzColor xyz-* x, y, z

XyzColor is reused for both xyz-d65 and xyz-d50; the white point depends on the method or route you call.

Channel scales and conventions

RgbColor channels are always normalized to 0-1, on every API surface. The same object can be passed to SpaceConverter, CssSerializer, LiteralSerializer, ColorMixResolver, and the manipulators without rescaling, and rgbToX() / xToRgb() pairs are exact inverses of each other.

Bytes appear only at the input and output boundary:

  • LiteralParser accepts byte-based hex literals and named colors, and returns normalized RgbColor.
  • LiteralSerializer and HexEncoder convert normalized channels back to hex bytes.
  • NamedColors::NAMED_RGB stores byte-like channel values, since it mirrors the CSS named color table.

Other spaces keep the scale their CSS function uses: HslColor and HwbColor use percentage-like 0-100 channels, LabColor, LchColor, OklabColor and OklchColor store lightness on 0-100, and chroma or a/b axes stay on their native numeric ranges.

SpaceRouter routes by string space name on top of the channel API. It is best suited for CSS color(<space> ...) flows, where spaces such as srgb, display-p3, rec2020, and xyz-* are passed around as normalized channel triples. lab, lch, oklab, and oklch are also accepted for symmetry, but the typed SpaceConverter methods are usually clearer when you already know the target space at compile time.

Migrating from 0.3

RgbColor no longer accepts byte channels. Divide existing values by 255, or let LiteralParser do it:

// before
$color = new RgbColor(r: 255.0, g: 128.0, b: 0.0, a: 1.0);

// after
$color = new RgbColor(r: 1.0, g: 0.502, b: 0.0, a: 1.0);
// or
$color = (new LiteralParser())->toRgb('#ff8000');

Other breaking changes:

  • CssSerializer::toCss() now emits a percentage lightness for oklab() and oklch(), matching what it already did for lab() and lch(). The previous bare-number form was not valid CSS.
  • ColorMixResolver::mixHsl(), mixLab(), mixLch(), mixOklab() and mixOklch() gained a $premultiplied flag, mirroring mixSrgb(). Serializer enables it, so color-mix() results change for colors with alpha below 1.
  • color-mix(in srgb-linear, ...) now interpolates in linear-light sRGB instead of gamma-encoded sRGB.
  • Percentage channels in lch(), oklab() and oklch() are resolved against the CSS Color 4 reference ranges instead of being dropped.
  • SpaceConverter::normalizedChannelsToOklch() is deprecated in favor of rgbToOklch(); both now behave identically.

When to use what

  • SpaceConverter: typed color math and direct conversions between concrete spaces.
  • SpaceRouter: dispatch from a string space name to RGBA or XYZ D65.
  • Serializer: normalize CSS color strings and optionally convert supported functions to hex.
  • CssSerializer: serialize typed color objects back to CSS functions.
  • LiteralConverter and LiteralSerializer: convert hex and named colors to or from RgbColor.
  • LegacyManipulator, PerceptualManipulator, SrgbManipulator: adjust, mix, and transform colors at different abstraction levels.

Usage

Creating color objects

use Bugo\Iris\Spaces\HslColor;
use Bugo\Iris\Spaces\OklchColor;
use Bugo\Iris\Spaces\RgbColor;

$red   = new RgbColor(r: 1.0, g: 0.0, b: 0.0, a: 1.0);
$green = new HslColor(h: 120.0, s: 100.0, l: 50.0, a: 1.0);
$blue  = new OklchColor(l: 45.2, c: 0.313, h: 264.1, a: 1.0);

Converting between color spaces

use Bugo\Iris\Converters\SpaceConverter;
use Bugo\Iris\Spaces\RgbColor;

$converter = new SpaceConverter();
$rgb       = new RgbColor(r: 1.0, g: 0.5, b: 0.0, a: 1.0);

// RGB -> OKLCh object
$oklch = $converter->rgbToOklch($rgb);
echo $oklch->l; // ~73 on the 0-100 lightness scale
echo $oklch->h; // ~53

// RGB -> XYZ D65 object
$xyz = $converter->rgbToXyzD65($rgb);
echo $xyz->x;

// HSL channels -> RGB channels (returns [r, g, b] as normalized floats)
[$r, $g, $b] = $converter->hslToRgb(30.0, 1.0, 0.5);

// sRGB channels -> XYZ D65 object
$xyzFromChannels = $converter->srgbToXyzD65(1.0, 0.5, 0.0);

The *Channels* methods are the low-level channel API. Methods that accept color objects such as RgbColor or OklchColor remain object-oriented entry points; both operate on the same normalized sRGB channels, so rgbToOklch() and oklchToRgb() round-trip exactly.

Routing by space name

use Bugo\Iris\SpaceRouter;
use Bugo\Iris\Exceptions\UnsupportedColorSpace;

$router = new SpaceRouter();

try {
    $rgba = $router->convertToRgba('display-p3', 1.0, 0.5, 0.0, 1.0);
    echo $rgba->r; // normalized 0-1
} catch (UnsupportedColorSpace $e) {
    // unknown color space
}

$xyz = $router->convertToXyzD65('rec2020', 0.4, 0.3, 0.2);
echo $xyz->y;

Color manipulations

use Bugo\Iris\Manipulators\LegacyManipulator;
use Bugo\Iris\Spaces\RgbColor;

$manipulator = new LegacyManipulator();
$color       = new RgbColor(r: 0.784, g: 0.392, b: 0.196, a: 1.0);

$gray      = $manipulator->grayscale($color);
$mixed     = $manipulator->mix($color, new RgbColor(0.0, 0.588, 1.0, 1.0), 0.5);
$darker    = $manipulator->darken($color, 10.0);
$saturated = $manipulator->saturate($color, 20.0);
$rotated   = $manipulator->spin($color, 30.0);

Gamut mapping

GamutMapper maps out-of-sRGB-gamut colors back into gamut using two algorithms from CSS Color Level 4.

use Bugo\Iris\Operations\GamutMapper;
use Bugo\Iris\Spaces\OklchColor;

$mapper = new GamutMapper();
$oklch  = new OklchColor(l: 70.0, c: 0.35, h: 30.0, a: 1.0);

$clipped = $mapper->clip($oklch);
$mapped  = $mapper->localMinde($oklch);

Both methods accept and return OklchColor. For other spaces, convert to OklchColor first.

CSS color-mix() interpolation

ColorMixResolver implements CSS Color Level 4 interpolation rules, including none channel handling and all four hue interpolation methods.

use Bugo\Iris\Operations\ColorMixResolver;
use Bugo\Iris\Spaces\OklchColor;
use Bugo\Iris\Spaces\RgbColor;

$resolver = new ColorMixResolver();

$mixSrgb = $resolver->mixSrgb(
    new RgbColor(r: 1.0, g: 0.0, b: 0.0, a: 1.0),
    new RgbColor(r: 0.0, g: 0.0, b: 1.0, a: 1.0),
    0.5,
);

$mixOklch = $resolver->mixOklch(
    new OklchColor(l: 70.0, c: 0.2, h: 30.0, a: 1.0),
    new OklchColor(l: 50.0, c: 0.1, h: 200.0, a: 1.0),
    0.5,
    hueMethod: 'shorter',
);

If one side uses null for a channel, the other side wins instead of interpolating. If both sides are null, the result stays null.

Every mix*() method takes an optional $premultiplied flag. Per CSS Color Level 4, interpolation should happen in premultiplied form when alpha differs between the two colors; the hue channel of cylindrical spaces is never premultiplied. Serializer enables the flag when resolving color-mix().

Hex encoding

use Bugo\Iris\Encoders\HexEncoder;
use Bugo\Iris\Encoders\HexNormalizer;
use Bugo\Iris\Encoders\HexShortener;

$encoder    = new HexEncoder();
$shortener  = new HexShortener();
$normalizer = new HexNormalizer();

$hex   = $encoder->encodeRgb(255, 128, 0);       // '#ff8000'
$hexA  = $encoder->encodeRgba(255, 128, 0, 255); // '#ff8000ff'
$short = $shortener->shorten('#aabbcc');         // '#abc'
$norm  = $normalizer->normalize('#AABBCC');      // '#abc'

Parsing CSS color literals

use Bugo\Iris\LiteralParser;
use Bugo\Iris\Serializers\LiteralSerializer;
use Bugo\Iris\Spaces\RgbColor;

$converter  = new LiteralParser();
$serializer = new LiteralSerializer();

$rgbFromHex  = $converter->toRgb('#ff8000');
$rgbFromName = $converter->toRgb('tomato');

echo $serializer->serialize(new RgbColor(r: 1.0, g: 0.0, b: 0.0, a: 1.0));   // 'red'
echo $serializer->serialize(new RgbColor(r: 1.0, g: 0.502, b: 0.0, a: 1.0)); // '#ff8000'

Serializer vs CssSerializer

Use Serializer when the input is already a CSS string and you want normalization or optional hex conversion.

use Bugo\Iris\Serializers\Serializer;

$serializer = new Serializer();

echo $serializer->serialize('#AABBCC', false);          // '#abc'
echo $serializer->serialize('rgb(255, 128, 0)', true); // '#ff8000'
echo $serializer->serialize('rgb(255, 128, 0)', false); // 'rgb(255, 128, 0)'

Use CssSerializer when the input is a typed color object and you want a CSS function string.

use Bugo\Iris\Serializers\CssSerializer;
use Bugo\Iris\Spaces\HslColor;
use Bugo\Iris\Spaces\LabColor;
use Bugo\Iris\Spaces\LchColor;
use Bugo\Iris\Spaces\OklabColor;
use Bugo\Iris\Spaces\OklchColor;
use Bugo\Iris\Spaces\XyzColor;

$serializer = new CssSerializer();
$oklch      = new OklchColor(l: 70.0, c: 0.15, h: 55.0, a: 1.0);

echo $serializer->toCss($oklch);       // 'oklch(70% 0.15 55)'
echo $serializer->toCss($oklch, true); // still serialized as a CSS color string

$hsl   = new HslColor(h: 30.0, s: 100.0, l: 50.0, a: 0.8);
$lab   = new LabColor(l: 50.0, a: 20.0, b: -30.0, alpha: 1.0);
$lch   = new LchColor(l: 70.0, c: 30.0, h: 180.0, alpha: 1.0);
$oklab = new OklabColor(l: 50.0, a: 0.1, b: -0.05, alpha: 1.0);
$xyz   = new XyzColor(x: 0.9505, y: 1.0, z: 1.0890);

echo $serializer->toCss($hsl);   // 'hsl(30 100% 50% / 0.80)'
echo $serializer->toCss($lab);   // 'lab(50% 20 -30)'
echo $serializer->toCss($lch);   // 'lch(70% 30 180)'
echo $serializer->toCss($oklab); // 'oklab(50% 0.1 -0.05)'
echo $serializer->toCss($xyz);   // 'color(xyz-d65 0.9505 1 1.089)'

If you specifically need hex from an RgbColor, call CssSerializer::toHex() or LiteralSerializer. Both read the same normalized channels; LiteralSerializer additionally prefers a CSS named color when one matches exactly.

Model conversion

use Bugo\Iris\Converters\ModelConverter;
use Bugo\Iris\Spaces\RgbColor;

$converter = new ModelConverter();
$rgb       = new RgbColor(r: 1.0, g: 0.5, b: 0.0, a: 1.0);
$hsl       = $converter->rgbToHslColor($rgb);
$rgbBack   = $converter->hslToRgbColor($hsl);

Perceptual manipulations

use Bugo\Iris\Manipulators\PerceptualManipulator;
use Bugo\Iris\Spaces\LabColor;
use Bugo\Iris\Spaces\OklchColor;

$manipulator = new PerceptualManipulator();

$adjusted = $manipulator->adjustOklch(
    new OklchColor(l: 70.0, c: 0.15, h: 55.0, a: 1.0),
    ['lightness' => 10.0, 'chroma' => -0.05, 'hue' => 20.0],
);

$labChanged = $manipulator->changeLab(
    new LabColor(l: 50.0, a: 20.0, b: -30.0, alpha: 1.0),
    ['lightness' => 70.0, 'alpha' => 0.5],
);

Linear RGB manipulations

use Bugo\Iris\Manipulators\SrgbManipulator;

$manipulator = new SrgbManipulator();

$adjusted = $manipulator->adjust(
    red: 1.0,
    green: 0.5,
    blue: 0.0,
    values: ['red' => -0.1, 'green' => 0.1, 'blue' => 0.05],
);

Wide-gamut color spaces

use Bugo\Iris\Converters\SpaceConverter;
use Bugo\Iris\Spaces\RgbColor;
use Bugo\Iris\Spaces\XyzColor;

$converter = new SpaceConverter();
$rgb       = new RgbColor(r: 1.0, g: 0.5, b: 0.0, a: 1.0);

[$p3R, $p3G, $p3B] = $converter->rgbToP3Channels($rgb);

$a98      = $converter->rgbToA98Channels($rgb);
$prophoto = $converter->rgbToProphotoChannels($rgb);
$rec2020  = $converter->rgbToRec2020Channels($rgb);

$xyz       = new XyzColor(x: 0.5, y: 0.4, z: 0.2);
$p3FromXyz = $converter->xyzD65ToP3Channels($xyz);

Polar math utilities

use Bugo\Iris\Operations\PolarMath;

$math = new PolarMath();

[$a, $b] = $math->toCartesian(chroma: 0.2, hue: 55.0);
$radians = $math->toRadians(180.0); // pi

Named colors

use Bugo\Iris\NamedColors;

$tomatoRgb = NamedColors::NAMED_RGB['tomato']; // [255.0, 99.0, 71.0]
$redRgb    = NamedColors::NAMED_RGB['red'];    // [255.0, 0.0, 0.0]

$hex = NamedColors::toHex('tomato');      // '#ff6347'
$hex = NamedColors::toHex('transparent'); // '#00000000'

NamedColors::isNamedColor('tomato'); // true
$names = NamedColors::getNames();

NamedColors::NAMED_RGB stores byte-like channel values, mirroring the CSS named color table. Use LiteralParser::toRgb() to get a normalized RgbColor from a name.

ColorValueInterface

All Spaces/* classes implement Bugo\Iris\Contracts\ColorValueInterface.

use Bugo\Iris\Contracts\ColorValueInterface;
use Bugo\Iris\Spaces\OklchColor;

function describeColor(ColorValueInterface $color): string
{
    return sprintf(
        'Space: %s, channels: [%s], alpha: %s',
        $color->getSpace(),
        implode(', ', $color->getChannels()),
        $color->getAlpha(),
    );
}

echo describeColor(new OklchColor(l: 70.0, c: 0.15, h: 55.0, a: 1.0));

Exceptions

use Bugo\Iris\Exceptions\IrisException;
use Bugo\Iris\Exceptions\InvalidColorChannel;
use Bugo\Iris\Exceptions\InvalidColorFormat;
use Bugo\Iris\Exceptions\UnsupportedColorSpace;

All exceptions extend IrisException, which extends \RuntimeException.

  • UnsupportedColorSpace: unknown space passed to SpaceRouter or other string-based conversion entry points.
  • InvalidColorFormat: malformed CSS color literals, unsupported function syntax, or invalid serialization input.
  • InvalidColorChannel: out-of-domain or malformed channel values for APIs that validate channel content.

Comparison with other implementations

See comparisons_results.md for the results.

Useful links