97a94b1c99c4d56942ad9b5540b1e44f60477446
[GitHub/WoltLab/WCF.git] /
1 <?php
2
3 namespace Pelago\Emogrifier\HtmlProcessor;
4
5 /**
6 * This HtmlProcessor can convert style HTML attributes to the corresponding other visual HTML attributes,
7 * e.g. it converts style="width: 100px" to width="100".
8 *
9 * It will only add attributes, but leaves the style attribute untouched.
10 *
11 * To trigger the conversion, call the convertCssToVisualAttributes method.
12 *
13 * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
14 *
15 * @author Oliver Klee <github@oliverklee.de>
16 */
17 class CssToAttributeConverter extends AbstractHtmlProcessor
18 {
19 /**
20 * This multi-level array contains simple mappings of CSS properties to
21 * HTML attributes. If a mapping only applies to certain HTML nodes or
22 * only for certain values, the mapping is an object with a whitelist
23 * of nodes and values.
24 *
25 * @var mixed[][]
26 */
27 private $cssToHtmlMap = [
28 'background-color' => [
29 'attribute' => 'bgcolor',
30 ],
31 'text-align' => [
32 'attribute' => 'align',
33 'nodes' => ['p', 'div', 'td'],
34 'values' => ['left', 'right', 'center', 'justify'],
35 ],
36 'float' => [
37 'attribute' => 'align',
38 'nodes' => ['table', 'img'],
39 'values' => ['left', 'right'],
40 ],
41 'border-spacing' => [
42 'attribute' => 'cellspacing',
43 'nodes' => ['table'],
44 ],
45 ];
46
47 /**
48 * @var string[][]
49 */
50 private static $parsedCssCache = [];
51
52 /**
53 * Maps the CSS from the style nodes to visual HTML attributes.
54 *
55 * @return CssToAttributeConverter fluent interface
56 */
57 public function convertCssToVisualAttributes()
58 {
59 /** @var \DOMElement $node */
60 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
61 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
62 $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
63 }
64
65 return $this;
66 }
67
68 /**
69 * Returns a list with all DOM nodes that have a style attribute.
70 *
71 * @return \DOMNodeList
72 */
73 private function getAllNodesWithStyleAttribute()
74 {
75 $xPath = new \DOMXPath($this->domDocument);
76
77 return $xPath->query('//*[@style]');
78 }
79
80 /**
81 * Parses a CSS declaration block into property name/value pairs.
82 *
83 * Example:
84 *
85 * The declaration block
86 *
87 * "color: #000; font-weight: bold;"
88 *
89 * will be parsed into the following array:
90 *
91 * "color" => "#000"
92 * "font-weight" => "bold"
93 *
94 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
95 *
96 * @return string[]
97 * the CSS declarations with the property names as array keys and the property values as array values
98 */
99 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
100 {
101 if (isset(self::$parsedCssCache[$cssDeclarationsBlock])) {
102 return self::$parsedCssCache[$cssDeclarationsBlock];
103 }
104
105 $properties = [];
106 $declarations = \preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
107
108 foreach ($declarations as $declaration) {
109 $matches = [];
110 if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
111 continue;
112 }
113
114 $propertyName = \strtolower($matches[1]);
115 $propertyValue = $matches[2];
116 $properties[$propertyName] = $propertyValue;
117 }
118 self::$parsedCssCache[$cssDeclarationsBlock] = $properties;
119
120 return $properties;
121 }
122
123 /**
124 * Applies $styles to $node.
125 *
126 * This method maps CSS styles to HTML attributes and adds those to the
127 * node.
128 *
129 * @param string[] $styles the new CSS styles taken from the global styles to be applied to this node
130 * @param \DOMElement $node node to apply styles to
131 *
132 * @return void
133 */
134 private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
135 {
136 foreach ($styles as $property => $value) {
137 // Strip !important indicator
138 $value = \trim(\str_replace('!important', '', $value));
139 $this->mapCssToHtmlAttribute($property, $value, $node);
140 }
141 }
142
143 /**
144 * Tries to apply the CSS style to $node as an attribute.
145 *
146 * This method maps a CSS rule to HTML attributes and adds those to the node.
147 *
148 * @param string $property the name of the CSS property to map
149 * @param string $value the value of the style rule to map
150 * @param \DOMElement $node node to apply styles to
151 *
152 * @return void
153 */
154 private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
155 {
156 if (!$this->mapSimpleCssProperty($property, $value, $node)) {
157 $this->mapComplexCssProperty($property, $value, $node);
158 }
159 }
160
161 /**
162 * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
163 *
164 * @param string $property the name of the CSS property to map
165 * @param string $value the value of the style rule to map
166 * @param \DOMElement $node node to apply styles to
167 *
168 * @return bool true if the property can be mapped using the simple mapping table
169 */
170 private function mapSimpleCssProperty($property, $value, \DOMElement $node)
171 {
172 if (!isset($this->cssToHtmlMap[$property])) {
173 return false;
174 }
175
176 $mapping = $this->cssToHtmlMap[$property];
177 $nodesMatch = !isset($mapping['nodes']) || \in_array($node->nodeName, $mapping['nodes'], true);
178 $valuesMatch = !isset($mapping['values']) || \in_array($value, $mapping['values'], true);
179 if (!$nodesMatch || !$valuesMatch) {
180 return false;
181 }
182
183 $node->setAttribute($mapping['attribute'], $value);
184
185 return true;
186 }
187
188 /**
189 * Maps CSS properties that need special transformation to an HTML attribute.
190 *
191 * @param string $property the name of the CSS property to map
192 * @param string $value the value of the style rule to map
193 * @param \DOMElement $node node to apply styles to
194 *
195 * @return void
196 */
197 private function mapComplexCssProperty($property, $value, \DOMElement $node)
198 {
199 switch ($property) {
200 case 'background':
201 $this->mapBackgroundProperty($node, $value);
202 break;
203 case 'width':
204 // intentional fall-through
205 case 'height':
206 $this->mapWidthOrHeightProperty($node, $value, $property);
207 break;
208 case 'margin':
209 $this->mapMarginProperty($node, $value);
210 break;
211 case 'border':
212 $this->mapBorderProperty($node, $value);
213 break;
214 default:
215 }
216 }
217
218 /**
219 * @param \DOMElement $node node to apply styles to
220 * @param string $value the value of the style rule to map
221 *
222 * @return void
223 */
224 private function mapBackgroundProperty(\DOMElement $node, $value)
225 {
226 // parse out the color, if any
227 $styles = \explode(' ', $value);
228 $first = $styles[0];
229 if (!\is_numeric($first[0]) && \strpos($first, 'url') !== 0) {
230 // as this is not a position or image, assume it's a color
231 $node->setAttribute('bgcolor', $first);
232 }
233 }
234
235 /**
236 * @param \DOMElement $node node to apply styles to
237 * @param string $value the value of the style rule to map
238 * @param string $property the name of the CSS property to map
239 *
240 * @return void
241 */
242 private function mapWidthOrHeightProperty(\DOMElement $node, $value, $property)
243 {
244 // only parse values in px and %, but not values like "auto"
245 if (!\preg_match('/^(\\d+)(px|%)$/', $value)) {
246 return;
247 }
248
249 $number = \preg_replace('/[^0-9.%]/', '', $value);
250 $node->setAttribute($property, $number);
251 }
252
253 /**
254 * @param \DOMElement $node node to apply styles to
255 * @param string $value the value of the style rule to map
256 *
257 * @return void
258 */
259 private function mapMarginProperty(\DOMElement $node, $value)
260 {
261 if (!$this->isTableOrImageNode($node)) {
262 return;
263 }
264
265 $margins = $this->parseCssShorthandValue($value);
266 if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
267 $node->setAttribute('align', 'center');
268 }
269 }
270
271 /**
272 * @param \DOMElement $node node to apply styles to
273 * @param string $value the value of the style rule to map
274 *
275 * @return void
276 */
277 private function mapBorderProperty(\DOMElement $node, $value)
278 {
279 if (!$this->isTableOrImageNode($node)) {
280 return;
281 }
282
283 if ($value === 'none' || $value === '0') {
284 $node->setAttribute('border', '0');
285 }
286 }
287
288 /**
289 * @param \DOMElement $node
290 *
291 * @return bool
292 */
293 private function isTableOrImageNode(\DOMElement $node)
294 {
295 return $node->nodeName === 'table' || $node->nodeName === 'img';
296 }
297
298 /**
299 * Parses a shorthand CSS value and splits it into individual values
300 *
301 * @param string $value a string of CSS value with 1, 2, 3 or 4 sizes
302 * For example: padding: 0 auto;
303 * '0 auto' is split into top: 0, left: auto, bottom: 0,
304 * right: auto.
305 *
306 * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
307 */
308 private function parseCssShorthandValue($value)
309 {
310 $values = \preg_split('/\\s+/', $value);
311
312 $css = [];
313 $css['top'] = $values[0];
314 $css['right'] = (\count($values) > 1) ? $values[1] : $css['top'];
315 $css['bottom'] = (\count($values) > 2) ? $values[2] : $css['top'];
316 $css['left'] = (\count($values) > 3) ? $values[3] : $css['right'];
317
318 return $css;
319 }
320 }