3 namespace Pelago\Emogrifier\HtmlProcessor;
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".
9 * It will only add attributes, but leaves the style attribute untouched.
11 * To trigger the conversion, call the convertCssToVisualAttributes method.
13 * @internal This class currently is a new technology preview, and its API is still in flux. Don't use it in production.
15 * @author Oliver Klee <github@oliverklee.de>
17 class CssToAttributeConverter extends AbstractHtmlProcessor
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.
27 private $cssToHtmlMap = [
28 'background-color' => [
29 'attribute' => 'bgcolor',
32 'attribute' => 'align',
33 'nodes' => ['p', 'div', 'td'],
34 'values' => ['left', 'right', 'center', 'justify'],
37 'attribute' => 'align',
38 'nodes' => ['table', 'img'],
39 'values' => ['left', 'right'],
42 'attribute' => 'cellspacing',
50 private static $parsedCssCache = [];
53 * Maps the CSS from the style nodes to visual HTML attributes.
55 * @return CssToAttributeConverter fluent interface
57 public function convertCssToVisualAttributes()
59 /** @var \DOMElement $node */
60 foreach ($this->getAllNodesWithStyleAttribute() as $node) {
61 $inlineStyleDeclarations = $this->parseCssDeclarationsBlock($node->getAttribute('style'));
62 $this->mapCssToHtmlAttributes($inlineStyleDeclarations, $node);
69 * Returns a list with all DOM nodes that have a style attribute.
71 * @return \DOMNodeList
73 private function getAllNodesWithStyleAttribute()
75 $xPath = new \DOMXPath($this->domDocument);
77 return $xPath->query('//*[@style]');
81 * Parses a CSS declaration block into property name/value pairs.
85 * The declaration block
87 * "color: #000; font-weight: bold;"
89 * will be parsed into the following array:
92 * "font-weight" => "bold"
94 * @param string $cssDeclarationsBlock the CSS declarations block without the curly braces, may be empty
97 * the CSS declarations with the property names as array keys and the property values as array values
99 private function parseCssDeclarationsBlock($cssDeclarationsBlock)
101 if (isset(self::$parsedCssCache[$cssDeclarationsBlock])) {
102 return self::$parsedCssCache[$cssDeclarationsBlock];
106 $declarations = \preg_split('/;(?!base64|charset)/', $cssDeclarationsBlock);
108 foreach ($declarations as $declaration) {
110 if (!\preg_match('/^([A-Za-z\\-]+)\\s*:\\s*(.+)$/s', \trim($declaration), $matches)) {
114 $propertyName = \strtolower($matches[1]);
115 $propertyValue = $matches[2];
116 $properties[$propertyName] = $propertyValue;
118 self::$parsedCssCache[$cssDeclarationsBlock] = $properties;
124 * Applies $styles to $node.
126 * This method maps CSS styles to HTML attributes and adds those to the
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
134 private function mapCssToHtmlAttributes(array $styles, \DOMElement $node)
136 foreach ($styles as $property => $value) {
137 // Strip !important indicator
138 $value = \trim(\str_replace('!important', '', $value));
139 $this->mapCssToHtmlAttribute($property, $value, $node);
144 * Tries to apply the CSS style to $node as an attribute.
146 * This method maps a CSS rule to HTML attributes and adds those to the node.
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
154 private function mapCssToHtmlAttribute($property, $value, \DOMElement $node)
156 if (!$this->mapSimpleCssProperty($property, $value, $node)) {
157 $this->mapComplexCssProperty($property, $value, $node);
162 * Looks up the CSS property in the mapping table and maps it if it matches the conditions.
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
168 * @return bool true if the property can be mapped using the simple mapping table
170 private function mapSimpleCssProperty($property, $value, \DOMElement $node)
172 if (!isset($this->cssToHtmlMap[$property])) {
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) {
183 $node->setAttribute($mapping['attribute'], $value);
189 * Maps CSS properties that need special transformation to an HTML attribute.
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
197 private function mapComplexCssProperty($property, $value, \DOMElement $node)
201 $this->mapBackgroundProperty($node, $value);
204 // intentional fall-through
206 $this->mapWidthOrHeightProperty($node, $value, $property);
209 $this->mapMarginProperty($node, $value);
212 $this->mapBorderProperty($node, $value);
219 * @param \DOMElement $node node to apply styles to
220 * @param string $value the value of the style rule to map
224 private function mapBackgroundProperty(\DOMElement $node, $value)
226 // parse out the color, if any
227 $styles = \explode(' ', $value);
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);
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
242 private function mapWidthOrHeightProperty(\DOMElement $node, $value, $property)
244 // only parse values in px and %, but not values like "auto"
245 if (!\preg_match('/^(\\d+)(px|%)$/', $value)) {
249 $number = \preg_replace('/[^0-9.%]/', '', $value);
250 $node->setAttribute($property, $number);
254 * @param \DOMElement $node node to apply styles to
255 * @param string $value the value of the style rule to map
259 private function mapMarginProperty(\DOMElement $node, $value)
261 if (!$this->isTableOrImageNode($node)) {
265 $margins = $this->parseCssShorthandValue($value);
266 if ($margins['left'] === 'auto' && $margins['right'] === 'auto') {
267 $node->setAttribute('align', 'center');
272 * @param \DOMElement $node node to apply styles to
273 * @param string $value the value of the style rule to map
277 private function mapBorderProperty(\DOMElement $node, $value)
279 if (!$this->isTableOrImageNode($node)) {
283 if ($value === 'none' || $value === '0') {
284 $node->setAttribute('border', '0');
289 * @param \DOMElement $node
293 private function isTableOrImageNode(\DOMElement $node)
295 return $node->nodeName === 'table' || $node->nodeName === 'img';
299 * Parses a shorthand CSS value and splits it into individual values
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,
306 * @return string[] an array of values for top, right, bottom and left (using these as associative array keys)
308 private function parseCssShorthandValue($value)
310 $values = \preg_split('/\\s+/', $value);
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'];