3 namespace Sabberworm\CSS
;
5 use Sabberworm\CSS\CSSList\CSSList
;
6 use Sabberworm\CSS\CSSList\Document
;
7 use Sabberworm\CSS\CSSList\KeyFrame
;
8 use Sabberworm\CSS\Parsing\SourceException
;
9 use Sabberworm\CSS\Property\AtRule
;
10 use Sabberworm\CSS\Property\Import
;
11 use Sabberworm\CSS\Property\Charset
;
12 use Sabberworm\CSS\Property\CSSNamespace
;
13 use Sabberworm\CSS\RuleSet\AtRuleSet
;
14 use Sabberworm\CSS\CSSList\AtRuleBlockList
;
15 use Sabberworm\CSS\RuleSet\DeclarationBlock
;
16 use Sabberworm\CSS\Value\CSSFunction
;
17 use Sabberworm\CSS\Value\RuleValueList
;
18 use Sabberworm\CSS\Value\Size
;
19 use Sabberworm\CSS\Value\Color
;
20 use Sabberworm\CSS\Value\URL
;
21 use Sabberworm\CSS\Value\CSSString
;
22 use Sabberworm\CSS\Rule\Rule
;
23 use Sabberworm\CSS\Parsing\UnexpectedTokenException
;
24 use Sabberworm\CSS\Comment\Comment
;
27 * Parser class parses CSS from text into a data structure.
33 private $iCurrentPosition;
34 private $oParserSettings;
43 * Note that that iLineNo starts from 1 and not 0
46 * @param Settings|null $oParserSettings
49 public function __construct($sText, Settings
$oParserSettings = null, $iLineNo = 1) {
50 $this->sText
= $sText;
51 $this->iCurrentPosition
= 0;
52 $this->iLineNo
= $iLineNo;
53 if ($oParserSettings === null) {
54 $oParserSettings = Settings
::create();
56 $this->oParserSettings
= $oParserSettings;
57 $this->blockRules
= explode('/', AtRule
::BLOCK_RULES
);
59 foreach (explode('/', Size
::ABSOLUTE_SIZE_UNITS
.'/'.Size
::RELATIVE_SIZE_UNITS
.'/'.Size
::NON_SIZE_UNITS
) as $val) {
60 $iSize = strlen($val);
61 if(!isset($this->aSizeUnits
[$iSize])) {
62 $this->aSizeUnits
[$iSize] = array();
64 $this->aSizeUnits
[$iSize][strtolower($val)] = $val;
66 ksort($this->aSizeUnits
, SORT_NUMERIC
);
69 public function setCharset($sCharset) {
70 $this->sCharset
= $sCharset;
71 $this->aText
= $this->strsplit($this->sText
);
72 $this->iLength
= count($this->aText
);
75 public function getCharset() {
76 return $this->sCharset
;
79 public function parse() {
80 $this->setCharset($this->oParserSettings
->sDefaultCharset
);
81 $oResult = new Document($this->iLineNo
);
82 $this->parseDocument($oResult);
86 private function parseDocument(Document
$oDocument) {
87 $this->parseList($oDocument, true);
90 private function parseList(CSSList
$oList, $bIsRoot = false) {
91 while (!$this->isEnd()) {
92 $comments = $this->consumeWhiteSpace();
94 if($this->oParserSettings
->bLenientParsing
) {
96 $oListItem = $this->parseListItem($oList, $bIsRoot);
97 } catch (UnexpectedTokenException
$e) {
101 $oListItem = $this->parseListItem($oList, $bIsRoot);
103 if($oListItem === null) {
104 // List parsing finished
108 $oListItem->setComments($comments);
109 $oList->append($oListItem);
113 throw new SourceException("Unexpected end of document", $this->iLineNo
);
117 private function parseListItem(CSSList
$oList, $bIsRoot = false) {
118 if ($this->comes('@')) {
119 $oAtRule = $this->parseAtRule();
120 if($oAtRule instanceof Charset
) {
122 throw new UnexpectedTokenException('@charset may only occur in root document', '', 'custom', $this->iLineNo
);
124 if(count($oList->getContents()) > 0) {
125 throw new UnexpectedTokenException('@charset must be the first parseable token in a document', '', 'custom', $this->iLineNo
);
127 $this->setCharset($oAtRule->getCharset()->getString());
130 } else if ($this->comes('}')) {
133 throw new SourceException("Unopened {", $this->iLineNo
);
138 return $this->parseSelector();
142 private function parseAtRule() {
144 $sIdentifier = $this->parseIdentifier(false);
145 $iIdentifierLineNum = $this->iLineNo
;
146 $this->consumeWhiteSpace();
147 if ($sIdentifier === 'import') {
148 $oLocation = $this->parseURLValue();
149 $this->consumeWhiteSpace();
151 if (!$this->comes(';')) {
152 $sMediaQuery = $this->consumeUntil(';');
155 return new Import($oLocation, $sMediaQuery, $iIdentifierLineNum);
156 } else if ($sIdentifier === 'charset') {
157 $sCharset = $this->parseStringValue();
158 $this->consumeWhiteSpace();
160 return new Charset($sCharset, $iIdentifierLineNum);
161 } else if ($this->identifierIs($sIdentifier, 'keyframes')) {
162 $oResult = new KeyFrame($iIdentifierLineNum);
163 $oResult->setVendorKeyFrame($sIdentifier);
164 $oResult->setAnimationName(trim($this->consumeUntil('{', false, true)));
165 $this->parseList($oResult);
167 } else if ($sIdentifier === 'namespace') {
169 $mUrl = $this->parsePrimitiveValue();
170 if (!$this->comes(';')) {
172 $mUrl = $this->parsePrimitiveValue();
175 if ($sPrefix !== null && !is_string($sPrefix)) {
176 throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum);
178 if (!($mUrl instanceof CSSString ||
$mUrl instanceof URL
)) {
179 throw new UnexpectedTokenException('Wrong namespace url of invalid type', $mUrl, 'custom', $iIdentifierLineNum);
181 return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum);
183 //Unknown other at rule (font-face or such)
184 $sArgs = trim($this->consumeUntil('{', false, true));
186 foreach($this->blockRules
as $sBlockRuleName) {
187 if($this->identifierIs($sIdentifier, $sBlockRuleName)) {
188 $bUseRuleSet = false;
193 $oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum);
194 $this->parseRuleSet($oAtRule);
196 $oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum);
197 $this->parseList($oAtRule);
203 private function parseIdentifier($bAllowFunctions = true, $bIgnoreCase = true) {
204 $sResult = $this->parseCharacter(true);
205 if ($sResult === null) {
206 throw new UnexpectedTokenException($sResult, $this->peek(5), 'identifier', $this->iLineNo
);
209 while (($sCharacter = $this->parseCharacter(true)) !== null) {
210 $sResult .= $sCharacter;
213 $sResult = $this->strtolower($sResult);
215 if ($bAllowFunctions && $this->comes('(')) {
217 $aArguments = $this->parseValue(array('=', ' ', ','));
218 $sResult = new CSSFunction($sResult, $aArguments, ',', $this->iLineNo
);
224 private function parseStringValue() {
225 $sBegin = $this->peek();
227 if ($sBegin === "'") {
229 } else if ($sBegin === '"') {
232 if ($sQuote !== null) {
233 $this->consume($sQuote);
237 if ($sQuote === null) {
238 //Unquoted strings end in whitespace or with braces, brackets, parentheses
239 while (!preg_match('/[\\s{}()<>\\[\\]]/isu', $this->peek())) {
240 $sResult .= $this->parseCharacter(false);
243 while (!$this->comes($sQuote)) {
244 $sContent = $this->parseCharacter(false);
245 if ($sContent === null) {
246 throw new SourceException("Non-well-formed quoted string {$this->peek(3)}", $this->iLineNo
);
248 $sResult .= $sContent;
250 $this->consume($sQuote);
252 return new CSSString($sResult, $this->iLineNo
);
255 private function parseCharacter($bIsForIdentifier) {
256 if ($this->peek() === '\\') {
257 if ($bIsForIdentifier && $this->oParserSettings
->bLenientParsing
&& ($this->comes('\0') ||
$this->comes('\9'))) {
258 // Non-strings can contain \0 or \9 which is an IE hack supported in lenient parsing.
261 $this->consume('\\');
262 if ($this->comes('\n') ||
$this->comes('\r')) {
265 if (preg_match('/[0-9a-fA-F]/Su', $this->peek()) === 0) {
266 return $this->consume(1);
268 $sUnicode = $this->consumeExpression('/^[0-9a-fA-F]{1,6}/u', 6);
269 if ($this->strlen($sUnicode) < 6) {
270 //Consume whitespace after incomplete unicode escape
271 if (preg_match('/\\s/isSu', $this->peek())) {
272 if ($this->comes('\r\n')) {
279 $iUnicode = intval($sUnicode, 16);
281 for ($i = 0; $i < 4; ++
$i) {
282 $sUtf32 .= chr($iUnicode & 0xff);
283 $iUnicode = $iUnicode >> 8;
285 return iconv('utf-32le', $this->sCharset
, $sUtf32);
287 if ($bIsForIdentifier) {
288 $peek = ord($this->peek());
289 // Ranges: a-z A-Z 0-9 - _
290 if (($peek >= 97 && $peek <= 122) ||
291 ($peek >= 65 && $peek <= 90) ||
292 ($peek >= 48 && $peek <= 57) ||
296 return $this->consume(1);
299 return $this->consume(1);
304 private function parseSelector() {
305 $aComments = array();
306 $oResult = new DeclarationBlock($this->iLineNo
);
307 $oResult->setSelector($this->consumeUntil('{', false, true, $aComments));
308 $oResult->setComments($aComments);
309 $this->parseRuleSet($oResult);
313 private function parseRuleSet($oRuleSet) {
314 while ($this->comes(';')) {
317 while (!$this->comes('}')) {
319 if($this->oParserSettings
->bLenientParsing
) {
321 $oRule = $this->parseRule();
322 } catch (UnexpectedTokenException
$e) {
324 $sConsume = $this->consumeUntil(array("\n", ";", '}'), true);
325 // We need to “unfind” the matches to the end of the ruleSet as this will be matched later
326 if($this->streql(substr($sConsume, -1), '}')) {
327 --$this->iCurrentPosition
;
329 while ($this->comes(';')) {
333 } catch (UnexpectedTokenException
$e) {
334 // We’ve reached the end of the document. Just close the RuleSet.
339 $oRule = $this->parseRule();
342 $oRuleSet->addRule($oRule);
348 private function parseRule() {
349 $aComments = $this->consumeWhiteSpace();
350 $oRule = new Rule($this->parseIdentifier(), $this->iLineNo
);
351 $oRule->setComments($aComments);
352 $oRule->addComments($this->consumeWhiteSpace());
354 $oValue = $this->parseValue(self
::listDelimiterForRule($oRule->getRule()));
355 $oRule->setValue($oValue);
356 if ($this->oParserSettings
->bLenientParsing
) {
357 while ($this->comes('\\')) {
358 $this->consume('\\');
359 $oRule->addIeHack($this->consume());
360 $this->consumeWhiteSpace();
363 if ($this->comes('!')) {
365 $this->consumeWhiteSpace();
366 $this->consume('important');
367 $oRule->setIsImportant(true);
369 while ($this->comes(';')) {
375 private function parseValue($aListDelimiters) {
377 $this->consumeWhiteSpace();
378 //Build a list of delimiters and parsed values
379 while (!($this->comes('}') ||
$this->comes(';') ||
$this->comes('!') ||
$this->comes(')') ||
$this->comes('\\'))) {
380 if (count($aStack) > 0) {
381 $bFoundDelimiter = false;
382 foreach ($aListDelimiters as $sDelimiter) {
383 if ($this->comes($sDelimiter)) {
384 array_push($aStack, $this->consume($sDelimiter));
385 $this->consumeWhiteSpace();
386 $bFoundDelimiter = true;
390 if (!$bFoundDelimiter) {
391 //Whitespace was the list delimiter
392 array_push($aStack, ' ');
395 array_push($aStack, $this->parsePrimitiveValue());
396 $this->consumeWhiteSpace();
398 //Convert the list to list objects
399 foreach ($aListDelimiters as $sDelimiter) {
400 if (count($aStack) === 1) {
403 $iStartPosition = null;
404 while (($iStartPosition = array_search($sDelimiter, $aStack, true)) !== false) {
405 $iLength = 2; //Number of elements to be joined
406 for ($i = $iStartPosition +
2; $i < count($aStack); $i+
=2, ++
$iLength) {
407 if ($sDelimiter !== $aStack[$i]) {
411 $oList = new RuleValueList($sDelimiter, $this->iLineNo
);
412 for ($i = $iStartPosition - 1; $i - $iStartPosition +
1 < $iLength * 2; $i+
=2) {
413 $oList->addListComponent($aStack[$i]);
415 array_splice($aStack, $iStartPosition - 1, $iLength * 2 - 1, array($oList));
421 private static function listDelimiterForRule($sRule) {
422 if (preg_match('/^font($|-)/', $sRule)) {
423 return array(',', '/', ' ');
425 return array(',', ' ', '/');
428 private function parsePrimitiveValue() {
430 $this->consumeWhiteSpace();
431 if (is_numeric($this->peek()) ||
($this->comes('-.') && is_numeric($this->peek(1, 2))) ||
(($this->comes('-') ||
$this->comes('.')) && is_numeric($this->peek(1, 1)))) {
432 $oValue = $this->parseNumericValue();
433 } else if ($this->comes('#') ||
$this->comes('rgb', true) ||
$this->comes('hsl', true)) {
434 $oValue = $this->parseColorValue();
435 } else if ($this->comes('url', true)) {
436 $oValue = $this->parseURLValue();
437 } else if ($this->comes("'") ||
$this->comes('"')) {
438 $oValue = $this->parseStringValue();
439 } else if ($this->comes("progid:") && $this->oParserSettings
->bLenientParsing
) {
440 $oValue = $this->parseMicrosoftFilter();
442 $oValue = $this->parseIdentifier(true, false);
444 $this->consumeWhiteSpace();
448 private function parseNumericValue($bForColor = false) {
450 if ($this->comes('-')) {
451 $sSize .= $this->consume('-');
453 while (is_numeric($this->peek()) ||
$this->comes('.')) {
454 if ($this->comes('.')) {
455 $sSize .= $this->consume('.');
457 $sSize .= $this->consume(1);
462 foreach ($this->aSizeUnits
as $iLength => &$aValues) {
463 $sKey = strtolower($this->peek($iLength));
464 if(array_key_exists($sKey, $aValues)) {
465 if (($sUnit = $aValues[$sKey]) !== null) {
466 $this->consume($iLength);
471 return new Size(floatval($sSize), $sUnit, $bForColor, $this->iLineNo
);
474 private function parseColorValue() {
476 if ($this->comes('#')) {
478 $sValue = $this->parseIdentifier(false);
479 if ($this->strlen($sValue) === 3) {
480 $sValue = $sValue[0] . $sValue[0] . $sValue[1] . $sValue[1] . $sValue[2] . $sValue[2];
482 $aColor = array('r' => new Size(intval($sValue[0] . $sValue[1], 16), null, true, $this->iLineNo
), 'g' => new Size(intval($sValue[2] . $sValue[3], 16), null, true, $this->iLineNo
), 'b' => new Size(intval($sValue[4] . $sValue[5], 16), null, true, $this->iLineNo
));
484 $sColorMode = $this->parseIdentifier(false);
485 $this->consumeWhiteSpace();
487 $iLength = $this->strlen($sColorMode);
488 for ($i = 0; $i < $iLength; ++
$i) {
489 $this->consumeWhiteSpace();
490 $aColor[$sColorMode[$i]] = $this->parseNumericValue(true);
491 $this->consumeWhiteSpace();
492 if ($i < ($iLength - 1)) {
498 return new Color($aColor, $this->iLineNo
);
501 private function parseMicrosoftFilter() {
502 $sFunction = $this->consumeUntil('(', false, true);
503 $aArguments = $this->parseValue(array(',', '='));
504 return new CSSFunction($sFunction, $aArguments, ',', $this->iLineNo
);
507 private function parseURLValue() {
508 $bUseUrl = $this->comes('url', true);
510 $this->consume('url');
511 $this->consumeWhiteSpace();
514 $this->consumeWhiteSpace();
515 $oResult = new URL($this->parseStringValue(), $this->iLineNo
);
517 $this->consumeWhiteSpace();
524 * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. We need to check for these versions too.
526 private function identifierIs($sIdentifier, $sMatch) {
527 return (strcasecmp($sIdentifier, $sMatch) === 0)
528 ?
: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1;
531 private function comes($sString, $bCaseInsensitive = false) {
532 $sPeek = $this->peek(strlen($sString));
533 return ($sPeek == '')
535 : $this->streql($sPeek, $sString, $bCaseInsensitive);
538 private function peek($iLength = 1, $iOffset = 0) {
539 $iOffset +
= $this->iCurrentPosition
;
540 if ($iOffset >= $this->iLength
) {
543 return $this->substr($iOffset, $iLength);
546 private function consume($mValue = 1) {
547 if (is_string($mValue)) {
548 $iLineCount = substr_count($mValue, "\n");
549 $iLength = $this->strlen($mValue);
550 if (!$this->streql($this->substr($this->iCurrentPosition
, $iLength), $mValue)) {
551 throw new UnexpectedTokenException($mValue, $this->peek(max($iLength, 5)), $this->iLineNo
);
553 $this->iLineNo +
= $iLineCount;
554 $this->iCurrentPosition +
= $this->strlen($mValue);
557 if ($this->iCurrentPosition +
$mValue > $this->iLength
) {
558 throw new UnexpectedTokenException($mValue, $this->peek(5), 'count', $this->iLineNo
);
560 $sResult = $this->substr($this->iCurrentPosition
, $mValue);
561 $iLineCount = substr_count($sResult, "\n");
562 $this->iLineNo +
= $iLineCount;
563 $this->iCurrentPosition +
= $mValue;
568 private function consumeExpression($mExpression, $iMaxLength = null) {
570 $sInput = $iMaxLength !== null ?
$this->peek($iMaxLength) : $this->inputLeft();
571 if (preg_match($mExpression, $sInput, $aMatches, PREG_OFFSET_CAPTURE
) === 1) {
572 return $this->consume($aMatches[0][0]);
574 throw new UnexpectedTokenException($mExpression, $this->peek(5), 'expression', $this->iLineNo
);
577 private function consumeWhiteSpace() {
580 while (preg_match('/\\s/isSu', $this->peek()) === 1) {
583 if($this->oParserSettings
->bLenientParsing
) {
585 $oComment = $this->consumeComment();
586 } catch(UnexpectedTokenException
$e) {
587 // When we can’t find the end of a comment, we assume the document is finished.
588 $this->iCurrentPosition
= $this->iLength
;
592 $oComment = $this->consumeComment();
594 if ($oComment !== false) {
595 $comments[] = $oComment;
597 } while($oComment !== false);
602 * @return false|Comment
604 private function consumeComment() {
606 if ($this->comes('/*')) {
607 $iLineNo = $this->iLineNo
;
610 while (($char = $this->consume(1)) !== '') {
612 if ($this->comes('*/')) {
619 if ($mComment !== false) {
620 // We skip the * which was included in the comment.
621 return new Comment(substr($mComment, 1), $iLineNo);
627 private function isEnd() {
628 return $this->iCurrentPosition
>= $this->iLength
;
631 private function consumeUntil($aEnd, $bIncludeEnd = false, $consumeEnd = false, array &$comments = array()) {
632 $aEnd = is_array($aEnd) ?
$aEnd : array($aEnd);
634 $start = $this->iCurrentPosition
;
636 while (($char = $this->consume(1)) !== '') {
637 if (in_array($char, $aEnd)) {
640 } elseif (!$consumeEnd) {
641 $this->iCurrentPosition
-= $this->strlen($char);
646 if ($comment = $this->consumeComment()) {
647 $comments[] = $comment;
651 $this->iCurrentPosition
= $start;
652 throw new UnexpectedTokenException('One of ("'.implode('","', $aEnd).'")', $this->peek(5), 'search', $this->iLineNo
);
655 private function inputLeft() {
656 return $this->substr($this->iCurrentPosition
, -1);
659 private function substr($iStart, $iLength) {
661 $iLength = $this->iLength
- $iStart +
$iLength;
663 if ($iStart +
$iLength > $this->iLength
) {
664 $iLength = $this->iLength
- $iStart;
667 while ($iLength > 0) {
668 $sResult .= $this->aText
[$iStart];
675 private function strlen($sString) {
676 if ($this->oParserSettings
->bMultibyteSupport
) {
677 return mb_strlen($sString, $this->sCharset
);
679 return strlen($sString);
683 private function streql($sString1, $sString2, $bCaseInsensitive = true) {
684 if($bCaseInsensitive) {
685 return $this->strtolower($sString1) === $this->strtolower($sString2);
687 return $sString1 === $sString2;
691 private function strtolower($sString) {
692 if ($this->oParserSettings
->bMultibyteSupport
) {
693 return mb_strtolower($sString, $this->sCharset
);
695 return strtolower($sString);
699 private function strsplit($sString) {
700 if ($this->oParserSettings
->bMultibyteSupport
) {
701 if ($this->streql($this->sCharset
, 'utf-8')) {
702 return preg_split('//u', $sString, null, PREG_SPLIT_NO_EMPTY
);
704 $iLength = mb_strlen($sString, $this->sCharset
);
706 for ($i = 0; $i < $iLength; ++
$i) {
707 $aResult[] = mb_substr($sString, $i, 1, $this->sCharset
);
712 if($sString === '') {
715 return str_split($sString);
720 private function strpos($sString, $sNeedle, $iOffset) {
721 if ($this->oParserSettings
->bMultibyteSupport
) {
722 return mb_strpos($sString, $sNeedle, $iOffset, $this->sCharset
);
724 return strpos($sString, $sNeedle, $iOffset);