InlineStyle::__construct()   B
last analyzed

Complexity

Conditions 5
Paths 4

Size

Total Lines 12
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 12
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 8
nc 4
nop 1
1
<?php
2
namespace InlineStyle;
3
4
/*
5
 * InlineStyle MIT License
6
 *
7
 * Copyright (c) 2012 Christiaan Baartse
8
 *
9
 * Permission is hereby granted, free of charge, to any person obtaining
10
 * a copy of this software and associated documentation files (the
11
 * "Software"), to deal in the Software without restriction, including
12
 * without limitation the rights to use, copy, modify, merge, publish,
13
 * distribute, sublicense, and/or sell copies of the Software, and to
14
 * permit persons to whom the Software is furnished to do so, subject to
15
 * the following conditions:
16
 *
17
 * The above copyright notice and this permission notice shall be
18
 * included in all copies or substantial portions of the Software.
19
 *
20
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
24
 * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
25
 * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
26
 * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
27
 */
28
29
use Symfony\Component\CssSelector\Exception\ParseException;
30
31
/**
32
 * Parses a html file and applies all embedded and external stylesheets inline
33
 */
34
class InlineStyle
35
{
36
    /**
37
     * @var \DOMDocument the HTML as DOMDocument
38
     */
39
    private $_dom;
40
41
    /**
42
     * Prepare all the necessary objects
43
     *
44
     * @param string $html
45
     */
46
    public function __construct($html = '')
47
    {
48
        if ($html) {
49
            if ($html instanceof \DOMDocument) {
50
                $this->loadDomDocument(clone $html);
51
            } else if (strlen($html) <= PHP_MAXPATHLEN && file_exists($html)) {
52
                $this->loadHTMLFile($html);
53
            } else {
54
                $this->loadHTML($html);
55
            }
56
        }
57
    }
58
59
    /**
60
     * Load HTML file
61
     *
62
     * @param string $filename
63
     */
64
    public function loadHTMLFile($filename)
65
    {
66
        $this->loadHTML(file_get_contents($filename));
67
    }
68
69
    /**
70
     * Load HTML string (UTF-8 encoding assumed)
71
     *
72
     * @param string $html
73
     */
74
    public function loadHTML($html)
75
    {
76
        $dom = new \DOMDocument();
77
        $dom->formatOutput = true;
78
79
        // strip illegal XML UTF-8 chars
80
        // remove all control characters except CR, LF and tab
81
        $html = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/u', '', $html); // 00-09, 11-31, 127
82
83
        $dom->loadHTML($html);
84
        $this->loadDomDocument($dom);
85
    }
86
87
    /**
88
     * Load the HTML as a DOMDocument directly
89
     *
90
     * @param \DOMDocument $domDocument
91
     */
92
    public function loadDomDocument(\DOMDocument $domDocument)
93
    {
94
        $this->_dom = $domDocument;
95
        foreach ($this->_getNodesForCssSelector('[style]') as $node) {
96
            $node->setAttribute(
97
                'inlinestyle-original-style',
98
                $node->getAttribute('style')
99
            );
100
        }
101
    }
102
103
    /**
104
     * Applies one or more stylesheets to the current document
105
     *
106
     * @param string|string[] $stylesheet
107
     * @return InlineStyle self
108
     */
109
    public function applyStylesheet($stylesheet)
110
    {
111
        $stylesheet = (array) $stylesheet;
112
        foreach($stylesheet as $ss) {
113
            $parsed = $this->parseStylesheet($ss);
114
            $parsed = $this->sortSelectorsOnSpecificity($parsed);
115
            foreach($parsed as $arr) {
116
                list($selector, $style) = $arr;
117
                $this->applyRule($selector, $style);
118
            }
119
        }
120
121
        return $this;
122
    }
123
124
    /**
125
     * @param string $sel Css Selector
126
     * @return array|\DOMNodeList|\DOMElement[]
127
     */
128
    private function _getNodesForCssSelector($sel)
129
    {
130
        try {
131
            if (class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) {
132
                $converter = new \Symfony\Component\CssSelector\CssSelectorConverter(true);
133
                $xpathQuery = $converter->toXPath($sel);
134
            } else {
135
                $xpathQuery = \Symfony\Component\CssSelector\CssSelector::toXPath($sel);
136
            }
137
138
            return $this->_getDomXpath()->query($xpathQuery);
139
        }
140
        catch(ParseException $e) {
141
            // ignore css rule parse exceptions
142
        }
143
144
        return array();
145
    }
146
147
    /**
148
     * Applies a style rule on the document
149
     * @param string $selector
150
     * @param string $style
151
     * @return InlineStyle self
152
     */
153
    public function applyRule($selector, $style)
154
    {
155
        if($selector) {
156
            $nodes = $this->_getNodesForCssSelector($selector);
157
            $style = $this->_styleToArray($style);
158
159
            foreach($nodes as $node) {
160
                $current = $node->hasAttribute("style") ?
161
                    $this->_styleToArray($node->getAttribute("style")) :
162
                    array();
163
164
                $current = $this->_mergeStyles($current, $style);
165
166
                $node->setAttribute("style", $this->_arrayToStyle($current));
167
            }
168
        }
169
170
        return $this;
171
    }
172
173
    /**
174
     * Returns the DOMDocument as html
175
     *
176
     * @return string the HTML
177
     */
178
    public function getHTML()
179
    {
180
        $clone = clone $this;
181
        foreach ($clone->_getNodesForCssSelector('[inlinestyle-original-style]') as $node) {
182
            $current = $node->hasAttribute("style") ?
183
                $this->_styleToArray($node->getAttribute("style")) :
184
                array();
185
            $original = $node->hasAttribute("inlinestyle-original-style") ?
186
                $this->_styleToArray($node->getAttribute("inlinestyle-original-style")) :
187
                array();
188
189
            $current = $clone->_mergeStyles($current, $original);
190
191
            $node->setAttribute("style", $this->_arrayToStyle($current));
192
            $node->removeAttribute('inlinestyle-original-style');
193
        }
194
195
        return $clone->_dom->saveHTML();
196
    }
197
198
    /**
199
     * Recursively extracts the stylesheet nodes from the DOMNode
200
     *
201
     * This cannot be done with XPath or a CSS selector because the order in
202
     * which the elements are found matters
203
     *
204
     * @param \DOMNode $node leave empty to extract from the whole document
205
     * @param string $base The base URI for relative stylesheets
206
     * @param array $devices Considered devices
207
     * @param boolean $remove Should it remove the original stylesheets
208
     * @return array the extracted stylesheets
209
     */
210
    public function extractStylesheets($node = null, $base = '', $devices = array('all', 'screen', 'handheld'), $remove = true)
211
    {
212
        if(null === $node) {
213
            $node = $this->_dom;
214
        }
215
216
        $stylesheets = array();
217
        if($node->hasChildNodes()) {
218
            $removeQueue = array();
219
            /** @var $child \DOMElement */
220
            foreach($node->childNodes as $child) {
221
                $nodeName = strtolower($child->nodeName);
222
                if($nodeName === "style" && $this->isForAllowedMediaDevice($child->getAttribute('media'), $devices)) {
223
                    $stylesheets[] = $child->nodeValue;
224
                    $removeQueue[] = $child;
225
                } else if($nodeName === "link" && strtolower($child->getAttribute('rel')) === 'stylesheet' && $this->isForAllowedMediaDevice($child->getAttribute('media'), $devices)) {
226
                    if($child->hasAttribute("href")) {
227
                        $href = $child->getAttribute("href");
228
229
                        if($base && false === strpos($href, "://")) {
230
                            $href = "{$base}/{$href}";
231
                        }
232
233
                        $ext = @file_get_contents($href);
234
235
                        if($ext) {
236
                            $removeQueue[] = $child;
237
                            $stylesheets[] = $ext;
238
                        }
239
                    }
240
                } else {
241
                    $stylesheets = array_merge($stylesheets,
242
                        $this->extractStylesheets($child, $base, $devices, $remove));
243
                }
244
            }
245
246
            if ($remove) {
247
                foreach ($removeQueue as $child) {
248
                    $child->parentNode->removeChild($child);
249
                }
250
            }
251
        }
252
253
        return $stylesheets;
254
    }
255
256
    /**
257
     * Extracts the stylesheet nodes nodes specified by the xpath
258
     *
259
     * @param string $xpathQuery xpath query to the desired stylesheet
260
     * @return array the extracted stylesheets
261
     */
262
    public function extractStylesheetsWithXpath($xpathQuery)
263
    {
264
        $stylesheets = array();
265
266
        $nodes = $this->_getDomXpath()->query($xpathQuery);
267
        foreach ($nodes as $node)
268
        {
269
            $stylesheets[] = $node->nodeValue;
270
            $node->parentNode->removeChild($node);
271
        }
272
273
        return $stylesheets;
274
    }
275
276
    /**
277
     * Parses a stylesheet to selectors and properties
278
     * @param string $stylesheet
279
     * @return array
280
     */
281
    public function parseStylesheet($stylesheet)
282
    {
283
        $parsed = array();
284
        $stylesheet = $this->_stripStylesheet($stylesheet);
285
        $stylesheet = trim(trim($stylesheet), "}");
286
        foreach(explode("}", $stylesheet) as $rule) {
287
            //Don't parse empty rules
288
        	if(!trim($rule))continue;
289
        	list($selector, $style) = explode("{", $rule, 2);
290
            foreach (explode(',', $selector) as $sel) {
291
                $parsed[] = array(trim($sel), trim(trim($style), ";"));
292
            }
293
        }
294
295
        return $parsed;
296
    }
297
298
    public function sortSelectorsOnSpecificity($parsed)
299
    {
300
        usort($parsed, array($this, 'sortOnSpecificity'));
301
        return $parsed;
302
    }
303
304
    private function sortOnSpecificity($a, $b)
305
    {
306
        $a = $this->getScoreForSelector($a[0]);
307
        $b = $this->getScoreForSelector($b[0]);
308
309
        foreach (range(0, 2) as $i) {
310
            if ($a[$i] !== $b[$i]) {
311
                return $a[$i] < $b[$i] ? -1 : 1;
312
            }
313
        }
314
        return -1;
315
    }
316
317
    public function getScoreForSelector($selector)
318
    {
319
        return array(
320
            preg_match_all('/#\w/i', $selector, $result), // ID's
321
            preg_match_all('/\.\w/i', $selector, $result), // Classes
322
            preg_match_all('/^\w|\ \w|\(\w|\:[^not]/i', $selector, $result) // Tags
323
        );
324
    }
325
326
    /**
327
     * Parses style properties to a array which can be merged by mergeStyles()
328
     * @param string $style
329
     * @return array
330
     */
331
    private function _styleToArray($style)
332
    {
333
        $styles = array();
334
        $style = trim(trim($style), ";");
335
        if($style) {
336
            foreach(explode(";", $style) as $props) {
337
                $props = trim(trim($props), ";");
338
                //Don't parse empty props
339
                if(!trim($props))continue;
340
                //Only match valid CSS rules
341
                if (preg_match('#^([-a-z0-9\*]+):(.*)$#i', $props, $matches) && isset($matches[0], $matches[1], $matches[2])) {
342
                    list(, $prop, $val) = $matches;
343
                    $styles[$prop] = $val;
344
                }
345
            }
346
        }
347
348
        return $styles;
349
    }
350
351
    private function _arrayToStyle($array)
352
    {
353
        $st = array();
354
        foreach($array as $prop => $val) {
355
            $st[] = "{$prop}:{$val}";
356
        }
357
        return \implode(';', $st);
358
    }
359
360
    /**
361
     * Merges two sets of style properties taking !important into account
362
     * @param array $styleA
363
     * @param array $styleB
364
     * @return array
365
     */
366
    private function _mergeStyles(array $styleA, array $styleB)
367
    {
368
        foreach($styleB as $prop => $val) {
369
            if(!isset($styleA[$prop])
370
                || substr(str_replace(" ", "", strtolower($styleA[$prop])), -10) !== "!important")
371
            {
372
                $styleA[$prop] = $val;
373
            }
374
        }
375
376
        return $styleA;
377
    }
378
379
    private function _stripStylesheet($s)
380
    {
381
        // strip comments
382
        $s = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!','', $s);
383
        
384
        // strip keyframes rules
385
        $s = preg_replace('/@[-|keyframes].*?\{.*?\}[ \r\n]*\}/s', '', $s);
386
387
        return $s;
388
    }
389
390
    private function _getDomXpath()
391
    {
392
        return new \DOMXPath($this->_dom);
393
    }
394
395
    public function __clone()
396
    {
397
        $this->_dom = clone $this->_dom;
398
    }
399
400
    private function isForAllowedMediaDevice($mediaAttribute, array $devices)
401
    {
402
        $mediaAttribute = strtolower($mediaAttribute);
403
        $mediaDevices = explode(',', $mediaAttribute);
404
405
        foreach ($mediaDevices as $device) {
406
            $device = trim($device);
407
            if (!$device || in_array($device, $devices)) {
408
                return true;
409
            }
410
        }
411
        return false;
412
    }
413
}
414