diff --git a/CSSParser.php b/CSSParser.php index 54779275..e63040b1 100644 --- a/CSSParser.php +++ b/CSSParser.php @@ -1,4 +1,6 @@ false, + 'absolute_urls' => false, + 'base_url' => null, + 'input_encoding' => null, + 'output_encoding' => null + ); + + /** + * 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 are: + * + **/ + public function __construct(array $aOptions=array()) { + $this->setOptions($aOptions); + } + + /** + * Gets an option value. + * + * @param string $sName The option name + * @param mixed $mDefault The default value (null by default) + * + * @return mixed The option value or the default value + */ + public function getOption($sName, $mDefault=null) { + return isset($this->aOptions[$sName]) ? $this->aOptions[$sName] : $mDefault; + } + /** + * Sets an option value. + * + * @param string $sName The option name + * @param mixed $mValue The default value + * + * @return CSSParser The current CSSParser instance + */ + public function setOption($sName, $mValue) { + $this->aOptions[$sName] = $mValue; + return $this; + } + + /** + * Returns the options of the current instance. + * + * @return array The current instance's options + **/ + public function getOptions() { + return $this->aOptions; + } + + /** + * Merge given options with the current options + * + * @param array $aOptions The options to merge + * + * @return CSSParser The current CSSParser instance + **/ + public function setOptions(array $aOptions) { + $this->aOptions = array_merge($this->aOptions, $aOptions); + return $this; + } + + /** + * @todo Access should be private, since calling this method + * from the outside world could lead to unpredicable results. + **/ public function setCharset($sCharset) { $this->sCharset = $sCharset; $this->iLength = mb_strlen($this->sText, $this->sCharset); } public function getCharset() { - return $this->sCharset; - } - - public function parse() { + return $this->sCharset; + } + + /** + * Returns an array of all the loaded stylesheets. + * + * @return array The loaded stylesheets + **/ + public function getLoadedFiles() { + return $this->aLoadedFiles; + } + + /** + * Parses a local stylesheet into a CSSDocument object. + * + * @param string $sPath Path to a file to load + * @param array $aLoadedFiles An array of files to exclude + * + * @return CSSDocument the resulting CSSDocument + **/ + public function parseFile($sPath, $aLoadedFiles=array()) { + if(!$this->getOption('base_url')) { + $this->setOption('base_url', dirname($sPath)); + } + if($this->getOption('absolute_urls') && !CSSUrlUtils::isAbsUrl($this->getOption('base_url'))) { + $this->setOption('base_url', realpath($this->getOption('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); + } + + /** + * Parses a remote stylesheet into a CSSDocument object. + * + * @param string $sPath URL of a file to load + * @param array $aLoadedFiles An array of files to exclude + * + * @return CSSDocument the resulting CSSDocument + **/ + public function parseURL($sPath, $aLoadedFiles=array()) { + if(!$this->getOption('base_url')) { + $this->setOption('base_url', CSSUrlUtils::dirname($sPath)); + } + $this->sImportMode = self::IMPORT_URL; + $aLoadedFiles[] =$sPath; + $this->aLoadedFiles = array_merge($this->aLoadedFiles, $aLoadedFiles); + $aResult = CSSUrlUtils::loadURL($sPath); + $sResponse = $aResult['response']; + // charset from Content-Type HTTP header + // TODO: what do we do if the header returns a wrong charset ? + if($aResult['charset']) { + return $this->parseString($sResponse, $aResult['charset']); + } + return $this->parseString($sResponse); + } + + /** + * Parses a string into a CSSDocument object. + * + * @param string $sString A CSS String + * @param array $sCharset An optional charset to use (overridden by the "input_encoding" option). + * + * @return CSSDocument the resulting CSSDocument + **/ + + public function parseString($sString, $sCharset=null) { + $this->bIsAbsBaseUrl = CSSUrlUtils::isAbsUrl($this->getOption('base_url')); + if($this->getOption('input_encoding')) { + // The input encoding has been overriden by user. + $sCharset = $this->getOption('input_encoding'); + $this->bIgnoreCharsetRules = true; + } + if(!$sCharset) { + // detect charset from BOM and/or @charset rule + $sCharset = CSSCharsetUtils::detectCharset($sString); + if(!$sCharset) { + $sCharset = 'UTF-8'; + } + } + $sString = CSSCharsetUtils::removeBOM($sString); + if($this->getOption('output_encoding')) { + $sString = CSSCharsetUtils::convert($sString, $sCharset, $this->getOption('output_encoding')); + $sCharset = $this->getOption('output_encoding'); + $this->bIgnoreCharsetRules = true; + } + $this->sText = $sString; + $this->iCurrentPosition = 0; + $this->setCharset($sCharset); $oResult = new CSSDocument(); $this->parseDocument($oResult); + $this->postParse($oResult); return $oResult; - } + } + + /** + * Post processes the parsed CSSDocument object. + * + * Handles removal of ignored values and resolving of @import rules. + * + * @todo Should CSSIgnoredValue exist ? + * Another solution would be to add values only if they are not === null, + * i.e. in CSSList::append(), CSSRule::addValue() etc... + **/ + private function postParse($oDoc) { + $aCharsets = array(); + $aImports = array(); + $aContents = $oDoc->getContents(); + foreach($aContents as $i => $oItem) { + if($oItem instanceof CSSIgnoredValue) { + unset($aContents[$i]); + } else if($oItem instanceof CSSCharset) { + $aCharsets[] = $oItem; + unset($aContents[$i]); + } else if($oItem instanceof CSSImport) { + $aImports[] = $oItem; + unset($aContents[$i]); + } + } + $aImportedItems = array(); + $aImportOptions = array_merge($this->getOptions(), array( + 'output_encoding' => $this->sCharset, + 'base_url' => null + )); + foreach($aImports as $oImport) { + if($this->getOption('resolve_imports')) { + $parser = new CSSParser($aImportOptions); + $sPath = $oImport->getLocation()->getURL()->getString(); + $bIsAbsUrl = CSSUrlUtils::isAbsUrl($sPath); + if($this->sImportMode == self::IMPORT_URL || $bIsAbsUrl) { + 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 +317,8 @@ private function parseList(CSSList $oList, $bIsRoot = false) { return; } } else { + $this->bIgnoreCharsetRules = true; + $this->bIgnoreImportRules = true; $oList->append($this->parseSelector()); } $this->consumeWhiteSpace(); @@ -65,8 +331,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 +342,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 +350,25 @@ private function parseAtRule() { $sMediaQuery = $this->consumeUntil(';'); } $this->consume(';'); - return new CSSImport($oLocation, $sMediaQuery); + $oImport = new CSSImport($oLocation, $sMediaQuery); + if($this->bIgnoreImportRules) { + return new CSSIgnoredValue($oImport); + } + return $oImport; } 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(';'); + $oCharset = new CSSCharset($sCharset); + if($this->bIgnoreCharsetRules) { + return new CSSIgnoredValue($oCharset); + } + $this->bIgnoreCharsetRules = true; + return $oCharset; } else { //Unknown other at rule (font-face or such) + $this->bIgnoreCharsetRules = true; + $this->bIgnoreImportRules = true; $this->consume('{'); $this->consumeWhiteSpace(); $oAtRule = new CSSAtRule($sIdentifier); @@ -173,10 +452,10 @@ private function parseCharacter($bIsForIdentifier) { $sUtf32 .= chr($iUnicode & 0xff); $iUnicode = $iUnicode >> 8; } - return iconv('utf-32le', $this->sCharset, $sUtf32); + return CSSCharsetUtils::convert($sUtf32, 'UTF-32LE', $this->sCharset); } 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 +654,22 @@ private function parseURLValue() { $this->consume('('); } $this->consumeWhiteSpace(); - $oResult = new CSSURL($this->parseStringValue()); + $sValue = $this->parseStringValue(); + if($this->getOption('absolute_urls') || $this->getOption('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->getOption('base_url'), $sURL + ); + $sValue = new CSSString($sURL); + } + } + $oResult = new CSSURL($sValue); if($bUseUrl) { $this->consumeWhiteSpace(); $this->consume(')'); diff --git a/lib/CSSCharsetUtils.php b/lib/CSSCharsetUtils.php new file mode 100644 index 00000000..b9f3158c --- /dev/null +++ b/lib/CSSCharsetUtils.php @@ -0,0 +1,252 @@ + '#^\xEF\xBB\xBF\x40\x63\x68\x61\x72\x73\x65\x74\x20\x22([\x20-\x7F]*)\x22\x3B#', + 'charset' => null, + 'endianness' => null + ), + array( + 'pattern' => '#^\xEF\xBB\xBF#', + 'charset' => "UTF-8", + 'endianness' => null + ), + array( + 'pattern' => '#^\x40\x63\x68\x61\x72\x73\x65\x74\x20\x22([\x20-\x7F]*)\x22\x3B#', + 'charset' => null, + 'endianness' => null + ), + array( + 'pattern' => '#^\xFE\xFF\x00\x40\x00\x63\x00\x68\x00\x61\x00\x72\x00\x73\x00\x65\x00\x74\x00\x20\x00\x22((?:\x00[\x20-\x7F])*)\x00\x22\x00\x3B#', + 'charset' => null, + 'endianness' => 'BE' + ), + array( + 'pattern' => '#^\x00\x40\x00\x63\x00\x68\x00\x61\x00\x72\x00\x73\x00\x65\x00\x74\x00\x20\x00\x22((?:\x00[\x20-\x7F])*)\x00\x22\x00\x3B#', + 'charset' => null, + 'endianness' => 'BE' + ), + array( + 'pattern' => '#^\xFF\xFE\x40\x00\x63\x00\x68\x00\x61\x00\x72\x00\x73\x00\x65\x00\x74\x00\x20\x00\x22\x00((?:\x00[\x20-\x7F])*)\x22\x00\x3B\x00#', + 'charset' => null, + 'endianness' => 'BE' + ), + array( + 'pattern' => '#^\x40\x00\x63\x00\x68\x00\x61\x00\x72\x00\x73\x00\x65\x00\x74\x00\x20\x00\x22\x00((?:\x00[\x20-\x7F])*)\x22\x00\x3B\x00#', + 'charset' => null, + 'endianness' => 'LE' + ), + array( + 'pattern' => '#^\x00\x00\xFE\xFF\x00\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22((?:\x00\x00\x00[\x20-\x7F])*)\x00\x00\x00\x22\x00\x00\x00\x3B#', + 'charset' => null, + 'endianness' => 'BE' + ), + array( + 'pattern' => '#^\x00\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22((?:\x00\x00\x00[\x20-\x7F])*)\x00\x00\x00\x22\x00\x00\x00\x3B#', + 'charset' => null, + 'endianness' => 'BE' + ), + array( + 'pattern' => '#^\x00\x00\xFF\xFE\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00((?:\x00\x00[\x20-\x7F]\x00)*)\x00\x00\x22\x00\x00\x00\x3B\x00#', + 'charset' => null, + 'endianness' => '2143' + ), + array( + 'pattern' => '#^\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00((?:\x00\x00[\x20-\x7F]\x00)*)\x00\x00\x22\x00\x00\x00\x3B\x00#', + 'charset' => null, + 'endianness' => '2143' + ), + array( + 'pattern' => '#^\xFE\xFF\x00\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00\x00((?:\x00[\x20-\x7F]\x00\x00)*)\x00\x22\x00\x00\x00\x3B\x00\x00#', + 'charset' => null, + 'endianness' => '3412' + ), + array( + 'pattern' => '#^\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00\x00((?:\x00[\x20-\x7F]\x00\x00)*)\x00\x22\x00\x00\x00\x3B\x00\x00#', + 'charset' => null, + 'endianness' => '3412' + ), + array( + 'pattern' => '#^\xFF\xFE\x00\x00\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00\x00\x00((?:[\x20-\x7F]\x00\x00\x00)*)\x22\x00\x00\x00\x3B\x00\x00\x00#', + 'charset' => null, + 'endianness' => 'LE' + ), + array( + 'pattern' => '#^\x40\x00\x00\x00\x63\x00\x00\x00\x68\x00\x00\x00\x61\x00\x00\x00\x72\x00\x00\x00\x73\x00\x00\x00\x65\x00\x00\x00\x74\x00\x00\x00\x20\x00\x00\x00\x22\x00\x00\x00((?:[\x20-\x7F]\x00\x00\x00)*)\x22\x00\x00\x00\x3B\x00\x00\x00#', + 'charset' => null, + 'endianness' => 'LE' + ), + array( + 'pattern' => '#^\x00\x00\xFE\xFF#', + 'charset' => 'UTF-32BE', + 'endianness' => null + ), + array( + 'pattern' => '#^\xFF\xFE\x00\x00#', + 'charset' => 'UTF-32LE', + 'endianness' => null + ), + array( + 'pattern' => '#^\x00\x00\xFF\xFE#', + 'charset' => 'UTF-32-2143', + 'endianness' => null + ), + array( + 'pattern' => '#^\xFE\xFF\x00\x00#', + 'charset' => 'UTF-32-3412', + 'endianness' => null + ), + array( + 'pattern' => '#^\xFE\xFF#', + 'charset' => "UTF-16BE", + 'endianness' => null + ), + array( + 'pattern' => '#^\xFF\xFE#', + 'charset' => 'UTF-16LE', + 'endianness' => null + ), + /** + * These encodings are not supported by mbstring extension. + **/ + //array( + //'pattern' => '/^\x7C\x83\x88\x81\x99\xA2\x85\xA3\x40\x7F(YY)*\x7F\x5E/', + //'charset' => null, + //'endianness' => null, + //'transcoded-from' => 'EBCDIC' + //), + //array( + //'pattern' => '/^\xAE\x83\x88\x81\x99\xA2\x85\xA3\x40\xFC(YY)*\xFC\x5E/', + //'charset' => null, + //'endianness' => null, + //'transcoded-from' => 'IBM1026' + //), + //array( + //'pattern' => '/^\x00\x63\x68\x61\x72\x73\x65\x74\x20\x22(YY)*\x22\x3B/', + //'charset' => null, + //'endianness' => null + //'transcoded-from' => 'GSM 03.38' + //), + ); + + static function detectCharset($sText) { + $aSupportedEncodings = mb_list_encodings(); + foreach (self::$CHARSET_DETECTION_MAP as $aCharsetMap) { + $sPattern = $aCharsetMap['pattern']; + $aMatches = array(); + if(preg_match($sPattern, $sText, $aMatches)) { + if($aCharsetMap['charset']) { + $sCharset = $aCharsetMap['charset']; + } else { + $sCharset = $aMatches[1]; + } + return $sCharset; + } + } + return false; + } + + static function convert($sSubject, $sFromCharset, $sToCharset) { + return mb_convert_encoding($sSubject, $sToCharset, $sFromCharset); + //return iconv($sFromCharset, $sToCharset, $sSubject); + } + + static function removeBOM($sText) { + $iLen = strlen($sText); + if($iLen > 3) { + switch ($sText[0]) { + case "\xEF": + if(("\xBB" == $sText[1]) && ("\xBF" == $sText[2])) { + // EF BB BF UTF-) encoded BOM + return substr($sText, 3); + } + break; + case "\xFE": + if (("\xFF" == $sText[1]) && ("\x00" == $sText[2]) && ("\x00" == $sText[3])) { + // FE FF 00 00 UCS-4, unusual octet order BOM (3412) + return substr($sText, 4); + } else if ("\xFF" == $sText[1]) { + // FE FF UTF-16, big endian BOM + return substr($sText, 2); + } + break; + case "\x00": + if (("\x00" == $sText[1]) && ("\xFE" == $sText[2]) && ("\xFF" == $sText[3])) { + // 00 00 FE FF UTF-32, big-endian BOM + return substr($sText, 4); + } else if (("\x00" == $sText[1]) && ("\xFF" == $sText[2]) && ("\xFE" == $sText[3])) { + // 00 00 FF FE UCS-4, unusual octet order BOM (2143) + return substr($sText, 4); + } + break; + case "\xFF": + if (("\xFE" == $sText[1]) && ("\x00" == $sText[2]) && ("\x00" == $sText[3])) { + // FF FE 00 00 UTF-32, little-endian BOM + return substr($sText, 4); + } else if ("\xFE" == $sText[1]) { + // FF FE UTF-16, little endian BOM + return substr($sText, 2); + } + break; + } + } + return $sText; + } + + static function checkForBOM($sText) { + $iLen = strlen($sText); + if($iLen > 3) { + switch ($sText[0]) { + case "\xEF": + if(("\xBB" == $sText[1]) && ("\xBF" == $sText[2])) { + // EF BB BF UTF-) encoded BOM + return 'UTF-8'; + } + break; + case "\xFE": + if (("\xFF" == $sText[1]) && ("\x00" == $sText[2]) && ("\x00" == $sText[3])) { + // FE FF 00 00 UCS-4, unusual octet order BOM (3412) + return "X-ISO-10646-UCS-4-3412"; + } else if ("\xFF" == $sText[1]) { + // FE FF UTF-16, big endian BOM + return "UTF-16BE"; + } + break; + case "\x00": + if (("\x00" == $sText[1]) && ("\xFE" == $sText[2]) && ("\xFF" == $sText[3])) { + // 00 00 FE FF UTF-32, big-endian BOM + return "UTF-32BE"; + } else if (("\x00" == $sText[1]) && ("\xFF" == $sText[2]) && ("\xFE" == $sText[3])) { + // 00 00 FF FE UCS-4, unusual octet order BOM (2143) + return "X-ISO-10646-UCS-4-2143"; + } + break; + case "\xFF": + if (("\xFE" == $sText[1]) && ("\x00" == $sText[2]) && ("\x00" == $sText[3])) { + // FF FE 00 00 UTF-32, little-endian BOM + return "UTF-32LE"; + } else if ("\xFE" == $sText[1]) { + // FF FE UTF-16, little endian BOM + return "UTF-16LE"; + } + break; + } + } + return false; + } + + static function printBytes($sString, $iLen=null) + { + if($iLen == null) $iLen = strlen($sString); + $aBytes = array(); + for($i = 0; $i < $iLen; $i++) { + $aBytes[] = "0x".dechex(ord($sString[$i])); + } + return implode(' ', $aBytes); + } + +} 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..10f90600 --- /dev/null +++ b/lib/CSSUrlUtils.php @@ -0,0 +1,145 @@ + the charset of the response as specified by the + * HTTP Content-Type header, if specified + * 'response' => the response body + **/ + static public function loadURL($sURL) { + $rCurl = curl_init(); + curl_setopt($rCurl, CURLOPT_URL, $sURL); + //curl_setopt($rCurl, CURLOPT_HEADER, true); + curl_setopt($rCurl, CURLOPT_ENCODING, 'deflate,gzip'); + curl_setopt($rCurl, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($rCurl, CURLOPT_USERAGENT, 'PHP-CSS-Parser v0.1'); + curl_setopt($rCurl, CURLOPT_RETURNTRANSFER, true); + $mResponse = curl_exec($rCurl); + $aInfos = curl_getinfo($rCurl); + curl_close($rCurl); + if(false === $mResponse) return false; + $aResult = array( + 'charset' => null, + 'response' => $mResponse + ); + if($aInfos['content_type']) { + if(preg_match('/charset=([a-zA-Z0-9-]*)/', $aInfos['content_type'], $aMatches)) { + $aResult['charset'] = $aMatches[0]; + } + } + return $aResult; + } + + /** + * CSSUrlUtils::joinPaths( string $head, string $tail [, string $...] ) + * + * @param string $head the head component of the path + * @param string $tail at least one path component + * @returns string the resulting path + **/ + static public function joinPaths() { + $num_args = func_num_args(); + if($num_args < 1) return ''; + $args = func_get_args(); + if($num_args == 1) return rtrim($args[0], DIRECTORY_SEPARATOR); + + $head = array_shift($args); + $head = rtrim($head, DIRECTORY_SEPARATOR); + $output = array($head); + foreach ($args as $arg) { + $output[] = trim($arg, DIRECTORY_SEPARATOR); + } + return implode(DIRECTORY_SEPARATOR, $output); + } + + /** + * Returns boolean based on whether given path is absolute or not. + * + * @param string $path Given path + * @return boolean True if the path is absolute, false if it is not + */ + static public function isAbsPath($sPath) { + if (preg_match('#(?:/|\\\)\.\.(?=/|$)#', $sPath)) { + return false; + } + if (OS_WIN32) { + return (($sPath[0] == '/') || preg_match('#^[a-zA-Z]:(\\\|/)#', $sPath)); + } + return ($sPath[0] == '/') || ($sPath[0] == '~'); + } + + /** + * Tests if an URL is absolute + * + * @param string $sURL + * @return boolean + **/ + static public function isAbsURL($sURL) { + return preg_match('#^(http|https|ftp)://#', $sURL); + } + + /** + * Returns the parent path of an URL or path + * + * @param string $sURL an URL + * @returns string an URL + **/ + static public function dirname($sURL) { + $aURL = parse_url($sURL); + if(isset($aURL['path'])) { + $sPath = dirname($aURL['path']); + if($sPath == '/') { + unset($aURL['path']); + } else { + $aURL['path'] = $sPath; + } + } + return self::buildURL($aURL); + } + + /** + * Builds an URL from an array of URL parts + * + * @param array $aURL URL parts in the format returned by parse_url + * @return string the builded URL + * @see http://php.net/manual/function.parse-url.php + **/ + static public function buildURL(array $aURL) + { + $sURL = ''; + if(isset($aURL['scheme'])) { + $sURL .= $aURL['scheme'] . '://'; + } + if(isset($aURL['user'])) { + $sURL .= $aURL['user']; + if(isset($aURL['pass'])) { + $sURL .= ':' . $aURL['pass']; + } + $sURL .= '@'; + } + if(isset($aURL['host'])) { + $sURL .= $aURL['host']; + } + if(isset($aURL['port'])) { + $sURL .= ':' . $aURL['port']; + } + if(isset($aURL['path'])) { + if(strpos($aURL['path'], '/') !== 0) $sURL .= '/'; + $sURL .= $aURL['path']; + } + if(isset($aURL['query'])) { + $sURL .= '?' . $aURL['query']; + } + if(isset($aURL['fragment'])) { + $sURL .= '#' . $aURL['fragment']; + } + return $sURL; + } +} diff --git a/lib/CSSValue.php b/lib/CSSValue.php index 020cd684..458d9eb8 100644 --- a/lib/CSSValue.php +++ b/lib/CSSValue.php @@ -8,6 +8,19 @@ abstract class CSSPrimitiveValue extends CSSValue { } +class CSSIgnoredValue extends CSSValue { + private $mValue; + public function __construct($mValue) { + $this->mValue = $mValue; + } + public function getValue() { + return $this->mValue; + } + public function __toString() { + return $this->mValue->__toString(); + } +} + class CSSSize extends CSSPrimitiveValue { private $fSize; private $sUnit; @@ -45,7 +58,7 @@ public function isColorComponent() { */ public function isSize() { $aNonSizeUnits = array('deg', 'grad', 'rad', 'turns', 's', 'ms', 'Hz', 'kHz'); - if(in_array($this->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..59dcafef --- /dev/null +++ b/tests/CSSImportTest.php @@ -0,0 +1,59 @@ + 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");}' + ), + ); + } + + /** + * @dataProvider testResolveImportsProvider + **/ + public function testResolveImports($sFile, $sExpected) + { + $oParser = new CSSParser(array('resolve_imports' => true)); + $oDoc = $oParser->parseFile($sFile); + $this->assertEquals((string)$oDoc, $sExpected); + } + public function testResolveImportsProvider() + { + return array( + array( + dirname(__FILE__)."/files/import.css", + '@font-face {font-family: "CrassRoots";src: url("/home/ju1ius/code/php/third-party/PHP-CSS-Parser/tests/files/../media/cr.ttf");}html, body {font-size: 1.6em;}header {width: 618px;height: 120px;}.euc-jp {content: "もっと強く";}body.im_utf-32 {color: green;}div.im_utf-16 {background: url("/home/ju1ius/code/php/third-party/PHP-CSS-Parser/tests/files/import/barfoo.png");}body#icomelast {color: fuschia;padding: 5px;background-image: url("http://foobar.com");}' + ) + ); + } + +} 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..5efed294 --- /dev/null +++ b/tests/files/import.css @@ -0,0 +1,10 @@ +@import "imported.css"; +@import "import/utf-16.css"; + +body#icomelast{ + color: fuschia; + padding: 5px; + background-image: url(http://foobar.com); +} + +@import "nope.css"; diff --git a/tests/files/import/subdir/euc-jp.css b/tests/files/import/subdir/euc-jp.css new file mode 100644 index 00000000..c4d3d40a --- /dev/null +++ b/tests/files/import/subdir/euc-jp.css @@ -0,0 +1,5 @@ +@charset "EUC-JP"; + +.euc-jp { + content: "äȶ"; +} diff --git a/tests/files/import/subdir/utf-32.css b/tests/files/import/subdir/utf-32.css new file mode 100644 index 00000000..3dd69ed3 Binary files /dev/null and b/tests/files/import/subdir/utf-32.css differ diff --git a/tests/files/import/utf-16.css b/tests/files/import/utf-16.css new file mode 100644 index 00000000..056cb1ab Binary files /dev/null and b/tests/files/import/utf-16.css differ 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; +}