Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.55% |
532 / 551 |
|
80.65% |
25 / 31 |
CRAP | |
0.00% |
0 / 1 |
Html | |
96.55% |
532 / 551 |
|
80.65% |
25 / 31 |
201 | |
0.00% |
0 / 1 |
addHtml | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
4.02 | |||
parseInlineStyle | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
17 | |||
parseNode | |
100.00% |
56 / 56 |
|
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% |
3 / 3 |
|
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 | |
97.01% |
130 / 134 |
|
0.00% |
0 / 1 |
47 | |||
parseImage | |
100.00% |
76 / 76 |
|
100.00% |
1 / 1 |
25 | |||
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 | |||
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 | |||
convertRgb | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This file is part of PHPWord - A pure PHP library for reading and writing |
4 | * word processing documents. |
5 | * |
6 | * PHPWord is free software distributed under the terms of the GNU Lesser |
7 | * General Public License version 3 as published by the Free Software Foundation. |
8 | * |
9 | * For the full copyright and license information, please read the LICENSE |
10 | * file that was distributed with this source code. For the full list of |
11 | * contributors, visit https://github.com/PHPOffice/PHPWord/contributors. |
12 | * |
13 | * @see https://github.com/PHPOffice/PHPWord |
14 | * |
15 | * @license http://www.gnu.org/licenses/lgpl.txt LGPL version 3 |
16 | */ |
17 | |
18 | namespace PhpOffice\PhpWord\Shared; |
19 | |
20 | use DOMAttr; |
21 | use DOMDocument; |
22 | use DOMNode; |
23 | use DOMXPath; |
24 | use Exception; |
25 | use PhpOffice\PhpWord\Element\AbstractContainer; |
26 | use PhpOffice\PhpWord\Element\Row; |
27 | use PhpOffice\PhpWord\Element\Table; |
28 | use PhpOffice\PhpWord\Settings; |
29 | use PhpOffice\PhpWord\SimpleType\Jc; |
30 | use PhpOffice\PhpWord\SimpleType\NumberFormat; |
31 | use PhpOffice\PhpWord\Style\Paragraph; |
32 | |
33 | /** |
34 | * Common Html functions. |
35 | * |
36 | * @SuppressWarnings(PHPMD.UnusedPrivateMethod) For readWPNode |
37 | */ |
38 | class Html |
39 | { |
40 | private const RGB_REGEXP = '/^\s*rgb\s*[(]\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*[)]\s*$/'; |
41 | |
42 | protected static $listIndex = 0; |
43 | |
44 | protected static $xpath; |
45 | |
46 | protected static $options; |
47 | |
48 | /** |
49 | * @var Css |
50 | */ |
51 | protected static $css; |
52 | |
53 | /** |
54 | * Add HTML parts. |
55 | * |
56 | * Note: $stylesheet parameter is removed to avoid PHPMD error for unused parameter |
57 | * Warning: Do not pass user-generated HTML here, as that would allow an attacker to read arbitrary |
58 | * files or perform server-side request forgery by passing local file paths or URLs in <img>. |
59 | * |
60 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element Where the parts need to be added |
61 | * @param string $html The code to parse |
62 | * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag |
63 | * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed |
64 | */ |
65 | public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void |
66 | { |
67 | /* |
68 | * @todo parse $stylesheet for default styles. Should result in an array based on id, class and element, |
69 | * which could be applied when such an element occurs in the parseNode function. |
70 | */ |
71 | static::$options = $options; |
72 | |
73 | // Preprocess: remove all line ends, decode HTML entity, |
74 | // fix ampersand and angle brackets and add body tag for HTML fragments |
75 | $html = str_replace(["\n", "\r"], '', $html); |
76 | $html = str_replace(['<', '>', '&', '"'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html); |
77 | $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8'); |
78 | $html = str_replace('&', '&', $html); |
79 | $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['<', '>', '&', '"'], $html); |
80 | |
81 | if (false === $fullHTML) { |
82 | $html = '<body>' . $html . '</body>'; |
83 | } |
84 | |
85 | // Load DOM |
86 | if (\PHP_VERSION_ID < 80000) { |
87 | $orignalLibEntityLoader = libxml_disable_entity_loader(true); |
88 | } |
89 | $dom = new DOMDocument(); |
90 | $dom->preserveWhiteSpace = $preserveWhiteSpace; |
91 | $dom->loadXML($html); |
92 | static::$xpath = new DOMXPath($dom); |
93 | $node = $dom->getElementsByTagName('body'); |
94 | |
95 | static::parseNode($node->item(0), $element); |
96 | if (\PHP_VERSION_ID < 80000) { |
97 | libxml_disable_entity_loader($orignalLibEntityLoader); |
98 | } |
99 | } |
100 | |
101 | /** |
102 | * parse Inline style of a node. |
103 | * |
104 | * @param DOMNode $node Node to check on attributes and to compile a style array |
105 | * @param array<string, mixed> $styles is supplied, the inline style attributes are added to the already existing style |
106 | * |
107 | * @return array |
108 | */ |
109 | protected static function parseInlineStyle($node, $styles = []) |
110 | { |
111 | if (XML_ELEMENT_NODE == $node->nodeType) { |
112 | $attributes = $node->attributes; // get all the attributes(eg: id, class) |
113 | |
114 | $attributeDir = $attributes->getNamedItem('dir'); |
115 | $attributeDirValue = $attributeDir ? $attributeDir->nodeValue : ''; |
116 | $bidi = $attributeDirValue === 'rtl'; |
117 | foreach ($attributes as $attribute) { |
118 | $val = $attribute->value; |
119 | switch (strtolower($attribute->name)) { |
120 | case 'align': |
121 | $styles['alignment'] = self::mapAlign(trim($val), $bidi); |
122 | |
123 | break; |
124 | case 'lang': |
125 | $styles['lang'] = $val; |
126 | |
127 | break; |
128 | case 'width': |
129 | // tables, cells |
130 | if (false !== strpos($val, '%')) { |
131 | // e.g. <table width="100%"> or <td width="50%"> |
132 | $styles['width'] = (int) $val * 50; |
133 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; |
134 | } else { |
135 | // e.g. <table width="250> where "250" = 250px (always pixels) |
136 | $styles['width'] = Converter::pixelToTwip($val); |
137 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP; |
138 | } |
139 | |
140 | break; |
141 | case 'cellspacing': |
142 | // tables e.g. <table cellspacing="2">, where "2" = 2px (always pixels) |
143 | $val = (int) $val . 'px'; |
144 | $styles['cellSpacing'] = Converter::cssToTwip($val); |
145 | |
146 | break; |
147 | case 'bgcolor': |
148 | // tables, rows, cells e.g. <tr bgColor="#FF0000"> |
149 | $styles['bgColor'] = self::convertRgb($val); |
150 | |
151 | break; |
152 | case 'valign': |
153 | // cells e.g. <td valign="middle"> |
154 | if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) { |
155 | $styles['valign'] = self::mapAlignVertical($matches[0]); |
156 | } |
157 | |
158 | break; |
159 | } |
160 | } |
161 | |
162 | $attributeIdentifier = $attributes->getNamedItem('id'); |
163 | if ($attributeIdentifier && self::$css) { |
164 | $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->nodeValue), $styles); |
165 | } |
166 | |
167 | $attributeClass = $attributes->getNamedItem('class'); |
168 | if ($attributeClass) { |
169 | if (self::$css) { |
170 | $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->nodeValue), $styles); |
171 | } |
172 | $styles['className'] = $attributeClass->nodeValue; |
173 | } |
174 | |
175 | $attributeStyle = $attributes->getNamedItem('style'); |
176 | if ($attributeStyle) { |
177 | $styles = self::parseStyle($attributeStyle, $styles); |
178 | } |
179 | } |
180 | |
181 | return $styles; |
182 | } |
183 | |
184 | /** |
185 | * Parse a node and add a corresponding element to the parent element. |
186 | * |
187 | * @param DOMNode $node node to parse |
188 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element object to add an element corresponding with the node |
189 | * @param array $styles Array with all styles |
190 | * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems |
191 | */ |
192 | protected static function parseNode($node, $element, $styles = [], $data = []): void |
193 | { |
194 | if ($node->nodeName == 'style') { |
195 | self::$css = new Css($node->textContent); |
196 | self::$css->process(); |
197 | |
198 | return; |
199 | } |
200 | |
201 | // Populate styles array |
202 | $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell']; |
203 | foreach ($styleTypes as $styleType) { |
204 | if (!isset($styles[$styleType])) { |
205 | $styles[$styleType] = []; |
206 | } |
207 | } |
208 | |
209 | // Node mapping table |
210 | $nodes = [ |
211 | // $method $node $element $styles $data $argument1 $argument2 |
212 | 'p' => ['Paragraph', $node, $element, $styles, null, null, null], |
213 | 'h1' => ['Heading', null, $element, $styles, null, 'Heading1', null], |
214 | 'h2' => ['Heading', null, $element, $styles, null, 'Heading2', null], |
215 | 'h3' => ['Heading', null, $element, $styles, null, 'Heading3', null], |
216 | 'h4' => ['Heading', null, $element, $styles, null, 'Heading4', null], |
217 | 'h5' => ['Heading', null, $element, $styles, null, 'Heading5', null], |
218 | 'h6' => ['Heading', null, $element, $styles, null, 'Heading6', null], |
219 | '#text' => ['Text', $node, $element, $styles, null, null, null], |
220 | 'strong' => ['Property', null, null, $styles, null, 'bold', true], |
221 | 'b' => ['Property', null, null, $styles, null, 'bold', true], |
222 | 'em' => ['Property', null, null, $styles, null, 'italic', true], |
223 | 'i' => ['Property', null, null, $styles, null, 'italic', true], |
224 | 'u' => ['Property', null, null, $styles, null, 'underline', 'single'], |
225 | 'sup' => ['Property', null, null, $styles, null, 'superScript', true], |
226 | 'sub' => ['Property', null, null, $styles, null, 'subScript', true], |
227 | 'span' => ['Span', $node, null, $styles, null, null, null], |
228 | 'font' => ['Span', $node, null, $styles, null, null, null], |
229 | 'table' => ['Table', $node, $element, $styles, null, null, null], |
230 | 'tr' => ['Row', $node, $element, $styles, null, null, null], |
231 | 'td' => ['Cell', $node, $element, $styles, null, null, null], |
232 | 'th' => ['Cell', $node, $element, $styles, null, null, null], |
233 | 'ul' => ['List', $node, $element, $styles, $data, null, null], |
234 | 'ol' => ['List', $node, $element, $styles, $data, null, null], |
235 | 'li' => ['ListItem', $node, $element, $styles, $data, null, null], |
236 | 'img' => ['Image', $node, $element, $styles, null, null, null], |
237 | 'br' => ['LineBreak', null, $element, $styles, null, null, null], |
238 | 'a' => ['Link', $node, $element, $styles, null, null, null], |
239 | 'input' => ['Input', $node, $element, $styles, null, null, null], |
240 | 'hr' => ['HorizRule', $node, $element, $styles, null, null, null], |
241 | ]; |
242 | |
243 | $newElement = null; |
244 | $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2']; |
245 | |
246 | if (isset($nodes[$node->nodeName])) { |
247 | // Execute method based on node mapping table and return $newElement or null |
248 | // Arguments are passed by reference |
249 | $arguments = []; |
250 | $args = []; |
251 | [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName]; |
252 | for ($i = 0; $i <= 5; ++$i) { |
253 | if ($args[$i] !== null) { |
254 | $arguments[$keys[$i]] = &$args[$i]; |
255 | } |
256 | } |
257 | $method = "parse{$method}"; |
258 | $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments)); |
259 | |
260 | // Retrieve back variables from arguments |
261 | foreach ($keys as $key) { |
262 | if (array_key_exists($key, $arguments)) { |
263 | $$key = $arguments[$key]; |
264 | } |
265 | } |
266 | } |
267 | |
268 | if ($newElement === null) { |
269 | $newElement = $element; |
270 | } |
271 | |
272 | static::parseChildNodes($node, $newElement, $styles, $data); |
273 | } |
274 | |
275 | /** |
276 | * Parse child nodes. |
277 | * |
278 | * @param DOMNode $node |
279 | * @param \PhpOffice\PhpWord\Element\AbstractContainer|Row|Table $element |
280 | * @param array $styles |
281 | * @param array $data |
282 | */ |
283 | protected static function parseChildNodes($node, $element, $styles, $data): void |
284 | { |
285 | if ('li' != $node->nodeName) { |
286 | $cNodes = $node->childNodes; |
287 | if (!empty($cNodes)) { |
288 | foreach ($cNodes as $cNode) { |
289 | if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) { |
290 | self::parseNode($cNode, $element, $styles, $data); |
291 | } |
292 | } |
293 | } |
294 | } |
295 | } |
296 | |
297 | /** |
298 | * Parse paragraph node. |
299 | * |
300 | * @param DOMNode $node |
301 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
302 | * @param array &$styles |
303 | * |
304 | * @return \PhpOffice\PhpWord\Element\PageBreak|\PhpOffice\PhpWord\Element\TextRun |
305 | */ |
306 | protected static function parseParagraph($node, $element, &$styles) |
307 | { |
308 | $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']); |
309 | if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) { |
310 | return $element->addPageBreak(); |
311 | } |
312 | |
313 | return $element->addTextRun($styles['paragraph']); |
314 | } |
315 | |
316 | /** |
317 | * Parse input node. |
318 | * |
319 | * @param DOMNode $node |
320 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
321 | * @param array &$styles |
322 | */ |
323 | protected static function parseInput($node, $element, &$styles): void |
324 | { |
325 | $attributes = $node->attributes; |
326 | if (null === $attributes->getNamedItem('type')) { |
327 | return; |
328 | } |
329 | |
330 | $inputType = $attributes->getNamedItem('type')->nodeValue; |
331 | switch ($inputType) { |
332 | case 'checkbox': |
333 | $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->nodeValue === 'true' ? true : false; |
334 | $textrun = $element->addTextRun($styles['paragraph']); |
335 | $textrun->addFormField('checkbox')->setValue($checked); |
336 | |
337 | break; |
338 | } |
339 | } |
340 | |
341 | /** |
342 | * Parse heading node. |
343 | * |
344 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
345 | * @param array &$styles |
346 | * @param string $argument1 Name of heading style |
347 | * |
348 | * @return \PhpOffice\PhpWord\Element\TextRun |
349 | * |
350 | * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that |
351 | * Heading1 - Heading6 are already defined somewhere |
352 | */ |
353 | protected static function parseHeading($element, &$styles, $argument1) |
354 | { |
355 | $styles['paragraph'] = $argument1; |
356 | $newElement = $element->addTextRun($styles['paragraph']); |
357 | |
358 | return $newElement; |
359 | } |
360 | |
361 | /** |
362 | * Parse text node. |
363 | * |
364 | * @param DOMNode $node |
365 | * @param \PhpOffice\PhpWord\Element\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 \PhpOffice\PhpWord\Element\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 \PhpOffice\PhpWord\Element\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 \PhpOffice\PhpWord\Element\Table $element |
464 | * @param array &$styles |
465 | * |
466 | * @return \PhpOffice\PhpWord\Element\Cell|\PhpOffice\PhpWord\Element\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 \PhpOffice\PhpWord\Element\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 \PhpOffice\PhpWord\Element\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 DOMAttr $attribute |
669 | * @param array $styles |
670 | * |
671 | * @return array |
672 | */ |
673 | protected static function parseStyle($attribute, $styles) |
674 | { |
675 | $properties = explode(';', trim($attribute->value, " \t\n\r\0\x0B;")); |
676 | |
677 | $selectors = []; |
678 | foreach ($properties as $property) { |
679 | [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null); |
680 | $selectors[strtolower(trim($cKey))] = trim($cValue ?? ''); |
681 | } |
682 | |
683 | return self::parseStyleDeclarations($selectors, $styles); |
684 | } |
685 | |
686 | protected static function parseStyleDeclarations(array $selectors, array $styles) |
687 | { |
688 | $bidi = ($selectors['direction'] ?? '') === 'rtl'; |
689 | foreach ($selectors as $property => $value) { |
690 | switch ($property) { |
691 | case 'text-decoration': |
692 | switch ($value) { |
693 | case 'underline': |
694 | $styles['underline'] = 'single'; |
695 | |
696 | break; |
697 | case 'line-through': |
698 | $styles['strikethrough'] = true; |
699 | |
700 | break; |
701 | } |
702 | |
703 | break; |
704 | case 'text-align': |
705 | $styles['alignment'] = self::mapAlign($value, $bidi); |
706 | |
707 | break; |
708 | case 'display': |
709 | $styles['hidden'] = $value === 'none' || $value === 'hidden'; |
710 | |
711 | break; |
712 | case 'direction': |
713 | $styles['rtl'] = $value === 'rtl'; |
714 | $styles['bidi'] = $value === 'rtl'; |
715 | |
716 | break; |
717 | case 'font-size': |
718 | $styles['size'] = Converter::cssToPoint($value); |
719 | |
720 | break; |
721 | case 'font-family': |
722 | $value = array_map('trim', explode(',', $value)); |
723 | $styles['name'] = ucwords($value[0]); |
724 | |
725 | break; |
726 | case 'color': |
727 | $styles['color'] = self::convertRgb($value); |
728 | |
729 | break; |
730 | case 'background-color': |
731 | $styles['bgColor'] = self::convertRgb($value); |
732 | |
733 | break; |
734 | case 'line-height': |
735 | $matches = []; |
736 | if ($value === 'normal') { |
737 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
738 | $spacing = 0; |
739 | } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) { |
740 | //matches number with a unit, e.g. 12px, 15pt, 20mm, ... |
741 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT; |
742 | $spacing = Converter::cssToTwip($matches[1]); |
743 | } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { |
744 | //matches percentages |
745 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
746 | //we are subtracting 1 line height because the Spacing writer is adding one line |
747 | $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; |
748 | } else { |
749 | //any other, wich is a multiplier. E.g. 1.2 |
750 | $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; |
751 | //we are subtracting 1 line height because the Spacing writer is adding one line |
752 | $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; |
753 | } |
754 | $styles['spacingLineRule'] = $spacingLineRule; |
755 | $styles['line-spacing'] = $spacing; |
756 | |
757 | break; |
758 | case 'letter-spacing': |
759 | $styles['letter-spacing'] = Converter::cssToTwip($value); |
760 | |
761 | break; |
762 | case 'text-indent': |
763 | $styles['indentation']['firstLine'] = Converter::cssToTwip($value); |
764 | |
765 | break; |
766 | case 'font-weight': |
767 | $tValue = false; |
768 | if (preg_match('#bold#', $value)) { |
769 | $tValue = true; // also match bolder |
770 | } |
771 | $styles['bold'] = $tValue; |
772 | |
773 | break; |
774 | case 'font-style': |
775 | $tValue = false; |
776 | if (preg_match('#(?:italic|oblique)#', $value)) { |
777 | $tValue = true; |
778 | } |
779 | $styles['italic'] = $tValue; |
780 | |
781 | break; |
782 | case 'font-variant': |
783 | $tValue = false; |
784 | if (preg_match('#small-caps#', $value)) { |
785 | $tValue = true; |
786 | } |
787 | $styles['smallCaps'] = $tValue; |
788 | |
789 | break; |
790 | case 'margin': |
791 | $value = Converter::cssToTwip($value); |
792 | $styles['spaceBefore'] = $value; |
793 | $styles['spaceAfter'] = $value; |
794 | |
795 | break; |
796 | case 'margin-top': |
797 | // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) |
798 | $styles['spaceBefore'] = Converter::cssToTwip($value); |
799 | |
800 | break; |
801 | case 'margin-bottom': |
802 | // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) |
803 | $styles['spaceAfter'] = Converter::cssToTwip($value); |
804 | |
805 | break; |
806 | case 'border-color': |
807 | self::mapBorderColor($styles, $value); |
808 | |
809 | break; |
810 | case 'border-width': |
811 | $styles['borderSize'] = Converter::cssToPoint($value); |
812 | |
813 | break; |
814 | case 'border-style': |
815 | $styles['borderStyle'] = self::mapBorderStyle($value); |
816 | |
817 | break; |
818 | case 'width': |
819 | if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) { |
820 | $styles['width'] = Converter::cssToTwip($matches[1]); |
821 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP; |
822 | } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { |
823 | $styles['width'] = $matches[1] * 50; |
824 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; |
825 | } elseif (preg_match('/([0-9]+)/', $value, $matches)) { |
826 | $styles['width'] = $matches[1]; |
827 | $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO; |
828 | } |
829 | |
830 | break; |
831 | case 'height': |
832 | $styles['height'] = Converter::cssToTwip($value); |
833 | $styles['exactHeight'] = true; |
834 | |
835 | break; |
836 | case 'border': |
837 | case 'border-top': |
838 | case 'border-bottom': |
839 | case 'border-right': |
840 | case 'border-left': |
841 | // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid" |
842 | // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC |
843 | if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) { |
844 | if (false !== strpos($property, '-')) { |
845 | $tmp = explode('-', $property); |
846 | $which = $tmp[1]; |
847 | $which = ucfirst($which); // e.g. bottom -> Bottom |
848 | } else { |
849 | $which = ''; |
850 | } |
851 | // Note - border width normalization: |
852 | // Width of border in Word is calculated differently than HTML borders, usually showing up too bold. |
853 | // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips. |
854 | // Therefore we need to normalize converted twip value to cca 1/2 of value. |
855 | // This may be adjusted, if better ratio or formula found. |
856 | // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size) |
857 | $size = Converter::cssToTwip($matches[1]); |
858 | $size = (int) ($size / 2); |
859 | // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc .. |
860 | $styles["border{$which}Size"] = $size; // twips |
861 | $styles["border{$which}Color"] = trim($matches[2], '#'); |
862 | $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]); |
863 | } |
864 | |
865 | break; |
866 | case 'vertical-align': |
867 | // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align |
868 | if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) { |
869 | $styles['valign'] = self::mapAlignVertical($matches[0]); |
870 | } |
871 | |
872 | break; |
873 | case 'page-break-after': |
874 | if ($value == 'always') { |
875 | $styles['isPageBreak'] = true; |
876 | } |
877 | |
878 | break; |
879 | } |
880 | } |
881 | |
882 | return $styles; |
883 | } |
884 | |
885 | /** |
886 | * Parse image node. |
887 | * |
888 | * @param DOMNode $node |
889 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
890 | * |
891 | * @return \PhpOffice\PhpWord\Element\Image |
892 | */ |
893 | protected static function parseImage($node, $element) |
894 | { |
895 | $style = []; |
896 | $src = null; |
897 | foreach ($node->attributes as $attribute) { |
898 | switch ($attribute->name) { |
899 | case 'src': |
900 | $src = $attribute->value; |
901 | |
902 | break; |
903 | case 'width': |
904 | $width = $attribute->value; |
905 | |
906 | // pt |
907 | if (false !== strpos($width, 'pt')) { |
908 | $width = Converter::pointToPixel((float) str_replace('pt', '', $width)); |
909 | } |
910 | |
911 | // px |
912 | if (false !== strpos($width, 'px')) { |
913 | $width = str_replace('px', '', $width); |
914 | } |
915 | |
916 | $style['width'] = $width; |
917 | $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; |
918 | |
919 | break; |
920 | case 'height': |
921 | $height = $attribute->value; |
922 | |
923 | // pt |
924 | if (false !== strpos($height, 'pt')) { |
925 | $height = Converter::pointToPixel((float) str_replace('pt', '', $height)); |
926 | } |
927 | |
928 | // px |
929 | if (false !== strpos($height, 'px')) { |
930 | $height = str_replace('px', '', $height); |
931 | } |
932 | |
933 | $style['height'] = $height; |
934 | $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; |
935 | |
936 | break; |
937 | case 'style': |
938 | $styleattr = explode(';', $attribute->value); |
939 | foreach ($styleattr as $attr) { |
940 | if (strpos($attr, ':')) { |
941 | [$k, $v] = explode(':', $attr); |
942 | switch ($k) { |
943 | case 'float': |
944 | if (trim($v) == 'right') { |
945 | $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT; |
946 | $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area |
947 | $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; |
948 | $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; |
949 | $style['overlap'] = true; |
950 | } |
951 | if (trim($v) == 'left') { |
952 | $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT; |
953 | $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area |
954 | $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; |
955 | $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; |
956 | $style['overlap'] = true; |
957 | } |
958 | |
959 | break; |
960 | } |
961 | } |
962 | } |
963 | |
964 | break; |
965 | } |
966 | } |
967 | $originSrc = $src; |
968 | if (strpos($src, 'data:image') !== false) { |
969 | $tmpDir = Settings::getTempDir() . '/'; |
970 | |
971 | $match = []; |
972 | preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match); |
973 | |
974 | $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1]; |
975 | |
976 | $ifp = fopen($imgFile, 'wb'); |
977 | |
978 | if ($ifp !== false) { |
979 | fwrite($ifp, base64_decode($match[2])); |
980 | fclose($ifp); |
981 | } |
982 | } |
983 | $src = urldecode($src); |
984 | |
985 | if (!is_file($src) |
986 | && null !== self::$options |
987 | && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE']) |
988 | ) { |
989 | $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src); |
990 | } |
991 | |
992 | if (!is_file($src)) { |
993 | if ($imgBlob = @file_get_contents($src)) { |
994 | $tmpDir = Settings::getTempDir() . '/'; |
995 | $match = []; |
996 | preg_match('/.+\.(\w+)$/', $src, $match); |
997 | $src = $tmpDir . uniqid(); |
998 | if (isset($match[1])) { |
999 | $src .= '.' . $match[1]; |
1000 | } |
1001 | |
1002 | $ifp = fopen($src, 'wb'); |
1003 | |
1004 | if ($ifp !== false) { |
1005 | fwrite($ifp, $imgBlob); |
1006 | fclose($ifp); |
1007 | } |
1008 | } |
1009 | } |
1010 | |
1011 | if (is_file($src)) { |
1012 | $newElement = $element->addImage($src, $style); |
1013 | } else { |
1014 | throw new Exception("Could not load image $originSrc"); |
1015 | } |
1016 | |
1017 | return $newElement; |
1018 | } |
1019 | |
1020 | /** |
1021 | * Transforms a CSS border style into a word border style. |
1022 | * |
1023 | * @param string $cssBorderStyle |
1024 | * |
1025 | * @return null|string |
1026 | */ |
1027 | protected static function mapBorderStyle($cssBorderStyle) |
1028 | { |
1029 | switch ($cssBorderStyle) { |
1030 | case 'none': |
1031 | case 'dashed': |
1032 | case 'dotted': |
1033 | case 'double': |
1034 | return $cssBorderStyle; |
1035 | default: |
1036 | return 'single'; |
1037 | } |
1038 | } |
1039 | |
1040 | protected static function mapBorderColor(&$styles, $cssBorderColor): void |
1041 | { |
1042 | $numColors = substr_count($cssBorderColor, '#'); |
1043 | if ($numColors === 1) { |
1044 | $styles['borderColor'] = trim($cssBorderColor, '#'); |
1045 | } elseif ($numColors > 1) { |
1046 | $colors = explode(' ', $cssBorderColor); |
1047 | $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor']; |
1048 | for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) { |
1049 | $styles[$borders[$i]] = trim($colors[$i], '#'); |
1050 | } |
1051 | } |
1052 | } |
1053 | |
1054 | /** |
1055 | * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc. |
1056 | * |
1057 | * @param string $cssAlignment |
1058 | * @param bool $bidi |
1059 | * |
1060 | * @return null|string |
1061 | */ |
1062 | protected static function mapAlign($cssAlignment, $bidi) |
1063 | { |
1064 | switch ($cssAlignment) { |
1065 | case 'right': |
1066 | return $bidi ? Jc::START : Jc::END; |
1067 | case 'center': |
1068 | return Jc::CENTER; |
1069 | case 'justify': |
1070 | return Jc::BOTH; |
1071 | default: |
1072 | return $bidi ? Jc::END : Jc::START; |
1073 | } |
1074 | } |
1075 | |
1076 | /** |
1077 | * Transforms a HTML/CSS vertical alignment. |
1078 | * |
1079 | * @param string $alignment |
1080 | * |
1081 | * @return null|string |
1082 | */ |
1083 | protected static function mapAlignVertical($alignment) |
1084 | { |
1085 | $alignment = strtolower($alignment); |
1086 | switch ($alignment) { |
1087 | case 'top': |
1088 | case 'baseline': |
1089 | case 'bottom': |
1090 | return $alignment; |
1091 | case 'middle': |
1092 | return 'center'; |
1093 | case 'sub': |
1094 | return 'bottom'; |
1095 | case 'text-top': |
1096 | case 'baseline': |
1097 | return 'top'; |
1098 | default: |
1099 | // @discuss - which one should apply: |
1100 | // - Word uses default vert. alignment: top |
1101 | // - all browsers use default vert. alignment: middle |
1102 | // Returning empty string means attribute wont be set so use Word default (top). |
1103 | return ''; |
1104 | } |
1105 | } |
1106 | |
1107 | /** |
1108 | * Map list style for ordered list. |
1109 | * |
1110 | * @param string $cssListType |
1111 | */ |
1112 | protected static function mapListType($cssListType) |
1113 | { |
1114 | switch ($cssListType) { |
1115 | case 'a': |
1116 | return NumberFormat::LOWER_LETTER; // a, b, c, .. |
1117 | case 'A': |
1118 | return NumberFormat::UPPER_LETTER; // A, B, C, .. |
1119 | case 'i': |
1120 | return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, .. |
1121 | case 'I': |
1122 | return NumberFormat::UPPER_ROMAN; // I, II, III, IV, .. |
1123 | case '1': |
1124 | default: |
1125 | return NumberFormat::DECIMAL; // 1, 2, 3, .. |
1126 | } |
1127 | } |
1128 | |
1129 | /** |
1130 | * Parse line break. |
1131 | * |
1132 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
1133 | */ |
1134 | protected static function parseLineBreak($element): void |
1135 | { |
1136 | $element->addTextBreak(); |
1137 | } |
1138 | |
1139 | /** |
1140 | * Parse link node. |
1141 | * |
1142 | * @param DOMNode $node |
1143 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
1144 | * @param array $styles |
1145 | */ |
1146 | protected static function parseLink($node, $element, &$styles) |
1147 | { |
1148 | $target = null; |
1149 | foreach ($node->attributes as $attribute) { |
1150 | switch ($attribute->name) { |
1151 | case 'href': |
1152 | $target = $attribute->value; |
1153 | |
1154 | break; |
1155 | } |
1156 | } |
1157 | $styles['font'] = self::parseInlineStyle($node, $styles['font']); |
1158 | |
1159 | if (empty($target)) { |
1160 | $target = '#'; |
1161 | } |
1162 | |
1163 | if (strpos($target, '#') === 0 && strlen($target) > 1) { |
1164 | return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true); |
1165 | } |
1166 | |
1167 | return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']); |
1168 | } |
1169 | |
1170 | /** |
1171 | * Render horizontal rule |
1172 | * Note: Word rule is not the same as HTML's <hr> since it does not support width and thus neither alignment. |
1173 | * |
1174 | * @param DOMNode $node |
1175 | * @param \PhpOffice\PhpWord\Element\AbstractContainer $element |
1176 | */ |
1177 | protected static function parseHorizRule($node, $element): void |
1178 | { |
1179 | $styles = self::parseInlineStyle($node); |
1180 | |
1181 | // <hr> is implemented as an empty paragraph - extending 100% inside the section |
1182 | // Some properties may be controlled, e.g. <hr style="border-bottom: 3px #DDDDDD solid; margin-bottom: 0;"> |
1183 | |
1184 | $fontStyle = $styles + ['size' => 3]; |
1185 | |
1186 | $paragraphStyle = $styles + [ |
1187 | 'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc |
1188 | 'spacing' => 0, // twip |
1189 | 'spaceBefore' => 120, // twip, 240/2 (default line height) |
1190 | 'spaceAfter' => 120, // twip |
1191 | 'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'], |
1192 | 'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'], |
1193 | 'borderBottomStyle' => 'single', // same as "solid" |
1194 | ]; |
1195 | |
1196 | $element->addText('', $fontStyle, $paragraphStyle); |
1197 | |
1198 | // Notes: <hr/> cannot be: |
1199 | // - table - throws error "cannot be inside textruns", e.g. lists |
1200 | // - line - that is a shape, has different behaviour |
1201 | // - repeated text, e.g. underline "_", because of unpredictable line wrapping |
1202 | } |
1203 | |
1204 | private static function convertRgb(string $rgb): string |
1205 | { |
1206 | if (preg_match(self::RGB_REGEXP, $rgb, $matches) === 1) { |
1207 | return sprintf('%02X%02X%02X', $matches[1], $matches[2], $matches[3]); |
1208 | } |
1209 | |
1210 | return trim($rgb, '# '); |
1211 | } |
1212 | } |