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 | } |