Completed
Pull Request — master (#152)
by Christophe
03:00
created

CssToInlineStyles::convert()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 18
Bugs 1 Features 0
Metric Value
c 18
b 1
f 0
dl 0
loc 18
rs 9.4285
cc 2
eloc 9
nc 2
nop 2
1
<?php
2
3
namespace TijsVerkoyen\CssToInlineStyles;
4
5
use Symfony\Component\CssSelector\CssSelector;
6
use Symfony\Component\CssSelector\CssSelectorConverter;
7
use Symfony\Component\CssSelector\Exception\ExceptionInterface;
8
use TijsVerkoyen\CssToInlineStyles\Css\Processor;
9
use TijsVerkoyen\CssToInlineStyles\Css\Property\Processor as PropertyProcessor;
10
use TijsVerkoyen\CssToInlineStyles\Css\Rule\Rule;
11
12
class CssToInlineStyles
13
{
14
    private $cssConverter;
15
16
    public function __construct()
17
    {
18
        if (class_exists('Symfony\Component\CssSelector\CssSelectorConverter')) {
19
            $this->cssConverter = new CssSelectorConverter();
20
        }
21
    }
22
23
    /**
24
     * Will inline the $css into the given $html
25
     *
26
     * Remark: if the html contains <style>-tags those will be used, the rules
27
     * in $css will be appended.
28
     *
29
     * @param string $html
30
     * @param string $css
31
     * @return string
32
     */
33
    public function convert($html, $css = null)
34
    {
35
        $document = $this->createDomDocumentFromHtml($html);
36
        $processor = new Processor();
37
38
        // get all styles from the style-tags
39
        $rules = $processor->getRules(
40
            $processor->getCssFromStyleTags($html)
41
        );
42
43
        if ($css !== null) {
44
            $rules = $processor->getRules($css, $rules);
45
        }
46
47
        $document = $this->inline($document, $rules);
48
49
        return $this->getHtmlFromDocument($document);
50
    }
51
52
    /**
53
     * Inle the given properties on an given DOMElement
54
     *
55
     * @param \DOMElement             $element
56
     * @param Css\Property\Property[] $properties
57
     * @return \DOMElement
58
     */
59
    public function inlineCssOnElement(\DOMElement $element, array $properties)
60
    {
61
        if (empty($properties)) {
62
            return $element;
63
        }
64
65
        $cssProperties = array();
66
        $inlineProperties = $this->getInlineStyles($element);
67
68
        if (!empty($inlineProperties)) {
69
            foreach ($inlineProperties as $property) {
70
                $cssProperties[$property->getName()] = $property;
71
            }
72
        }
73
74
        foreach ($properties as $property) {
75
            if (!isset($cssProperties[$property->getName()])) {
76
                $cssProperties[$property->getName()] = $property;
77
            }
78
        }
79
80
        $rules = array();
81
        foreach ($cssProperties as $property) {
82
            $rules[] = $property->toString();
83
        }
84
        $element->setAttribute('style', implode(' ', $rules));
85
86
        return $element;
87
    }
88
89
    /**
90
     * Get the current inline styles for a given DOMElement
91
     *
92
     * @param \DOMElement $element
93
     * @return Css\Property\Property[]
94
     */
95
    public function getInlineStyles(\DOMElement $element)
96
    {
97
        $processor = new PropertyProcessor();
98
99
        return $processor->convertArrayToObjects(
100
            $processor->splitIntoSeparateProperties(
101
                $element->getAttribute('style')
102
            )
103
        );
104
    }
105
106
    /**
107
     * @param string $html
108
     * @return \DOMDocument
109
     */
110
    protected function createDomDocumentFromHtml($html)
111
    {
112
        $html = trim($html);
113
        if (strstr('<?xml', $html) !== 0) {
0 ignored issues
show
Unused Code Bug introduced by
The strict comparison !== seems to always evaluate to true as the types of strstr('<?xml', $html) (string) and 0 (integer) can never be identical. Maybe you want to use a loose comparison != instead?
Loading history...
114
            $xmlHeader = '<?xml encoding="utf-8" ?>';
115
            $html = $xmlHeader . $html;
116
        }
117
118
        $document = new \DOMDocument('1.0', 'UTF-8');
119
        $internalErrors = libxml_use_internal_errors(true);
120
        $document->loadHTML($html);
121
        libxml_use_internal_errors($internalErrors);
122
        $document->formatOutput = true;
123
124
        return $document;
125
    }
126
127
    /**
128
     * @param \DOMDocument $document
129
     * @return string
130
     */
131
    protected function getHtmlFromDocument(\DOMDocument $document)
132
    {
133
        $xml = $document->saveXML(null, LIBXML_NOEMPTYTAG);
134
135
        $html = preg_replace(
136
            '|<\?xml (.*)\?>|',
137
            '',
138
            $xml
139
        );
140
141
        return ltrim($html);
142
    }
143
144
    /**
145
     * @param \DOMDocument    $document
146
     * @param Css\Rule\Rule[] $rules
147
     * @return \DOMDocument
148
     */
149
    protected function inline(\DOMDocument $document, array $rules)
150
    {
151
        if (empty($rules)) {
152
            return $document;
153
        }
154
155
        $xPath = new \DOMXPath($document);
156
        foreach ($rules as $rule) {
157
            try {
158
                if (null !== $this->cssConverter) {
159
                    $expression = $this->cssConverter->toXPath($rule->getSelector());
160
                } else {
161
                    // Compatibility layer for Symfony 2.7 and older
162
                    $expression = CssSelector::toXPath($rule->getSelector());
163
                }
164
            } catch (ExceptionInterface $e) {
165
                continue;
166
            }
167
168
            $elements = $xPath->query($expression);
169
170
            if ($elements === false) {
171
                continue;
172
            }
173
174
            foreach ($elements as $element) {
175
                $this->calculatePropertiesToBeApplied($element, $rule->getProperties());
176
            }
177
        }
178
179
        $elements = $xPath->query('//*[@data-css-to-inline-styles]');
180
181
        foreach ($elements as $element) {
182
            $propertiesToBeApplied = $element->attributes->getNamedItem('data-css-to-inline-styles');
183
            $element->removeAttribute('data-css-to-inline-styles');
184
185
            if ($propertiesToBeApplied !== null) {
186
                $properties = unserialize(base64_decode($propertiesToBeApplied->value));
187
                $this->inlineCssOnElement($element, $properties);
188
            }
189
        }
190
191
        return $document;
192
    }
193
194
    /**
195
     * Store the calculated values in a temporary data-attribute
196
     *
197
     * @param \DOMElement             $element
198
     * @param Css\Property\Property[] $properties
199
     * @return \DOMElement
200
     */
201
    private function calculatePropertiesToBeApplied(
202
        \DOMElement $element,
203
        array $properties
204
    ) {
205
        if (empty($properties)) {
206
            return $element;
207
        }
208
209
        $cssProperties = array();
210
        $currentStyles = $element->attributes->getNamedItem('data-css-to-inline-styles');
211
212
        if ($currentStyles !== null) {
213
            $currentProperties = unserialize(
214
                base64_decode(
215
                    $currentStyles->value
216
                )
217
            );
218
219
            foreach ($currentProperties as $property) {
220
                $cssProperties[$property->getName()] = $property;
221
            }
222
        }
223
224
        foreach ($properties as $property) {
225
            if (isset($cssProperties[$property->getName()])) {
226
                $existingProperty = $cssProperties[$property->getName()];
227
228
                if (
229
                    ($existingProperty->isImportant() && $property->isImportant()) &&
230
                    ($existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0)
231
                ) {
232
                    // if both the properties are important we should use the specificity
233
                    $cssProperties[$property->getName()] = $property;
234
                } elseif (!$existingProperty->isImportant() && $property->isImportant()) {
235
                    // if the existing property is not important but the new one is, it should be overruled
236
                    $cssProperties[$property->getName()] = $property;
237
                } elseif (
238
                    !$existingProperty->isImportant() &&
239
                    ($existingProperty->getOriginalSpecificity()->compareTo($property->getOriginalSpecificity()) <= 0)
240
                ) {
241
                    // if the existing property is not important we should check the specificity
242
                    $cssProperties[$property->getName()] = $property;
243
                }
244
            } else {
245
                $cssProperties[$property->getName()] = $property;
246
            }
247
        }
248
249
        $element->setAttribute(
250
            'data-css-to-inline-styles',
251
            base64_encode(
252
                serialize(
253
                    array_values($cssProperties)
254
                )
255
            )
256
        );
257
258
        return $element;
259
    }
260
}
261