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