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.
*