From 0199631e02dfcc395990379ca765d9ba44485ea3 Mon Sep 17 00:00:00 2001 From: Dominic Tubach Date: Thu, 20 Jun 2024 09:54:06 +0200 Subject: [PATCH] Fix handling of styles --- .../LocalUnoconv/PhpWordTemplateProcessor.php | 32 +-- Civi/Civioffice/PhpWord/StyleMerger.php | 95 ++++++++ ...alUnoconv_PhpWordTemplateProcessorTest.php | 230 +++++++++++++++++- .../Civioffice/PhpWord/StyleMergerTest.php | 64 +++++ tests/phpunit/bootstrap.php | 9 + 5 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 Civi/Civioffice/PhpWord/StyleMerger.php create mode 100644 tests/phpunit/Civi/Civioffice/PhpWord/StyleMergerTest.php diff --git a/CRM/Civioffice/DocumentRendererType/LocalUnoconv/PhpWordTemplateProcessor.php b/CRM/Civioffice/DocumentRendererType/LocalUnoconv/PhpWordTemplateProcessor.php index 5e533b6..4a4b973 100644 --- a/CRM/Civioffice/DocumentRendererType/LocalUnoconv/PhpWordTemplateProcessor.php +++ b/CRM/Civioffice/DocumentRendererType/LocalUnoconv/PhpWordTemplateProcessor.php @@ -15,6 +15,7 @@ declare(strict_types = 1); +use Civi\Civioffice\PhpWord\StyleMerger; use CRM_Civioffice_ExtensionUtil as E; use PhpOffice\PhpWord; @@ -111,13 +112,6 @@ public function replaceHtmlToken(string $macroVariable, string $renderedTokenMes // will not be removed (i.e. leave an empty paragraph). $this->setValue($macroVariable, ''); } - elseif (count($elements) === 1 && $elements[0] instanceof PhpWord\Element\Text) { - // ... either as plain text (if there is only a single Text - // element, which can't have style properties), ... - // Note: $rendered_token_message shouldn't be used directly - // because it may contain HTML entities. - $this->setValue($macroVariable, $elements[0]->getText()); - } else { // ... or as HTML: Render all elements and insert in the text // run or paragraph containing the macro. @@ -145,14 +139,14 @@ public function replaceHtmlToken(string $macroVariable, string $renderedTokenMes * * @param \PhpOffice\PhpWord\Element\AbstractElement[] $elements * @param bool $inheritStyle - * If TRUE and an element contains no style, it will be inherited from the - * paragraph/text run the macro is inside. + * 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); - $elementsData = ''; + $elementsDataList = []; $hasParagraphs = FALSE; foreach ($elements as $element) { $elementName = substr( @@ -169,7 +163,7 @@ public function setElementsValue(string $search, array $elements, bool $inheritS /** @var \PhpOffice\PhpWord\Writer\Word2007\Element\AbstractElement $elementWriter */ $elementWriter = new $objectClass($xmlWriter, $element, !$withParagraph); $elementWriter->write(); - $elementsData .= $xmlWriter->getData(); + $elementsDataList[] = preg_replace('/>\s+<', $xmlWriter->getData()); } $blockType = $hasParagraphs ? 'w:p' : 'w:r'; $where = $this->findContainingXmlBlockForMacro($search, $blockType); @@ -182,10 +176,17 @@ public function setElementsValue(string $search, array $elements, bool $inheritS ? $this->splitParagraphIntoParagraphs($block, $paragraphStyle, $textRunStyle) : $this->splitTextIntoTexts($block, $textRunStyle); if ($inheritStyle) { - $elementsData = str_replace(['', ''], [$paragraphStyle, $textRunStyle], $elementsData); + $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, $elementsData, $blockType); + $this->replaceXmlBlock($search, implode('', $elementsDataList), $blockType); } } @@ -212,8 +213,9 @@ public function splitParagraphIntoParagraphs( preg_match('##i', $paragraph, $matches); $extractedParagraphStyle = $matches[0] ?? ''; - preg_match('##i', $paragraph, $matches); - $extractedTextRunStyle = $matches[0] ?? ''; + // may contain itself so we have to match for inside of + preg_match('#.*().*#i', $paragraph, $matches); + $extractedTextRunStyle = $matches[1] ?? ''; $result = str_replace( [ diff --git a/Civi/Civioffice/PhpWord/StyleMerger.php b/Civi/Civioffice/PhpWord/StyleMerger.php new file mode 100644 index 0000000..25ac1e1 --- /dev/null +++ b/Civi/Civioffice/PhpWord/StyleMerger.php @@ -0,0 +1,95 @@ +. + */ + +declare(strict_types = 1); + +namespace Civi\Civioffice\PhpWord; + +final class StyleMerger { + + private \DOMElement $styleElement; + + /** + * @phpstan-var array + */ + private array $elements = []; + + 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 __construct(string $style) { + $this->styleElement = $this->createStyleElement($style); + foreach ($this->styleElement->childNodes as $node) { + if ($node instanceof \DOMElement) { + $this->elements[$node->tagName] = $node; + } + } + } + + 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])) { + // @phpstan-ignore-next-line + $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; + } + + public function getStyleString(): string { + // @phpstan-ignore-next-line + return $this->styleElement->ownerDocument->saveXML($this->styleElement); + } + + 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 . '' + ); + + // @phpstan-ignore-next-line + foreach ($doc->documentElement->childNodes as $node) { + if ($node instanceof \DOMElement) { + return $node; + } + } + + throw new \RuntimeException('Could not create style element'); + } + +} diff --git a/tests/phpunit/CRM/Civioffice/DocumentRendererType/LocalUnoconv/CRM_Civioffice_DocumentRendererType_LocalUnoconv_PhpWordTemplateProcessorTest.php b/tests/phpunit/CRM/Civioffice/DocumentRendererType/LocalUnoconv/CRM_Civioffice_DocumentRendererType_LocalUnoconv_PhpWordTemplateProcessorTest.php index b67aa6a..d8bd369 100644 --- a/tests/phpunit/CRM/Civioffice/DocumentRendererType/LocalUnoconv/CRM_Civioffice_DocumentRendererType_LocalUnoconv_PhpWordTemplateProcessorTest.php +++ b/tests/phpunit/CRM/Civioffice/DocumentRendererType/LocalUnoconv/CRM_Civioffice_DocumentRendererType_LocalUnoconv_PhpWordTemplateProcessorTest.php @@ -61,7 +61,19 @@ public function testReplaceSimple(): void { - Foo test 123 bar + Foo + + + + + + test 123 + + + + + + bar @@ -105,7 +117,19 @@ public function testReplaceSpan(): void { - Foo test 123 bar + Foo + + + + + + test 123 + + + + + + bar @@ -360,6 +384,208 @@ public function testReplaceParagraphEnd(): void { +EOD; + + static::assertXmlStringEqualsXmlString($expectedMainPart, $templateProcessor->getMainPart()); + } + + public function testStrong(): void { + $mainPart = << + + + + + + + + + + + Foo {place.holder} bar + + + + +EOD; + + $templateProcessor = new TestablePhpWordTemplateProcessor($mainPart); + $templateProcessor->civiTokensToMacros(); + $templateProcessor->replaceHtmlToken('place.holder', 'bold'); + + $expectedMainPart = << + + + + + + + + + + + Foo + + + + + + + + bold + + + + + + + bar + + + + +EOD; + + static::assertXmlStringEqualsXmlString($expectedMainPart, $templateProcessor->getMainPart()); + } + + /** + * Tests replace with a paragraph where the paragraph that contains the + * placeholder has a paragraph style that has a text run style. + */ + public function testReplaceParagraphWithTextRunStyle(): void { + $mainPart = << + + + + + + + + + + + + + Foo {place.holder} + + + + +EOD; + + $templateProcessor = new TestablePhpWordTemplateProcessor($mainPart); + $templateProcessor->civiTokensToMacros(); + $templateProcessor->replaceHtmlToken('place.holder', '

test 123

'); + + $expectedMainPart = << + + + + + + + + + + + + + Foo + + + + + + + + + + + + + + test 123 + + + + +EOD; + + static::assertXmlStringEqualsXmlString($expectedMainPart, $templateProcessor->getMainPart()); + } + + /** + * Tests replace with a paragraph that has a text run style where the + * paragraph that contains the placeholder has a paragraph style that has a + * text run style. + */ + public function testReplaceParagraphWithRunStyleAndStrong(): void { + $mainPart = << + + + + + + + + + + + + + + Foo {place.holder} + + + + +EOD; + + $templateProcessor = new TestablePhpWordTemplateProcessor($mainPart); + $templateProcessor->civiTokensToMacros(); + $templateProcessor->replaceHtmlToken('place.holder', '

test 123

'); + + $expectedMainPart = << + + + + + + + + + + + + + + Foo + + + + + + + + + + + + + + + + test 123 + + + + EOD; static::assertXmlStringEqualsXmlString($expectedMainPart, $templateProcessor->getMainPart()); diff --git a/tests/phpunit/Civi/Civioffice/PhpWord/StyleMergerTest.php b/tests/phpunit/Civi/Civioffice/PhpWord/StyleMergerTest.php new file mode 100644 index 0000000..cc3a9bc --- /dev/null +++ b/tests/phpunit/Civi/Civioffice/PhpWord/StyleMergerTest.php @@ -0,0 +1,64 @@ +. + */ + +declare(strict_types = 1); + +namespace phpunit\Civi\Civioffice\PhpWord; + +use Civi\Civioffice\PhpWord\StyleMerger; +use PHPUnit\Framework\TestCase; + +/** + * @covers \Civi\Civioffice\PhpWord\StyleMerger + */ +final class StyleMergerTest extends TestCase { + + public function testMerge(): void { + $style = << + + +
+EOD; + + $styleToMerge = << + + + + + +
+EOD; + + $expectedStyle = << + + + + + + +
+EOD; + + $styleMerger = new StyleMerger($style); + $styleMerger->merge($styleToMerge); + static::assertXmlStringEqualsXmlString($expectedStyle, $styleMerger->getStyleString()); + } + +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index fd5ce30..2e87718 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -18,6 +18,15 @@ $loader->register(); +// Make CRM_Civioffice_ExtensionUtil available. +require_once __DIR__ . '/../../civioffice.civix.php'; + +if (!function_exists('ts')) { + // Ensure function ts() is available - it's declared in the same file as CRM_Core_I18n in CiviCRM < 5.74. + // In later versions the function is registered following the composer conventions. + \CRM_Core_I18n::singleton(); +} + /** * Call the "cv" command. *