allow vendord
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m3s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 32s
Build, Push and Deploy / deploy-production (push) Has been skipped
Build, Push and Deploy / cleanup (push) Successful in 1s

This commit is contained in:
2026-03-30 14:54:57 +07:00
parent 66aed7c4e8
commit b5e3a778ce
21316 changed files with 2777892 additions and 13 deletions

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
/**
* A `CSSFunction` represents a special kind of value that also contains a function name and where the values are the
* functions arguments. It also handles equals-sign-separated argument lists like `filter: alpha(opacity=90);`.
*/
class CSSFunction extends ValueList
{
/**
* @var non-empty-string
*
* @internal since 8.8.0
*/
protected $name;
/**
* @param non-empty-string $name
* @param RuleValueList|array<Value|string> $arguments
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $name, $arguments, string $separator = ',', ?int $lineNumber = null)
{
if ($arguments instanceof RuleValueList) {
$separator = $arguments->getListSeparator();
$arguments = $arguments->getListComponents();
}
$this->name = $name;
$this->setPosition($lineNumber); // TODO: redundant?
parent::__construct($arguments, $separator, $lineNumber);
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
{
$name = self::parseName($parserState, $ignoreCase);
$parserState->consume('(');
$arguments = self::parseArguments($parserState);
$result = new CSSFunction($name, $arguments, ',', $parserState->currentLine());
$parserState->consume(')');
return $result;
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseName(ParserState $parserState, bool $ignoreCase = false): string
{
return $parserState->parseIdentifier($ignoreCase);
}
/**
* @return Value|string
*
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseArguments(ParserState $parserState)
{
return Value::parseValue($parserState, ['=', ' ', ',']);
}
/**
* @return non-empty-string
*/
public function getName(): string
{
return $this->name;
}
/**
* @param non-empty-string $name
*/
public function setName(string $name): void
{
$this->name = $name;
}
/**
* @return array<Value|string>
*/
public function getArguments(): array
{
return $this->components;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$arguments = parent::render($outputFormat);
return "{$this->name}({$arguments})";
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
/**
* This class is a wrapper for quoted strings to distinguish them from keywords.
*
* `CSSString`s always output with double quotes.
*/
class CSSString extends PrimitiveValue
{
/**
* @var string
*/
private $string;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $string, ?int $lineNumber = null)
{
$this->string = $string;
parent::__construct($lineNumber);
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): CSSString
{
$begin = $parserState->peek();
$quote = null;
if ($begin === "'") {
$quote = "'";
} elseif ($begin === '"') {
$quote = '"';
}
if ($quote !== null) {
$parserState->consume($quote);
}
$result = '';
$content = null;
if ($quote === null) {
// Unquoted strings end in whitespace or with braces, brackets, parentheses
while (\preg_match('/[\\s{}()<>\\[\\]]/isu', $parserState->peek()) !== 1) {
$result .= $parserState->parseCharacter(false);
}
} else {
while (!$parserState->comes($quote)) {
$content = $parserState->parseCharacter(false);
if ($content === null) {
throw new SourceException(
"Non-well-formed quoted string {$parserState->peek(3)}",
$parserState->currentLine()
);
}
$result .= $content;
}
$parserState->consume($quote);
}
return new CSSString($result, $parserState->currentLine());
}
public function setString(string $string): void
{
$this->string = $string;
}
public function getString(): string
{
return $this->string;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$string = \addslashes($this->string);
$string = \str_replace("\n", '\\A', $string);
return $outputFormat->getStringQuotingType() . $string . $outputFormat->getStringQuotingType();
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
class CalcFunction extends CSSFunction
{
/**
* @var int
*/
private const T_OPERAND = 1;
/**
* @var int
*/
private const T_OPERATOR = 2;
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $ignoreCase = false): CSSFunction
{
$operators = ['+', '-', '*', '/'];
$function = $parserState->parseIdentifier();
if ($parserState->peek() !== '(') {
// Found ; or end of line before an opening bracket
throw new UnexpectedTokenException('(', $parserState->peek(), 'literal', $parserState->currentLine());
} elseif ($function !== 'calc') {
// Found invalid calc definition. Example calc (...
throw new UnexpectedTokenException('calc', $function, 'literal', $parserState->currentLine());
}
$parserState->consume('(');
$calcRuleValueList = new CalcRuleValueList($parserState->currentLine());
$list = new RuleValueList(',', $parserState->currentLine());
$nestingLevel = 0;
$lastComponentType = null;
while (!$parserState->comes(')') || $nestingLevel > 0) {
if ($parserState->isEnd() && $nestingLevel === 0) {
break;
}
$parserState->consumeWhiteSpace();
if ($parserState->comes('(')) {
$nestingLevel++;
$calcRuleValueList->addListComponent($parserState->consume(1));
$parserState->consumeWhiteSpace();
continue;
} elseif ($parserState->comes(')')) {
$nestingLevel--;
$calcRuleValueList->addListComponent($parserState->consume(1));
$parserState->consumeWhiteSpace();
continue;
}
if ($lastComponentType !== CalcFunction::T_OPERAND) {
$value = Value::parsePrimitiveValue($parserState);
$calcRuleValueList->addListComponent($value);
$lastComponentType = CalcFunction::T_OPERAND;
} else {
if (\in_array($parserState->peek(), $operators, true)) {
if (($parserState->comes('-') || $parserState->comes('+'))) {
if (
$parserState->peek(1, -1) !== ' '
|| !($parserState->comes('- ')
|| $parserState->comes('+ '))
) {
throw new UnexpectedTokenException(
" {$parserState->peek()} ",
$parserState->peek(1, -1) . $parserState->peek(2),
'literal',
$parserState->currentLine()
);
}
}
$calcRuleValueList->addListComponent($parserState->consume(1));
$lastComponentType = CalcFunction::T_OPERATOR;
} else {
throw new UnexpectedTokenException(
\sprintf(
'Next token was expected to be an operand of type %s. Instead "%s" was found.',
\implode(', ', $operators),
$parserState->peek()
),
'',
'custom',
$parserState->currentLine()
);
}
}
$parserState->consumeWhiteSpace();
}
$list->addListComponent($calcRuleValueList);
if (!$parserState->isEnd()) {
$parserState->consume(')');
}
return new CalcFunction($function, $list, ',', $parserState->currentLine());
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
class CalcRuleValueList extends RuleValueList
{
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
parent::__construct(',', $lineNumber);
}
public function render(OutputFormat $outputFormat): string
{
return $outputFormat->getFormatter()->implode(' ', $this->components);
}
}

View File

@@ -0,0 +1,390 @@
<?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);
}
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 . ')';
}
}

