meyfa/php-svg

View on GitHub
src/Utilities/Colors/Color.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php

namespace SVG\Utilities\Colors;

use SVG\Shims\Str;
use SVG\Utilities\Units\Angle;

final class Color
{
    /**
     * Converts any valid SVG color string into an array of RGBA components.
     *
     * All of the components are ints 0-255.
     *
     * @param string $color The color string to convert, as specified in SVG.
     *
     * @return int[] The color converted to RGBA components.
     */
    public static function parse(string $color): array
    {
        $lookupResult = ColorLookup::get($color);
        if (isset($lookupResult)) {
            return $lookupResult;
        }

        // pass on to dedicated functions depending on notation
        if (preg_match('/^#([0-9A-F]+)$/i', $color, $matches)) {
            [$r, $g, $b, $a] = self::parseHexComponents($matches[1]);
        } elseif (preg_match('/^rgba?\((.*)\)$/', $color, $matches)) {
            [$r, $g, $b, $a] = self::parseRGBAComponents($matches[1]);
        } elseif (preg_match('/^hsla?\((.*)\)$/', $color, $matches)) {
            [$r, $g, $b, $a] = self::parseHSLAComponents($matches[1]);
        }

        // any illegal component invalidates all components
        if (!isset($r) || !isset($g) || !isset($b) || !isset($a)) {
            return [0, 0, 0, 0];
        }

        return self::clamp($r, $g, $b, $a);
    }

    /**
     * Clamps the RGBA components into the range 0 - 255 (inclusive). All values
     * are converted to integers.
     *
     * @param float $r
     * @param float $g
     * @param float $b
     * @param float $a
     * @return int[] The clamped integer components array.
     */
    private static function clamp(float $r, float $g, float $b, float $a): array
    {
        return [
            $r < 0 ? 0 : ($r > 255 ? 255 : (int) $r),
            $g < 0 ? 0 : ($g > 255 ? 255 : (int) $g),
            $b < 0 ? 0 : ($b > 255 ? 255 : (int) $b),
            $a < 0 ? 0 : ($a > 255 ? 255 : (int) $a),
        ];
    }

    /**
     * Takes a hex string of length 3, 4, 6 or 8 and converts it into an array
     * of floating-point RGBA components.
     *
     * For strings of invalid length, all components will be null.
     *
     * @param string $str The hexadecimal color string to convert.
     *
     * @return array The RGBA components (0 - 255).
     */
    private static function parseHexComponents(string $str): array
    {
        $len = strlen($str);

        $r = $g = $b = $a = null;

        if ($len === 6 || $len === 8) {
            $r = hexdec($str[0] . $str[1]);
            $g = hexdec($str[2] . $str[3]);
            $b = hexdec($str[4] . $str[5]);
            $a = $len === 8 ? hexdec($str[6] . $str[7]) : 255;
        } elseif ($len === 3 || $len === 4) {
            $r = hexdec($str[0] . $str[0]);
            $g = hexdec($str[1] . $str[1]);
            $b = hexdec($str[2] . $str[2]);
            $a = $len === 4 ? hexdec($str[3] . $str[3]) : 255;
        }

        return [$r, $g, $b, $a];
    }

    /**
     * Takes a parameter string from the rgba functional notation
     * (i.e., the 'x' inside 'rgb(x)') and converts it into an array of
     * floating-point RGBA components.
     *
     * If any of the components could not be deduced, that component will be
     * null. No other component will be influenced.
     *
     * @param string $str The parameter string to convert.
     *
     * @return array The RGBA components.
     */
    private static function parseRGBAComponents(string $str): array
    {
        $params = preg_split('/(\s*[\/,]\s*)|(\s+)/', Str::trim($str));
        if (count($params) !== 3 && count($params) !== 4) {
            return [null, null, null, null];
        }

        $r = self::parseRGBAComponent($params[0]);
        $g = self::parseRGBAComponent($params[1]);
        $b = self::parseRGBAComponent($params[2]);
        $a = count($params) < 4 ? 255 : self::parseRGBAComponent($params[3], 1, 255);

        return [$r, $g, $b, $a];
    }

