1: <?php
2:
3: /**
4: * Jyxo PHP Library
5: *
6: * LICENSE
7: *
8: * This source file is subject to the new BSD license that is bundled
9: * with this package in the file license.txt.
10: * It is also available through the world-wide-web at this URL:
11: * https://github.com/jyxo/php/blob/master/license.txt
12: */
13:
14: namespace Jyxo;
15:
16: /**
17: * Class for working with CSS stylesheets.
18: *
19: * @category Jyxo
20: * @package Jyxo\Css
21: * @copyright Copyright (c) 2005-2011 Jyxo, s.r.o.
22: * @license https://github.com/jyxo/php/blob/master/license.txt
23: * @author Jaroslav HanslĂk
24: */
25: class Css
26: {
27: /**
28: * Constructor preventing from creating static class instances.
29: *
30: * @throws \LogicException When trying to create an instance
31: */
32: public final function __construct()
33: {
34: throw new \LogicException(sprintf('Cannot create an instance of a static class %s.', get_class($this)));
35: }
36:
37: /**
38: * Cleans up a CSS stylesheet.
39: *
40: * @param string $css Stylesheet definition
41: * @return string
42: */
43: public static function repair($css)
44: {
45: // Convert properties to lowercase
46: $css = preg_replace_callback('~((?:^|\{|;)\s*)([-a-z]+)(\s*:)~i', function($matches) {
47: return $matches[1] . strtolower($matches[2]) . $matches[3];
48: }, $css);
49: // Convert rgb() and url() to lowercase
50: $css = preg_replace_callback('~(rgb|url)(?=\s*\()~i', function($matches) {
51: return strtolower($matches[1]);
52: }, $css);
53: // Remove properties without values
54: $css = preg_replace_callback('~\s*[-a-z]+\s*:\s*([;}]|$)~i', function($matches) {
55: return '}' === $matches[1] ? '}' : '';
56: }, $css);
57: // Remove MS Office properties
58: $css = preg_replace('~\s*mso-[-a-z]+\s*:[^;}]*;?~i', '', $css);
59: // Convert color definitions to lowercase
60: $css = preg_replace_callback('~(:[^:]*?)(#[abcdef0-9]{3,6})~i', function($matches) {
61: return $matches[1] . strtolower($matches[2]);
62: }, $css);
63: // Convert colors from RGB to HEX
64: $css = preg_replace_callback('~rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)~', function($matches) {
65: return sprintf('#%02x%02x%02x', $matches[1], $matches[2], $matches[3]);
66: }, $css);
67:
68: return $css;
69: }
70:
71: /**
72: * Filters given properties.
73: *
74: * @param string $css Stylesheet definition
75: * @param array $properties Filtered properties
76: * @param boolean $exclude If true, $properties will be removed from the stylesheet; if false, only $properties will be left
77: * @return string
78: */
79: public static function filterProperties($css, array $properties, $exclude = true)
80: {
81: $properties = array_flip($properties);
82: return preg_replace_callback('~\s*([-a-z]+)\s*:[^;}]*;?~i', function($matches) use ($properties, $exclude) {
83: if ($exclude) {
84: return isset($properties[$matches[1]]) ? '' : $matches[0];
85: } else {
86: return isset($properties[$matches[1]]) ? $matches[0] : '';
87: }
88: }, $css);
89: }
90:
91: /**
92: * Removes unnecessary characters from a CSS stylesheet.
93: *
94: * It is recommended to use the repair() method on the stylesheet definition first.
95: *
96: * @param string $css Stylesheet definition
97: * @return string
98: */
99: public static function pack($css)
100: {
101: // Comments
102: $packed = preg_replace('~/\*.*\*/~sU', '', $css);
103: // Whitespace
104: $packed = preg_replace('~\s*([>+\~,{:;}])\s*~', '\1', $packed);
105: $packed = preg_replace('~\(\s+~', '(', $packed);
106: $packed = preg_replace('~\s+\)~', ')', $packed);
107: $packed = trim($packed);
108: // Convert colors from #ffffff to #fff
109: $packed = preg_replace('~(:[^:]*?#)([abcdef0-9]{1})\2([abcdef0-9]{1})\3([abcdef0-9]{1})\4~', '\1\2\3\4', $packed);
110: // Empty selectors
111: $packed = preg_replace('~(?<=})[^{]+\{\}~', '', $packed);
112: // Remove units when 0
113: $packed = preg_replace('~([\s:]0)(?:px|pt|pc|in|mm|cm|em|ex|%)~', '\1', $packed);
114: // Unnecessary semicolons
115: $packed = str_replace(';}', '}', $packed);
116: $packed = trim($packed, ';');
117:
118: return $packed;
119: }
120:
121: /**
122: * Converts HTML styles inside <style> elements to inline styles.
123: *
124: * Supported selectors:
125: * * a {...}
126: * * #header {...}
127: * * .icon {...}
128: * * h1#header {...}
129: * * a.icon.small {...}
130: * * a#remove.icon.small {...}
131: * * a img {...}
132: * * h1, h2 {...}
133: * * a:link {...} - converts to a {...}
134: *
135: * @param string $html Processed HTML source
136: * @return string
137: */
138: public static function convertStyleToInline($html)
139: {
140: // Extract styles from the source
141: $cssList = self::parseStyle($html);
142:
143: // If no styles were found, return the original HTML source
144: if (empty($cssList)) {
145: return $html;
146: }
147:
148: // Parse the HTML source
149: preg_match_all('~(?:<\w+[^>]*(?: /)?>)|(?:</\w+>)|(?:<![^>]+>)|(?:[^<]+)~', $html, $matches);
150: $path = array();
151: $html = '';
152: $inStyle = false;
153: foreach ($matches[0] as $htmlPart) {
154: // Skip <style> elements
155: if (0 === strpos($htmlPart, '<style')) {
156: // <style> opening tag
157: $inStyle = true;
158: } elseif (0 === strpos($htmlPart, '</style')) {
159: // <style> closing tag
160: $inStyle = false;
161: } elseif (!$inStyle) {
162: // Not inside the <style> element
163:
164: // Closing tag
165: if (0 === strpos($htmlPart, '</')) {
166: array_pop($path);
167: } elseif (('<' === $htmlPart[0]) && (0 !== strpos($htmlPart, '<!'))) {
168: // Opening tag or empty element, ignoring comments
169:
170: $htmlPart = trim($htmlPart, '/<> ');
171:
172: $attributeList = array('id' => '', 'class' => '');
173: // If there is no space, there are no attributes
174: if (false !== strpos($htmlPart, ' ')) {
175: list($tag, $attributes) = explode(' ', $htmlPart, 2);
176:
177: // Parse attributes
178: foreach (explode('" ', $attributes) as $attribute) {
179: list($attributeName, $attributeContent) = explode('="', $attribute);
180: $attributeList[$attributeName] = trim($attributeContent, '"');
181: }
182: } else {
183: $tag = $htmlPart;
184: }
185: $attributeClass = !empty($attributeList['class']) ? explode(' ', $attributeList['class']) : array();
186:
187: // Add element information to the path
188: array_push($path, array(
189: 'tag' => $tag,
190: 'id' => $attributeList['id'],
191: 'class' => $attributeClass
192: )
193: );
194:
195: // Walk through the CSS definition list and add applicable properties
196: // Because of inheritance, walk the array in reversed order
197: foreach (array_reverse($cssList) as $css) {
198: // Selectors have to have equal or less parts than the HTML element nesting level
199: if (count($css['selector']) > count($path)) {
200: continue;
201: }
202:
203: // The last selector part must correspond to the last processed tag
204: $lastSelector = end($css['selector']);
205: if (((!empty($lastSelector['tag'])) && ($tag !== $lastSelector['tag']))
206: || ((!empty($lastSelector['id'])) && ($attributeList['id'] !== $lastSelector['id']))
207: || (count(array_diff($lastSelector['class'], $attributeClass)) > 0)) {
208: continue;
209: }
210:
211: $add = true;
212: $lastPathKey = count($path);
213: foreach (array_reverse($css['selector']) as $selector) {
214: // Nothing was found in the previous search, no reason to continue searching
215: if (!$add) {
216: break;
217: }
218:
219: for ($i = ($lastPathKey - 1); $i >= 0; $i--) {
220: if (((empty($selector['tag'])) || ($path[$i]['tag'] === $selector['tag']))
221: && ((empty($selector['id'])) || ($path[$i]['id'] === $selector['id']))
222: && (0 === count(array_diff($selector['class'], $path[$i]['class'])))) {
223: $lastPathKey = $i;
224: continue 2;
225: }
226: }
227:
228: $add = false;
229: }
230:
231: if ($add) {
232: // Add a semicolon if missing
233: if (';' !== substr($css['rules'], -1)) {
234: $css['rules'] .= ';';
235: }
236: // CSS is processed in the reversed order, co place to the beginning
237: $attributeList['style'] = $css['rules'] . (isset($attributeList['style']) ? $attributeList['style'] : '');
238: }
239: }
240:
241: // Creates a new tag
242: $htmlPart = '<' . $tag;
243: foreach ($attributeList as $attributeName => $attributeContent) {
244: // Not using empty() because it would make attributes with the "0" value ignored
245: if ('' !== $attributeContent) {
246: $htmlPart .= ' ' . $attributeName . '="' . $attributeContent . '"';
247: }
248: }
249:
250: // Empty tags
251: switch ($tag) {
252: case 'br':
253: case 'hr':
254: case 'img':
255: case 'input':
256: $htmlPart .= ' />';
257: break;
258: default:
259: $htmlPart .= '>';
260: break;
261: }
262: }
263: }
264:
265: // Append the part to the HTML source
266: $html .= $htmlPart;
267: }
268:
269: // In case of float: add a cleaner (if there is none present already)
270: $cleaner = '<div style="clear: both; visibility: hidden; overflow: hidden; height: 1px;">';
271: if ((preg_match('~<\w+[^>]+style="[^"]*float:[^"]*"~', $html))
272: && (!preg_match('~' . preg_quote($cleaner) . '\s*</body>\s*</html>\s*$~', $html))) {
273: $html = str_replace('</body>', $cleaner . '</body>', $html);
274: }
275:
276: return $html;
277: }
278:
279: /**
280: * Helper method for searching and parsing <style> definitions inside a HTML source.
281: *
282: * @param string $html Processed HTML source
283: * @return array
284: * @see \Jyxo\Css::convertStyleToInline()
285: */
286: private static function parseStyle($html)
287: {
288: // Find <style> elements
289: if (!preg_match_all('~<style\s+(?:[^>]+\s+)*type="text/css"[^>]*>(.*?)</style>~s', $html, $styles)) {
290: return array();
291: }
292:
293: $cssList = array();
294: foreach ($styles[1] as $style) {
295: // Remove CDATA and HTML comments
296: $style = str_replace(array('<![CDATA[', ']]>', '<!--', '-->'), '', $style);
297:
298: // Optimize the parsed definitions
299: $style = self::pack($style);
300:
301: if (empty($style)) {
302: continue;
303: }
304:
305: // Replace quotes with apostrophes
306: $style = str_replace('"', "'", $style);
307:
308: // Remove the last empty part
309: $definitions = explode('}', $style, -1);
310:
311: foreach ($definitions as $definition) {
312: // Allows only supported selectors with valid rules
313: if (!preg_match('~^(?:(?:(?:[-_\w#.:]+)\s?)+,?)+{(?:[-\w]+:[^;]+[;]?)+$~', $definition)) {
314: continue;
315: }
316:
317: list($selector, $rules) = explode('{', $definition);
318: foreach (explode(',', $selector) as $part) {
319: // Convert a:link to a
320: $part = str_replace(':link', '', $part);
321:
322: $parsedSelector = array();
323: foreach (explode(' ', $part) as $selectorPart) {
324: // If no tag name was given use a fake one
325: if (('.' === $selectorPart[0]) || ('#' === $selectorPart[0])) {
326: $selectorPart = ' ' . $selectorPart;
327: }
328:
329: if (false !== strpos($selectorPart, '.')) {
330: list($selectorPart, $class) = explode('.', $selectorPart, 2);
331: // There can be multiple classes
332: $class = explode('.', $class);
333: } else {
334: $class = array();
335: }
336: if (false !== strpos($selectorPart, '#')) {
337: list($selectorPart, $id) = explode('#', $selectorPart, 2);
338: } else {
339: $id = '';
340: }
341: $tag = trim($selectorPart);
342:
343: $parsedSelector[] = array(
344: 'tag' => strtolower($tag),
345: 'id' => $id,
346: 'class' => $class
347: );
348: }
349:
350: $cssList[] = array(
351: 'selector' => $parsedSelector,
352: 'rules' => $rules
353: );
354: }
355: }
356: }
357:
358: return $cssList;
359: }
360: }
361: