diff --git a/src/PhpWord/StyleMerger.php b/src/PhpWord/StyleMerger.php new file mode 100644 index 0000000000..8cd8e78ac1 --- /dev/null +++ b/src/PhpWord/StyleMerger.php @@ -0,0 +1,102 @@ + + */ + private $elements = []; + + public function __construct(string $style) + { + $this->styleElement = $this->createStyleElement($style); + foreach ($this->styleElement->childNodes as $node) { + if ($node instanceof \DOMElement) { + $this->elements[$node->tagName] = $node; + } + } + } + + public static function mergeStyles(string $style, string ...$styles): string + { + $styleMerger = new self($style); + foreach ($styles as $styleToMerge) { + $styleMerger->merge($styleToMerge); + } + + return $styleMerger->getStyleString(); + } + + public function merge(string $style): self + { + $styleElement = $this->createStyleElement($style); + foreach ($styleElement->childNodes as $node) { + if ($node instanceof \DOMElement) { + // @todo Do we need recursive merging for some elements? + if (!isset($this->elements[$node->tagName])) { + $importedNode = $this->styleElement->ownerDocument->importNode($node, TRUE); + if (!$importedNode instanceof \DOMElement) { + throw new \RuntimeException('Importing node failed'); + } + + $this->styleElement->appendChild($importedNode); + $this->elements[$node->tagName] = $importedNode; + } + } + } + + return $this; + } + + private function createStyleElement(string $style): \DOMElement + { + if (NULL === $style = preg_replace('/>\s+<', $style)) { + throw new \RuntimeException('Error processing style'); + } + + $doc = new \DOMDocument(); + $doc->loadXML( + '' . $style . '' + ); + + foreach ($doc->documentElement->childNodes as $node) { + if ($node instanceof \DOMElement) { + return $node; + } + } + + throw new \RuntimeException('Could not create style element'); + } + + public function getStyleString(): string + { + return $this->styleElement->ownerDocument->saveXML($this->styleElement); + } + +} diff --git a/src/PhpWord/TemplateProcessor.php b/src/PhpWord/TemplateProcessor.php index 8aee40c546..faffd439ea 100644 --- a/src/PhpWord/TemplateProcessor.php +++ b/src/PhpWord/TemplateProcessor.php @@ -315,6 +315,64 @@ public function setComplexBlock($search, Element\AbstractElement $complexType): $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p'); } + /** + * Replaces a search string (macro) with a set of rendered elements, splitting + * surrounding texts, text runs or paragraphs before and after the macro, + * depending on the types of elements to insert. + * + * @param \PhpOffice\PhpWord\Element\AbstractElement[] $elements + * @param bool $inheritStyle + * If TRUE the style will be inherited from the paragraph/text run the macro + * is inside. If the element already contains styles, they will be merged. + * + * @throws \PhpOffice\PhpWord\Exception\Exception + */ + public function setElementsValue(string $search, array $elements, bool $inheritStyle = FALSE): void { + $search = static::ensureMacroCompleted($search); + $elementsDataList = []; + $hasParagraphs = FALSE; + foreach ($elements as $element) { + $elementName = substr( + get_class($element), + (int) strrpos(get_class($element), '\\') + 1 + ); + $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; + + // For inline elements, do not create a new paragraph. + $withParagraph = \PhpOffice\PhpWord\Writer\Word2007\Element\Text::class !== $objectClass; + $hasParagraphs = $hasParagraphs || $withParagraph; + + $xmlWriter = new XMLWriter(); + /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */ + $elementWriter = new $objectClass($xmlWriter, $element, !$withParagraph); + $elementWriter->write(); + $elementsDataList[] = preg_replace('/>\s+<', $xmlWriter->getData()); + } + $blockType = $hasParagraphs ? 'w:p' : 'w:r'; + $where = $this->findContainingXmlBlockForMacro($search, $blockType); + if (is_array($where)) { + /** @phpstan-var array{start: int, end: int} $where */ + $block = $this->getSlice($where['start'], $where['end']); + $paragraphStyle = ''; + $textRunStyle = ''; + $parts = $hasParagraphs + ? $this->splitParagraphIntoParagraphs($block, $paragraphStyle, $textRunStyle) + : $this->splitTextIntoTexts($block, $textRunStyle); + if ($inheritStyle) { + $elementsDataList = preg_replace_callback_array([ + '##' => fn() => $paragraphStyle, + '##' => fn (array $matches) => StyleMerger::mergeStyles($matches[0], $paragraphStyle), + // may contain itself so we have to match for inside of + '#.*#' => fn(array $matches) => str_replace('', $textRunStyle, $matches[0]), + '#.*().*#' => fn (array $matches) => + preg_replace('##', StyleMerger::mergeStyles($matches[1], $textRunStyle), $matches[0]), + ], $elementsDataList); + } + $this->replaceXmlBlock($search, $parts, $blockType); + $this->replaceXmlBlock($search, implode('', $elementsDataList), $blockType); + } + } + /** * @param mixed $search * @param mixed $replace @@ -1440,28 +1498,101 @@ protected function findXmlBlockEnd($offset, $blockType) } /** - * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r. + * Adds output parameter for extracted style. * * @param string $text + * @param string $extractedStyle + * Is set to the extracted text run style (w:rPr). * * @return string + * @throws \PhpOffice\PhpWord\Exception\Exception */ - protected function splitTextIntoTexts($text) - { + protected function splitTextIntoTexts($text, string &$extractedStyle = '') { + if (NULL === $unformattedText = preg_replace('/>\s+<', $text)) { + throw new Exception('Error processing PhpWord document.'); + } + + $matches = []; + preg_match('//i', $unformattedText, $matches); + $extractedStyle = $matches[0] ?? ''; + if (!$this->textNeedsSplitting($text)) { return $text; } - $matches = []; - if (preg_match('/()/i', $text, $matches)) { - $extractedStyle = $matches[0]; - } else { - $extractedStyle = ''; + + $result = str_replace( + ['', '${', '}'], + [ + '', + '' . $extractedStyle . '${', + '}' . $extractedStyle . '', + ], + $unformattedText + ); + + $emptyTextRun = '' . $extractedStyle . ''; + + return str_replace($emptyTextRun, '', $result); + } + + /** + * Splits a w:p into a list of w:p where each ${macro} is in a separate w:p. + * + * @param string $extractedParagraphStyle + * Is set to the extracted paragraph style (w:pPr). + * @param string $extractedTextRunStyle + * Is set to the extracted text run style (w:rPr). + * + * @throws \PhpOffice\PhpWord\Exception\Exception + */ + public function splitParagraphIntoParagraphs( + string $paragraph, + string &$extractedParagraphStyle = '', + string &$extractedTextRunStyle = '' + ): string { + if (NULL === $paragraph = preg_replace('/>\s+<', $paragraph)) { + throw new Exception('Error processing PhpWord document.'); } - $unformattedText = preg_replace('/>\s+<', $text); - $result = str_replace([self::$macroOpeningChars, self::$macroClosingChars], ['' . $extractedStyle . '' . self::$macroOpeningChars, self::$macroClosingChars . '' . $extractedStyle . ''], $unformattedText); + $matches = []; + preg_match('##i', $paragraph, $matches); + $extractedParagraphStyle = $matches[0] ?? ''; + + // may contain itself so we have to match for inside of + preg_match('#.*().*#i', $paragraph, $matches); + $extractedTextRunStyle = $matches[1] ?? ''; + + $result = str_replace( + [ + '', + '${', + '}', + ], + [ + '', + sprintf( + '%s%s${', + $extractedParagraphStyle, + $extractedTextRunStyle + ), + sprintf( + '}%s%s', + $extractedParagraphStyle, + $extractedTextRunStyle + ), + ], + $paragraph + ); + + // Remove empty paragraphs that might have been created before/after the + // macro. + $emptyParagraph = sprintf( + '%s%s', + $extractedParagraphStyle, + $extractedTextRunStyle + ); - return str_replace(['' . $extractedStyle . '', '', ''], ['', '', ''], $result); + return str_replace($emptyParagraph, '', $result); } /**