    /**
     * Converts a single numeric color component (e.g. '10.5' or '20%') into a
     * floating-point value.
     *
     * The optional base argument represents 100%. It should be set to 255 for
     * the RGB components and to 1 for the A component.
     *
     * The optional scalar argument is the multiplier applied to the result. It
     * should be set to 1 for the RGB components (since they are already in the
     * correct final range) and to 255 for the A component (since it would
     * otherwise be between 0 and 1).
     *
     * @param string $str    The component string.
     * @param int    $base   The base value for percentages.
     * @param int    $scalar A multiplier for the final value.
     *
     * @return float|null The floating-point converted component, or null on failure.
     */
    private static function parseRGBAComponent(string $str, int $base = 255, int $scalar = 1): ?float
    {
        $regex = '/^([+-]?(?:\d+|\d*\.\d+))(%)?$/';
        if (!preg_match($regex, $str, $matches)) {
            return null;
        }
        if (isset($matches[2]) && $matches[2] === '%') {
            return (float) $matches[1] * $base / 100 * $scalar;
        }
        return (float) $matches[1] * $scalar;
    }

    /**
     * Takes a parameter string from the hsla functional notation
     * (i.e., the 'x' inside 'hsl(x)') and converts it into an array of
     * floating-point RGBA components.
     *
     * If any of the components could not be deduced, that component will be
     * null. No other component will be influenced.
     *
     * @param string $str The parameter string to convert.
     *
     * @return array The RGBA components.
     */
    private static function parseHSLAComponents(string $str): array
    {
        // split on delimiters
        $params = preg_split('/(\s*[\/,]\s*)|(\s+)/', Str::trim($str));
        if (count($params) !== 3 && count($params) !== 4) {
            return [null, null, null, null];
        }

        // parse HSL
        $h = Angle::convert($params[0]);
        $s = self::parseRGBAComponent($params[1], 1);
        $l = self::parseRGBAComponent($params[2], 1);

        // convert HSL to RGB
        $r = $g = $b = null;
        if (isset($h) && isset($s) && isset($l)) {
            [$r, $g, $b] = self::convertHSLtoRGB($h, $s, $l);
        }
        // add alpha
        $a = count($params) < 4 ? 255 : self::parseRGBAComponent($params[3], 1, 255);

        return [$r, $g, $b, $a];
    }

    /**
     * Takes three arguments H (0 - 360), S (0 - 1), L (0 - 1) and converts them
     * to RGB components (0 - 255).
     *
     * @param float $h The hue.
     * @param float $s The saturation.
     * @param float $l The lightness.
     *
     * @return float[] An RGB array with values ranging from 0 - 255 each.
     */
    private static function convertHSLtoRGB(float $h, float $s, float $l): array
    {
        $s = min(max($s, 0), 1);
        $l = min(max($l, 0), 1);

        if ((float) $s === 0.0) {
            // shortcut if grayscale
            return [$l * 255, $l * 255, $l * 255];
        }

        // compute intermediates
        $m2 = ($l <= 0.5) ? ($l * (1 + $s)) : ($l + $s - $l * $s);
        $m1 = 2 * $l - $m2;

        // convert intermediates + hue to components
        $r = self::convertHSLHueToRGBComponent($m1, $m2, $h + 120);
        $g = self::convertHSLHueToRGBComponent($m1, $m2, $h);
        $b = self::convertHSLHueToRGBComponent($m1, $m2, $h - 120);

        return [$r, $g, $b];
    }

    /**
     * Takes the two intermediate values from `convertHSLtoRGB()` and the hue,
     * and computes the component's value.
     *
     * @param float $m1  Intermediate 1.
     * @param float $m2  Intermediate 2.
     * @param float $hue The hue, adapted to the component.
     *
     * @return float The component's value (0 - 255).
     */
    private static function convertHSLHueToRGBComponent(float $m1, float $m2, float $hue): float
    {
        // bring hue into range (fmod assures that 0 <= abs($hue) < 360, while
        // the next step assures that it's positive)
        $hue = fmod($hue, 360);
        if ($hue < 0) {
            $hue += 360;
        }

        $v = $m1;

        if ($hue < 60) {
            $v = $m1 + ($m2 - $m1) * $hue / 60;
        } elseif ($hue < 180) {
            $v = $m2;
        } elseif ($hue < 240) {
            $v = $m1 + ($m2 - $m1) * (240 - $hue) / 60;
        }

        return $v * 255;
    }
}