diff --git a/CSSParser.php b/CSSParser.php index 54779275..d7677435 100644 --- a/CSSParser.php +++ b/CSSParser.php @@ -1,4 +1,5 @@ false, + 'resolve_imports' => false, + 'absolute_urls' => false, + 'base_url' => null, + 'default_charset' => 'utf-8' + ); + + /** + * Parser internal pointers + **/ private $sText; private $iCurrentPosition; private $iLength; + private $aLoadedFiles = array(); + + /** + * Data for resolving imports + **/ + const IMPORT_FILE = 'file'; + const IMPORT_URL = 'url'; + const IMPORT_NONE = 'none'; + private $sImportMode = 'none'; + + /** + * flags + **/ + private $bIgnoreCharsetRules = false; + private $bIgnoreImportRules = false; + private $bIsAbsBaseUrl; - public function __construct($sText, $sDefaultCharset = 'utf-8') { - $this->sText = $sText; - $this->iCurrentPosition = 0; - $this->setCharset($sDefaultCharset); - } + /** + * @param $aOptions array of options + * + * Valid options: + * * default_charset: + * default charset used by the parser. + * Will be overriden by @charset rules unless ignore_charset_rules is set to true + * * ignore_charset_rules: + * @charset rules are parsed but do not change the parser's default charset + * * resolve_imports: + * recursively import embedded stylesheets + * * absolute_urls: + * make all urls absolute + * * base_url: + * the base url to use for absolute urls and resolving imports + * if not set, will be computed from the file path + **/ + public function __construct(array $aOptions=array()) { + $this->aOptions = array_merge($this->aOptions, $aOptions); + $this->bIgnoreCharsetRules = $this->aOptions['ignore_charset_rules']; + $this->bIsAbsBaseUrl = CSSUrlUtils::isAbsUrl($this->aOptions['base_url']); + } public function setCharset($sCharset) { $this->sCharset = $sCharset; @@ -29,12 +77,116 @@ public function setCharset($sCharset) { public function getCharset() { return $this->sCharset; } - - public function parse() { + + public function getLoadedFiles() { + return $this->aLoadedFiles; + } + + /** + * @param $sPath string path to a file to load + **/ + public function parseFile($sPath, $aLoadedFiles=array()) + { + if(!$this->aOptions['base_url']) { + $this->aOptions['base_url'] = dirname($sPath); + } + if($this->aOptions['absolute_urls']) { + $this->aOptions['base_url'] = realpath($this->aOptions['base_url']); + } + //printf(">>> Parsing %s in %s\n", $sPath, $this->aOptions['base_url']); + $this->sImportMode = self::IMPORT_FILE; + $sPath = realpath($sPath); + $aLoadedFiles[] = $sPath; + $this->aLoadedFiles = array_merge($this->aLoadedFiles, $aLoadedFiles); + $sCss = file_get_contents($sPath); + return $this->parseString($sCss); + } + + public function parseURL($sPath, $aLoadedFiles=array()) + { + if(!$this->aOptions['base_url']) { + $this->aOptions['base_url'] = dirname($sPath); + } + //printf(">>> Parsing %s in %s\n", $sPath, $this->aOptions['base_url']); + //if($this->aOptions['absolute_urls'] && !CSSUrlUtils::isAbsUrl($this->aOptions['base_url'])) { + //$sProtocol = $_SERVER['HTTPS'] ? 'https' : 'http'; + //$sPath = $sProtocol.'://'.$_SERVER['HTTP_HOST'].'/'.ltrim($sPath, '/'); + //$this->aOptions['base_url'] = dirname($sPath); + //} + $this->sImportMode = self::IMPORT_URL; + $aLoadedFiles[] =$sPath; + $this->aLoadedFiles = array_merge($this->aLoadedFiles, $aLoadedFiles); + $sCss = CSSUrlUtils::loadURL($sPath); + return $this->parseString($sCss); + } + + + public function parseString($sString) { + $this->sText = $sString; + $this->iCurrentPosition = 0; + $this->setCharset($this->aOptions['default_charset']); $oResult = new CSSDocument(); $this->parseDocument($oResult); + $this->postParse($oResult); return $oResult; - } + } + + public function postParse($oDoc) + { + $aImports = array(); + $aCharsets = array(); + $aContents = $oDoc->getContents(); + foreach($aContents as $i => $oItem) { + if($oItem instanceof CSSIgnoredValue) { + unset($aContents[$i]); + } else if($oItem instanceof CSSImport) { + $aImports[] = $oItem; + unset($aContents[$i]); + } else if ($oItem instanceof CSSCharset) { + $aCharsets[] = $oItem; + unset($aContents[$i]); + } + } + $aImportedItems = array(); + $aImportOptions = array_merge($this->aOptions, array( + 'default_charset' => $this->sCharset, + 'ignore_charset_rules' => true, + 'base_url' => null + )); + foreach($aImports as $oImport) { + if($this->aOptions['resolve_imports']) { + $parser = new CSSParser($aImportOptions); + $sPath = $oImport->getLocation()->getURL()->getString(); + if($this->sImportMode == self::IMPORT_URL) { + if(!in_array($sPath, $this->aLoadedFiles)) { + $oImportedDoc = $parser->parseURL($sPath, $this->aLoadedFiles); + $this->aLoadedFiles = $parser->getLoadedFiles(); + $aImportedContents = $oImportedDoc->getContents(); + } + } else if($this->sImportMode == self::IMPORT_FILE) { + $sPath = realpath($sPath); + if(!in_array($sPath, $this->aLoadedFiles)) { + $oImportedDoc = $parser->parseFile($sPath, $this->aLoadedFiles); + $this->aLoadedFiles = $parser->getLoadedFiles(); + $aImportedContents = $oImportedDoc->getContents(); + } + } + if($oImport->getMediaQuery() !== null) { + $sMediaQuery = $oImport->getMediaQuery(); + $oMediaQuery = new CSSMediaQuery(); + $oMediaQuery->setQuery($sMediaQuery); + $oMediaQuery->setContents($aImportedContents); + $aImportedContents = array($oMediaQuery); + } + } else { + $aImportedContents = array($oImport); + } + $aImportedItems = array_merge($aImportedItems, $aImportedContents); + } + $aContents = array_merge($aImportedItems, $aContents); + if(isset($aCharsets[0])) array_unshift($aContents, $aCharsets[0]); + $oDoc->setContents($aContents); + } private function parseDocument(CSSDocument $oDocument) { $this->consumeWhiteSpace(); @@ -53,6 +205,8 @@ private function parseList(CSSList $oList, $bIsRoot = false) { return; } } else { + $this->bIgnoreCharsetRules = true; + $this->bIgnoreImportRules = true; $oList->append($this->parseSelector()); } $this->consumeWhiteSpace(); @@ -65,8 +219,10 @@ private function parseList(CSSList $oList, $bIsRoot = false) { private function parseAtRule() { $this->consume('@'); $sIdentifier = $this->parseIdentifier(); - $this->consumeWhiteSpace(); + $this->consumeWhiteSpace(); if($sIdentifier === 'media') { + $this->bIgnoreCharsetRules = true; + $this->bIgnoreImportRules = true; $oResult = new CSSMediaQuery(); $oResult->setQuery(trim($this->consumeUntil('{'))); $this->consume('{'); @@ -74,6 +230,7 @@ private function parseAtRule() { $this->parseList($oResult); return $oResult; } else if($sIdentifier === 'import') { + $this->bIgnoreCharsetRules = true; $oLocation = $this->parseURLValue(); $this->consumeWhiteSpace(); $sMediaQuery = null; @@ -81,15 +238,26 @@ private function parseAtRule() { $sMediaQuery = $this->consumeUntil(';'); } $this->consume(';'); - return new CSSImport($oLocation, $sMediaQuery); + if($this->bIgnoreImportRules) { + return new CSSIgnoredValue(); + } + return new CSSImport($oLocation, $sMediaQuery); } else if($sIdentifier === 'charset') { - $sCharset = $this->parseStringValue(); - $this->consumeWhiteSpace(); - $this->consume(';'); - $this->setCharset($sCharset->getString()); - return new CSSCharset($sCharset); + $sCharset = $this->parseStringValue(); + $this->consumeWhiteSpace(); + $this->consume(';'); + // Only the first charset rule should exist ! + if($this->bIgnoreCharsetRules) { + return new CSSIgnoredValue(); + } else { + $this->setCharset($sCharset->getString()); + $this->bIgnoreCharsetRules = true; + return new CSSCharset($sCharset); + } } else { //Unknown other at rule (font-face or such) + $this->bIgnoreCharsetRules = true; + $this->bIgnoreImportRules = true; $this->consume('{'); $this->consumeWhiteSpace(); $oAtRule = new CSSAtRule($sIdentifier); @@ -176,7 +344,7 @@ private function parseCharacter($bIsForIdentifier) { return iconv('utf-32le', $this->sCharset, $sUtf32); } if($bIsForIdentifier) { - if(preg_match('/[a-zA-Z0-9]|-|_/u', $this->peek()) === 1) { + if(preg_match('/\*|[a-zA-Z0-9]|-|_/u', $this->peek()) === 1) { return $this->consume(1); } else if(ord($this->peek()) > 0xa1) { return $this->consume(1); @@ -375,7 +543,22 @@ private function parseURLValue() { $this->consume('('); } $this->consumeWhiteSpace(); - $oResult = new CSSURL($this->parseStringValue()); + $sValue = $this->parseStringValue(); + if($this->aOptions['absolute_urls'] || $this->aOptions['resolve_imports']) { + $sURL = $sValue->getString(); + // resolve only if: + // (url is not absolute) OR IF (url is absolute path AND base_url is absolute) + $bIsAbsPath = CSSUrlUtils::isAbsPath($sURL); + $bIsAbsUrl = CSSUrlUtils::isAbsUrl($sURL); + if( (!$bIsAbsUrl && !$bIsAbsPath) + || ($bIsAbsPath && $this->bIsAbsBaseUrl)) { + $sURL = CSSUrlUtils::joinPaths( + $this->aOptions['base_url'], $sURL + ); + $sValue = new CSSString($sURL); + } + } + $oResult = new CSSURL($sValue); if($bUseUrl) { $this->consumeWhiteSpace(); $this->consume(')'); diff --git a/lib/CSSList.php b/lib/CSSList.php index b89d9e65..bf3be0f2 100644 --- a/lib/CSSList.php +++ b/lib/CSSList.php @@ -10,10 +10,29 @@ abstract class CSSList { public function __construct() { $this->aContents = array(); } + + public function prepend($oItem) + { + array_unshift($this->aContents, $oItem); + } public function append($oItem) { $this->aContents[] = $oItem; } + + public function extend(Array $aItems) { + foreach ($aItems as $oItem) { + $this->aContents[] = $oItem; + } + } + + public function removeItemAt($iIndex) { + unset($this->aContents[$iIndex]); + } + + public function insertItemsAt(Array $oItems, $iIndex) { + array_splice($this->aContents, $iIndex, 0, $oItems); + } public function __toString() { $sResult = ''; @@ -25,7 +44,10 @@ public function __toString() { public function getContents() { return $this->aContents; - } + } + public function setContents(Array $aContents) { + $this->aContents = $aContents; + } protected function allDeclarationBlocks(&$aResult) { foreach($this->aContents as $mContent) { diff --git a/lib/CSSProperties.php b/lib/CSSProperties.php index 15c9edbd..28f6a498 100644 --- a/lib/CSSProperties.php +++ b/lib/CSSProperties.php @@ -13,12 +13,16 @@ public function __construct(CSSURL $oLocation, $sMediaQuery) { } public function setLocation($oLocation) { - $this->oLocation = $oLocation; + $this->oLocation = $oLocation; } public function getLocation() { - return $this->oLocation; + return $this->oLocation; } + + public function getMediaQuery() { + return $this->sMediaQuery; + } public function __toString() { return "@import ".$this->oLocation->__toString().($this->sMediaQuery === null ? '' : ' '.$this->sMediaQuery).';'; @@ -40,11 +44,11 @@ public function __construct($sCharset) { } public function setCharset($sCharset) { - $this->sCharset = $sCharset; + $this->sCharset = $sCharset; } public function getCharset() { - return $this->sCharset; + return $this->sCharset; } public function __toString() { diff --git a/lib/CSSUrlUtils.php b/lib/CSSUrlUtils.php new file mode 100644 index 00000000..c54287a5 --- /dev/null +++ b/lib/CSSUrlUtils.php @@ -0,0 +1,65 @@ +sUnit), $aNonSizeUnits) { + if(in_array($this->sUnit, $aNonSizeUnits)) { return false; } return !$this->isColorComponent(); diff --git a/tests/CSSDeclarationBlockTest.php b/tests/CSSDeclarationBlockTest.php index 550db934..4aa1f9b5 100644 --- a/tests/CSSDeclarationBlockTest.php +++ b/tests/CSSDeclarationBlockTest.php @@ -10,8 +10,8 @@ class CSSDeclarationBlockTest extends PHPUnit_Framework_TestCase **/ public function testExpandBorderShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->expandBorderShorthand(); @@ -35,8 +35,8 @@ public function expandBorderShorthandProvider() **/ public function testExpandFontShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->expandFontShorthand(); @@ -78,8 +78,8 @@ public function expandFontShorthandProvider() **/ public function testExpandBackgroundShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->expandBackgroundShorthand(); @@ -103,8 +103,8 @@ public function expandBackgroundShorthandProvider() **/ public function testExpandDimensionsShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->expandDimensionsShorthand(); @@ -127,8 +127,8 @@ public function expandDimensionsShorthandProvider() **/ public function testCreateBorderShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->createBorderShorthand(); @@ -150,8 +150,8 @@ public function createBorderShorthandProvider() **/ public function testCreateFontShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->createFontShorthand(); @@ -175,8 +175,8 @@ public function createFontShorthandProvider() **/ public function testCreateDimensionsShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->createDimensionsShorthand(); @@ -199,8 +199,8 @@ public function createDimensionsShorthandProvider() **/ public function testCreateBackgroundShorthand($sCss, $sExpected) { - $oParser = new CSSParser($sCss); - $oDoc = $oParser->parse(); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString($sCss); foreach($oDoc->getAllDeclarationBlocks() as $oDeclaration) { $oDeclaration->createBackgroundShorthand(); diff --git a/tests/CSSImportTest.php b/tests/CSSImportTest.php new file mode 100644 index 00000000..7c8160f3 --- /dev/null +++ b/tests/CSSImportTest.php @@ -0,0 +1,40 @@ + true, + 'base_url' => $sBaseUrl + )); + $oDoc = $parser->parseString($sCSS); + $this->assertEquals($oDoc->__toString(), $sExpected); + } + public function testAbsoluteUrlsProvider() + { + return array( + array( + '@import "styles.css";body{background: url("image.jpg")}', + 'http://foobar.biz', + '@import url("http://foobar.biz/styles.css");body {background: url("http://foobar.biz/image.jpg");}' + ), + array( + '@import "sub/styles.css";body{background: url("/root/image.jpg")}', + 'http://foobar.biz', + '@import url("http://foobar.biz/sub/styles.css");body {background: url("http://foobar.biz/root/image.jpg");}' + ), + array( + '@import "sub/styles.css";body{background: url("/root/image.jpg")}', + '/home/user/www', + '@import url("/home/user/www/sub/styles.css");body {background: url("/root/image.jpg");}' + ), + ); + } + +} diff --git a/tests/CSSParserTests.php b/tests/CSSParserTest.php similarity index 97% rename from tests/CSSParserTests.php rename to tests/CSSParserTest.php index 3d461c77..e659123f 100644 --- a/tests/CSSParserTests.php +++ b/tests/CSSParserTest.php @@ -4,7 +4,7 @@ require_once(dirname(__FILE__).'/../CSSParser.php'); -class CSSParserTests extends PHPUnit_Framework_TestCase { +class CSSParserTest extends PHPUnit_Framework_TestCase { function testCssFiles() { $sDirectory = dirname(__FILE__).DIRECTORY_SEPARATOR.'files'; @@ -21,9 +21,10 @@ function testCssFiles() { //Either a file which SHOULD fail or a future test of a as-of-now missing feature continue; } - $oParser = new CSSParser(file_get_contents($sDirectory.DIRECTORY_SEPARATOR.$sFileName)); + $oParser = new CSSParser(); + $oDoc = $oParser->parseString(file_get_contents($sDirectory.DIRECTORY_SEPARATOR.$sFileName)); try { - $this->assertNotEquals('', $oParser->parse()->__toString()); + $this->assertNotEquals('', $oDoc->__toString()); } catch(Exception $e) { $this->fail($e); } @@ -236,7 +237,7 @@ function testCreateShorthands() { function parsedStructureForFile($sFileName) { $sFile = dirname(__FILE__).DIRECTORY_SEPARATOR.'files'.DIRECTORY_SEPARATOR."$sFileName.css"; - $oParser = new CSSParser(file_get_contents($sFile)); - return $oParser->parse(); + $oParser = new CSSParser(); + return $oParser->parseString(file_get_contents($sFile)); } } diff --git a/tests/files/atrules.css b/tests/files/atrules.css index adfa9f99..f6233831 100644 --- a/tests/files/atrules.css +++ b/tests/files/atrules.css @@ -6,5 +6,5 @@ } html, body { - font-size: 1.6em + font-size: 1.6em; } diff --git a/tests/files/import.css b/tests/files/import.css new file mode 100644 index 00000000..de20f24c --- /dev/null +++ b/tests/files/import.css @@ -0,0 +1,11 @@ +@import "imported.css"; +@import "colortest.css" screen, print; +@import "subdir/sub.css"; + +body#icomelast{ + color: fuschia; + padding: 5px; + background-image: url(http://foobar.com); +} + +@import "nope.css"; diff --git a/tests/files/imported.css b/tests/files/imported.css new file mode 100644 index 00000000..2da8342a --- /dev/null +++ b/tests/files/imported.css @@ -0,0 +1,6 @@ +@import "atrules.css"; + +header{ + width: 618px; + height: 120px; +} diff --git a/tests/files/subdir/sub.css b/tests/files/subdir/sub.css new file mode 100644 index 00000000..7c85febb --- /dev/null +++ b/tests/files/subdir/sub.css @@ -0,0 +1 @@ +@import "sub2/sub2.css"; diff --git a/tests/files/subdir/sub2/sub2.css b/tests/files/subdir/sub2/sub2.css new file mode 100644 index 00000000..e69de29b