index/src/Colour/Colour.php

306 lines
9.5 KiB
PHP

<?php
// Colour.php
// Created: 2023-01-02
// Updated: 2023-11-09
namespace Index\Colour;
use Stringable;
use Index\XNumber;
/**
* Abstract class for representing colours.
*/
abstract class Colour implements Stringable {
/**
* Retrieves the Red RGB byte.
*
* @return int A number ranging from 0 to 255.
*/
public abstract function getRed(): int;
/**
* Retrieves the Green RGB byte.
*
* @return int A number ranging from 0 to 255.
*/
public abstract function getGreen(): int;
/**
* Retrieves the Blue RGB byte.
*
* @return int A number ranging from 0 to 255.
*/
public abstract function getBlue(): int;
/**
* Retrieves the alpha component.
*
* @return float A number ranging from 0.0 to 1.0.
*/
public abstract function getAlpha(): float;
/**
* Whether this colour should be ignored and another should be used instead.
*
* @return bool true if this colour should be ignored.
*/
public abstract function shouldInherit(): bool;
/**
* Returns this colour in a format CSS understands.
*
* @return string CSS compatible colour.
*/
public abstract function __toString(): string;
private const READABILITY_THRESHOLD = 186.0;
private const LUMINANCE_WEIGHT_RED = .299;
private const LUMINANCE_WEIGHT_GREEN = .587;
private const LUMINANCE_WEIGHT_BLUE = .114;
// luminance shit might need further review
/**
* Calculates the luminance value of this colour.
*
* @return float Luminance value for this colour.
*/
public function getLuminance(): float {
return self::LUMINANCE_WEIGHT_RED * $this->getRed()
+ self::LUMINANCE_WEIGHT_GREEN * $this->getGreen()
+ self::LUMINANCE_WEIGHT_BLUE * $this->getBlue();
}
/**
* Checks whether this colour is on the brighter side of things.
*
* @return bool true if this colour would not be very legible on a light background.
*/
public function isLight(): bool {
return $this->getLuminance() > self::READABILITY_THRESHOLD;
}
/**
* Checks whether this colour is on the darker side of things.
*
* @return bool true if this colour would not be very legible on a dark background.
*/
public function isDark(): bool {
return $this->getLuminance() <= self::READABILITY_THRESHOLD;
}
private static ColourNull $none;
/**
* Gets an empty colour.
*
* @return Colour A global instance of ColourNull.
*/
public static function none(): Colour {
return self::$none;
}
/**
* Mixes two colours.
* 0.0 -> Entirely $colour1
* 0.5 -> Midpoint between $colour1 and $colour2
* 1.0 -> Entirely $colour2
*
* @param Colour $colour1 Starting colour.
* @param Colour $colour2 Ending colour.
* @param float $weight Weight of $colour2.
* @return Colour Mixed colour.
*/
public static function mix(Colour $colour1, Colour $colour2, float $weight): Colour {
$weight = min(1, max(0, $weight));
if(XNumber::almostEquals($weight, 0.0))
return $colour1;
if(XNumber::almostEquals($weight, 1.0))
return $colour2;
if($colour1->shouldInherit() || $colour2->shouldInherit())
return self::none();
return new ColourRGB(
(int)round(XNumber::weighted($colour2->getRed(), $colour1->getRed(), $weight)),
(int)round(XNumber::weighted($colour2->getGreen(), $colour1->getGreen(), $weight)),
(int)round(XNumber::weighted($colour2->getBlue(), $colour1->getBlue(), $weight)),
XNumber::weighted($colour2->getAlpha(), $colour1->getAlpha(), $weight),
);
}
private const MSZ_INHERIT = 0x40000000;
/**
* Creates a Colour object from raw Misuzu format.
*
* If bit 31 is set the global instance of ColourNull will always be returned,
* otherwise an instance of ColourRGB will be created using the fromRawRGB method (top 8 bits ignored).
*
* @param int $raw Raw RGB colour in Misuzu format.
* @return Colour Colour instance representing the Misuzu colour.
*/
public static function fromMisuzu(int $raw): Colour {
if($raw & self::MSZ_INHERIT)
return self::$none;
return ColourRGB::fromRawRGB($raw);
}
/**
* Converts a Colour object to raw Misuzu format.
*
* If shouldInherit is true, an integer with only bit 31 will be returned,
* otherwise a raw RGB value will be returned.
*
* @param Colour $colour Colour to be converted to raw Misuzu format.
* @return int Raw Misuzu format colour.
*/
public static function toMisuzu(Colour $colour): int {
if($colour->shouldInherit())
return self::MSZ_INHERIT;
return ($colour->getRed() << 16) | ($colour->getGreen() << 8) | $colour->getBlue();
}
/**
* Attempts to parse a CSS format colour.
*
* @param ?string $value CSS format colour.
* @return Colour Parsed CSS colour.
*/
public static function parse(?string $value): Colour {
if($value === null)
return self::$none;
$value = strtolower(trim($value));
if($value === '' || $value === 'inherit')
return self::$none;
if(ctype_alpha($value) && ColourNamed::isValidName($value))
return new ColourNamed($value);
if($value[0] === '#') {
$value = substr($value, 1);
if(ctype_xdigit($value)) {
$length = strlen($value);
if($length === 3) {
$value = $value[0] . $value[0] . $value[1] . $value[1] . $value[2] . $value[2];
$length *= 2;
} elseif($length === 4) {
$value = $value[0] . $value[0] . $value[1] . $value[1] . $value[2] . $value[2] . $value[3] . $value[3];
$length *= 2;
}
if($length === 6)
return ColourRGB::fromRawRGB(hexdec($value));
if($length === 8)
return ColourRGB::fromRawRGBA(hexdec($value));
}
return self::$none;
}
if(str_starts_with($value, 'rgb(') || str_starts_with($value, 'rgba(')) {
$open = strpos($value, '(');
if($open === false)
return self::$none;
$close = strpos($value, ')', $open);
if($close === false)
return self::$none;
$open += 1;
$value = substr($value, $open, $close - $open);
if(strpos($value, ',') === false) {
// todo: support comma-less syntax
return self::$none;
} else {
$value = explode(',', $value, 4);
$parts = count($value);
if($parts !== 3 && $parts !== 4)
return self::$none;
$value[0] = (int)trim($value[0]);
$value[1] = (int)trim($value[1]);
$value[2] = (int)trim($value[2]);
$value[3] = (float)trim($value[3] ?? '1');
}
return new ColourRGB(...$value);
}
if(str_starts_with($value, 'hsl(') || str_starts_with($value, 'hsla(')) {
$open = strpos($value, '(');
if($open === false)
return self::$none;
$close = strpos($value, ')', $open);
if($close === false)
return self::$none;
$open += 1;
$value = substr($value, $open, $close - $open);
if(strpos($value, ',') === false) {
// todo: support comma-less syntax
return self::$none;
} else {
$value = explode(',', $value, 4);
$parts = count($value);
if($parts !== 3 && $parts !== 4)
return self::$none;
for($i = 0; $i < $parts; ++$i)
$value[$i] = trim($value[$i]);
if(str_ends_with($value[1], '%'))
$value[1] = substr($value[1], 0, -1);
if(str_ends_with($value[2], '%'))
$value[2] = substr($value[2], 0, -1);
$value[1] = (float)$value[1];
$value[2] = (float)$value[2];
if($value[1] < 0 || $value[1] > 100 || $value[2] < 0 || $value[2] > 100)
return self::$none;
$value[1] /= 100.0;
$value[2] /= 100.0;
if(ctype_digit($value[0])) {
$value[0] = (float)$value[0];
} else {
if(str_ends_with($value[0], 'deg')) {
$value[0] = (float)substr($value[0], 0, -3);
} elseif(str_ends_with($value[0], 'grad')) {
$value[0] = 0.9 * (float)substr($value[0], 0, -4);
} elseif(str_ends_with($value[0], 'rad')) {
$value[0] = round(rad2deg((float)substr($value[0], 0, -3)));
} elseif(str_ends_with($value[0], 'turn')) {
$value[0] = 360.0 * ((float)substr($value[0], 0, -4));
} else {
return self::$none;
}
}
$value[3] = (float)trim($value[3] ?? '1');
}
return new ColourHSL(...$value);
}
return self::$none;
}
/** @internal */
public static function init(): void {
self::$none = new ColourNull;
}
}
Colour::init();