diff --git a/lib/CSSList.php b/lib/CSSList.php index 8f014203..5c948683 100644 --- a/lib/CSSList.php +++ b/lib/CSSList.php @@ -152,6 +152,28 @@ public function getSelectorsBySpecificity($sSpecificitySearch = null) { $this->allSelectors($aResult, $sSpecificitySearch); return $aResult; } + + /** + * Expands all shorthand properties to their long value + */ + public function expandShorthands() + { + foreach($this->getAllDeclarationBlocks() as $oDeclaration) + { + $oDeclaration->expandShorthands(); + } + } + + /* + * Create shorthands properties whenever possible + */ + public function createShorthands() + { + foreach($this->getAllDeclarationBlocks() as $oDeclaration) + { + $oDeclaration->createShorthands(); + } + } } /** diff --git a/lib/CSSRuleSet.php b/lib/CSSRuleSet.php index 23685178..8badc9e5 100644 --- a/lib/CSSRuleSet.php +++ b/lib/CSSRuleSet.php @@ -128,6 +128,497 @@ public function setSelector($mSelector) { public function getSelectors() { return $this->aSelectors; } + + /* + * Split shorthand declarations (e.g. +margin+ or +font+) into their constituent parts. + **/ + public function expandShorthands() + { + // border must be expanded before dimensions + $this->expandBorderShorthand(); + $this->expandDimensionsShorthand(); + $this->expandFontShorthand(); + $this->expandBackgroundShorthand(); + } + + /* + * Create shorthand declarations (e.g. +margin+ or +font+) whenever possible. + **/ + public function createShorthands() + { + $this->createBackgroundShorthand(); + $this->createDimensionsShorthand(); + // border must be shortened after dimensions + $this->createBorderShorthand(); + $this->createFontShorthand(); + } + + /** + * Split shorthand border declarations (e.g. border: 1px red;) + * Additional splitting happens in expandDimensionsShorthand + **/ + public function expandBorderShorthand() + { + $aBorderRules = array( + 'border', 'border-left', 'border-right', 'border-top', 'border-bottom' + ); + $aBorderSizes = array( + 'thin', 'medium', 'thick' + ); + $aRules = $this->getRules(); + foreach ($aBorderRules as $sBorderRule) + { + if(!isset($aRules[$sBorderRule])) continue; + $oRule = $aRules[$sBorderRule]; + foreach ($oRule->getValues() as $aValues) + { + // multiple borders are not yet supported as of CSS3 + $mValue = $aValues[0]; + if($mValue instanceof CSSValue) + { + $mNewValue = clone $mValue; + } + else + { + $mNewValue = $mValue; + } + if($mValue instanceof CSSSize) + { + $sNewRuleName = $sBorderRule."-width"; + } + else if($mValue instanceof CSSColor) + { + $sNewRuleName = $sBorderRule."-color"; + } + else + { + if(in_array($mValue, $aBorderSizes)) + { + $sNewRuleName = $sBorderRule."-width"; + } + else //if(in_array($mValue, $aBorderStyles)) + { + $sNewRuleName = $sBorderRule."-style"; + } + } + $oNewRule = new CSSRule($sNewRuleName); + $oNewRule->setIsImportant($oRule->getIsImportant()); + $oNewRule->addValue(array($mNewValue)); + $this->addRule($oNewRule); + } + $this->removeRule($sBorderRule); + } + } + + /** + * Split shorthand dimensional declarations (e.g. margin: 0px auto;) + * into their constituent parts. + * Handles margin, padding, border-color, border-style and border-width. + **/ + public function expandDimensionsShorthand() + { + $aExpansions = array( + 'margin' => 'margin-%s', + 'padding' => 'padding-%s', + 'border-color' => 'border-%s-color', + 'border-style' => 'border-%s-style', + 'border-width' => 'border-%s-width' + ); + $aRules = $this->getRules(); + foreach ($aExpansions as $sProperty => $sExpanded) + { + if(!isset($aRules[$sProperty])) continue; + $oRule = $aRules[$sProperty]; + $aValues = $oRule->getValues(); + $top = $right = $bottom = $left = null; + switch(count($aValues)) + { + case 1: + $top = $right = $bottom = $left = $aValues[0]; + break; + case 2: + $top = $bottom = $aValues[0]; + $left = $right = $aValues[1]; + break; + case 3: + $top = $aValues[0]; + $left = $right = $aValues[1]; + $bottom = $aValues[2]; + break; + case 4: + $top = $aValues[0]; + $right = $aValues[1]; + $bottom = $aValues[2]; + $left = $aValues[3]; + break; + } + foreach(array('top', 'right', 'bottom', 'left') as $sPosition) + { + $oNewRule = new CSSRule(sprintf($sExpanded, $sPosition)); + $oNewRule->setIsImportant($oRule->getIsImportant()); + $oNewRule->addValue(${$sPosition}); + $this->addRule($oNewRule); + } + $this->removeRule($sProperty); + } + } + + /* + * Convert shorthand font declarations + * (e.g. font: 300 italic 11px/14px verdana, helvetica, sans-serif;) + * into their constituent parts. + **/ + public function expandFontShorthand() + { + $aRules = $this->getRules(); + if(!isset($aRules['font'])) return; + $oRule = $aRules['font']; + // reset properties to 'normal' per http://www.w3.org/TR/CSS21/fonts.html#font-shorthand + $aFontProperties = array( + 'font-style' => 'normal', 'font-variant' => 'normal', 'font-weight' => 'normal', + 'font-size' => 'normal', 'line-height' => 'normal' + ); + foreach($oRule->getValues() as $aValues) + { + $mValue = $aValues[0]; + if(!$mValue instanceof CSSValue) + { + $mValue = strtolower($mValue); + } + if(in_array($mValue, array('normal', 'inherit'))) + { + foreach (array('font-style', 'font-weight', 'font-variant') as $sProperty) + { + if(!isset($aFontProperties[$sProperty])) + { + $aFontProperties[$sProperty] = $aValues; + } + } + } + else if(in_array($mValue, array('italic', 'oblique'))) + { + $aFontProperties['font-style'] = $aValues; + } + else if($mValue == 'small-caps') + { + $aFontProperties['font-variant'] = $aValues; + } + else if(in_array($mValue, array('bold', 'bolder', 'lighter')) + || ($mValue instanceof CSSSize + && in_array($mValue->getSize(), range(100, 900, 100)) + ) + ){ + $aFontProperties['font-weight'] = $aValues; + } + else if($mValue instanceof CSSSlashedValue) + { + $aFontProperties['font-size'] = array($mValue->getValue1()); + $aFontProperties['line-height'] = array($mValue->getValue2()); + } + else if($mValue instanceof CSSSize && $mValue->getUnit() !== null) + { + $aFontProperties['font-size'] = $aValues; + } + else + { + $aFontProperties['font-family'] = $aValues; + } + } + foreach ($aFontProperties as $sProperty => $aValues) + { + if(!is_array($aValues)) $aValues = array($aValues); + $oNewRule = new CSSRule($sProperty); + $oNewRule->setValues(array($aValues)); + $oNewRule->setIsImportant($oRule->getIsImportant()); + $this->addRule($oNewRule); + } + $this->removeRule('font'); + } + + /* + * Convert shorthand background declarations + * (e.g. background: url("chess.png") gray 50% repeat fixed;) + * into their constituent parts. + * @see http://www.w3.org/TR/CSS21/colors.html#propdef-background + **/ + public function expandBackgroundShorthand() + { + $aRules = $this->getRules(); + if(!isset($aRules['background'])) return; + $oRule = $aRules['background']; + $aBgProperties = array( + 'background-color' => array('transparent'), 'background-image' => array('none'), + 'background-repeat' => array('repeat'), 'background-attachment' => array('scroll'), + 'background-position' => array(new CSSSize(0, '%'), new CSSSize(0, '%')) + ); + $aValuesList = $oRule->getValues(); + if(count($aValuesList) == 1 && $aValuesList[0][0] == 'inherit') + { + foreach ($aBgProperties as $sProperty => $aValues) { + $oNewRule = new CSSRule($sProperty); + $oNewRule->addValue(array('inherit')); + $oNewRule->setIsImportant($oRule->getIsImportant()); + $this->addRule($oNewRule); + } + $this->removeRule('background'); + return; + } + $iNumBgPos = 0; + foreach($aValuesList as $aValues) + { + $mValue = $aValues[0]; + if(!$mValue instanceof CSSValue) + { + $mValue = strtolower($mValue); + } + if ($mValue instanceof CSSURL) + { + $aBgProperties['background-image'] = $aValues; + } + else if($mValue instanceof CSSColor) + { + $aBgProperties['background-color'] = $aValues; + } + else if(in_array($mValue, array('scroll', 'fixed'))) + { + $aBgProperties['background-attachment'] = $aValues; + } + else if(in_array($mValue, array('repeat','no-repeat', 'repeat-x', 'repeat-y'))) + { + $aBgProperties['background-repeat'] = $aValues; + } + else if(in_array($mValue, array('left','center','right','top','bottom')) + || $mValue instanceof CSSSize + ){ + if($iNumBgPos == 0) + { + $aBgProperties['background-position'][0] = $mValue; + $aBgProperties['background-position'][1] = 'center'; + } + else + { + $aBgProperties['background-position'][$iNumBgPos] = $mValue; + } + $iNumBgPos++; + } + } + foreach ($aBgProperties as $sProperty => $aValues) { + $oNewRule = new CSSRule($sProperty); + $oNewRule->setIsImportant($oRule->getIsImportant()); + $oNewRule->addValue($aValues); + $this->addRule($oNewRule); + } + $this->removeRule('background'); + } + + public function createBackgroundShorthand() + { + $aProperties = array( + 'background-color', 'background-image', 'background-repeat', + 'background-position', 'background-attachment' + ); + $aRules = $this->getRules(); + $oNewRule = new CSSRule('background'); + foreach($aProperties as $sProperty) + { + if(!isset($aRules[$sProperty])) continue; + $oRule = $aRules[$sProperty]; + if(!$oRule->getIsImportant()) + { + foreach($aRules[$sProperty]->getValues() as $aValues) + { + $oNewRule->addValue($aValues); + } + $this->removeRule($sProperty); + } + } + if(count($oNewRule->getValues()) > 0) + { + $this->addRule($oNewRule); + } + } + + /** + * Combine border-color, border-style and border-width into border + * Should be run after create_dimensions_shorthand! + * + * TODO: this is extremely similar to createBackgroundShorthand and should be combined + **/ + public function createBorderShorthand() + { + $aBorderRules = array( + 'border-width', 'border-style', 'border-color' + ); + $oNewRule = new CSSRule('border'); + $aRules = $this->getRules(); + foreach ($aBorderRules as $sBorderRule) + { + if(!isset($aRules[$sBorderRule])) continue; + + $oRule = $aRules[$sBorderRule]; + if(!$oRule->getIsImportant()) + { + // Can't merge if multiple values ! + if(count($oRule->getValues()) > 1) continue; + foreach($oRule->getValues() as $aValues) + { + $mValue = $aValues[0]; + if($mValue instanceof CSSValue) + { + $mNewValue = clone $mValue; + $oNewRule->addValue(array($mNewValue)); + } + else + { + $oNewRule->addValue(array($mValue)); + } + } + } + } + if(count($oNewRule->getValues())) + { + $this->addRule($oNewRule); + foreach ($aBorderRules as $sRuleName) + { + $this->removeRule($sRuleName); + } + } + } + + /* + * Looks for long format CSS dimensional properties + * (margin, padding, border-color, border-style and border-width) + * and converts them into shorthand CSS properties. + **/ + public function createDimensionsShorthand() + { + $aPositions = array('top', 'right', 'bottom', 'left'); + $aExpansions = array( + 'margin' => 'margin-%s', + 'padding' => 'padding-%s', + 'border-color' => 'border-%s-color', + 'border-style' => 'border-%s-style', + 'border-width' => 'border-%s-width' + ); + $aRules = $this->getRules(); + foreach ($aExpansions as $sProperty => $sExpanded) + { + $aFoldable = array(); + foreach($aRules as $sRuleName => $oRule) + { + foreach ($aPositions as $sPosition) + { + if($sRuleName == sprintf($sExpanded, $sPosition)) + { + $aFoldable[$sRuleName] = $oRule; + } + } + } + // All four dimensions must be present + if(count($aFoldable) == 4) + { + $aValues = array(); + foreach ($aPositions as $sPosition) + { + $aValuesList = $aRules[sprintf($sExpanded, $sPosition)]->getValues(); + $aValues[$sPosition] = $aValuesList[0]; + } + $oNewRule = new CSSRule($sProperty); + if((string)$aValues['left'][0] == (string)$aValues['right'][0]) + { + if((string)$aValues['top'][0] == (string)$aValues['bottom'][0]) + { + if((string)$aValues['top'][0] == (string)$aValues['left'][0]) + { + // All 4 sides are equal + $oNewRule->addValue($aValues['top']); + } + else + { + // Top and bottom are equal, left and right are equal + $oNewRule->addValue($aValues['top']); + $oNewRule->addValue($aValues['left']); + } + } + else + { + // Only left and right are equal + $oNewRule->addValue($aValues['top']); + $oNewRule->addValue($aValues['left']); + $oNewRule->addValue($aValues['bottom']); + } + } + else + { + // No sides are equal + $oNewRule->addValue($aValues['top']); + $oNewRule->addValue($aValues['left']); + $oNewRule->addValue($aValues['bottom']); + $oNewRule->addValue($aValues['right']); + } + $this->addRule($oNewRule); + foreach ($aPositions as $sPosition) + { + $this->removeRule(sprintf($sExpanded, $sPosition)); + } + } + } + } + + /** + * Looks for long format CSS font properties (e.g. font-weight) and + * tries to convert them into a shorthand CSS font property. + * At least font-size AND font-family must be present in order to create a shorthand declaration. + **/ + public function createFontShorthand() + { + $aFontProperties = array( + 'font-style', 'font-variant', 'font-weight', 'font-size', 'line-height', 'font-family' + ); + $aRules = $this->getRules(); + if(!isset($aRules['font-size']) || !isset($aRules['font-family'])) + { + return; + } + $oNewRule = new CSSRule('font'); + foreach(array('font-style', 'font-variant', 'font-weight') as $sProperty) + { + if(isset($aRules[$sProperty])) + { + $oRule = $aRules[$sProperty]; + $aValuesList = $oRule->getValues(); + if($aValuesList[0][0] !== 'normal') + { + $oNewRule->addValue($aValuesList[0]); + } + } + } + // Get the font-size value + $aFSValues = $aRules['font-size']->getValues(); + // But wait to know if we have line-height to add it + if(isset($aRules['line-height'])) + { + $aLHValues = $aRules['line-height']->getValues(); + if($aLHValues[0][0] !== 'normal') + { + $val = new CSSSlashedValue($aFSValues[0][0], $aLHValues[0][0]); + $oNewRule->addValue(array($val)); + } + } + else + { + $oNewRule->addValue($aFSValues[0]); + } + + $aFFValues = $aRules['font-family']->getValues(); + $oNewRule->addValue($aFFValues[0]); + + $this->addRule($oNewRule); + foreach ($aFontProperties as $sProperty) + { + $this->removeRule($sProperty); + } + } public function __toString() { $sResult = implode(', ', $this->aSelectors).' {'; diff --git a/tests/CSSParserTests.php b/tests/CSSParserTests.php index a2c2f9c7..f40fa952 100644 --- a/tests/CSSParserTests.php +++ b/tests/CSSParserTests.php @@ -195,7 +195,25 @@ function testFunctionSyntax() { $sExpected = str_replace(array('0.2s', '0.3s', '90deg'), array('0.4s', '0.6s', '180deg'), $sExpected); $this->assertSame($sExpected, $oDoc->__toString()); } + + function testExpandShorthands() { + $oDoc = $this->parsedStructureForFile('expand-shorthands'); + $sExpected = 'body {font: italic 500 14px/1.618 "Trebuchet MS", Georgia, serif;border: 2px solid rgb(255,0,255);background: rgb(204,204,204) url("/images/foo.png") no-repeat left top;margin: 1em;padding: 2px 6px 3px;}'; + $this->assertSame($sExpected, $oDoc->__toString()); + $oDoc->expandShorthands(); + $sExpected = 'body {margin-top: 1em !important;margin-right: 1em !important;margin-bottom: 1em !important;margin-left: 1em !important;padding-top: 2px;padding-right: 6px;padding-bottom: 3px;padding-left: 6px;border-top-color: rgb(255,0,255);border-right-color: rgb(255,0,255);border-bottom-color: rgb(255,0,255);border-left-color: rgb(255,0,255);border-top-style: solid;border-right-style: solid;border-bottom-style: solid;border-left-style: solid;border-top-width: 2px;border-right-width: 2px;border-bottom-width: 2px;border-left-width: 2px;font-style: italic;font-variant: normal;font-weight: 500;font-size: 14px;line-height: 1.618;font-family: "Trebuchet MS", Georgia, serif;background-color: rgb(204,204,204);background-image: url("/images/foo.png");background-repeat: no-repeat;background-attachment: scroll;background-position: left, top;}'; + $this->assertSame($sExpected, $oDoc->__toString()); + } + function testCreateShorthands() { + $oDoc = $this->parsedStructureForFile('create-shorthands'); + $sExpected = 'body {font-size: 2em;font-family: Helvetica, Arial, sans-serif;font-weight: bold;border-width: 2px;border-color: rgb(153,153,153);border-style: dotted;background-color: rgb(255,255,255);background-image: url("foobar.png");background-repeat: repeat-y;margin-top: 2px;margin-right: 3px;margin-bottom: 4px;margin-left: 5px;}'; + $this->assertSame($sExpected, $oDoc->__toString()); + $oDoc->createShorthands(); + $sExpected = 'body {background: rgb(255,255,255) url("foobar.png") repeat-y;margin: 2px 5px 4px 3px;border: 2px dotted rgb(153,153,153);font: bold 2em Helvetica, Arial, sans-serif;}'; + $this->assertSame($sExpected, $oDoc->__toString()); + } + function parsedStructureForFile($sFileName) { $sFile = dirname(__FILE__).DIRECTORY_SEPARATOR.'files'.DIRECTORY_SEPARATOR."$sFileName.css"; $oParser = new CSSParser(file_get_contents($sFile)); diff --git a/tests/files/create-shorthands.css b/tests/files/create-shorthands.css new file mode 100644 index 00000000..c784d674 --- /dev/null +++ b/tests/files/create-shorthands.css @@ -0,0 +1,7 @@ +body +{ + font-size: 2em; font-family: Helvetica,Arial,sans-serif; font-weight: bold; + border-width: 2px; border-color: #999; border-style: dotted; + background-color: #fff; background-image: url('foobar.png'); background-repeat: repeat-y; + margin-top: 2px; margin-right: 3px; margin-bottom: 4px; margin-left: 5px; +} diff --git a/tests/files/expand-shorthands.css b/tests/files/expand-shorthands.css new file mode 100644 index 00000000..33d0c74c --- /dev/null +++ b/tests/files/expand-shorthands.css @@ -0,0 +1,8 @@ +body +{ + font: italic 500 14px/1.618 "Trebuchet MS", Georgia, serif; + border: 2px solid #f0f; + background: #ccc url("/images/foo.png") no-repeat left top; + margin: 1em !important; + padding: 2px 6px 3px; +}