Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.07% |
598 / 629 |
|
76.47% |
26 / 34 |
CRAP | |
0.00% |
0 / 1 |
| Html | |
95.07% |
598 / 629 |
|
76.47% |
26 / 34 |
238 | |
0.00% |
0 / 1 |
| addHtml | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
4.02 | |||
| parseInlineStyle | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
18 | |||
| parseNode | |
100.00% |
57 / 57 |
|
100.00% |
1 / 1 |
10 | |||
| parseChildNodes | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
7 | |||
| parseParagraph | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| parseInput | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| parseHeading | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| parseText | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| parseProperty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parseSpan | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parseTable | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| parseRow | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| parseCell | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
| shouldAddTextRun | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| recursiveParseStylesInHierarchy | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| filterOutNonInheritedStyles | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
1 | |||
| parseList | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
9 | |||
| getListStyle | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
2 | |||
| parseListItem | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| parseStyle | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| parseStyleDeclarations | |
94.35% |
167 / 177 |
|
0.00% |
0 / 1 |
62.69 | |||
| parseImage | |
98.51% |
66 / 67 |
|
0.00% |
0 / 1 |
22 | |||
| mapBorderStyle | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
6 | |||
| mapBorderColor | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| mapAlign | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
7 | |||
| mapRubyAlign | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
14.11 | |||
| mapAlignVertical | |
53.85% |
7 / 13 |
|
0.00% |
0 / 1 |
16.96 | |||
| mapListType | |
50.00% |
5 / 10 |
|
0.00% |
0 / 1 |
13.12 | |||
| parseLineBreak | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parseLink | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| parseHorizRule | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| parseRuby | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
16 | |||
| convertRgb | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| convertHtmlSize | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * This file is part of PHPWord - A pure PHP library for reading and writing |
| 5 | * word processing documents. |
| 6 | * |
| 7 | * PHPWord is free software distributed under the terms of the GNU Lesser |
| 8 | * General Public License version 3 as published by the Free Software Foundation. |
| 9 | * |
| 10 | * For the full copyright and license information, please read the LICENSE |
| 11 | * file that was distributed with this source code. For the full list of |
| 12 | * contributors, visit https://github.com/PHPOffice/PHPWord/contributors. |
| 13 | * |
| 14 | * @see https://github.com/PHPOffice/PHPWord |
| 15 | * |
| 16 | * @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3 |
| 17 | */ |
| 18 | |
| 19 | namespace PhpOffice\PhpWord\Shared; |
| 20 | |
| 21 | use DOMDocument; |
| 22 | use DOMNode; |
| 23 | use DOMXPath; |
| 24 | use Exception; |
| 25 | use PhpOffice\PhpWord\ComplexType\RubyProperties; |
| 26 | use PhpOffice\PhpWord\Element\AbstractContainer; |
| 27 | use PhpOffice\PhpWord\Element\Row; |
| 28 | use PhpOffice\PhpWord\Element\Table; |
| 29 | use PhpOffice\PhpWord\Element\TextRun; |
| 30 | use PhpOffice\PhpWord\Settings; |
| 31 | use PhpOffice\PhpWord\SimpleType\Jc; |
| 32 | use PhpOffice\PhpWord\SimpleType\NumberFormat; |
| 33 | use PhpOffice\PhpWord\Style\Paragraph; |
| 34 | |
| 35 | /** |
| 36 | * Common Html functions. |
| 37 | * |
| 38 | * @SuppressWarnings("PHPMD.UnusedPrivateMethod") For readWPNode |
| 39 | */ |
| 40 | class Html |
| 41 | { |
| 42 | private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/'; |
| 43 | |
| 44 | protected static $listIndex = 0; |
| 45 | |
| 46 | protected static $xpath; |
| 47 | |
| 48 | protected static $options; |
| 49 | |
| 50 | /** |
| 51 | * @var Css |
| 52 | */ |
| 53 | protected static $css; |
| 54 | |
| 55 | /** |
| 56 | * Add HTML parts. |
| 57 | * |
| 58 | * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter |
| 59 | * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary |
| 60 | * files or perform server-side request forgery by passing local file paths or URLs in <img>. |
| 61 | * |
| 62 | * @param AbstractContainer $element Where the parts need to be added |
| 63 | * @param string $html The code to parse |
| 64 | * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag |
| 65 | * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed |
| 66 | */ |
| 67 | public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void |
| 68 | { |
| 69 | /* |
| 70 | * @todo parse $stylesheet for default styles. Should result in an array based on id, class and element, |
| 71 | * which could be applied when such an element occurs in the parseNode function. |
| 72 | */ |
| 73 | static::$options = $options; |
| 74 | |
| 75 | // Preprocess: remove all line ends, decode HTML entity, |
| 76 | // fix ampersand and angle brackets and add body tag for HTML fragments |
| 77 | $html = str_replace(["\n", "\r"], '', $html); |
| 78 | $html = str_replace(['<', '>', '&', '"'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html); |
| 79 | $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8'); |
| 80 | $html = str_replace('&', '&', $html); |
| 81 | $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['<', '>', '&', '"'], $html); |
| 82 | |
| 83 | if (false === $fullHTML) { |
| 84 | $html = '<body>' . $html . '</body>'; |
| 85 | } |
| 86 | |
| 87 | // Load DOM |
| 88 | if (\PHP_VERSION_ID < 80000) { |
| 89 | $orignalLibEntityLoader = libxml_disable_entity_loader(true); |
| 90 | } |
| 91 | $dom = new DOMDocument(); |
| 92 | $dom->preserveWhiteSpace = $preserveWhiteSpace; |
| 93 | $dom->loadXML($html); |
| 94 | static::$xpath = new DOMXPath($dom); |
| 95 | $node = $dom->getElementsByTagName('body'); |
| 96 | |
| 97 | static::parseNode($node->item(0), $element); |
| 98 | if (\PHP_VERSION_ID < 80000) { |
| 99 | libxml_disable_entity_loader($orignalLibEntityLoader); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * parse Inline style of a node. |
| 105 | * |
| 106 | * @param DOMNode $node Node to check on attributes and to compile a style array |
| 107 | * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style |
| 108 | * |
| 109 | * @return array |
| 110 | */ |
| 111 | protected static function parseInlineStyle($node, $styles = []) |
| 112 | { |
| 113 | if (XML_ELEMENT_NODE == $node->nodeType) { |
| 114 | $attributes = $node->attributes; // get all the attributes(eg: id, class) |
| 115 | |
| 116 | $attributeDir = $attributes->getNamedItem('dir'); |
| 117 | $attributeDirValue = $attributeDir ? $attributeDir->nodeValue : ''; |
| 118 | $bidi = $attributeDirValue === 'rtl'; |
| 119 | foreach ($attributes as $attribute) { |
| 120 | $val = $attribute->value; |
| 121 | switch (strtolower($attribute->name)) { |
| 122 | case 'align': |
| 123 | $styles['alignment'] = self::mapAlign(trim($val), $bidi); |
| 124 | |
| 125 | break; |
| 126 | case 'lang': |
| 127 | $styles['lang'] = $val; |
| 128 | |
| 129 | break; |
| 130 | case 'width': |
| 131 | // tables, cells |
| 132 | $val = $val === 'auto' ? '100%' : $val; |
| 133 | if (false !== strpos($val, '%')) { |
| 134 | // e.g. <table width="100%"> or <td width="50%"> |
| 135 | $styles['width'] = (int) $val * 50; |
| 136 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; |
| 137 | } else { |
| 138 | // e.g. <table width="250> where "250" = 250px (always pixels) |
| 139 | $styles['width'] = Converter::pixelToTwip(self::convertHtmlSize($val)); |
| 140 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP; |
| 141 | } |
| 142 | |
| 143 | break; |
| 144 | case 'cellspacing': |
| 145 | // tables e.g. <table cellspacing="2">, where "2" = 2px (always pixels) |
| 146 | $styles['cellSpacing'] = Converter::pixelToTwip(self::convertHtmlSize($val)); |
| 147 | |
| 148 | break; |
| 149 | case 'bgcolor': |
| 150 | // tables, rows, cells e.g. <tr bgColor="#FF0000"> |
| 151 | $styles['bgColor'] = self::convertRgb($val); |
| 152 | |
| 153 | break; |
| 154 | case 'valign': |
| 155 | // cells e.g. <td valign="middle"> |
| 156 | if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) { |
| 157 | $styles['valign'] = self::mapAlignVertical($matches[0]); |
| 158 | } |
| 159 | |
| 160 | break; |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | $attributeIdentifier = $attributes->getNamedItem('id'); |
| 165 | if ($attributeIdentifier && self::$css) { |
| 166 | $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles); |
| 167 | } |
| 168 | |
| 169 | $attributeClass = $attributes->getNamedItem('class'); |
| 170 | if ($attributeClass) { |
| 171 | if (self::$css) { |
| 172 | $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles); |
| 173 | } |
| 174 | $styles['className'] = $attributeClass->nodeValue; |
| 175 | } |
| 176 | |
| 177 | $attributeStyle = $attributes->getNamedItem('style'); |
| 178 | if ($attributeStyle) { |
| 179 | $styles = self::parseStyle($attributeStyle, $styles); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | return $styles; |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Parse a node and add a corresponding element to the parent element. |
| 188 | * |
| 189 | * @param DOMNode $node node to parse |
| 190 | * @param AbstractContainer $element object to add an element corresponding with the node |
| 191 | * @param array $styles Array with all styles |
| 192 | * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems |
| 193 | */ |
| 194 | protected static function parseNode($node, $element, $styles = [], $data = []): void |
| 195 | { |
| 196 | if ($node->nodeName == 'style') { |
| 197 | self::$css = new Css($node->textContent); |
| 198 | self::$css->process(); |
| 199 | |
| 200 | return; |
| 201 | } |
| 202 | |
| 203 | // Populate styles array |
| 204 | $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell']; |
| 205 | foreach ($styleTypes as $styleType) { |
| 206 | if (!isset($styles[$styleType])) { |
| 207 | $styles[$styleType] = []; |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | // Node mapping table |
| 212 | $nodes = [ |
| 213 | // $method $node $element $styles $data $argument1 $argument2 |
| 214 | 'p' => ['Paragraph', $node, $element, $styles, null, null, null], |
| 215 | 'h1' => ['Heading', $node, $element, $styles, null, 'Heading1', null], |
| 216 | 'h2' => ['Heading', $node, $element, $styles, null, 'Heading2', null], |
| 217 | 'h3' => ['Heading', $node, $element, $styles, null, 'Heading3', null], |
| 218 | 'h4' => ['Heading', $node, $element, $styles, null, 'Heading4', null], |
| 219 | 'h5' => ['Heading', $node, $element, $styles, null, 'Heading5', null], |
| 220 | 'h6' => ['Heading', $node, $element, $styles, null, 'Heading6', null], |
| 221 | '#text' => ['Text', $node, $element, $styles, null, null, null], |
| 222 | 'strong' => ['Property', null, null, $styles, null, 'bold', true], |
| 223 | 'b' => ['Property', null, null, $styles, null, 'bold', true], |
| 224 | 'em' => ['Property', null, null, $styles, null, 'italic', true], |
| 225 | 'i' => ['Property', null, null, $styles, null, 'italic', true], |
| 226 | 'u' => ['Property', null, null, $styles, null, 'underline', 'single'], |
| 227 | 'sup' => ['Property', null, null, $styles, null, 'superScript', true], |
| 228 | 'sub' => ['Property', null, null, $styles, null, 'subScript', true], |
| 229 | 'span' => ['Span', $node, null, $styles, null, null, null], |
| 230 | 'font' => ['Span', $node, null, $styles, null, null, null], |
| 231 | 'table' => ['Table', $node, $element, $styles, null, null, null], |
| 232 | 'tr' => ['Row', $node, $element, $styles, null, null, null], |
| 233 | 'td' => ['Cell', $node, $element, $styles, null, null, null], |
| 234 | 'th' => ['Cell', $node, $element, $styles, null, null, null], |
| 235 | 'ul' => ['List', $node, $element, $styles, $data, null, null], |
| 236 | 'ol' => ['List', $node, $element, $styles, $data, null, null], |
| 237 | 'li' => ['ListItem', $node, $element, $styles, $data, null, null], |
| 238 | 'img' => ['Image', $node, $element, $styles, null, null, null], |
| 239 | 'br' => ['LineBreak', null, $element, $styles, null, null, null], |
| 240 | 'a' => ['Link', $node, $element, $styles, null, null, null], |
| 241 | 'input' => ['Input', $node, $element, $styles, null, null, null], |
| 242 | 'hr' => ['HorizRule', $node, $element, $styles, null, null, null], |
| 243 | 'ruby' => ['Ruby', $node, $element, $styles, null, null, null], |
| 244 | ]; |
| 245 | |
| 246 | $newElement = null; |
| 247 | $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2']; |
| 248 | |
| 249 | if (isset($nodes[$node->nodeName])) { |
| 250 | // Execute method based on node mapping table and return $newElement or null |
| 251 | // Arguments are passed by reference |
| 252 | $arguments = []; |
| 253 | $args = []; |
| 254 | [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName]; |
| 255 | for ($i = 0; $i <= 5; ++$i) { |
| 256 | if ($args[$i] !== null) { |
| 257 | $arguments[$keys[$i]] = &$args[$i]; |
| 258 | } |
| 259 | } |
| 260 | $method = "parse{$method}"; |
| 261 | $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments)); |
| 262 | |
| 263 | // Retrieve back variables from arguments |
| 264 | foreach ($keys as $key) { |
| 265 | if (array_key_exists($key, $arguments)) { |
| 266 | $$key = $arguments[$key]; |
| 267 | } |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | if ($newElement === null) { |
| 272 | $newElement = $element; |
| 273 | } |
| 274 | |
| 275 | static::parseChildNodes($node, $newElement, $styles, $data); |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Parse child nodes. |
| 280 | * |
| 281 | * @param DOMNode $node |
| 282 | * @param AbstractContainer|Row|Table $element |
| 283 | * @param array $styles |
| 284 | * @param array $data |
| 285 | */ |
| 286 | protected static function parseChildNodes($node, $element, $styles, $data): void |
| 287 | { |
| 288 | if ('li' != $node->nodeName) { |
| 289 | $cNodes = $node->childNodes; |
| 290 | if (!empty($cNodes)) { |
| 291 | foreach ($cNodes as $cNode) { |
| 292 | if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) { |
| 293 | self::parseNode($cNode, $element, $styles, $data); |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Parse paragraph node. |
| 302 | * |
| 303 | * @param DOMNode $node |
| 304 | * @param AbstractContainer $element |
| 305 | * @param array &$styles |
| 306 | * |
| 307 | * @return \PhpOffice\PhpWord\Element\PageBreak|TextRun |
| 308 | */ |
| 309 | protected static function parseParagraph($node, $element, &$styles) |
| 310 | { |
| 311 | $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']); |
| 312 | if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) { |
| 313 | return $element->addPageBreak(); |
| 314 | } |
| 315 | |
| 316 | return $element->addTextRun($styles['paragraph']); |
| 317 | } |
| 318 | |
| 319 | /** |
| 320 | * Parse input node. |
| 321 | * |
| 322 | * @param DOMNode $node |
| 323 | * @param AbstractContainer $element |
| 324 | * @param array &$styles |
| 325 | */ |
| 326 | protected static function parseInput($node, $element, &$styles): void |
| 327 | { |
| 328 | $attributes = $node->attributes; |
| 329 | if (null === $attributes->getNamedItem('type')) { |
| 330 | return; |
| 331 | } |
| 332 | |
| 333 | $inputType = $attributes->getNamedItem('type')->nodeValue; |
| 334 | switch ($inputType) { |
| 335 | case 'checkbox': |
| 336 | $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->nodeValue === 'true' ? true : false; |
| 337 | $textrun = $element->addTextRun($styles['paragraph']); |
| 338 | $textrun->addFormField('checkbox')->setValue($checked); |
| 339 | |
| 340 | break; |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | /** |
| 345 | * Parse heading node. |
| 346 | * |
| 347 | * @param string $argument1 Name of heading style |
| 348 | * |
| 349 | * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that |
| 350 | * Heading1 - Heading6 are already defined somewhere |
| 351 | */ |
| 352 | protected static function parseHeading(DOMNode $node, AbstractContainer $element, array &$styles, string $argument1): TextRun |
| 353 | { |
| 354 | $style = new Paragraph(); |
| 355 | $style->setStyleName($argument1); |
| 356 | $style->setStyleByArray(self::parseInlineStyle($node, $styles['paragraph'])); |
| 357 | |
| 358 | return $element->addTextRun($style); |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * Parse text node. |
| 363 | * |
| 364 | * @param DOMNode $node |
| 365 | * @param AbstractContainer $element |
| 366 | * @param array &$styles |
| 367 | */ |
| 368 | protected static function parseText($node, $element, &$styles): void |
| 369 | { |
| 370 | $styles['font'] = self::recursiveParseStylesInHierarchy($node, $styles['font']); |
| 371 | |
| 372 | //alignment applies on paragraph, not on font. Let's copy it there |
| 373 | if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) { |
| 374 | $styles['paragraph']['alignment'] = $styles['font']['alignment']; |
| 375 | } |
| 376 | |
| 377 | if (is_callable([$element, 'addText'])) { |
| 378 | $element->addText($node->nodeValue, $styles['font'], $styles['paragraph']); |
| 379 | } |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * Parse property node. |
| 384 | * |
| 385 | * @param array &$styles |
| 386 | * @param string $argument1 Style name |
| 387 | * @param string $argument2 Style value |
| 388 | */ |
| 389 | protected static function parseProperty(&$styles, $argument1, $argument2): void |
| 390 | { |
| 391 | $styles['font'][$argument1] = $argument2; |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * Parse span node. |
| 396 | * |
| 397 | * @param DOMNode $node |
| 398 | * @param array &$styles |
| 399 | */ |
| 400 | protected static function parseSpan($node, &$styles): void |
| 401 | { |
| 402 | self::parseInlineStyle($node, $styles['font']); |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Parse table node. |
| 407 | * |
| 408 | * @param DOMNode $node |
| 409 | * @param AbstractContainer $element |
| 410 | * @param array &$styles |
| 411 | * |
| 412 | * @return Table $element |
| 413 | * |
| 414 | * @todo As soon as TableItem, RowItem and CellItem support relative width and height |
| 415 | */ |
| 416 | protected static function parseTable($node, $element, &$styles) |
| 417 | { |
| 418 | $elementStyles = self::parseInlineStyle($node, $styles['table']); |
| 419 | |
| 420 | $newElement = $element->addTable($elementStyles); |
| 421 | |
| 422 | // Add style name from CSS Class |
| 423 | if (isset($elementStyles['className'])) { |
| 424 | $newElement->getStyle()->setStyleName($elementStyles['className']); |
| 425 | } |
| 426 | |
| 427 | $attributes = $node->attributes; |
| 428 | if ($attributes->getNamedItem('border')) { |
| 429 | $border = (int) $attributes->getNamedItem('border')->nodeValue; |
| 430 | $newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border)); |
| 431 | } |
| 432 | |
| 433 | return $newElement; |
| 434 | } |
| 435 | |
| 436 | /** |
| 437 | * Parse a table row. |
| 438 | * |
| 439 | * @param DOMNode $node |
| 440 | * @param Table $element |
| 441 | * @param array &$styles |
| 442 | * |
| 443 | * @return Row $element |
| 444 | */ |
| 445 | protected static function parseRow($node, $element, &$styles) |
| 446 | { |
| 447 | $rowStyles = self::parseInlineStyle($node, $styles['row']); |
| 448 | if ($node->parentNode->nodeName == 'thead') { |
| 449 | $rowStyles['tblHeader'] = true; |
| 450 | } |
| 451 | |
| 452 | // set cell height to control row heights |
| 453 | $height = $rowStyles['height'] ?? null; |
| 454 | unset($rowStyles['height']); // would not apply |
| 455 | |
| 456 | return $element->addRow($height, $rowStyles); |
| 457 | } |
| 458 | |
| 459 | /** |
| 460 | * Parse table cell. |
| 461 | * |
| 462 | * @param DOMNode $node |
| 463 | * @param Table $element |
| 464 | * @param array &$styles |
| 465 | * |
| 466 | * @return \PhpOffice\PhpWord\Element\Cell|TextRun $element |
| 467 | */ |
| 468 | protected static function parseCell($node, $element, &$styles) |
| 469 | { |
| 470 | $cellStyles = self::recursiveParseStylesInHierarchy($node, $styles['cell']); |
| 471 | |
| 472 | $colspan = $node->getAttribute('colspan'); |
| 473 | if (!empty($colspan)) { |
| 474 | $cellStyles['gridSpan'] = $colspan - 0; |
| 475 | } |
| 476 | |
| 477 | // set cell width to control column widths |
| 478 | $width = $cellStyles['width'] ?? null; |
| 479 | unset($cellStyles['width']); // would not apply |
| 480 | $cell = $element->addCell($width, $cellStyles); |
| 481 | |
| 482 | if (self::shouldAddTextRun($node)) { |
| 483 | return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph']))); |
| 484 | } |
| 485 | |
| 486 | return $cell; |
| 487 | } |
| 488 | |
| 489 | /** |
| 490 | * Checks if $node contains an HTML element that cannot be added to TextRun. |
| 491 | * |
| 492 | * @return bool Returns true if the node contains an HTML element that cannot be added to TextRun |
| 493 | */ |
| 494 | protected static function shouldAddTextRun(DOMNode $node) |
| 495 | { |
| 496 | $containsBlockElement = self::$xpath->query('.//table|./p|./ul|./ol|./h1|./h2|./h3|./h4|./h5|./h6', $node)->length > 0; |
| 497 | if ($containsBlockElement) { |
| 498 | return false; |
| 499 | } |
| 500 | |
| 501 | return true; |
| 502 | } |
| 503 | |
| 504 | /** |
| 505 | * Recursively parses styles on parent nodes |
| 506 | * TODO if too slow, add caching of parent nodes, !! everything is static here so watch out for concurrency !! |
| 507 | */ |
| 508 | protected static function recursiveParseStylesInHierarchy(DOMNode $node, array $style) |
| 509 | { |
| 510 | $parentStyle = []; |
| 511 | if ($node->parentNode != null && XML_ELEMENT_NODE == $node->parentNode->nodeType) { |
| 512 | $parentStyle = self::recursiveParseStylesInHierarchy($node->parentNode, []); |
| 513 | } |
| 514 | if ($node->nodeName === '#text') { |
| 515 | $parentStyle = array_merge($parentStyle, $style); |
| 516 | } else { |
| 517 | $parentStyle = self::filterOutNonInheritedStyles($parentStyle); |
| 518 | } |
| 519 | $style = self::parseInlineStyle($node, $parentStyle); |
| 520 | |
| 521 | return $style; |
| 522 | } |
| 523 | |
| 524 | /** |
| 525 | * Removes non-inherited styles from array. |
| 526 | */ |
| 527 | protected static function filterOutNonInheritedStyles(array $styles) |
| 528 | { |
| 529 | $nonInheritedStyles = [ |
| 530 | 'borderSize', |
| 531 | 'borderTopSize', |
| 532 | 'borderRightSize', |
| 533 | 'borderBottomSize', |
| 534 | 'borderLeftSize', |
| 535 | 'borderColor', |
| 536 | 'borderTopColor', |
| 537 | 'borderRightColor', |
| 538 | 'borderBottomColor', |
| 539 | 'borderLeftColor', |
| 540 | 'borderStyle', |
| 541 | 'spaceAfter', |
| 542 | 'spaceBefore', |
| 543 | 'underline', |
| 544 | 'strikethrough', |
| 545 | 'hidden', |
| 546 | ]; |
| 547 | |
| 548 | $styles = array_diff_key($styles, array_flip($nonInheritedStyles)); |
| 549 | |
| 550 | return $styles; |
| 551 | } |
| 552 | |
| 553 | /** |
| 554 | * Parse list node. |
| 555 | * |
| 556 | * @param DOMNode $node |
| 557 | * @param AbstractContainer $element |
| 558 | * @param array &$styles |
| 559 | * @param array &$data |
| 560 | */ |
| 561 | protected static function parseList($node, $element, &$styles, &$data) |
| 562 | { |
| 563 | $isOrderedList = $node->nodeName === 'ol'; |
| 564 | if (isset($data['listdepth'])) { |
| 565 | ++$data['listdepth']; |
| 566 | } else { |
| 567 | $data['listdepth'] = 0; |
| 568 | $styles['list'] = 'listStyle_' . self::$listIndex++; |
| 569 | $style = $element->getPhpWord()->addNumberingStyle($styles['list'], self::getListStyle($isOrderedList)); |
| 570 | |
| 571 | // extract attributes start & type e.g. <ol type="A" start="3"> |
| 572 | $start = 0; |
| 573 | $type = ''; |
| 574 | foreach ($node->attributes as $attribute) { |
| 575 | switch ($attribute->name) { |
| 576 | case 'start': |
| 577 | $start = (int) $attribute->value; |
| 578 | |
| 579 | break; |
| 580 | case 'type': |
| 581 | $type = $attribute->value; |
| 582 | |
| 583 | break; |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | $levels = $style->getLevels(); |
| 588 | /** @var \PhpOffice\PhpWord\Style\NumberingLevel */ |
| 589 | $level = $levels[0]; |
| 590 | if ($start > 0) { |
| 591 | $level->setStart($start); |
| 592 | } |
| 593 | $type = $type ? self::mapListType($type) : null; |
| 594 | if ($type) { |
| 595 | $level->setFormat($type); |
| 596 | } |
| 597 | } |
| 598 | if ($node->parentNode->nodeName === 'li') { |
| 599 | return $element->getParent(); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | /** |
| 604 | * @param bool $isOrderedList |
| 605 | * |
| 606 | * @return array |
| 607 | */ |
| 608 | protected static function getListStyle($isOrderedList) |
| 609 | { |
| 610 | if ($isOrderedList) { |
| 611 | return [ |
| 612 | 'type' => 'multilevel', |
| 613 | 'levels' => [ |
| 614 | ['format' => NumberFormat::DECIMAL, 'text' => '%1.', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360], |
| 615 | ['format' => NumberFormat::LOWER_LETTER, 'text' => '%2.', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360], |
| 616 | ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%3.', 'alignment' => 'right', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 180], |
| 617 | ['format' => NumberFormat::DECIMAL, 'text' => '%4.', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360], |
| 618 | ['format' => NumberFormat::LOWER_LETTER, 'text' => '%5.', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360], |
| 619 | ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%6.', 'alignment' => 'right', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 180], |
| 620 | ['format' => NumberFormat::DECIMAL, 'text' => '%7.', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360], |
| 621 | ['format' => NumberFormat::LOWER_LETTER, 'text' => '%8.', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360], |
| 622 | ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%9.', 'alignment' => 'right', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 180], |
| 623 | ], |
| 624 | ]; |
| 625 | } |
| 626 | |
| 627 | return [ |
| 628 | 'type' => 'hybridMultilevel', |
| 629 | 'levels' => [ |
| 630 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], |
| 631 | ['format' => NumberFormat::BULLET, 'text' => 'â—¦', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], |
| 632 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], |
| 633 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], |
| 634 | ['format' => NumberFormat::BULLET, 'text' => 'â—¦', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], |
| 635 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], |
| 636 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], |
| 637 | ['format' => NumberFormat::BULLET, 'text' => 'â—¦', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], |
| 638 | ['format' => NumberFormat::BULLET, 'text' => '•', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], |
| 639 | ], |
| 640 | ]; |
| 641 | } |
| 642 | |
| 643 | /** |
| 644 | * Parse list item node. |
| 645 | * |
| 646 | * @param DOMNode $node |
| 647 | * @param AbstractContainer $element |
| 648 | * @param array &$styles |
| 649 | * @param array $data |
| 650 | * |
| 651 | * @todo This function is almost the same like `parseChildNodes`. Merged? |
| 652 | * @todo As soon as ListItem inherits from AbstractContainer or TextRun delete parsing part of childNodes |
| 653 | */ |
| 654 | protected static function parseListItem($node, $element, &$styles, $data): void |
| 655 | { |
| 656 | $cNodes = $node->childNodes; |
| 657 | if (!empty($cNodes)) { |
| 658 | $listRun = $element->addListItemRun($data['listdepth'], $styles['list'], $styles['paragraph']); |
| 659 | foreach ($cNodes as $cNode) { |
| 660 | self::parseNode($cNode, $listRun, $styles, $data); |
| 661 | } |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | /** |
| 666 | * Parse style. |
| 667 | * |
| 668 | * @param DOMNode $attribute |
| 669 | */ |
| 670 | protected static function parseStyle($attribute, array $styles): array |
| 671 | { |
| 672 | $properties = explode(';', trim($attribute->nodeValue, " \t\n\r\0\x0B;")); |
| 673 | |
| 674 | $selectors = []; |
| 675 | foreach ($properties as $property) { |
| 676 | [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null); |
| 677 | $selectors[strtolower(trim($cKey))] = trim($cValue ?? ''); |
| 678 | } |
| 679 | |
| 680 | return self::parseStyleDeclarations($selectors, $styles); |
| 681 | } |
| 682 | |
| 683 | protected static function parseStyleDeclarations(array $selectors, array $styles): array |
| 684 | { |
| 685 | $bidi = ($selectors['direction'] ?? '') === 'rtl'; |
| 686 | foreach ($selectors as $property => $value) { |
| 687 | switch ($property) { |
| 688 | case 'text-decoration': |
| 689 | switch ($value) { |
| 690 | case 'underline': |
| 691 | $styles['underline'] = 'single'; |
| 692 | |
| 693 | break; |
| 694 | case 'line-through': |
| 695 | $styles['strikethrough'] = true; |
| 696 | |
| 697 | break; |
| 698 | } |
| 699 | |
| 700 | break; |
| 701 | case 'text-align': |
| 702 | $styles['alignment'] = self::mapAlign($value, $bidi); |
| 703 | |
| 704 | break; |
| 705 | case 'ruby-align': |
| 706 | $styles['rubyAlignment'] = self::mapRubyAlign($value); |
| 707 | |
| 708 | break; |
| 709 | case 'display': |
| 710 | $styles['hidden'] = $value === 'none' || $value === 'hidden'; |
| 711 | |
| 712 | break; |
| 713 | case 'direction': |
| 714 | $styles['rtl'] = $value === 'rtl'; |
| 715 | $styles['bidi'] = $value === 'rtl'; |
| 716 | |
| 717 | break; |
| 718 | case 'font-size': |
| 719 | $styles['size'] = Converter::cssToPoint($value); |
| 720 | |
| 721 | break; |
| 722 | case 'font-family': |
| 723 | $value = array_map('trim', explode(',', $value)); |
| 724 | $styles['name'] = ucwords($value[0]); |
| 725 | |
| 726 | break; |
| 727 | case 'color': |
| 728 | $styles['color'] = self::convertRgb($value); |
| 729 | |
| 730 | break; |
| 731 | case 'background-color': |
| 732 | $styles['bgColor'] = self::convertRgb($value); |
| 733 | |
| 734 | break; |
| 735 | case 'line-height': |
| 736 | $matches = []; |
| 737 | if ($value === 'normal' || $value === 'inherit') { |
| 738 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
| 739 | $spacing = 0; |
| 740 | } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) { |
| 741 | //matches number with a unit, e.g. 12px, 15pt, 20mm, ... |
| 742 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT; |
| 743 | $spacing = Converter::cssToTwip($matches[1]); |
| 744 | } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { |
| 745 | //matches percentages |
| 746 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
| 747 | //we are subtracting 1 line height because the Spacing writer is adding one line |
| 748 | $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; |
| 749 | } else { |
| 750 | //any other, wich is a multiplier. E.g. 1.2 |
| 751 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
| 752 | //we are subtracting 1 line height because the Spacing writer is adding one line |
| 753 | $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; |
| 754 | } |
| 755 | $styles['spacingLineRule'] = $spacingLineRule; |
| 756 | $styles['line-spacing'] = $spacing; |
| 757 | |
| 758 | break; |
| 759 | case 'letter-spacing': |
| 760 | $styles['letter-spacing'] = Converter::cssToTwip($value); |
| 761 | |
| 762 | break; |
| 763 | case 'text-indent': |
| 764 | $styles['indentation']['firstLine'] = Converter::cssToTwip($value); |
| 765 | |
| 766 | break; |
| 767 | case 'font-weight': |
| 768 | $tValue = false; |
| 769 | if (preg_match('#bold#', $value)) { |
| 770 | $tValue = true; // also match bolder |
| 771 | } |
| 772 | $styles['bold'] = $tValue; |
| 773 | |
| 774 | break; |
| 775 | case 'font-style': |
| 776 | $tValue = false; |
| 777 | if (preg_match('#(?:italic|oblique)#', $value)) { |
| 778 | $tValue = true; |
| 779 | } |
| 780 | $styles['italic'] = $tValue; |
| 781 | |
| 782 | break; |
| 783 | case 'font-variant': |
| 784 | $tValue = false; |
| 785 | if (preg_match('#small-caps#', $value)) { |
| 786 | $tValue = true; |
| 787 | } |
| 788 | $styles['smallCaps'] = $tValue; |
| 789 | |
| 790 | break; |
| 791 | case 'margin': |
| 792 | $value = Converter::cssToTwip($value); |
| 793 | $styles['spaceBefore'] = $value; |
| 794 | $styles['spaceAfter'] = $value; |
| 795 | |
| 796 | break; |
| 797 | case 'margin-top': |
| 798 | // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) |
| 799 | $styles['spaceBefore'] = Converter::cssToTwip($value); |
| 800 | |
| 801 | break; |
| 802 | case 'margin-bottom': |
| 803 | // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) |
| 804 | $styles['spaceAfter'] = Converter::cssToTwip($value); |
| 805 | |
| 806 | break; |
| 807 | |
| 808 | case 'padding': |
| 809 | $valueTop = $valueRight = $valueBottom = $valueLeft = null; |
| 810 | $cValue = preg_replace('# +#', ' ', trim($value)); |
| 811 | $paddingArr = explode(' ', $cValue); |
| 812 | $countParams = count($paddingArr); |
| 813 | if ($countParams == 1) { |
| 814 | $valueTop = $valueRight = $valueBottom = $valueLeft = $paddingArr[0]; |
| 815 | } elseif ($countParams == 2) { |
| 816 | $valueTop = $valueBottom = $paddingArr[0]; |
| 817 | $valueRight = $valueLeft = $paddingArr[1]; |
| 818 | } elseif ($countParams == 3) { |
| 819 | $valueTop = $paddingArr[0]; |
| 820 | $valueRight = $valueLeft = $paddingArr[1]; |
| 821 | $valueBottom = $paddingArr[2]; |
| 822 | } elseif ($countParams == 4) { |
| 823 | $valueTop = $paddingArr[0]; |
| 824 | $valueRight = $paddingArr[1]; |
| 825 | $valueBottom = $paddingArr[2]; |
| 826 | $valueLeft = $paddingArr[3]; |
| 827 | } |
| 828 | if ($valueTop !== null) { |
| 829 | $styles['paddingTop'] = Converter::cssToTwip($valueTop); |
| 830 | } |
| 831 | if ($valueRight !== null) { |
| 832 | $styles['paddingRight'] = Converter::cssToTwip($valueRight); |
| 833 | } |
| 834 | if ($valueBottom !== null) { |
| 835 | $styles['paddingBottom'] = Converter::cssToTwip($valueBottom); |
| 836 | } |
| 837 | if ($valueLeft !== null) { |
| 838 | $styles['paddingLeft'] = Converter::cssToTwip($valueLeft); |
| 839 | } |
| 840 | |
| 841 | break; |
| 842 | case 'padding-top': |
| 843 | $styles['paddingTop'] = Converter::cssToTwip($value); |
| 844 | |
| 845 | break; |
| 846 | case 'padding-right': |
| 847 | $styles['paddingRight'] = Converter::cssToTwip($value); |
| 848 | |
| 849 | break; |
| 850 | case 'padding-bottom': |
| 851 | $styles['paddingBottom'] = Converter::cssToTwip($value); |
| 852 | |
| 853 | break; |
| 854 | case 'padding-left': |
| 855 | $styles['paddingLeft'] = Converter::cssToTwip($value); |
| 856 | |
| 857 | break; |
| 858 | |
| 859 | case 'border-color': |
| 860 | self::mapBorderColor($styles, $value); |
| 861 | |
| 862 | break; |
| 863 | case 'border-width': |
| 864 | $styles['borderSize'] = Converter::cssToPoint($value); |
| 865 | |
| 866 | break; |
| 867 | case 'border-style': |
| 868 | $styles['borderStyle'] = self::mapBorderStyle($value); |
| 869 | |
| 870 | break; |
| 871 | case 'width': |
| 872 | if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) { |
| 873 | $styles['width'] = Converter::cssToTwip($matches[1]); |
| 874 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP; |
| 875 | } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { |
| 876 | $styles['width'] = $matches[1] * 50; |
| 877 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; |
| 878 | } elseif (preg_match('/([0-9]+)/', $value, $matches)) { |
| 879 | $styles['width'] = $matches[1]; |
| 880 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO; |
| 881 | } |
| 882 | |
| 883 | break; |
| 884 | case 'height': |
| 885 | $styles['height'] = Converter::cssToTwip($value); |
| 886 | $styles['exactHeight'] = true; |
| 887 | |
| 888 | break; |
| 889 | case 'border': |
| 890 | case 'border-top': |
| 891 | case 'border-bottom': |
| 892 | case 'border-right': |
| 893 | case 'border-left': |
| 894 | // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid" |
| 895 | // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC |
| 896 | if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) { |
| 897 | if (false !== strpos($property, '-')) { |
| 898 | $tmp = explode('-', $property); |
| 899 | $which = $tmp[1]; |
| 900 | $which = ucfirst($which); // e.g. bottom -> Bottom |
| 901 | } else { |
| 902 | $which = ''; |
| 903 | } |
| 904 | // Note - border width normalization: |
| 905 | // Width of border in Word is calculated differently than HTML borders, usually showing up too bold. |
| 906 | // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips. |
| 907 | // Therefore we need to normalize converted twip value to cca 1/2 of value. |
| 908 | // This may be adjusted, if better ratio or formula found. |
| 909 | // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size) |
| 910 | $size = Converter::cssToTwip($matches[1]); |
| 911 | $size = (int) ($size / 2); |
| 912 | // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc .. |
| 913 | $styles["border{$which}Size"] = $size; // twips |
| 914 | $styles["border{$which}Color"] = trim($matches[2], '#'); |
| 915 | $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]); |
| 916 | } |
| 917 | |
| 918 | break; |
| 919 | case 'vertical-align': |
| 920 | // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align |
| 921 | if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) { |
| 922 | $styles['valign'] = self::mapAlignVertical($matches[0]); |
| 923 | } |
| 924 | |
| 925 | break; |
| 926 | case 'page-break-after': |
| 927 | if ($value == 'always') { |
| 928 | $styles['isPageBreak'] = true; |
| 929 | } |
| 930 | |
| 931 | break; |
| 932 | } |
| 933 | } |
| 934 | |
| 935 | return $styles; |
| 936 | } |
| 937 | |
| 938 | /** |
| 939 | * Parse image node. |
| 940 | * |
| 941 | * @param DOMNode $node |
| 942 | * @param AbstractContainer $element |
| 943 | * |
| 944 | * @return \PhpOffice\PhpWord\Element\Image |
| 945 | */ |
| 946 | protected static function parseImage($node, $element) |
| 947 | { |
| 948 | $style = []; |
| 949 | $src = null; |
| 950 | foreach ($node->attributes as $attribute) { |
| 951 | switch ($attribute->name) { |
| 952 | case 'src': |
| 953 | $src = $attribute->value; |
| 954 | |
| 955 | break; |
| 956 | case 'width': |
| 957 | $style['width'] = self::convertHtmlSize($attribute->value); |
| 958 | $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; |
| 959 | |
| 960 | break; |
| 961 | case 'height': |
| 962 | $style['height'] = self::convertHtmlSize($attribute->value); |
| 963 | $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; |
| 964 | |
| 965 | break; |
| 966 | case 'style': |
| 967 | $styleattr = explode(';', $attribute->value); |
| 968 | foreach ($styleattr as $attr) { |
| 969 | if (strpos($attr, ':')) { |
| 970 | [$k, $v] = explode(':', $attr); |
| 971 | switch ($k) { |
| 972 | case 'float': |
| 973 | if (trim($v) == 'right') { |
| 974 | $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT; |
| 975 | $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area |
| 976 | $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; |
| 977 | $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; |
| 978 | $style['overlap'] = true; |
| 979 | } |
| 980 | if (trim($v) == 'left') { |
| 981 | $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT; |
| 982 | $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area |
| 983 | $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; |
| 984 | $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; |
| 985 | $style['overlap'] = true; |
| 986 | } |
| 987 | |
| 988 | break; |
| 989 | } |
| 990 | } |
| 991 | } |
| 992 | |
| 993 | break; |
| 994 | } |
| 995 | } |
| 996 | $originSrc = $src; |
| 997 | if (strpos($src, 'data:image') !== false) { |
| 998 | $tmpDir = Settings::getTempDir() . '/'; |
| 999 | |
| 1000 | $match = []; |
| 1001 | preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match); |
| 1002 | if (!empty($match)) { |
| 1003 | $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1]; |
| 1004 | |
| 1005 | $ifp = fopen($imgFile, 'wb'); |
| 1006 | |
| 1007 | if ($ifp !== false) { |
| 1008 | fwrite($ifp, base64_decode($match[2])); |
| 1009 | fclose($ifp); |
| 1010 | } |
| 1011 | } |
| 1012 | } |
| 1013 | $src = urldecode($src); |
| 1014 | |
| 1015 | if (!is_file($src) |
| 1016 | && null !== self::$options |
| 1017 | && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE']) |
| 1018 | ) { |
| 1019 | $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src); |
| 1020 | } |
| 1021 | |
| 1022 | if (!is_file($src)) { |
| 1023 | if ($imgBlob = @file_get_contents($src)) { |
| 1024 | $tmpDir = Settings::getTempDir() . '/'; |
| 1025 | $match = []; |
| 1026 | preg_match('/.+\.(\w+)$/', $src, $match); |
| 1027 | $src = $tmpDir . uniqid(); |
| 1028 | if (isset($match[1])) { |
| 1029 | $src .= '.' . $match[1]; |
| 1030 | } |
| 1031 | |
| 1032 | $ifp = fopen($src, 'wb'); |
| 1033 | |
| 1034 | if ($ifp !== false) { |
| 1035 | fwrite($ifp, $imgBlob); |
| 1036 | fclose($ifp); |
| 1037 | } |
| 1038 | } |
| 1039 | } |
| 1040 | |
| 1041 | if (is_file($src)) { |
| 1042 | $newElement = $element->addImage($src, $style); |
| 1043 | } else { |
| 1044 | throw new Exception("Could not load image $originSrc"); |
| 1045 | } |
| 1046 | |
| 1047 | return $newElement; |
| 1048 | } |
| 1049 | |
| 1050 | /** |
| 1051 | * Transforms a CSS border style into a word border style. |
| 1052 | * |
| 1053 | * @param string $cssBorderStyle |
| 1054 | * |
| 1055 | * @return null|string |
| 1056 | */ |
| 1057 | protected static function mapBorderStyle($cssBorderStyle) |
| 1058 | { |
| 1059 | switch ($cssBorderStyle) { |
| 1060 | case 'none': |
| 1061 | case 'dashed': |
| 1062 | case 'dotted': |
| 1063 | case 'double': |
| 1064 | return $cssBorderStyle; |
| 1065 | default: |
| 1066 | return 'single'; |
| 1067 | } |
| 1068 | } |
| 1069 | |
| 1070 | protected static function mapBorderColor(&$styles, $cssBorderColor): void |
| 1071 | { |
| 1072 | $numColors = substr_count($cssBorderColor, '#'); |
| 1073 | if ($numColors === 1) { |
| 1074 | $styles['borderColor'] = trim($cssBorderColor, '#'); |
| 1075 | } elseif ($numColors > 1) { |
| 1076 | $colors = explode(' ', $cssBorderColor); |
| 1077 | $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor']; |
| 1078 | for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) { |
| 1079 | $styles[$borders[$i]] = trim($colors[$i], '#'); |
| 1080 | } |
| 1081 | } |
| 1082 | } |
| 1083 | |
| 1084 | /** |
| 1085 | * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc. |
| 1086 | * |
| 1087 | * @param string $cssAlignment |
| 1088 | * @param bool $bidi |
| 1089 | * |
| 1090 | * @return null|string |
| 1091 | */ |
| 1092 | protected static function mapAlign($cssAlignment, $bidi) |
| 1093 | { |
| 1094 | switch ($cssAlignment) { |
| 1095 | case 'right': |
| 1096 | return $bidi ? Jc::START : Jc::END; |
| 1097 | case 'center': |
| 1098 | return Jc::CENTER; |
| 1099 | case 'justify': |
| 1100 | return Jc::BOTH; |
| 1101 | default: |
| 1102 | return $bidi ? Jc::END : Jc::START; |
| 1103 | } |
| 1104 | } |
| 1105 | |
| 1106 | /** |
| 1107 | * Transforms a HTML/CSS ruby alignment into a \PhpOffice\PhpWord\SimpleType\Jc. |
| 1108 | */ |
| 1109 | protected static function mapRubyAlign(string $cssRubyAlignment): string |
| 1110 | { |
| 1111 | switch ($cssRubyAlignment) { |
| 1112 | case 'center': |
| 1113 | return RubyProperties::ALIGNMENT_CENTER; |
| 1114 | case 'start': |
| 1115 | return RubyProperties::ALIGNMENT_LEFT; |
| 1116 | case 'space-between': |
| 1117 | return RubyProperties::ALIGNMENT_DISTRIBUTE_SPACE; |
| 1118 | default: |
| 1119 | return ''; |
| 1120 | } |
| 1121 | } |
| 1122 | |
| 1123 | /** |
| 1124 | * Transforms a HTML/CSS vertical alignment. |
| 1125 | * |
| 1126 | * @param string $alignment |
| 1127 | * |
| 1128 | * @return null|string |
| 1129 | */ |
| 1130 | protected static function mapAlignVertical($alignment) |
| 1131 | { |
| 1132 | $alignment = strtolower($alignment); |
| 1133 | switch ($alignment) { |
| 1134 | case 'top': |
| 1135 | case 'baseline': |
| 1136 | case 'bottom': |
| 1137 | return $alignment; |
| 1138 | case 'middle': |
| 1139 | return 'center'; |
| 1140 | case 'sub': |
| 1141 | return 'bottom'; |
| 1142 | case 'text-top': |
| 1143 | case 'baseline': |
| 1144 | return 'top'; |
| 1145 | default: |
| 1146 | // @discuss - which one should apply: |
| 1147 | // - Word uses default vert. alignment: top |
| 1148 | // - all browsers use default vert. alignment: middle |
| 1149 | // Returning empty string means attribute wont be set so use Word default (top). |
| 1150 | return ''; |
| 1151 | } |
| 1152 | } |
| 1153 | |
| 1154 | /** |
| 1155 | * Map list style for ordered list. |
| 1156 | * |
| 1157 | * @param string $cssListType |
| 1158 | */ |
| 1159 | protected static function mapListType($cssListType) |
| 1160 | { |
| 1161 | switch ($cssListType) { |
| 1162 | case 'a': |
| 1163 | return NumberFormat::LOWER_LETTER; // a, b, c, .. |
| 1164 | case 'A': |
| 1165 | return NumberFormat::UPPER_LETTER; // A, B, C, .. |
| 1166 | case 'i': |
| 1167 | return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, .. |
| 1168 | case 'I': |
| 1169 | return NumberFormat::UPPER_ROMAN; // I, II, III, IV, .. |
| 1170 | case '1': |
| 1171 | default: |
| 1172 | return NumberFormat::DECIMAL; // 1, 2, 3, .. |
| 1173 | } |
| 1174 | } |
| 1175 | |
| 1176 | /** |
| 1177 | * Parse line break. |
| 1178 | * |
| 1179 | * @param AbstractContainer $element |
| 1180 | */ |
| 1181 | protected static function parseLineBreak($element): void |
| 1182 | { |
| 1183 | $element->addTextBreak(); |
| 1184 | } |
| 1185 | |
| 1186 | /** |
| 1187 | * Parse link node. |
| 1188 | * |
| 1189 | * @param DOMNode $node |
| 1190 | * @param AbstractContainer $element |
| 1191 | * @param array $styles |
| 1192 | */ |
| 1193 | protected static function parseLink($node, $element, &$styles) |
| 1194 | { |
| 1195 | $target = null; |
| 1196 | foreach ($node->attributes as $attribute) { |
| 1197 | switch ($attribute->name) { |
| 1198 | case 'href': |
| 1199 | $target = $attribute->value; |
| 1200 | |
| 1201 | break; |
| 1202 | } |
| 1203 | } |
| 1204 | $styles['font'] = self::parseInlineStyle($node, $styles['font']); |
| 1205 | |
| 1206 | if (empty($target)) { |
| 1207 | $target = '#'; |
| 1208 | } |
| 1209 | |
| 1210 | if (strpos($target, '#') === 0 && strlen($target) > 1) { |
| 1211 | return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true); |
| 1212 | } |
| 1213 | |
| 1214 | return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']); |
| 1215 | } |
| 1216 | |
| 1217 | /** |
| 1218 | * Render horizontal rule |
| 1219 | * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment. |
| 1220 | * |
| 1221 | * @param DOMNode $node |
| 1222 | * @param AbstractContainer $element |
| 1223 | */ |
| 1224 | protected static function parseHorizRule($node, $element): void |
| 1225 | { |
| 1226 | $styles = self::parseInlineStyle($node); |
| 1227 | |
| 1228 | // <hr> is implemented as an empty paragraph - extending 100% inside the section |
| 1229 | // Some properties may be controlled, e.g. <hr style="border-bottom: 3px #DDDDDD solid; margin-bottom: 0;"> |
| 1230 | |
| 1231 | $fontStyle = $styles + ['size' => 3]; |
| 1232 | |
| 1233 | $paragraphStyle = $styles + [ |
| 1234 | 'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc |
| 1235 | 'spacing' => 0, // twip |
| 1236 | 'spaceBefore' => 120, // twip, 240/2 (default line height) |
| 1237 | 'spaceAfter' => 120, // twip |
| 1238 | 'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'], |
| 1239 | 'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'], |
| 1240 | 'borderBottomStyle' => 'single', // same as "solid" |
| 1241 | ]; |
| 1242 | |
| 1243 | $element->addText('', $fontStyle, $paragraphStyle); |
| 1244 | |
| 1245 | // Notes: <hr/> cannot be: |
| 1246 | // - table - throws error "cannot be inside textruns", e.g. lists |
| 1247 | // - line - that is a shape, has different behaviour |
| 1248 | // - repeated text, e.g. underline "_", because of unpredictable line wrapping |
| 1249 | } |
| 1250 | |
| 1251 | /** |
| 1252 | * Parse ruby node. |
| 1253 | * |
| 1254 | * @param DOMNode $node |
| 1255 | * @param AbstractContainer $element |
| 1256 | * @param array $styles |
| 1257 | */ |
| 1258 | protected static function parseRuby($node, $element, &$styles) |
| 1259 | { |
| 1260 | $rubyProperties = new RubyProperties(); |
| 1261 | $baseTextRun = new TextRun($styles['paragraph']); |
| 1262 | $rubyTextRun = new TextRun(null); |
| 1263 | if ($node->hasAttributes()) { |
| 1264 | $langAttr = $node->attributes->getNamedItem('lang'); |
| 1265 | if ($langAttr !== null) { |
| 1266 | $rubyProperties->setLanguageId($langAttr->textContent); |
| 1267 | } |
| 1268 | $styleAttr = $node->attributes->getNamedItem('style'); |
| 1269 | if ($styleAttr !== null) { |
| 1270 | $styles = self::parseStyle($styleAttr, $styles['paragraph']); |
| 1271 | if (isset($styles['rubyAlignment']) && $styles['rubyAlignment'] !== '') { |
| 1272 | $rubyProperties->setAlignment($styles['rubyAlignment']); |
| 1273 | } |
| 1274 | if (isset($styles['size']) && $styles['size'] !== '') { |
| 1275 | $rubyProperties->setFontSizeForBaseText($styles['size']); |
| 1276 | } |
| 1277 | $baseTextRun->setParagraphStyle($styles); |
| 1278 | } |
| 1279 | } |
| 1280 | foreach ($node->childNodes as $child) { |
| 1281 | if ($child->nodeName === '#text') { |
| 1282 | $content = trim($child->textContent); |
| 1283 | if ($content !== '') { |
| 1284 | $baseTextRun->addText($content); |
| 1285 | } |
| 1286 | } elseif ($child->nodeName === 'rt') { |
| 1287 | $rubyTextRun->addText(trim($child->textContent)); |
| 1288 | if ($child->hasAttributes()) { |
| 1289 | $styleAttr = $child->attributes->getNamedItem('style'); |
| 1290 | if ($styleAttr !== null) { |
| 1291 | $styles = self::parseStyle($styleAttr, []); |
| 1292 | if (isset($styles['size']) && $styles['size'] !== '') { |
| 1293 | $rubyProperties->setFontFaceSize($styles['size']); |
| 1294 | } |
| 1295 | $rubyTextRun->setParagraphStyle($styles); |
| 1296 | } |
| 1297 | } |
| 1298 | } |
| 1299 | } |
| 1300 | |
| 1301 | return $element->addRuby($baseTextRun, $rubyTextRun, $rubyProperties); |
| 1302 | } |
| 1303 | |
| 1304 | private static function convertRgb(string $rgb): string |
| 1305 | { |
| 1306 | if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) { |
| 1307 | return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]); |
| 1308 | } |
| 1309 | |
| 1310 | return trim($rgb, '# '); |
| 1311 | } |
| 1312 | |
| 1313 | /** |
| 1314 | * Transform HTML sizes (pt, px) in pixels. |
| 1315 | */ |
| 1316 | protected static function convertHtmlSize(string $size): float |
| 1317 | { |
| 1318 | // pt |
| 1319 | if (false !== strpos($size, 'pt')) { |
| 1320 | return Converter::pointToPixel((float) str_replace('pt', '', $size)); |
| 1321 | } |
| 1322 | |
| 1323 | // px |
| 1324 | if (false !== strpos($size, 'px')) { |
| 1325 | return (float) str_replace('px', '', $size); |
| 1326 | } |
| 1327 | |
| 1328 | return (float) $size; |
| 1329 | } |
| 1330 | } |