. * * @param \PhpOffice\PhpWord\Element\AbstractContainer $element Where the parts need to be added * @param string $html The code to parse * @param bool $fullHTML If it's a full HTML, no need to add 'body' tag * @param bool $preserveWhiteSpace If false, the whitespaces between nodes will be removed */ public static function addHtml($element, $html, $fullHTML = false, $preserveWhiteSpace = true, $options = null): void { /* * @todo parse $stylesheet for default styles. Should result in an array based on id, class and element, * which could be applied when such an element occurs in the parseNode function. */ static::$options = $options; // Preprocess: remove all line ends, decode HTML entity, // fix ampersand and angle brackets and add body tag for HTML fragments $html = str_replace(["\n", "\r"], '', $html); $html = str_replace(['<', '>', '&', '"'], ['_lt_', '_gt_', '_amp_', '_quot_'], $html); $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8'); $html = str_replace('&', '&', $html); $html = str_replace(['_lt_', '_gt_', '_amp_', '_quot_'], ['<', '>', '&', '"'], $html); if (false === $fullHTML) { $html = '' . $html . ''; } // Load DOM if (\PHP_VERSION_ID < 80000) { $orignalLibEntityLoader = libxml_disable_entity_loader(true); } $dom = new DOMDocument(); $dom->preserveWhiteSpace = $preserveWhiteSpace; $dom->loadXML($html); static::$xpath = new DOMXPath($dom); $node = $dom->getElementsByTagName('body'); static::parseNode($node->item(0), $element); if (\PHP_VERSION_ID < 80000) { libxml_disable_entity_loader($orignalLibEntityLoader); } } /** * parse Inline style of a node. * * @param DOMNode $node Node to check on attributes and to compile a style array * @param array $styles is supplied, the inline style attributes are added to the already existing style * * @return array */ protected static function parseInlineStyle($node, $styles = []) { if (XML_ELEMENT_NODE == $node->nodeType) { $attributes = $node->attributes; // get all the attributes(eg: id, class) $bidi = ($attributes['dir'] ?? '') === 'rtl'; foreach ($attributes as $attribute) { $val = $attribute->value; switch (strtolower($attribute->name)) { case 'align': $styles['alignment'] = self::mapAlign(trim($val), $bidi); break; case 'lang': $styles['lang'] = $val; break; case 'width': // tables, cells if (false !== strpos($val, '%')) { // e.g. or
$styles['width'] = (int) $val * 50; $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; } else { // e.g. , where "2" = 2px (always pixels) $val = (int) $val . 'px'; $styles['cellSpacing'] = Converter::cssToTwip($val); break; case 'bgcolor': // tables, rows, cells e.g. $styles['bgColor'] = trim($val, '# '); break; case 'valign': // cells e.g.
if (preg_match('#(?:top|bottom|middle|baseline)#i', $val, $matches)) { $styles['valign'] = self::mapAlignVertical($matches[0]); } break; } } $attributeIdentifier = $attributes->getNamedItem('id'); if ($attributeIdentifier && self::$css) { $styles = self::parseStyleDeclarations(self::$css->getStyle('#' . $attributeIdentifier->value), $styles); } $attributeClass = $attributes->getNamedItem('class'); if ($attributeClass) { if (self::$css) { $styles = self::parseStyleDeclarations(self::$css->getStyle('.' . $attributeClass->value), $styles); } $styles['className'] = $attributeClass->value; } $attributeStyle = $attributes->getNamedItem('style'); if ($attributeStyle) { $styles = self::parseStyle($attributeStyle, $styles); } } return $styles; } /** * Parse a node and add a corresponding element to the parent element. * * @param DOMNode $node node to parse * @param \PhpOffice\PhpWord\Element\AbstractContainer $element object to add an element corresponding with the node * @param array $styles Array with all styles * @param array $data Array to transport data to a next level in the DOM tree, for example level of listitems */ protected static function parseNode($node, $element, $styles = [], $data = []): void { if ($node->nodeName == 'style') { self::$css = new Css($node->textContent); self::$css->process(); return; } // Populate styles array $styleTypes = ['font', 'paragraph', 'list', 'table', 'row', 'cell']; foreach ($styleTypes as $styleType) { if (!isset($styles[$styleType])) { $styles[$styleType] = []; } } // Node mapping table $nodes = [ // $method $node $element $styles $data $argument1 $argument2 'p' => ['Paragraph', $node, $element, $styles, null, null, null], 'h1' => ['Heading', null, $element, $styles, null, 'Heading1', null], 'h2' => ['Heading', null, $element, $styles, null, 'Heading2', null], 'h3' => ['Heading', null, $element, $styles, null, 'Heading3', null], 'h4' => ['Heading', null, $element, $styles, null, 'Heading4', null], 'h5' => ['Heading', null, $element, $styles, null, 'Heading5', null], 'h6' => ['Heading', null, $element, $styles, null, 'Heading6', null], '#text' => ['Text', $node, $element, $styles, null, null, null], 'strong' => ['Property', null, null, $styles, null, 'bold', true], 'b' => ['Property', null, null, $styles, null, 'bold', true], 'em' => ['Property', null, null, $styles, null, 'italic', true], 'i' => ['Property', null, null, $styles, null, 'italic', true], 'u' => ['Property', null, null, $styles, null, 'underline', 'single'], 'sup' => ['Property', null, null, $styles, null, 'superScript', true], 'sub' => ['Property', null, null, $styles, null, 'subScript', true], 'span' => ['Span', $node, null, $styles, null, null, null], 'font' => ['Span', $node, null, $styles, null, null, null], 'table' => ['Table', $node, $element, $styles, null, null, null], 'tr' => ['Row', $node, $element, $styles, null, null, null], 'td' => ['Cell', $node, $element, $styles, null, null, null], 'th' => ['Cell', $node, $element, $styles, null, null, null], 'ul' => ['List', $node, $element, $styles, $data, null, null], 'ol' => ['List', $node, $element, $styles, $data, null, null], 'li' => ['ListItem', $node, $element, $styles, $data, null, null], 'img' => ['Image', $node, $element, $styles, null, null, null], 'br' => ['LineBreak', null, $element, $styles, null, null, null], 'a' => ['Link', $node, $element, $styles, null, null, null], 'input' => ['Input', $node, $element, $styles, null, null, null], 'hr' => ['HorizRule', $node, $element, $styles, null, null, null], ]; $newElement = null; $keys = ['node', 'element', 'styles', 'data', 'argument1', 'argument2']; if (isset($nodes[$node->nodeName])) { // Execute method based on node mapping table and return $newElement or null // Arguments are passed by reference $arguments = []; $args = []; [$method, $args[0], $args[1], $args[2], $args[3], $args[4], $args[5]] = $nodes[$node->nodeName]; for ($i = 0; $i <= 5; ++$i) { if ($args[$i] !== null) { $arguments[$keys[$i]] = &$args[$i]; } } $method = "parse{$method}"; $newElement = call_user_func_array(['PhpOffice\PhpWord\Shared\Html', $method], array_values($arguments)); // Retrieve back variables from arguments foreach ($keys as $key) { if (array_key_exists($key, $arguments)) { $$key = $arguments[$key]; } } } if ($newElement === null) { $newElement = $element; } static::parseChildNodes($node, $newElement, $styles, $data); } /** * Parse child nodes. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer|Row|Table $element * @param array $styles * @param array $data */ protected static function parseChildNodes($node, $element, $styles, $data): void { if ('li' != $node->nodeName) { $cNodes = $node->childNodes; if (!empty($cNodes)) { foreach ($cNodes as $cNode) { if ($element instanceof AbstractContainer || $element instanceof Table || $element instanceof Row) { self::parseNode($cNode, $element, $styles, $data); } } } } } /** * Parse paragraph node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles * * @return \PhpOffice\PhpWord\Element\PageBreak|\PhpOffice\PhpWord\Element\TextRun */ protected static function parseParagraph($node, $element, &$styles) { $styles['paragraph'] = self::recursiveParseStylesInHierarchy($node, $styles['paragraph']); if (isset($styles['paragraph']['isPageBreak']) && $styles['paragraph']['isPageBreak']) { return $element->addPageBreak(); } return $element->addTextRun($styles['paragraph']); } /** * Parse input node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles */ protected static function parseInput($node, $element, &$styles): void { $attributes = $node->attributes; if (null === $attributes->getNamedItem('type')) { return; } $inputType = $attributes->getNamedItem('type')->value; switch ($inputType) { case 'checkbox': $checked = ($checked = $attributes->getNamedItem('checked')) && $checked->value === 'true' ? true : false; $textrun = $element->addTextRun($styles['paragraph']); $textrun->addFormField('checkbox')->setValue($checked); break; } } /** * Parse heading node. * * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles * @param string $argument1 Name of heading style * * @return \PhpOffice\PhpWord\Element\TextRun * * @todo Think of a clever way of defining header styles, now it is only based on the assumption, that * Heading1 - Heading6 are already defined somewhere */ protected static function parseHeading($element, &$styles, $argument1) { $styles['paragraph'] = $argument1; $newElement = $element->addTextRun($styles['paragraph']); return $newElement; } /** * Parse text node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles */ protected static function parseText($node, $element, &$styles): void { $styles['font'] = self::recursiveParseStylesInHierarchy($node, $styles['font']); //alignment applies on paragraph, not on font. Let's copy it there if (isset($styles['font']['alignment']) && is_array($styles['paragraph'])) { $styles['paragraph']['alignment'] = $styles['font']['alignment']; } if (is_callable([$element, 'addText'])) { $element->addText($node->nodeValue, $styles['font'], $styles['paragraph']); } } /** * Parse property node. * * @param array &$styles * @param string $argument1 Style name * @param string $argument2 Style value */ protected static function parseProperty(&$styles, $argument1, $argument2): void { $styles['font'][$argument1] = $argument2; } /** * Parse span node. * * @param DOMNode $node * @param array &$styles */ protected static function parseSpan($node, &$styles): void { self::parseInlineStyle($node, $styles['font']); } /** * Parse table node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles * * @return Table $element * * @todo As soon as TableItem, RowItem and CellItem support relative width and height */ protected static function parseTable($node, $element, &$styles) { $elementStyles = self::parseInlineStyle($node, $styles['table']); $newElement = $element->addTable($elementStyles); // Add style name from CSS Class if (isset($elementStyles['className'])) { $newElement->getStyle()->setStyleName($elementStyles['className']); } $attributes = $node->attributes; if ($attributes->getNamedItem('border') !== null) { $border = (int) $attributes->getNamedItem('border')->value; $newElement->getStyle()->setBorderSize(Converter::pixelToTwip($border)); } return $newElement; } /** * Parse a table row. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\Table $element * @param array &$styles * * @return Row $element */ protected static function parseRow($node, $element, &$styles) { $rowStyles = self::parseInlineStyle($node, $styles['row']); if ($node->parentNode->nodeName == 'thead') { $rowStyles['tblHeader'] = true; } // set cell height to control row heights $height = $rowStyles['height'] ?? null; unset($rowStyles['height']); // would not apply return $element->addRow($height, $rowStyles); } /** * Parse table cell. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\Table $element * @param array &$styles * * @return \PhpOffice\PhpWord\Element\Cell|\PhpOffice\PhpWord\Element\TextRun $element */ protected static function parseCell($node, $element, &$styles) { $cellStyles = self::recursiveParseStylesInHierarchy($node, $styles['cell']); $colspan = $node->getAttribute('colspan'); if (!empty($colspan)) { $cellStyles['gridSpan'] = $colspan - 0; } // set cell width to control column widths $width = $cellStyles['width'] ?? null; unset($cellStyles['width']); // would not apply $cell = $element->addCell($width, $cellStyles); if (self::shouldAddTextRun($node)) { return $cell->addTextRun(self::filterOutNonInheritedStyles(self::parseInlineStyle($node, $styles['paragraph']))); } return $cell; } /** * Checks if $node contains an HTML element that cannot be added to TextRun. * * @return bool Returns true if the node contains an HTML element that cannot be added to TextRun */ protected static function shouldAddTextRun(DOMNode $node) { $containsBlockElement = self::$xpath->query('.//table|./p|./ul|./ol|./h1|./h2|./h3|./h4|./h5|./h6', $node)->length > 0; if ($containsBlockElement) { return false; } return true; } /** * Recursively parses styles on parent nodes * TODO if too slow, add caching of parent nodes, !! everything is static here so watch out for concurrency !! */ protected static function recursiveParseStylesInHierarchy(DOMNode $node, array $style) { $parentStyle = []; if ($node->parentNode != null && XML_ELEMENT_NODE == $node->parentNode->nodeType) { $parentStyle = self::recursiveParseStylesInHierarchy($node->parentNode, []); } if ($node->nodeName === '#text') { $parentStyle = array_merge($parentStyle, $style); } else { $parentStyle = self::filterOutNonInheritedStyles($parentStyle); } $style = self::parseInlineStyle($node, $parentStyle); return $style; } /** * Removes non-inherited styles from array. */ protected static function filterOutNonInheritedStyles(array $styles) { $nonInheritedStyles = [ 'borderSize', 'borderTopSize', 'borderRightSize', 'borderBottomSize', 'borderLeftSize', 'borderColor', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderStyle', 'spaceAfter', 'spaceBefore', 'underline', 'strikethrough', 'hidden', ]; $styles = array_diff_key($styles, array_flip($nonInheritedStyles)); return $styles; } /** * Parse list node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles * @param array &$data */ protected static function parseList($node, $element, &$styles, &$data) { $isOrderedList = $node->nodeName === 'ol'; if (isset($data['listdepth'])) { ++$data['listdepth']; } else { $data['listdepth'] = 0; $styles['list'] = 'listStyle_' . self::$listIndex++; $style = $element->getPhpWord()->addNumberingStyle($styles['list'], self::getListStyle($isOrderedList)); // extract attributes start & type e.g.
    $start = 0; $type = ''; foreach ($node->attributes as $attribute) { switch ($attribute->name) { case 'start': $start = (int) $attribute->value; break; case 'type': $type = $attribute->value; break; } } $levels = $style->getLevels(); /** @var \PhpOffice\PhpWord\Style\NumberingLevel */ $level = $levels[0]; if ($start > 0) { $level->setStart($start); } $type = $type ? self::mapListType($type) : null; if ($type) { $level->setFormat($type); } } if ($node->parentNode->nodeName === 'li') { return $element->getParent(); } } /** * @param bool $isOrderedList * * @return array */ protected static function getListStyle($isOrderedList) { if ($isOrderedList) { return [ 'type' => 'multilevel', 'levels' => [ ['format' => NumberFormat::DECIMAL, 'text' => '%1.', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360], ['format' => NumberFormat::LOWER_LETTER, 'text' => '%2.', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360], ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%3.', 'alignment' => 'right', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 180], ['format' => NumberFormat::DECIMAL, 'text' => '%4.', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360], ['format' => NumberFormat::LOWER_LETTER, 'text' => '%5.', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360], ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%6.', 'alignment' => 'right', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 180], ['format' => NumberFormat::DECIMAL, 'text' => '%7.', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360], ['format' => NumberFormat::LOWER_LETTER, 'text' => '%8.', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360], ['format' => NumberFormat::LOWER_ROMAN, 'text' => '%9.', 'alignment' => 'right', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 180], ], ]; } return [ 'type' => 'hybridMultilevel', 'levels' => [ ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 720, 'left' => 720, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 1440, 'left' => 1440, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 2160, 'left' => 2160, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 2880, 'left' => 2880, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 3600, 'left' => 3600, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 4320, 'left' => 4320, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 5040, 'left' => 5040, 'hanging' => 360, 'font' => 'Symbol', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => 'o', 'alignment' => 'left', 'tabPos' => 5760, 'left' => 5760, 'hanging' => 360, 'font' => 'Courier New', 'hint' => 'default'], ['format' => NumberFormat::BULLET, 'text' => '', 'alignment' => 'left', 'tabPos' => 6480, 'left' => 6480, 'hanging' => 360, 'font' => 'Wingdings', 'hint' => 'default'], ], ]; } /** * Parse list item node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array &$styles * @param array $data * * @todo This function is almost the same like `parseChildNodes`. Merged? * @todo As soon as ListItem inherits from AbstractContainer or TextRun delete parsing part of childNodes */ protected static function parseListItem($node, $element, &$styles, $data): void { $cNodes = $node->childNodes; if (!empty($cNodes)) { $listRun = $element->addListItemRun($data['listdepth'], $styles['list'], $styles['paragraph']); foreach ($cNodes as $cNode) { self::parseNode($cNode, $listRun, $styles, $data); } } } /** * Parse style. * * @param DOMAttr $attribute * @param array $styles * * @return array */ protected static function parseStyle($attribute, $styles) { $properties = explode(';', trim($attribute->value, " \t\n\r\0\x0B;")); $selectors = []; foreach ($properties as $property) { [$cKey, $cValue] = array_pad(explode(':', $property, 2), 2, null); $selectors[strtolower(trim($cKey))] = trim($cValue ?? ''); } return self::parseStyleDeclarations($selectors, $styles); } protected static function parseStyleDeclarations(array $selectors, array $styles) { $bidi = ($selectors['direction'] ?? '') === 'rtl'; foreach ($selectors as $property => $value) { switch ($property) { case 'text-decoration': switch ($value) { case 'underline': $styles['underline'] = 'single'; break; case 'line-through': $styles['strikethrough'] = true; break; } break; case 'text-align': $styles['alignment'] = self::mapAlign($value, $bidi); break; case 'display': $styles['hidden'] = $value === 'none' || $value === 'hidden'; break; case 'direction': $styles['rtl'] = $value === 'rtl'; $styles['bidi'] = $value === 'rtl'; break; case 'font-size': $styles['size'] = Converter::cssToPoint($value); break; case 'font-family': $value = array_map('trim', explode(',', $value)); $styles['name'] = ucwords($value[0]); break; case 'color': $styles['color'] = trim($value, '#'); break; case 'background-color': $styles['bgColor'] = trim($value, '#'); break; case 'line-height': $matches = []; if ($value === 'normal') { $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; $spacing = 0; } elseif (preg_match('/([0-9]+\.?[0-9]*[a-z]+)/', $value, $matches)) { //matches number with a unit, e.g. 12px, 15pt, 20mm, ... $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::EXACT; $spacing = Converter::cssToTwip($matches[1]); } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { //matches percentages $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; //we are subtracting 1 line height because the Spacing writer is adding one line $spacing = ((((int) $matches[1]) / 100) * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; } else { //any other, wich is a multiplier. E.g. 1.2 $spacingLineRule = \PhpOffice\PhpWord\SimpleType\LineSpacingRule::AUTO; //we are subtracting 1 line height because the Spacing writer is adding one line $spacing = ($value * Paragraph::LINE_HEIGHT) - Paragraph::LINE_HEIGHT; } $styles['spacingLineRule'] = $spacingLineRule; $styles['line-spacing'] = $spacing; break; case 'letter-spacing': $styles['letter-spacing'] = Converter::cssToTwip($value); break; case 'text-indent': $styles['indentation']['firstLine'] = Converter::cssToTwip($value); break; case 'font-weight': $tValue = false; if (preg_match('#bold#', $value)) { $tValue = true; // also match bolder } $styles['bold'] = $tValue; break; case 'font-style': $tValue = false; if (preg_match('#(?:italic|oblique)#', $value)) { $tValue = true; } $styles['italic'] = $tValue; break; case 'font-variant': $tValue = false; if (preg_match('#small-caps#', $value)) { $tValue = true; } $styles['smallCaps'] = $tValue; break; case 'margin': $value = Converter::cssToTwip($value); $styles['spaceBefore'] = $value; $styles['spaceAfter'] = $value; break; case 'margin-top': // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) $styles['spaceBefore'] = Converter::cssToTwip($value); break; case 'margin-bottom': // BC change: up to ver. 0.17.0 incorrectly converted to points - Converter::cssToPoint($value) $styles['spaceAfter'] = Converter::cssToTwip($value); break; case 'border-color': self::mapBorderColor($styles, $value); break; case 'border-width': $styles['borderSize'] = Converter::cssToPoint($value); break; case 'border-style': $styles['borderStyle'] = self::mapBorderStyle($value); break; case 'width': if (preg_match('/([0-9]+[a-z]+)/', $value, $matches)) { $styles['width'] = Converter::cssToTwip($matches[1]); $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::TWIP; } elseif (preg_match('/([0-9]+)%/', $value, $matches)) { $styles['width'] = $matches[1] * 50; $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::PERCENT; } elseif (preg_match('/([0-9]+)/', $value, $matches)) { $styles['width'] = $matches[1]; $styles['unit'] = \PhpOffice\PhpWord\SimpleType\TblWidth::AUTO; } break; case 'height': $styles['height'] = Converter::cssToTwip($value); $styles['exactHeight'] = true; break; case 'border': case 'border-top': case 'border-bottom': case 'border-right': case 'border-left': // must have exact order [width color style], e.g. "1px #0011CC solid" or "2pt green solid" // Word does not accept shortened hex colors e.g. #CCC, only full e.g. #CCCCCC if (preg_match('/([0-9]+[^0-9]*)\s+(\#[a-fA-F0-9]+|[a-zA-Z]+)\s+([a-z]+)/', $value, $matches)) { if (false !== strpos($property, '-')) { $tmp = explode('-', $property); $which = $tmp[1]; $which = ucfirst($which); // e.g. bottom -> Bottom } else { $which = ''; } // Note - border width normalization: // Width of border in Word is calculated differently than HTML borders, usually showing up too bold. // Smallest 1px (or 1pt) appears in Word like 2-3px/pt in HTML once converted to twips. // Therefore we need to normalize converted twip value to cca 1/2 of value. // This may be adjusted, if better ratio or formula found. // BC change: up to ver. 0.17.0 was $size converted to points - Converter::cssToPoint($size) $size = Converter::cssToTwip($matches[1]); $size = (int) ($size / 2); // valid variants may be e.g. borderSize, borderTopSize, borderLeftColor, etc .. $styles["border{$which}Size"] = $size; // twips $styles["border{$which}Color"] = trim($matches[2], '#'); $styles["border{$which}Style"] = self::mapBorderStyle($matches[3]); } break; case 'vertical-align': // https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align if (preg_match('#(?:top|bottom|middle|sub|baseline)#i', $value, $matches)) { $styles['valign'] = self::mapAlignVertical($matches[0]); } break; case 'page-break-after': if ($value == 'always') { $styles['isPageBreak'] = true; } break; } } return $styles; } /** * Parse image node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * * @return \PhpOffice\PhpWord\Element\Image */ protected static function parseImage($node, $element) { $style = []; $src = null; foreach ($node->attributes as $attribute) { switch ($attribute->name) { case 'src': $src = $attribute->value; break; case 'width': $width = $attribute->value; $style['width'] = $width; $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; break; case 'height': $height = $attribute->value; $style['height'] = $height; $style['unit'] = \PhpOffice\PhpWord\Style\Image::UNIT_PX; break; case 'style': $styleattr = explode(';', $attribute->value); foreach ($styleattr as $attr) { if (strpos($attr, ':')) { [$k, $v] = explode(':', $attr); switch ($k) { case 'float': if (trim($v) == 'right') { $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_RIGHT; $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; $style['overlap'] = true; } if (trim($v) == 'left') { $style['hPos'] = \PhpOffice\PhpWord\Style\Image::POS_LEFT; $style['hPosRelTo'] = \PhpOffice\PhpWord\Style\Image::POS_RELTO_MARGIN; // inner section area $style['pos'] = \PhpOffice\PhpWord\Style\Image::POS_RELATIVE; $style['wrap'] = \PhpOffice\PhpWord\Style\Image::WRAP_TIGHT; $style['overlap'] = true; } break; } } } break; } } $originSrc = $src; if (strpos($src, 'data:image') !== false) { $tmpDir = Settings::getTempDir() . '/'; $match = []; preg_match('/data:image\/(\w+);base64,(.+)/', $src, $match); $src = $imgFile = $tmpDir . uniqid() . '.' . $match[1]; $ifp = fopen($imgFile, 'wb'); if ($ifp !== false) { fwrite($ifp, base64_decode($match[2])); fclose($ifp); } } $src = urldecode($src); if (!is_file($src) && null !== self::$options && isset(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE']) ) { $src = str_replace(self::$options['IMG_SRC_SEARCH'], self::$options['IMG_SRC_REPLACE'], $src); } if (!is_file($src)) { if ($imgBlob = @file_get_contents($src)) { $tmpDir = Settings::getTempDir() . '/'; $match = []; preg_match('/.+\.(\w+)$/', $src, $match); $src = $tmpDir . uniqid(); if (isset($match[1])) { $src .= '.' . $match[1]; } $ifp = fopen($src, 'wb'); if ($ifp !== false) { fwrite($ifp, $imgBlob); fclose($ifp); } } } if (is_file($src)) { $newElement = $element->addImage($src, $style); } else { throw new Exception("Could not load image $originSrc"); } return $newElement; } /** * Transforms a CSS border style into a word border style. * * @param string $cssBorderStyle * * @return null|string */ protected static function mapBorderStyle($cssBorderStyle) { switch ($cssBorderStyle) { case 'none': case 'dashed': case 'dotted': case 'double': return $cssBorderStyle; default: return 'single'; } } protected static function mapBorderColor(&$styles, $cssBorderColor): void { $numColors = substr_count($cssBorderColor, '#'); if ($numColors === 1) { $styles['borderColor'] = trim($cssBorderColor, '#'); } elseif ($numColors > 1) { $colors = explode(' ', $cssBorderColor); $borders = ['borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor']; for ($i = 0; $i < min(4, $numColors, count($colors)); ++$i) { $styles[$borders[$i]] = trim($colors[$i], '#'); } } } /** * Transforms a HTML/CSS alignment into a \PhpOffice\PhpWord\SimpleType\Jc. * * @param string $cssAlignment * @param bool $bidi * * @return null|string */ protected static function mapAlign($cssAlignment, $bidi) { switch ($cssAlignment) { case 'right': return $bidi ? Jc::START : Jc::END; case 'center': return Jc::CENTER; case 'justify': return Jc::BOTH; default: return $bidi ? Jc::END : Jc::START; } } /** * Transforms a HTML/CSS vertical alignment. * * @param string $alignment * * @return null|string */ protected static function mapAlignVertical($alignment) { $alignment = strtolower($alignment); switch ($alignment) { case 'top': case 'baseline': case 'bottom': return $alignment; case 'middle': return 'center'; case 'sub': return 'bottom'; case 'text-top': case 'baseline': return 'top'; default: // @discuss - which one should apply: // - Word uses default vert. alignment: top // - all browsers use default vert. alignment: middle // Returning empty string means attribute wont be set so use Word default (top). return ''; } } /** * Map list style for ordered list. * * @param string $cssListType */ protected static function mapListType($cssListType) { switch ($cssListType) { case 'a': return NumberFormat::LOWER_LETTER; // a, b, c, .. case 'A': return NumberFormat::UPPER_LETTER; // A, B, C, .. case 'i': return NumberFormat::LOWER_ROMAN; // i, ii, iii, iv, .. case 'I': return NumberFormat::UPPER_ROMAN; // I, II, III, IV, .. case '1': default: return NumberFormat::DECIMAL; // 1, 2, 3, .. } } /** * Parse line break. * * @param \PhpOffice\PhpWord\Element\AbstractContainer $element */ protected static function parseLineBreak($element): void { $element->addTextBreak(); } /** * Parse link node. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element * @param array $styles */ protected static function parseLink($node, $element, &$styles) { $target = null; foreach ($node->attributes as $attribute) { switch ($attribute->name) { case 'href': $target = $attribute->value; break; } } $styles['font'] = self::parseInlineStyle($node, $styles['font']); if (strpos($target, '#') === 0) { return $element->addLink(substr($target, 1), $node->textContent, $styles['font'], $styles['paragraph'], true); } return $element->addLink($target, $node->textContent, $styles['font'], $styles['paragraph']); } /** * Render horizontal rule * Note: Word rule is not the same as HTML's
    since it does not support width and thus neither alignment. * * @param DOMNode $node * @param \PhpOffice\PhpWord\Element\AbstractContainer $element */ protected static function parseHorizRule($node, $element): void { $styles = self::parseInlineStyle($node); //
    is implemented as an empty paragraph - extending 100% inside the section // Some properties may be controlled, e.g.
    $fontStyle = $styles + ['size' => 3]; $paragraphStyle = $styles + [ 'lineHeight' => 0.25, // multiply default line height - e.g. 1, 1.5 etc 'spacing' => 0, // twip 'spaceBefore' => 120, // twip, 240/2 (default line height) 'spaceAfter' => 120, // twip 'borderBottomSize' => empty($styles['line-height']) ? 1 : $styles['line-height'], 'borderBottomColor' => empty($styles['color']) ? '000000' : $styles['color'], 'borderBottomStyle' => 'single', // same as "solid" ]; $element->addText('', $fontStyle, $paragraphStyle); // Notes:
    cannot be: // - table - throws error "cannot be inside textruns", e.g. lists // - line - that is a shape, has different behaviour // - repeated text, e.g. underline "_", because of unpredictable line wrapping } }