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