← Back
Editing: Color.php
<?php declare(strict_types=1); namespace Sabberworm\CSS\Value; use Sabberworm\CSS\OutputFormat; use Sabberworm\CSS\Parsing\ParserState; use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; /** * `Color's can be input in the form #rrggbb, #rgb or schema(val1, val2, …) but are always stored as an array of * ('s' => val1, 'c' => val2, 'h' => val3, …) and output in the second form. */ class Color extends CSSFunction { /** * @param array<non-empty-string, Value|string> $colorValues * @param int<1, max>|null $lineNumber */ public function __construct(array $colorValues, ?int $lineNumber = null) { parent::__construct(\implode('', \array_keys($colorValues)), $colorValues, ',', $lineNumber); } /** * @throws UnexpectedEOFException * @throws UnexpectedTokenException * * @internal since V8.8.0 */ public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction { return $parserState->comes('#') ? self::parseHexColor($parserState) : self::parseColorFunction($parserState); } /** * @throws UnexpectedEOFException * @throws UnexpectedTokenException */ private static function parseHexColor(ParserState $parserState): Color { $parserState->consume('#'); $hexValue = $parserState->parseIdentifier(false); if ($parserState->strlen($hexValue) === 3) { $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2]; } elseif ($parserState->strlen($hexValue) === 4) { $hexValue = $hexValue[0] . $hexValue[0] . $hexValue[1] . $hexValue[1] . $hexValue[2] . $hexValue[2] . $hexValue[3] . $hexValue[3]; } if ($parserState->strlen($hexValue) === 8) { $colorValues = [ 'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()), 'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()), 'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()), 'a' => new Size( \round(self::mapRange(\intval($hexValue[6] . $hexValue[7], 16), 0, 255, 0, 1), 2), null, true, $parserState->currentLine() ), ]; } elseif ($parserState->strlen($hexValue) === 6) { $colorValues = [ 'r' => new Size(\intval($hexValue[0] . $hexValue[1], 16), null, true, $parserState->currentLine()), 'g' => new Size(\intval($hexValue[2] . $hexValue[3], 16), null, true, $parserState->currentLine()), 'b' => new Size(\intval($hexValue[4] . $hexValue[5], 16), null, true, $parserState->currentLine()), ]; } else { throw new UnexpectedTokenException( 'Invalid hex color value', $hexValue, 'custom', $parserState->currentLine() ); } return new Color($colorValues, $parserState->currentLine()); } /** * @throws UnexpectedEOFException * @throws UnexpectedTokenException */ private static function parseColorFunction(ParserState $parserState): CSSFunction { $colorValues = []; $colorMode = $parserState->parseIdentifier(true); $parserState->consumeWhiteSpace(); $parserState->consume('('); // CSS Color Module Level 4 says that `rgb` and `rgba` are now aliases; likewise `hsl` and `hsla`. // So, attempt to parse with the `a`, and allow for it not being there. switch ($colorMode) { case 'rgb': $colorModeForParsing = 'rgba'; $mayHaveOptionalAlpha = true; break; case 'hsl': $colorModeForParsing = 'hsla'; $mayHaveOptionalAlpha = true; break; case 'rgba': // This is handled identically to the following case. case 'hsla': $colorModeForParsing = $colorMode; $mayHaveOptionalAlpha = true; break; default: $colorModeForParsing = $colorMode; $mayHaveOptionalAlpha = false; } $containsVar = false; $containsNone = false; $isLegacySyntax = false; $expectedArgumentCount = $parserState->strlen($colorModeForParsing); for ($argumentIndex = 0; $argumentIndex < $expectedArgumentCount; ++$argumentIndex) { $parserState->consumeWhiteSpace(); $valueKey = $colorModeForParsing[$argumentIndex]; if ($parserState->comes('var')) { $colorValues[$valueKey] = CSSFunction::parseIdentifierOrFunction($parserState); $containsVar = true; } elseif (!$isLegacySyntax && $parserState->comes('none')) { $colorValues[$valueKey] = $parserState->parseIdentifier(); $containsNone = true; } else { $colorValues[$valueKey] = Size::parse($parserState, true); } // This must be done first, to consume comments as well, so that the `comes` test will work. $parserState->consumeWhiteSpace(); // With a `var` argument, the function can have fewer arguments. // And as of CSS Color Module Level 4, the alpha argument is optional. $canCloseNow = $containsVar || ($mayHaveOptionalAlpha && $argumentIndex >= $expectedArgumentCount - 2); if ($canCloseNow && $parserState->comes(')')) { break; } // "Legacy" syntax is comma-delimited, and does not allow the `none` keyword. // "Modern" syntax is space-delimited, with `/` as alpha delimiter. // They cannot be mixed. if ($argumentIndex === 0 && !$containsNone) { // An immediate closing parenthesis is not valid. if ($parserState->comes(')')) { throw new UnexpectedTokenException( 'Color function with no arguments', '', 'custom', $parserState->currentLine() ); } $isLegacySyntax = $parserState->comes(','); } if ($isLegacySyntax && $argumentIndex < ($expectedArgumentCount - 1)) { $parserState->consume(','); } // In the "modern" syntax, the alpha value must be delimited with `/`. if (!$isLegacySyntax) { if ($containsVar) { // If the `var` substitution encompasses more than one argument, // the alpha deliminator may come at any time. if ($parserState->comes('/')) { $parserState->consume('/'); } } elseif (($colorModeForParsing[$argumentIndex + 1] ?? '') === 'a') { // Alpha value is the next expected argument. // Since a closing parenthesis was not found, a `/` separator is now required. $parserState->consume('/'); } } } $parserState->consume(')'); return $containsVar ? new CSSFunction($colorMode, \array_values($colorValues), ',', $parserState->currentLine()) : new Color($colorValues, $parserState->currentLine()); } private static function mapRange(float $value, float $fromMin, float $fromMax, float $toMin, float $toMax): float { $fromRange = $fromMax - $fromMin; $toRange = $toMax - $toMin; $multiplier = $toRange / $fromRange; $newValue = $value - $fromMin; $newValue *= $multiplier; return $newValue + $toMin; } /** * @return array<non-empty-string, Value|string> */ public function getColor(): array { return $this->components; } /** * @param array<non-empty-string, Value|string> $colorValues */ public function setColor(array $colorValues): void { $this->setName(\implode('', \array_keys($colorValues))); $this->components = $colorValues; } /** * @return non-empty-string */ public function getColorDescription(): string { return $this->getName(); } /** * @return non-empty-string */ public function render(OutputFormat $outputFormat): string { if ($this->shouldRenderAsHex($outputFormat)) { return $this->renderAsHex(); } if ($this->shouldRenderInModernSyntax()) { return $this->renderInModernSyntax($outputFormat); } return parent::render($outputFormat); } /** * @return array<string, bool|int|float|string|array<mixed>|null> * * @internal */ public function getArrayRepresentation(): array { throw new \BadMethodCallException('`getArrayRepresentation` is not yet implemented for `' . self::class . '`'); } private function shouldRenderAsHex(OutputFormat $outputFormat): bool { return $outputFormat->usesRgbHashNotation() && $this->getRealName() === 'rgb' && $this->allComponentsAreNumbers(); } /** * The function name is a concatenation of the array keys of the components, which is passed to the constructor. * However, this can be changed by calling {@see CSSFunction::setName}, * so is not reliable in situations where it's necessary to determine the function name based on the components. */ private function getRealName(): string { return \implode('', \array_keys($this->components)); } /** * Test whether all color components are absolute numbers (CSS type `number`), not percentages or anything else. * If any component is not an instance of `Size`, the method will also return `false`. */ private function allComponentsAreNumbers(): bool { foreach ($this->components as $component) { if (!($component instanceof Size) || $component->getUnit() !== null) { return false; } } return true; } /** * Note that this method assumes the following: * - The `components` array has keys for `r`, `g` and `b`; * - The values in the array are all instances of `Size`. * * Errors will be triggered or thrown if this is not the case. * * @return non-empty-string */ private function renderAsHex(): string { $result = \sprintf( '%02x%02x%02x', $this->components['r']->getSize(), $this->components['g']->getSize(), $this->components['b']->getSize() ); $canUseShortVariant = ($result[0] === $result[1]) && ($result[2] === $result[3]) && ($result[4] === $result[5]); return '#' . ($canUseShortVariant ? $result[0] . $result[2] . $result[4] : $result); } /** * The "legacy" syntax does not allow RGB colors to have a mixture of `percentage`s and `number`s, * and does not allow `none` as any component value. * * The "legacy" and "modern" monikers are part of the formal W3C syntax. * See the following for more information: * - {@link * https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb#formal_syntax * Description of the formal syntax for `rgb()` on MDN * }; * - {@link * https://www.w3.org/TR/css-color-4/#rgb-functions * The same in the CSS Color Module Level 4 W3C Candidate Recommendation Draft * } (as of 13 February 2024, at time of writing). */ private function shouldRenderInModernSyntax(): bool { if ($this->hasNoneAsComponentValue()) { return true; } if (!$this->colorFunctionMayHaveMixedValueTypes($this->getRealName())) { return false; } $hasPercentage = false; $hasNumber = false; foreach ($this->components as $key => $value) { if ($key === 'a') { // Alpha can have units that don't match those of the RGB components in the "legacy" syntax. // So it is not necessary to check it. It's also always last, hence `break` rather than `continue`. break; } if (!($value instanceof Size)) { // Unexpected, unknown, or modified via the API return false; } $unit = $value->getUnit(); // `switch` only does loose comparison if ($unit === null) { $hasNumber = true; } elseif ($unit === '%') { $hasPercentage = true; } else { // Invalid unit return false; } } return $hasPercentage && $hasNumber; } private function hasNoneAsComponentValue(): bool { return \in_array('none', $this->components, true); } /** * Some color functions, such as `rgb`, * may have a mixture of `percentage`, `number`, or possibly other types in their arguments. * * Note that this excludes the alpha component, which is treated separately. */ private function colorFunctionMayHaveMixedValueTypes(string $function): bool { $functionsThatMayHaveMixedValueTypes = ['rgb', 'rgba']; return \in_array($function, $functionsThatMayHaveMixedValueTypes, true); } /** * @return non-empty-string */ private function renderInModernSyntax(OutputFormat $outputFormat): string { // Maybe not yet without alpha, but will be... $componentsWithoutAlpha = $this->components; \end($componentsWithoutAlpha); if (\key($componentsWithoutAlpha) === 'a') { $alpha = $this->components['a']; unset($componentsWithoutAlpha['a']); } $formatter = $outputFormat->getFormatter(); $arguments = $formatter->implode(' ', $componentsWithoutAlpha); if (isset($alpha)) { $separator = $formatter->spaceBeforeListArgumentSeparator('/') . '/' . $formatter->spaceAfterListArgumentSeparator('/'); $arguments = $formatter->implode($separator, [$arguments, $alpha]); } return $this->getName() . '(' . $arguments . ')'; } }
Save File
Cancel