View File

@@ -0,0 +1,59 @@
<?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;
class LineName extends ValueList
{
/**
* @param array<Value|string> $components
* @param int<1, max>|null $lineNumber
*/
public function __construct(array $components = [], ?int $lineNumber = null)
{
parent::__construct($components, ' ', $lineNumber);
}
/**
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): LineName
{
$parserState->consume('[');
$parserState->consumeWhiteSpace();
$names = [];
do {
if ($parserState->getSettings()->usesLenientParsing()) {
try {
$names[] = $parserState->parseIdentifier();
} catch (UnexpectedTokenException $e) {
if (!$parserState->comes(']')) {
throw $e;
}
}
} else {
$names[] = $parserState->parseIdentifier();
}
$parserState->consumeWhiteSpace();
} while (!$parserState->comes(']'));
$parserState->consume(']');
return new LineName($names, $parserState->currentLine());
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return '[' . parent::render(OutputFormat::createCompact()) . ']';
}
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
abstract class PrimitiveValue extends Value {}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
/**
* This class is used to represent all multivalued rules like `font: bold 12px/3 Helvetica, Verdana, sans-serif;`
* (where the value would be a whitespace-separated list of the primitive value `bold`, a slash-separated list
* and a comma-separated list).
*/
class RuleValueList extends ValueList
{
/**
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct(string $separator = ',', ?int $lineNumber = null)
{
parent::__construct([], $separator, $lineNumber);
}
}

View File

@@ -0,0 +1,210 @@
<?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;
/**
* A `Size` consists of a numeric `size` value and a unit.
*/
class Size extends PrimitiveValue
{
/**
* vh/vw/vm(ax)/vmin/rem are absolute insofar as they dont scale to the immediate parent (only the viewport)
*
* @var list<non-empty-string>
*/
private const ABSOLUTE_SIZE_UNITS = [
'px',
'pt',
'pc',
'cm',
'mm',
'mozmm',
'in',
'vh',
'dvh',
'svh',
'lvh',
'vw',
'vmin',
'vmax',
'rem',
];
/**
* @var list<non-empty-string>
*/
private const RELATIVE_SIZE_UNITS = ['%', 'em', 'ex', 'ch', 'fr'];
/**
* @var list<non-empty-string>
*/
private const NON_SIZE_UNITS = ['deg', 'grad', 'rad', 's', 'ms', 'turn', 'Hz', 'kHz'];
/**
* @var array<int<1, max>, array<lowercase-string, non-empty-string>>|null
*/
private static $SIZE_UNITS = null;
/**
* @var float
*/
private $size;
/**
* @var string|null
*/
private $unit;
/**
* @var bool
*/
private $isColorComponent;
/**
* @param float|int|string $size
* @param int<1, max>|null $lineNumber
*/
public function __construct($size, ?string $unit = null, bool $isColorComponent = false, ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->size = (float) $size;
$this->unit = $unit;
$this->isColorComponent = $isColorComponent;
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState, bool $isColorComponent = false): Size
{
$size = '';
if ($parserState->comes('-')) {
$size .= $parserState->consume('-');
}
while (\is_numeric($parserState->peek()) || $parserState->comes('.') || $parserState->comes('e', true)) {
if ($parserState->comes('.')) {
$size .= $parserState->consume('.');
} elseif ($parserState->comes('e', true)) {
$lookahead = $parserState->peek(1, 1);
if (\is_numeric($lookahead) || $lookahead === '+' || $lookahead === '-') {
$size .= $parserState->consume(2);
} else {
break; // Reached the unit part of the number like "em" or "ex"
}
} else {
$size .= $parserState->consume(1);
}
}
$unit = null;
$sizeUnits = self::getSizeUnits();
foreach ($sizeUnits as $length => &$values) {
$key = \strtolower($parserState->peek($length));
if (\array_key_exists($key, $values)) {
if (($unit = $values[$key]) !== null) {
$parserState->consume($length);
break;
}
}
}
return new Size((float) $size, $unit, $isColorComponent, $parserState->currentLine());
}
/**
* @return array<int<1, max>, array<lowercase-string, non-empty-string>>
*/
private static function getSizeUnits(): array
{
if (!\is_array(self::$SIZE_UNITS)) {
self::$SIZE_UNITS = [];
$sizeUnits = \array_merge(self::ABSOLUTE_SIZE_UNITS, self::RELATIVE_SIZE_UNITS, self::NON_SIZE_UNITS);
foreach ($sizeUnits as $sizeUnit) {
$tokenLength = \strlen($sizeUnit);
if (!isset(self::$SIZE_UNITS[$tokenLength])) {
self::$SIZE_UNITS[$tokenLength] = [];
}
self::$SIZE_UNITS[$tokenLength][\strtolower($sizeUnit)] = $sizeUnit;
}
\krsort(self::$SIZE_UNITS, SORT_NUMERIC);
}
return self::$SIZE_UNITS;
}
public function setUnit(string $unit): void
{
$this->unit = $unit;
}
public function getUnit(): ?string
{
return $this->unit;
}
/**
* @param float|int|string $size
*/
public function setSize($size): void
{
$this->size = (float) $size;
}
public function getSize(): float
{
return $this->size;
}
public function isColorComponent(): bool
{
return $this->isColorComponent;
}
/**
* Returns whether the number stored in this Size really represents a size (as in a length of something on screen).
*
* Returns `false` if the unit is an angle, a duration, a frequency, or the number is a component in a `Color`
* object.
*/
public function isSize(): bool
{
if (\in_array($this->unit, self::NON_SIZE_UNITS, true)) {
return false;
}
return !$this->isColorComponent();
}
public function isRelative(): bool
{
if (\in_array($this->unit, self::RELATIVE_SIZE_UNITS, true)) {
return true;
}
if ($this->unit === null && $this->size !== 0.0) {
return true;
}
return false;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
$locale = \localeconv();
$decimalPoint = \preg_quote($locale['decimal_point'], '/');
$size = \preg_match('/[\\d\\.]+e[+-]?\\d+/i', (string) $this->size)
? \preg_replace("/$decimalPoint?0+$/", '', \sprintf('%f', $this->size)) : (string) $this->size;
return \preg_replace(["/$decimalPoint/", '/^(-?)0\\./'], ['.', '$1.'], $size) . ($this->unit ?? '');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
/**
* This class represents URLs in CSS. `URL`s always output in `URL("")` notation.
*/
class URL extends PrimitiveValue
{
/**
* @var CSSString
*/
private $url;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(CSSString $url, ?int $lineNumber = null)
{
parent::__construct($lineNumber);
$this->url = $url;
}
/**
* @throws SourceException
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parse(ParserState $parserState): URL
{
$anchor = $parserState->anchor();
$identifier = '';
for ($i = 0; $i < 3; $i++) {
$character = $parserState->parseCharacter(true);
if ($character === null) {
break;
}
$identifier .= $character;
}
$useUrl = $parserState->streql($identifier, 'url');
if ($useUrl) {
$parserState->consumeWhiteSpace();
$parserState->consume('(');
} else {
$anchor->backtrack();
}
$parserState->consumeWhiteSpace();
$result = new URL(CSSString::parse($parserState), $parserState->currentLine());
if ($useUrl) {
$parserState->consumeWhiteSpace();
$parserState->consume(')');
}
return $result;
}
public function setURL(CSSString $url): void
{
$this->url = $url;
}
public function getURL(): CSSString
{
return $this->url;
}
/**
* @return non-empty-string
*/
public function render(OutputFormat $outputFormat): string
{
return "url({$this->url->render($outputFormat)})";
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\SourceException;
use Sabberworm\CSS\Parsing\UnexpectedEOFException;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Position;
use Sabberworm\CSS\Position\Positionable;
/**
* Abstract base class for specific classes of CSS values: `Size`, `Color`, `CSSString` and `URL`, and another
* abstract subclass `ValueList`.
*/
abstract class Value implements CSSElement, Positionable
{
use Position;
/**
* @param int<1, max>|null $lineNumber
*/
public function __construct(?int $lineNumber = null)
{
$this->setPosition($lineNumber);
}
/**
* @param array<non-empty-string> $listDelimiters
*
* @return Value|string
*
* @throws UnexpectedTokenException
* @throws UnexpectedEOFException
*
* @internal since V8.8.0
*/
public static function parseValue(ParserState $parserState, array $listDelimiters = [])
{
/** @var list<Value|string> $stack */
$stack = [];
$parserState->consumeWhiteSpace();
//Build a list of delimiters and parsed values
while (
!($parserState->comes('}') || $parserState->comes(';') || $parserState->comes('!')
|| $parserState->comes(')')
|| $parserState->isEnd())
) {
if (\count($stack) > 0) {
$foundDelimiter = false;
foreach ($listDelimiters as $delimiter) {
if ($parserState->comes($delimiter)) {
\array_push($stack, $parserState->consume($delimiter));
$parserState->consumeWhiteSpace();
$foundDelimiter = true;
break;
}
}
if (!$foundDelimiter) {
//Whitespace was the list delimiter
\array_push($stack, ' ');
}
}
\array_push($stack, self::parsePrimitiveValue($parserState));
$parserState->consumeWhiteSpace();
}
// Convert the list to list objects
foreach ($listDelimiters as $delimiter) {
$stackSize = \count($stack);
if ($stackSize === 1) {
return $stack[0];
}
$newStack = [];
for ($offset = 0; $offset < $stackSize; ++$offset) {
if ($offset === ($stackSize - 1) || $delimiter !== $stack[$offset + 1]) {
$newStack[] = $stack[$offset];
continue;
}
$length = 2; //Number of elements to be joined
for ($i = $offset + 3; $i < $stackSize; $i += 2, ++$length) {
if ($delimiter !== $stack[$i]) {
break;
}
}
$list = new RuleValueList($delimiter, $parserState->currentLine());
for ($i = $offset; $i - $offset < $length * 2; $i += 2) {
$list->addListComponent($stack[$i]);
}
$newStack[] = $list;
$offset += $length * 2 - 2;
}
$stack = $newStack;
}
if (!isset($stack[0])) {
throw new UnexpectedTokenException(
" {$parserState->peek()} ",
$parserState->peek(1, -1) . $parserState->peek(2),
'literal',
$parserState->currentLine()
);
}
return $stack[0];
}
/**
* @return CSSFunction|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*
* @internal since V8.8.0
*/
public static function parseIdentifierOrFunction(ParserState $parserState, bool $ignoreCase = false)
{
$anchor = $parserState->anchor();
$result = $parserState->parseIdentifier($ignoreCase);
if ($parserState->comes('(')) {
$anchor->backtrack();
if ($parserState->streql('url', $result)) {
$result = URL::parse($parserState);
} elseif ($parserState->streql('calc', $result)) {
$result = CalcFunction::parse($parserState);
} else {
$result = CSSFunction::parse($parserState, $ignoreCase);
}
}
return $result;
}
/**
* @return CSSFunction|CSSString|LineName|Size|URL|string
*
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
* @throws SourceException
*
* @internal since V8.8.0
*/
public static function parsePrimitiveValue(ParserState $parserState)
{
$value = null;
$parserState->consumeWhiteSpace();
if (
\is_numeric($parserState->peek())
|| ($parserState->comes('-.')
&& \is_numeric($parserState->peek(1, 2)))
|| (($parserState->comes('-') || $parserState->comes('.')) && \is_numeric($parserState->peek(1, 1)))
) {
$value = Size::parse($parserState);
} elseif ($parserState->comes('#') || $parserState->comes('rgb', true) || $parserState->comes('hsl', true)) {
$value = Color::parse($parserState);
} elseif ($parserState->comes("'") || $parserState->comes('"')) {
$value = CSSString::parse($parserState);
} elseif ($parserState->comes('progid:') && $parserState->getSettings()->usesLenientParsing()) {
$value = self::parseMicrosoftFilter($parserState);
} elseif ($parserState->comes('[')) {
$value = LineName::parse($parserState);
} elseif ($parserState->comes('U+')) {
$value = self::parseUnicodeRangeValue($parserState);
} else {
$nextCharacter = $parserState->peek(1);
try {
$value = self::parseIdentifierOrFunction($parserState);
} catch (UnexpectedTokenException $e) {
if (\in_array($nextCharacter, ['+', '-', '*', '/'], true)) {
$value = $parserState->consume(1);
} else {
throw $e;
}
}
}
$parserState->consumeWhiteSpace();
return $value;
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseMicrosoftFilter(ParserState $parserState): CSSFunction
{
$function = $parserState->consumeUntil('(', false, true);
$arguments = Value::parseValue($parserState, [',', '=']);
return new CSSFunction($function, $arguments, ',', $parserState->currentLine());
}
/**
* @throws UnexpectedEOFException
* @throws UnexpectedTokenException
*/
private static function parseUnicodeRangeValue(ParserState $parserState): string
{
$codepointMaxLength = 6; // Code points outside BMP can use up to six digits
$range = '';
$parserState->consume('U+');
do {
if ($parserState->comes('-')) {
$codepointMaxLength = 13; // Max length is 2 six-digit code points + the dash(-) between them
}
$range .= $parserState->consume(1);
} while (\strlen($range) < $codepointMaxLength && \preg_match('/[A-Fa-f0-9\\?-]/', $parserState->peek()));
return "U+{$range}";
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Value;
use Sabberworm\CSS\OutputFormat;
/**
* A `ValueList` represents a lists of `Value`s, separated by some separation character
* (mostly `,`, whitespace, or `/`).
*
* There are two types of `ValueList`s: `RuleValueList` and `CSSFunction`
*/
abstract class ValueList extends Value
{
/**
* @var array<Value|string>
*
* @internal since 8.8.0
*/
protected $components;
/**
* @var non-empty-string
*
* @internal since 8.8.0
*/
protected $separator;
/**
* @param array<Value|string>|Value|string $components
* @param non-empty-string $separator
* @param int<1, max>|null $lineNumber
*/
public function __construct($components = [], $separator = ',', ?int $lineNumber = null)
{
parent::__construct($lineNumber);
if (!\is_array($components)) {
$components = [$components];
}
$this->components = $components;
$this->separator = $separator;
}
/**
* @param Value|string $component
*/
public function addListComponent($component): void
{
$this->components[] = $component;
}
/**
* @return array<Value|string>
*/
public function getListComponents(): array
{
return $this->components;
}
/**
* @param array<Value|string> $components
*/
public function setListComponents(array $components): void
{
$this->components = $components;
}
/**
* @return non-empty-string
*/
public function getListSeparator(): string
{
return $this->separator;
}
/**
* @param non-empty-string $separator
*/
public function setListSeparator(string $separator): void
{
$this->separator = $separator;
}
public function render(OutputFormat $outputFormat): string
{
$formatter = $outputFormat->getFormatter();
return $formatter->implode(
$formatter->spaceBeforeListArgumentSeparator($this->separator) . $this->separator
. $formatter->spaceAfterListArgumentSeparator($this->separator),
$this->components
);
}
}