From 3984f68e1194af8515735bdf3e266de6f09020f8 Mon Sep 17 00:00:00 2001 From: upstream Date: Thu, 15 Oct 2026 00:49:41 +0200 Subject: [PATCH] Upstream PHPOffice/PHPWord state of the touched files before c920ea6a3b --- docs/changes/1.x/1.5.0.md | 48 + src/PhpWord/TemplateProcessor.php | 1516 ++++++++++++++ tests/PhpWordTests/TemplateProcessorTest.php | 1868 ++++++++++++++++++ 3 files changed, 3432 insertions(+) create mode 100644 docs/changes/1.x/1.5.0.md create mode 100644 src/PhpWord/TemplateProcessor.php create mode 100644 tests/PhpWordTests/TemplateProcessorTest.php diff --git a/docs/changes/1.x/1.5.0.md b/docs/changes/1.x/1.5.0.md new file mode 100644 index 0000000..e7fa5fb --- /dev/null +++ b/docs/changes/1.x/1.5.0.md @@ -0,0 +1,48 @@ +# [1.5.0](https://github.com/PHPOffice/PHPWord/tree/1.5.0) (WIP) + +[Full Changelog](https://github.com/PHPOffice/PHPWord/compare/1.4.0...1.5.0) + +## Enhancements +- Add native ODF table-of-contents support to the ODText writer by [@potofcoffee](https://github.com/potofcoffee) fixing [#2917](https://github.com/PHPOffice/PHPWord/issues/2917) in [#2918](https://github.com/PHPOffice/PHPWord/pull/2918) +- Add native ODF header and footer variants to the ODText writer by [@potofcoffee](https://github.com/potofcoffee) fixing [#2919](https://github.com/PHPOffice/PHPWord/issues/2919) in [#2920](https://github.com/PHPOffice/PHPWord/pull/2920) +- Add native ODF mappings for REF, XE, and INDEX fields in the ODText writer by [@potofcoffee](https://github.com/potofcoffee) fixing [#2915](https://github.com/PHPOffice/PHPWord/issues/2915) in [#2916](https://github.com/PHPOffice/PHPWord/pull/2916) +- Added support for image alt text by [@jwoodhead](https://github.com/jwoodhead) in [#2873](https://github.com/PHPOffice/PHPWord/pull/2873) +- MPDF Writer : Make the mPDF temporary directory configurable via the `tempDir` PDF renderer option, defaulting to the system temporary directory by [@danielwirz](https://github.com/danielwirz) in [#2898](https://github.com/PHPOffice/PHPWord/pull/2898) +- GD extension is now optional by [@andrew-demb](https://github.com/andrew-demb) fixing [#2789] in [#2902](https://github.com/PHPOffice/PHPWord/pull/2902) +- Writer ODText: Serialize comments as native ODF annotations by [@potofcoffee](https://github.com/potofcoffee) fixing [#2921](https://github.com/PHPOffice/PHPWord/issues/2921) in [#2922](https://github.com/PHPOffice/PHPWord/pull/2922) +- Writer ODText: Support native shapes, text boxes, and watermarks by [@potofcoffee](https://github.com/potofcoffee) fixing [#2909](https://github.com/PHPOffice/PHPWord/issues/2909) in [#2910](https://github.com/PHPOffice/PHPWord/pull/2910) +- Writer ODText: Map SDTs to native ODF form controls by [@potofcoffee](https://github.com/potofcoffee) fixing [#2927](https://github.com/PHPOffice/PHPWord/issues/2927) in [#2928](https://github.com/PHPOffice/PHPWord/pull/2928) +- Writer ODText: Support native form controls by [@potofcoffee](https://github.com/potofcoffee) fixing [#2925](https://github.com/PHPOffice/PHPWord/issues/2925) in [#2926](https://github.com/PHPOffice/PHPWord/pull/2926) +- Writer ODText: Support native line drawings by [@potofcoffee](https://github.com/potofcoffee) fixing [#2907](https://github.com/PHPOffice/PHPWord/issues/2907) in [#2908](https://github.com/PHPOffice/PHPWord/pull/2908) +- Writer ODText: Support native bookmarks by [@potofcoffee](https://github.com/potofcoffee) fixing [#2911](https://github.com/PHPOffice/PHPWord/issues/2911) in [#2912](https://github.com/PHPOffice/PHPWord/pull/2912) +- Writer ODText: Support native footnotes and endnotes by [@potofcoffee](https://github.com/potofcoffee) fixing [#2913](https://github.com/PHPOffice/PHPWord/issues/2913) in [#2914](https://github.com/PHPOffice/PHPWord/pull/2914) +- Writer WPS: Basic support of the Microsoft Works word processor documents by [@businessarshgoyal](https://github.com/businessarshgoyal) fixing [#69](https://github.com/PHPOffice/PHPWord/issues/69) in [#2929](https://github.com/PHPOffice/PHPWord/pull/2929) +- Reader WPS: Read text from the Microsoft Works word processor documents by [@k4it00](https://github.com/k4it00) in [#2935](https://github.com/PHPOffice/PHPWord/pull/2935) +- Image: Added support for WMF, EMF and EMF+ images, with the optional library [phpoffice/wmf](https://github.com/PHPOffice/WMF) by [@Progi1984](https://github.com/Progi1984) fixing [#1480](https://github.com/PHPOffice/PHPWord/issues/1480), [#1612](https://github.com/PHPOffice/PHPWord/issues/1612) in [#2956](https://github.com/PHPOffice/PHPWord/pull/2956) + +### Bug fixes + +- Set writeAttribute return type by [@radarhere](https://github.com/radarhere) fixing [#2204](https://github.com/PHPOffice/PHPWord/issues/2204) in [#2776](https://github.com/PHPOffice/PHPWord/pull/2776) +- Fix "Using null as an array offset" deprecation in Style::getStyle() on PHP 8.5 by [@pwsdotru](https://github.com/pwsdotru) & [@slayerfx](https://github.com/slayerfx) fixing [#2863](https://github.com/PHPOffice/PHPWord/issues/2863) in [#2892](https://github.com/PHPOffice/PHPWord/pull/2892) +- Html: Map ``, `` and `` tags to strikethrough and fix decimal percentage table/cell widths (e.g. `49.85%`) being truncated by [@aswinkumar863](https://github.com/aswinkumar863) in [#2930](https://github.com/PHPOffice/PHPWord/pull/2930) +- Prevent `htmlspecialchars()` deprecation warnings by defaulting `null` values to empty strings in `AbstractPart` by [@dafydd-rhys](https://github.com/dafydd-rhys) fixing [#2829](https://github.com/PHPOffice/PHPWord/issues/2829) in [#2932](https://github.com/PHPOffice/PHPWord/pull/2932) +- Writer ODText: Write the alternative text of an image as the description of its frame by [@dkulyk](https://github.com/dkulyk) in [#2940](https://github.com/PHPOffice/PHPWord/pull/2940) +- Word2007 Writer: Give a heading style its outline level, and a title without a title style a style of its own, so that the title stays a heading; Word2007 Reader: Read a style named Title as the title style by [@dkulyk](https://github.com/dkulyk) in [#2939](https://github.com/PHPOffice/PHPWord/pull/2939) +- Reader Word2007: Read the alternative text of an image from `wp:docPr` of an inline or floating image, and from `v:shape` of a VML image by [@dkulyk](https://github.com/dkulyk) in [#2946](https://github.com/PHPOffice/PHPWord/pull/2946) +- Writer ODText: Write a list item added with `addListItem`, which was dropped by [@dkulyk](https://github.com/dkulyk) in [#2945](https://github.com/PHPOffice/PHPWord/pull/2945) +- Reader Word2007: Read the size, position and wrapping of an image, from VML and DrawingML, instead of giving it the size of its pixels by [@dkulyk](https://github.com/dkulyk) in [#2947](https://github.com/PHPOffice/PHPWord/pull/2947) +- Writer ODText: Write the language of a text run, which was dropped, take any BCP 47 tag with its script, and use en-US for a document without a language as the Word2007 writer does, instead of fr-FR, zh-CN and hi-IN by [@dkulyk](https://github.com/dkulyk) in [#2944](https://github.com/PHPOffice/PHPWord/pull/2944) +- Writer Word2007: Point the page number of a table of contents entry at the bookmark of its title (`PAGEREF _Toc…`), and write the text of a title made of a text run by [@dkulyk](https://github.com/dkulyk) in [#2952](https://github.com/PHPOffice/PHPWord/pull/2952) + +### Miscellaneous + +- Update phpstan/phpstan requirement from ^0.12.88 || ^1.0.0 to ^0.12.88 || ^1.0.0 || ^2.0.0 by [@dependabot](https://github.com/dependabot) & [@Progi1984](https://github.com/Progi1984) in [#2736](https://github.com/PHPOffice/PHPWord/pull/2736) +- Restrict phpunit/phpunit to `< 12` to fix unit tests on PHP 8.3/8.4 by [@slayerfx](https://github.com/slayerfx) fixing [#2842](https://github.com/PHPOffice/PHPWord/issues/2842) in [#2889](https://github.com/PHPOffice/PHPWord/pull/2889) +- Add PHP 8.5 to the CI matrix and fix chr()/ReflectionMethod::setAccessible() deprecations by [@slayerfx](https://github.com/slayerfx) in [#2892](https://github.com/PHPOffice/PHPWord/pull/2892) +- Added optional dependency phpoffice/wmf by [@Progi1984](https://github.com/Progi1984) in [#2956](https://github.com/PHPOffice/PHPWord/pull/2956) + +### Deprecations + +### BC Breaks + +### Notes diff --git a/src/PhpWord/TemplateProcessor.php b/src/PhpWord/TemplateProcessor.php new file mode 100644 index 0000000..2c6236c --- /dev/null +++ b/src/PhpWord/TemplateProcessor.php @@ -0,0 +1,1516 @@ +tempDocumentFilename = tempnam(Settings::getTempDir(), 'PhpWord'); + if (false === $this->tempDocumentFilename) { + throw new CreateTemporaryFileException(); // @codeCoverageIgnore + } + + // Template file cloning + if (false === copy($documentTemplate, $this->tempDocumentFilename)) { + throw new CopyFileException($documentTemplate, $this->tempDocumentFilename); // @codeCoverageIgnore + } + + // Temporary document content extraction + $this->zipClass = new ZipArchive(); + $this->zipClass->open($this->tempDocumentFilename); + $index = 1; + while (false !== $this->zipClass->locateName($this->getHeaderName($index))) { + $this->tempDocumentHeaders[$index] = $this->readPartWithRels($this->getHeaderName($index)); + ++$index; + } + $index = 1; + while (false !== $this->zipClass->locateName($this->getFooterName($index))) { + $this->tempDocumentFooters[$index] = $this->readPartWithRels($this->getFooterName($index)); + ++$index; + } + + $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName()); + $this->tempDocumentSettingsPart = $this->readPartWithRels($this->getSettingsPartName()); + $tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName()); + if (is_string($tempDocumentContentTypes)) { + $this->tempDocumentContentTypes = $tempDocumentContentTypes; + } + } + + public function __destruct() + { + // ZipClass + if ($this->zipClass) { + try { + $this->zipClass->close(); + } catch (Throwable $e) { + // Nothing to do here. + } + } + } + + /** + * Expose zip class. + * + * To replace an image: $templateProcessor->zip()->AddFromString("word/media/image1.jpg", file_get_contents($file));
+ * To read a file: $templateProcessor->zip()->getFromName("word/media/image1.jpg"); + * + * @return ZipArchive + */ + public function zip() + { + return $this->zipClass; + } + + /** + * @param string $fileName + * + * @return string + */ + protected function readPartWithRels($fileName) + { + $relsFileName = $this->getRelationsName($fileName); + $partRelations = $this->zipClass->getFromName($relsFileName); + if ($partRelations !== false) { + $this->tempDocumentRelations[$fileName] = $partRelations; + } + + return $this->fixBrokenMacros($this->zipClass->getFromName($fileName)); + } + + /** + * @param string $xml + * @param XSLTProcessor $xsltProcessor + * + * @return string + */ + protected function transformSingleXml($xml, $xsltProcessor) + { + if (\PHP_VERSION_ID < 80000) { + $orignalLibEntityLoader = libxml_disable_entity_loader(true); + } + $domDocument = new DOMDocument(); + if (false === $domDocument->loadXML($xml)) { + throw new Exception('Could not load the given XML document.'); + } + + $transformedXml = $xsltProcessor->transformToXml($domDocument); + if (false === $transformedXml) { + throw new Exception('Could not transform the given XML document.'); + } + if (\PHP_VERSION_ID < 80000) { + libxml_disable_entity_loader($orignalLibEntityLoader); + } + + return $transformedXml; + } + + /** + * @param mixed $xml + * @param XSLTProcessor $xsltProcessor + * + * @return mixed + */ + protected function transformXml($xml, $xsltProcessor) + { + if (is_array($xml)) { + foreach ($xml as &$item) { + $item = $this->transformSingleXml($item, $xsltProcessor); + } + unset($item); + } else { + $xml = $this->transformSingleXml($xml, $xsltProcessor); + } + + return $xml; + } + + /** + * Applies XSL style sheet to template's parts. + * + * Note: since the method doesn't make any guess on logic of the provided XSL style sheet, + * make sure that output is correctly escaped. Otherwise you may get broken document. + * + * @param DOMDocument $xslDomDocument + * @param array $xslOptions + * @param string $xslOptionsUri + */ + public function applyXslStyleSheet($xslDomDocument, $xslOptions = [], $xslOptionsUri = ''): void + { + $xsltProcessor = new XSLTProcessor(); + + $xsltProcessor->importStylesheet($xslDomDocument); + if (false === $xsltProcessor->setParameter($xslOptionsUri, $xslOptions)) { + throw new Exception('Could not set values for the given XSL style sheet parameters.'); + } + + $this->tempDocumentHeaders = $this->transformXml($this->tempDocumentHeaders, $xsltProcessor); + $this->tempDocumentMainPart = $this->transformXml($this->tempDocumentMainPart, $xsltProcessor); + $this->tempDocumentFooters = $this->transformXml($this->tempDocumentFooters, $xsltProcessor); + } + + /** + * @param string $macro + * + * @return string + */ + protected static function ensureMacroCompleted($macro) + { + if (substr($macro, 0, 2) !== self::$macroOpeningChars && substr($macro, -1) !== self::$macroClosingChars) { + $macro = self::$macroOpeningChars . $macro . self::$macroClosingChars; + } + + return $macro; + } + + /** + * @param ?string $subject + * + * @return string + */ + protected static function ensureUtf8Encoded($subject) + { + return (null !== $subject) ? Text::toUTF8($subject) : ''; + } + + /** + * @param string $search + */ + public function setComplexValue($search, Element\AbstractElement $complexType): void + { + $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1); + $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; + + $xmlWriter = new XMLWriter(); + /** @var Writer\Word2007\Element\AbstractElement $elementWriter */ + $elementWriter = new $objectClass($xmlWriter, $complexType, true); + $elementWriter->write(); + + $where = $this->findContainingXmlBlockForMacro($search, 'w:r'); + + if ($where === false) { + return; + } + + $block = $this->getSlice($where['start'], $where['end']); + $textParts = $this->splitTextIntoTexts($block); + $this->replaceXmlBlock($search, $textParts, 'w:r'); + + $search = static::ensureMacroCompleted($search); + $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:r'); + } + + /** + * @param string $search + */ + public function setComplexBlock($search, Element\AbstractElement $complexType): void + { + $elementName = substr(get_class($complexType), strrpos(get_class($complexType), '\\') + 1); + $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; + + $xmlWriter = new XMLWriter(); + /** @var Writer\Word2007\Element\AbstractElement $elementWriter */ + $elementWriter = new $objectClass($xmlWriter, $complexType, false); + $elementWriter->write(); + + $this->replaceXmlBlock($search, $xmlWriter->getData(), 'w:p'); + } + + /** + * @param array|string $search + * @param null|array|bool|float|int|string $replace + * @param int $limit + */ + public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void + { + if (is_array($search)) { + foreach ($search as &$item) { + $item = static::ensureMacroCompleted($item); + } + unset($item); + } else { + $search = static::ensureMacroCompleted($search); + } + + if (is_array($replace)) { + foreach ($replace as &$item) { + $item = static::ensureUtf8Encoded($item); + } + unset($item); + } else { + $replace = static::ensureUtf8Encoded(null === $replace ? null : (string) $replace); + } + + if (Settings::isOutputEscapingEnabled()) { + $xmlEscaper = new Xml(); + $replace = $xmlEscaper->escape($replace); + } + + // convert carriage returns + if (is_array($replace)) { + foreach ($replace as &$item) { + $item = $this->replaceCarriageReturns($item); + } + } else { + $replace = $this->replaceCarriageReturns($replace); + } + + $this->tempDocumentHeaders = $this->setValueForPart($search, $replace, $this->tempDocumentHeaders, $limit); + $this->tempDocumentMainPart = $this->setValueForPart($search, $replace, $this->tempDocumentMainPart, $limit); + $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit); + } + + /** + * Set values from a one-dimensional array of "variable => value"-pairs. + */ + public function setValues(array $values, int $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void + { + foreach ($values as $macro => $replace) { + $this->setValue($macro, $replace, $limit); + } + } + + public function setCheckbox(string $search, bool $checked): void + { + $search = static::ensureMacroCompleted($search); + $blockType = 'w:sdt'; + + $where = $this->findContainingXmlBlockForMacro($search, $blockType); + if (!is_array($where)) { + return; + } + + $block = $this->getSlice($where['start'], $where['end']); + + $val = $checked ? '1' : '0'; + $block = preg_replace('/()/', '$1"' . $val . '"$2', $block); + + $text = $checked ? '☒' : '☐'; + $block = preg_replace('/().*?(<\/w:t>)/', '$1' . $text . '$2', $block); + + $this->replaceXmlBlock($search, $block, $blockType); + } + + /** + * @param string $search + */ + public function setChart($search, Element\AbstractElement $chart): void + { + $elementName = substr(get_class($chart), strrpos(get_class($chart), '\\') + 1); + $objectClass = 'PhpOffice\\PhpWord\\Writer\\Word2007\\Element\\' . $elementName; + + // Get the next relation id + $rId = $this->getNextRelationsIndex($this->getMainPartName()); + $chart->setRelationId($rId); + + // Define the chart filename + $filename = "charts/chart{$rId}.xml"; + + // Get the part writer + $writerPart = new Writer\Word2007\Part\Chart(); + $writerPart->setElement($chart); + + // ContentTypes.xml + $this->zipClass->addFromString("word/{$filename}", $writerPart->write()); + + // add chart to content type + $xmlRelationsType = ""; + $this->tempDocumentContentTypes = str_replace('', $xmlRelationsType, $this->tempDocumentContentTypes) . ''; + + // Add the chart to relations + $xmlChartRelation = ""; + $this->tempDocumentRelations[$this->getMainPartName()] = str_replace('', $xmlChartRelation, $this->tempDocumentRelations[$this->getMainPartName()]) . ''; + + // Write the chart + $xmlWriter = new XMLWriter(); + $elementWriter = new $objectClass($xmlWriter, $chart, true); + $elementWriter->write(); + + // Place it in the template + $this->replaceXmlBlock($search, '' . $xmlWriter->getData() . '', 'w:p'); + } + + private function getImageArgs($varNameWithArgs) + { + $varElements = explode(':', $varNameWithArgs); + array_shift($varElements); // first element is name of variable => remove it + + $varInlineArgs = []; + // size format documentation: https://msdn.microsoft.com/en-us/library/documentformat.openxml.vml.shape%28v=office.14%29.aspx?f=255&MSPPError=-2147217396 + foreach ($varElements as $argIdx => $varArg) { + if (strpos($varArg, '=')) { // arg=value + [$argName, $argValue] = explode('=', $varArg, 2); + $argName = strtolower($argName); + if ($argName == 'size') { + [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $argValue, 2); + } else { + $varInlineArgs[strtolower($argName)] = $argValue; + } + } elseif (preg_match('/^([0-9]*[a-z%]{0,2}|auto)x([0-9]*[a-z%]{0,2}|auto)$/i', $varArg)) { // 60x40 + [$varInlineArgs['width'], $varInlineArgs['height']] = explode('x', $varArg, 2); + } else { // :60:40:f + switch ($argIdx) { + case 0: + $varInlineArgs['width'] = $varArg; + + break; + case 1: + $varInlineArgs['height'] = $varArg; + + break; + case 2: + $varInlineArgs['ratio'] = $varArg; + + break; + } + } + } + + return $varInlineArgs; + } + + private function chooseImageDimension($baseValue, $inlineValue, $defaultValue) + { + $value = $baseValue; + if (null === $value && isset($inlineValue)) { + $value = $inlineValue; + } + if (!preg_match('/^([0-9\.]*(cm|mm|in|pt|pc|px|%|em|ex|)|auto)$/i', $value ?? '')) { + $value = null; + } + if (null === $value) { + $value = $defaultValue; + } + if (is_numeric($value)) { + $value .= 'px'; + } + + return $value; + } + + private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight): void + { + $imageRatio = $actualWidth / $actualHeight; + + if (($width === '') && ($height === '')) { // defined size are empty + $width = $actualWidth . 'px'; + $height = $actualHeight . 'px'; + } elseif ($width === '') { // defined width is empty + $heightFloat = (float) $height; + $widthFloat = $heightFloat * $imageRatio; + $matches = []; + preg_match('/\\d([a-z%]+)$/', $height, $matches); + $width = $widthFloat . (!empty($matches) ? $matches[1] : 'px'); + } elseif ($height === '') { // defined height is empty + $widthFloat = (float) $width; + $heightFloat = $widthFloat / $imageRatio; + $matches = []; + preg_match('/\\d([a-z%]+)$/', $width, $matches); + $height = $heightFloat . (!empty($matches) ? $matches[1] : 'px'); + } else { // we have defined size, but we need also check it aspect ratio + $widthMatches = []; + preg_match('/\\d([a-z%]+)$/', $width, $widthMatches); + $heightMatches = []; + preg_match('/\\d([a-z%]+)$/', $height, $heightMatches); + // try to fix only if dimensions are same + if (!empty($widthMatches) + && !empty($heightMatches) + && $widthMatches[1] == $heightMatches[1]) { + $dimention = $widthMatches[1]; + $widthFloat = (float) $width; + $heightFloat = (float) $height; + $definedRatio = $widthFloat / $heightFloat; + + if ($imageRatio > $definedRatio) { // image wider than defined box + $height = ($widthFloat / $imageRatio) . $dimention; + } elseif ($imageRatio < $definedRatio) { // image higher than defined box + $width = ($heightFloat * $imageRatio) . $dimention; + } + } + } + } + + private function prepareImageAttrs($replaceImage, $varInlineArgs) + { + // get image path and size + $width = null; + $height = null; + $ratio = null; + + // a closure can be passed as replacement value which after resolving, can contain the replacement info for the image + // use case: only when a image if found, the replacement tags can be generated + if (is_callable($replaceImage)) { + $replaceImage = $replaceImage(); + } + + if (is_array($replaceImage) && isset($replaceImage['path'])) { + $imgPath = $replaceImage['path']; + if (isset($replaceImage['width'])) { + $width = $replaceImage['width']; + } + if (isset($replaceImage['height'])) { + $height = $replaceImage['height']; + } + if (isset($replaceImage['ratio'])) { + $ratio = $replaceImage['ratio']; + } + } else { + $imgPath = $replaceImage; + } + + $width = $this->chooseImageDimension($width, $varInlineArgs['width'] ?? null, 115); + $height = $this->chooseImageDimension($height, $varInlineArgs['height'] ?? null, 70); + + $imageData = @getimagesize($imgPath); + if (is_array($imageData)) { + $imageData[2] = image_type_to_mime_type($imageData[2]); + } elseif (is_file($imgPath)) { + // Windows metafiles (WMF, EMF, EMF+) + $imageData = Metafile::getImageSize((string) file_get_contents($imgPath)); + } + if (!is_array($imageData)) { + throw new Exception(sprintf('Invalid image: %s', $imgPath)); + } + [$actualWidth, $actualHeight, $imageMimeType] = $imageData; + + // fix aspect ratio (by default) + if (null === $ratio && isset($varInlineArgs['ratio'])) { + $ratio = $varInlineArgs['ratio']; + } + if (null === $ratio || !in_array(strtolower($ratio), ['', '-', 'f', 'false'])) { + $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight); + } + + $imageAttrs = [ + 'src' => $imgPath, + 'mime' => $imageMimeType, + 'width' => $width, + 'height' => $height, + ]; + + return $imageAttrs; + } + + private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType): void + { + // define templates + $typeTpl = ''; + $relationTpl = ''; + $newRelationsTpl = '' . "\n" . ''; + $newRelationsTypeTpl = ''; + $extTransform = [ + 'image/jpeg' => 'jpeg', + 'image/png' => 'png', + 'image/bmp' => 'bmp', + 'image/gif' => 'gif', + Metafile::MIME_WMF => 'wmf', + Metafile::MIME_EMF => 'emf', + ]; + + // get image embed name + if (isset($this->tempDocumentNewImages[$imgPath])) { + $imgName = $this->tempDocumentNewImages[$imgPath]; + } else { + // transform extension + if (isset($extTransform[$imageMimeType])) { + $imgExt = $extTransform[$imageMimeType]; + } else { + throw new Exception("Unsupported image type $imageMimeType"); + } + + // add image to document + $imgName = 'image_' . $rid . '_' . pathinfo($partFileName, PATHINFO_FILENAME) . '.' . $imgExt; + $this->zipClass->pclzipAddFile($imgPath, 'word/media/' . $imgName); + $this->tempDocumentNewImages[$imgPath] = $imgName; + + // setup type for image + $xmlImageType = str_replace(['{IMG}', '{MIME}'], [$imgName, $imageMimeType], $typeTpl); + $this->tempDocumentContentTypes = str_replace('', $xmlImageType, $this->tempDocumentContentTypes) . ''; + } + + $xmlImageRelation = str_replace(['{RID}', '{IMG}'], [$rid, $imgName], $relationTpl); + + if (!isset($this->tempDocumentRelations[$partFileName])) { + // create new relations file + $this->tempDocumentRelations[$partFileName] = $newRelationsTpl; + // and add it to content types + $xmlRelationsType = str_replace('{RELS}', $this->getRelationsName($partFileName), $newRelationsTypeTpl); + $this->tempDocumentContentTypes = str_replace('', $xmlRelationsType, $this->tempDocumentContentTypes) . ''; + } + + // add image to relations + $this->tempDocumentRelations[$partFileName] = str_replace('', $xmlImageRelation, $this->tempDocumentRelations[$partFileName]) . ''; + } + + /** + * @param mixed $search + * @param mixed $replace Path to image, or array("path" => xx, "width" => yy, "height" => zz) + * @param int $limit + */ + public function setImageValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT): void + { + // prepare $search_replace + if (!is_array($search)) { + $search = [$search]; + } + + $replacesList = []; + if (!is_array($replace) || isset($replace['path'])) { + $replacesList[] = $replace; + } else { + $replacesList = array_values($replace); + } + + $searchReplace = []; + foreach ($search as $searchIdx => $searchString) { + $searchReplace[$searchString] = $replacesList[$searchIdx] ?? $replacesList[0]; + } + + // collect document parts + $searchParts = [ + $this->getMainPartName() => &$this->tempDocumentMainPart, + ]; + foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) { + $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex]; + } + foreach (array_keys($this->tempDocumentFooters) as $footerIndex) { + $searchParts[$this->getFooterName($footerIndex)] = &$this->tempDocumentFooters[$footerIndex]; + } + + // define templates + // result can be verified via "Open XML SDK 2.5 Productivity Tool" (http://www.microsoft.com/en-us/download/details.aspx?id=30425) + $imgTpl = ''; + + $i = 0; + foreach ($searchParts as $partFileName => &$partContent) { + $partVariables = $this->getVariablesForPart($partContent); + + foreach ($searchReplace as $searchString => $replaceImage) { + $varsToReplace = array_filter($partVariables, function ($partVar) use ($searchString) { + return ($partVar == $searchString) || preg_match('/^' . preg_quote($searchString, '/') . ':/', $partVar); + }); + + foreach ($varsToReplace as $varNameWithArgs) { + $varInlineArgs = $this->getImageArgs($varNameWithArgs); + $preparedImageAttrs = $this->prepareImageAttrs($replaceImage, $varInlineArgs); + $imgPath = $preparedImageAttrs['src']; + + // get image index + $imgIndex = $this->getNextRelationsIndex($partFileName); + $rid = 'rId' . $imgIndex; + + // replace preparations + $this->addImageToRelations($partFileName, $rid, $imgPath, $preparedImageAttrs['mime']); + $xmlImage = str_replace(['{RID}', '{WIDTH}', '{HEIGHT}'], [$rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']], $imgTpl); + + // replace variable + $varNameWithArgsFixed = static::ensureMacroCompleted($varNameWithArgs); + $matches = []; + if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed, '/') . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) { + $wholeTag = $matches[0]; + array_shift($matches); + [$openTag, $prefix, , $postfix, $closeTag] = $matches; + $replaceXml = $openTag . $prefix . $closeTag . $xmlImage . $openTag . $postfix . $closeTag; + // replace on each iteration, because in one tag we can have 2+ inline variables => before proceed next variable we need to change $partContent + $partContent = $this->setValueForPart($wholeTag, $replaceXml, $partContent, $limit); + } + + if (++$i >= $limit) { + break; + } + } + } + } + } + + /** + * Returns count of all variables in template. + * + * @return array + */ + public function getVariableCount() + { + $variables = $this->getVariablesForPart($this->tempDocumentMainPart); + + foreach ($this->tempDocumentHeaders as $headerXML) { + $variables = array_merge( + $variables, + $this->getVariablesForPart($headerXML) + ); + } + + foreach ($this->tempDocumentFooters as $footerXML) { + $variables = array_merge( + $variables, + $this->getVariablesForPart($footerXML) + ); + } + + return array_count_values($variables); + } + + /** + * Returns array of all variables in template. + * + * @return string[] + */ + public function getVariables() + { + return array_keys($this->getVariableCount()); + } + + /** + * Clone a table row in a template document. + * + * @param string $search + * @param int $numberOfClones + */ + public function cloneRow($search, $numberOfClones): void + { + $search = static::ensureMacroCompleted($search); + + $tagPos = strpos($this->tempDocumentMainPart, $search); + if (!$tagPos) { + throw new Exception('Can not clone row, template variable not found or variable contains markup.'); + } + + $rowStart = $this->findRowStart($tagPos); + $rowEnd = $this->findRowEnd($tagPos); + $xmlRow = $this->getSlice($rowStart, $rowEnd); + + // Check if there's a cell spanning multiple rows. + if (preg_match('##', $xmlRow)) { + // $extraRowStart = $rowEnd; + $extraRowEnd = $rowEnd; + while (true) { + $extraRowStart = $this->findRowStart($extraRowEnd + 1); + $extraRowEnd = $this->findRowEnd($extraRowEnd + 1); + + // If extraRowEnd is lower then 7, there was no next row found. + if ($extraRowEnd < 7) { + break; + } + + // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row. + $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd); + if (!preg_match('##', $tmpXmlRow) && + !preg_match('##', $tmpXmlRow) + ) { + break; + } + // This row was a spanned row, update $rowEnd and search for the next row. + $rowEnd = $extraRowEnd; + } + $xmlRow = $this->getSlice($rowStart, $rowEnd); + } + + $result = $this->getSlice(0, $rowStart); + $result .= implode('', $this->indexClonedVariables($numberOfClones, $xmlRow)); + $result .= $this->getSlice($rowEnd); + + $this->tempDocumentMainPart = $result; + } + + /** + * Delete a table row in a template document. + */ + public function deleteRow(string $search): void + { + if (self::$macroOpeningChars !== substr($search, 0, 2) && self::$macroClosingChars !== substr($search, -1)) { + $search = self::$macroOpeningChars . $search . self::$macroClosingChars; + } + + $tagPos = strpos($this->tempDocumentMainPart, $search); + if (!$tagPos) { + throw new Exception(sprintf('Can not delete row %s, template variable not found or variable contains markup.', $search)); + } + + $tableStart = $this->findTableStart($tagPos); + $tableEnd = $this->findTableEnd($tagPos); + $xmlTable = $this->getSlice($tableStart, $tableEnd); + + if (substr_count($xmlTable, 'tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd); + + return; + } + + $rowStart = $this->findRowStart($tagPos); + $rowEnd = $this->findRowEnd($tagPos); + $xmlRow = $this->getSlice($rowStart, $rowEnd); + + $this->tempDocumentMainPart = $this->getSlice(0, $rowStart) . $this->getSlice($rowEnd); + + // Check if there's a cell spanning multiple rows. + if (preg_match('##', $xmlRow)) { + $extraRowStart = $rowStart; + while (true) { + $extraRowStart = $this->findRowStart($extraRowStart + 1); + $extraRowEnd = $this->findRowEnd($extraRowStart + 1); + + // If extraRowEnd is lower then 7, there was no next row found. + if ($extraRowEnd < 7) { + break; + } + + // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row. + $tmpXmlRow = $this->getSlice($extraRowStart, $extraRowEnd); + if (!preg_match('##', $tmpXmlRow) && + !preg_match('##', $tmpXmlRow) + ) { + break; + } + + $tableStart = $this->findTableStart($extraRowEnd + 1); + $tableEnd = $this->findTableEnd($extraRowEnd + 1); + $xmlTable = $this->getSlice($tableStart, $tableEnd); + if (substr_count($xmlTable, 'tempDocumentMainPart = $this->getSlice(0, $tableStart) . $this->getSlice($tableEnd); + + return; + } + + $this->tempDocumentMainPart = $this->getSlice(0, $extraRowStart) . $this->getSlice($extraRowEnd); + } + } + } + + /** + * Clones a table row and populates it's values from a two-dimensional array in a template document. + * + * @param string $search + * @param array $values + */ + public function cloneRowAndSetValues($search, $values): void + { + $this->cloneRow($search, count($values)); + + foreach ($values as $rowKey => $rowData) { + $rowNumber = $rowKey + 1; + foreach ($rowData as $macro => $replace) { + $this->setValue($macro . '#' . $rowNumber, $replace); + } + } + } + + /** + * Clone a block. + * + * @param string $blockname + * @param int $clones How many time the block should be cloned + * @param bool $replace + * @param bool $indexVariables If true, any variables inside the block will be indexed (postfixed with #1, #2, ...) + * @param array $variableReplacements Array containing replacements for macros found inside the block to clone + * + * @return null|string + */ + public function cloneBlock($blockname, $clones = 1, $replace = true, $indexVariables = false, $variableReplacements = null) + { + $xmlBlock = null; + $matches = []; + $escapedMacroOpeningChars = self::$macroOpeningChars; + $escapedMacroClosingChars = self::$macroClosingChars; + preg_match( + //'/(.*((?s)))(.*)((?s))/is', + '/(.*((?s)))(.*)((?s))/is', + //'/(.*((?s)))(.*)((?s))/is', + $this->tempDocumentMainPart, + $matches + ); + + if (isset($matches[3])) { + $xmlBlock = $matches[3]; + if ($indexVariables) { + $cloned = $this->indexClonedVariables($clones, $xmlBlock); + } elseif ($variableReplacements !== null && is_array($variableReplacements)) { + $cloned = $this->replaceClonedVariables($variableReplacements, $xmlBlock); + } else { + $cloned = []; + for ($i = 1; $i <= $clones; ++$i) { + $cloned[] = $xmlBlock; + } + } + + if ($replace) { + $this->tempDocumentMainPart = str_replace( + $matches[2] . $matches[3] . $matches[4], + implode('', $cloned), + $this->tempDocumentMainPart + ); + } + } + + return $xmlBlock; + } + + /** + * Replace a block. + * + * @param string $blockname + * @param string $replacement + */ + public function replaceBlock($blockname, $replacement): void + { + $matches = []; + $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars); + $escapedMacroClosingChars = preg_quote(self::$macroClosingChars); + preg_match( + '/(<\?xml.*)(' . $escapedMacroOpeningChars . $blockname . $escapedMacroClosingChars . '<\/w:.*?p>)(.*)()/is', + $this->tempDocumentMainPart, + $matches + ); + + if (isset($matches[3])) { + $this->tempDocumentMainPart = str_replace( + $matches[2] . $matches[3] . $matches[4], + $replacement, + $this->tempDocumentMainPart + ); + } + } + + /** + * Delete a block of text. + * + * @param string $blockname + */ + public function deleteBlock($blockname): void + { + $this->replaceBlock($blockname, ''); + } + + /** + * Automatically Recalculate Fields on Open. + * + * @param bool $update + */ + public function setUpdateFields($update = true): void + { + $string = $update ? 'true' : 'false'; + $matches = []; + if (preg_match('//', $this->tempDocumentSettingsPart, $matches)) { + $this->tempDocumentSettingsPart = str_replace($matches[0], '', $this->tempDocumentSettingsPart); + } else { + $this->tempDocumentSettingsPart = str_replace('', '', $this->tempDocumentSettingsPart); + } + } + + /** + * Saves the result document. + * + * @return string + */ + public function save() + { + foreach ($this->tempDocumentHeaders as $index => $xml) { + $this->savePartWithRels($this->getHeaderName($index), $xml); + } + + $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart); + $this->savePartWithRels($this->getSettingsPartName(), $this->tempDocumentSettingsPart); + + foreach ($this->tempDocumentFooters as $index => $xml) { + $this->savePartWithRels($this->getFooterName($index), $xml); + } + + $this->zipClass->addFromString($this->getDocumentContentTypesName(), $this->tempDocumentContentTypes); + + // Close zip file + if (false === $this->zipClass->close()) { + throw new Exception('Could not close zip file.'); // @codeCoverageIgnore + } + + return $this->tempDocumentFilename; + } + + /** + * @param string $fileName + * @param string $xml + */ + protected function savePartWithRels($fileName, $xml): void + { + $this->zipClass->addFromString($fileName, $xml); + if (isset($this->tempDocumentRelations[$fileName])) { + $relsFileName = $this->getRelationsName($fileName); + $this->zipClass->addFromString($relsFileName, $this->tempDocumentRelations[$fileName]); + } + } + + /** + * Saves the result document to the user defined file. + * + * @since 0.8.0 + * + * @param string $fileName + */ + public function saveAs($fileName): void + { + $tempFileName = $this->save(); + + if (file_exists($fileName)) { + unlink($fileName); + } + + /* + * Note: we do not use `rename` function here, because it loses file ownership data on Windows platform. + * As a result, user cannot open the file directly getting "Access denied" message. + * + * @see https://github.com/PHPOffice/PHPWord/issues/532 + */ + copy($tempFileName, $fileName); + unlink($tempFileName); + } + + /** + * Finds parts of broken macros and sticks them together. + * Macros, while being edited, could be implicitly broken by some of the word processors. + * + * @param string $documentPart The document part in XML representation + * + * @return string + */ + protected function fixBrokenMacros($documentPart) + { + $brokenMacroOpeningChars = substr(self::$macroOpeningChars, 0, 1); + $endMacroOpeningChars = substr(self::$macroOpeningChars, 1); + $macroClosingChars = self::$macroClosingChars; + + return preg_replace_callback( + '/\\' . $brokenMacroOpeningChars . '(?:\\' . $endMacroOpeningChars . '|[^{$]*\>\{)[^' . $macroClosingChars . '$]*\}/U', + function ($match) { + return strip_tags($match[0]); + }, + $documentPart + ); + } + + /** + * Find and replace macros in the given XML section. + * + * @param array|string $search + * @param array|string $replace + * @param array|string $documentPartXML + * @param int $limit + * + * @return ($documentPartXML is string ? string : array) + */ + protected function setValueForPart($search, $replace, $documentPartXML, $limit) + { + // Note: we can't use the same function for both cases here, because of performance considerations. + if (self::MAXIMUM_REPLACEMENTS_DEFAULT === $limit) { + return str_replace($search, $replace, $documentPartXML); + } + $regExpEscaper = new RegExp(); + + return preg_replace($regExpEscaper->escape($search), $replace, $documentPartXML, $limit); + } + + /** + * Find all variables in $documentPartXML. + * + * @param string $documentPartXML + * + * @return string[] + */ + protected function getVariablesForPart($documentPartXML) + { + $matches = []; + $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars); + $escapedMacroClosingChars = preg_quote(self::$macroClosingChars); + + preg_match_all("/$escapedMacroOpeningChars(.*?)$escapedMacroClosingChars/i", $documentPartXML, $matches); + + return $matches[1]; + } + + /** + * Get the name of the header file for $index. + * + * @param int $index + * + * @return string + */ + protected function getHeaderName($index) + { + return sprintf('word/header%d.xml', $index); + } + + /** + * Usually, the name of main part document will be 'document.xml'. However, some .docx files (possibly those from Office 365, experienced also on documents from Word Online created from blank templates) have file 'document22.xml' in their zip archive instead of 'document.xml'. This method searches content types file to correctly determine the file name. + * + * @return string + */ + protected function getMainPartName() + { + $contentTypes = $this->zipClass->getFromName('[Content_Types].xml'); + + $pattern = '~PartName="\/(word\/document.*?\.xml)" ContentType="application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document\.main\+xml"~'; + + $matches = []; + preg_match($pattern, $contentTypes, $matches); + + return array_key_exists(1, $matches) ? $matches[1] : 'word/document.xml'; + } + + /** + * The name of the file containing the Settings part. + * + * @return string + */ + protected function getSettingsPartName() + { + return 'word/settings.xml'; + } + + /** + * Get the name of the footer file for $index. + * + * @param int $index + * + * @return string + */ + protected function getFooterName($index) + { + return sprintf('word/footer%d.xml', $index); + } + + /** + * Get the name of the relations file for document part. + * + * @param string $documentPartName + * + * @return string + */ + protected function getRelationsName($documentPartName) + { + return 'word/_rels/' . pathinfo($documentPartName, PATHINFO_BASENAME) . '.rels'; + } + + protected function getNextRelationsIndex($documentPartName) + { + if (isset($this->tempDocumentRelations[$documentPartName])) { + $candidate = substr_count($this->tempDocumentRelations[$documentPartName], 'tempDocumentRelations[$documentPartName], 'Id="rId' . $candidate . '"') !== false) { + ++$candidate; + } + + return $candidate; + } + + return 1; + } + + /** + * @return string + */ + protected function getDocumentContentTypesName() + { + return '[Content_Types].xml'; + } + + /** + * Find the start position of the nearest table before $offset. + */ + private function findTableStart(int $offset): int + { + $rowStart = strrpos( + $this->tempDocumentMainPart, + 'tempDocumentMainPart) - $offset) * -1) + ); + + if (!$rowStart) { + $rowStart = strrpos( + $this->tempDocumentMainPart, + '', + ((strlen($this->tempDocumentMainPart) - $offset) * -1) + ); + } + if (!$rowStart) { + throw new Exception('Can not find the start position of the table.'); + } + + return $rowStart; + } + + /** + * Find the end position of the nearest table row after $offset. + */ + private function findTableEnd(int $offset): int + { + return strpos($this->tempDocumentMainPart, '', $offset) + 7; + } + + /** + * Find the start position of the nearest table row before $offset. + * + * @param int $offset + * + * @return int + */ + protected function findRowStart($offset) + { + $rowStart = strrpos($this->tempDocumentMainPart, 'tempDocumentMainPart) - $offset) * -1)); + + if (!$rowStart) { + $rowStart = strrpos($this->tempDocumentMainPart, '', ((strlen($this->tempDocumentMainPart) - $offset) * -1)); + } + if (!$rowStart) { + throw new Exception('Can not find the start position of the row to clone.'); + } + + return $rowStart; + } + + /** + * Find the end position of the nearest table row after $offset. + * + * @param int $offset + * + * @return int + */ + protected function findRowEnd($offset) + { + return strpos($this->tempDocumentMainPart, '', $offset) + 7; + } + + /** + * Get a slice of a string. + * + * @param int $startPosition + * @param int $endPosition + * + * @return string + */ + protected function getSlice($startPosition, $endPosition = 0) + { + if (!$endPosition) { + $endPosition = strlen($this->tempDocumentMainPart); + } + + return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition)); + } + + /** + * Replaces variable names in cloned + * rows/blocks with indexed names. + * + * @param int $count + * @param string $xmlBlock + * + * @return array + */ + protected function indexClonedVariables($count, $xmlBlock) + { + $results = []; + $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars); + $escapedMacroClosingChars = preg_quote(self::$macroClosingChars); + + for ($i = 1; $i <= $count; ++$i) { + $results[] = preg_replace("/$escapedMacroOpeningChars([^:]*?)(:.*?)?$escapedMacroClosingChars/", self::$macroOpeningChars . '\1#' . $i . '\2' . self::$macroClosingChars, $xmlBlock); + } + + return $results; + } + + /** + * Replace carriage returns with xml. + */ + public function replaceCarriageReturns(string $string): string + { + return str_replace(["\r\n", "\r", "\n"], '', $string); + } + + /** + * Replaces variables with values from array, array keys are the variable names. + * + * @param array $variableReplacements + * @param string $xmlBlock + * + * @return string[] + */ + protected function replaceClonedVariables($variableReplacements, $xmlBlock) + { + $results = []; + foreach ($variableReplacements as $replacementArray) { + $localXmlBlock = $xmlBlock; + foreach ($replacementArray as $search => $replacement) { + $localXmlBlock = $this->setValueForPart(self::ensureMacroCompleted($search), $replacement, $localXmlBlock, self::MAXIMUM_REPLACEMENTS_DEFAULT); + } + $results[] = $localXmlBlock; + } + + return $results; + } + + /** + * Replace an XML block surrounding a macro with a new block. + * + * @param string $macro Name of macro + * @param string $block New block content + * @param string $blockType XML tag type of block + * + * @return TemplateProcessor Fluent interface + */ + public function replaceXmlBlock($macro, $block, $blockType = 'w:p') + { + $where = $this->findContainingXmlBlockForMacro($macro, $blockType); + if (is_array($where)) { + $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']); + } + + return $this; + } + + /** + * Find start and end of XML block containing the given macro + * e.g. ...${macro}.... + * + * Note that only the first instance of the macro will be found + * + * @param string $macro Name of macro + * @param string $blockType XML tag for block + * + * @return bool|int[] FALSE if not found, otherwise array with start and end + */ + protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p') + { + $macroPos = $this->findMacro($macro); + if (0 > $macroPos) { + return false; + } + $start = $this->findXmlBlockStart($macroPos, $blockType); + if (0 > $start) { + return false; + } + $end = $this->findXmlBlockEnd($start, $blockType); + //if not found or if resulting string does not contain the macro we are searching for + if (0 > $end || strstr($this->getSlice($start, $end), $macro) === false) { + return false; + } + + return ['start' => $start, 'end' => $end]; + } + + /** + * Find the position of (the start of) a macro. + * + * Returns -1 if not found, otherwise position of opening $ + * + * Note that only the first instance of the macro will be found + * + * @param string $search Macro name + * @param int $offset Offset from which to start searching + * + * @return int -1 if macro not found + */ + protected function findMacro($search, $offset = 0) + { + $search = static::ensureMacroCompleted($search); + $pos = strpos($this->tempDocumentMainPart, $search, $offset); + + return ($pos === false) ? -1 : $pos; + } + + /** + * Find the start position of the nearest XML block start before $offset. + * + * @param int $offset Search position + * @param string $blockType XML Block tag + * + * @return int -1 if block start not found + */ + protected function findXmlBlockStart($offset, $blockType) + { + $reverseOffset = (strlen($this->tempDocumentMainPart) - $offset) * -1; + // first try XML tag with attributes + $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', $reverseOffset); + // if not found, or if found but contains the XML tag without attribute + if (false === $blockStart || strrpos($this->getSlice($blockStart, $offset), '<' . $blockType . '>')) { + // also try XML tag without attributes + $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', $reverseOffset); + } + + return ($blockStart === false) ? -1 : $blockStart; + } + + /** + * Find the nearest block end position after $offset. + * + * @param int $offset Search position + * @param string $blockType XML Block tag + * + * @return int -1 if block end not found + */ + protected function findXmlBlockEnd($offset, $blockType) + { + $blockEndStart = strpos($this->tempDocumentMainPart, '', $offset); + // return position of end of tag if found, otherwise -1 + + return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType); + } + + /** + * Splits a w:r/w:t into a list of w:r where each ${macro} is in a separate w:r. + * + * @param string $text + * + * @return string + */ + protected function splitTextIntoTexts($text) + { + if (!$this->textNeedsSplitting($text)) { + return $text; + } + $matches = []; + if (preg_match('/()/i', $text, $matches)) { + $extractedStyle = $matches[0]; + } else { + $extractedStyle = ''; + } + + $unformattedText = preg_replace('/>\s+<', $text); + $result = str_replace([self::$macroOpeningChars, self::$macroClosingChars], ['' . $extractedStyle . '' . self::$macroOpeningChars, self::$macroClosingChars . '' . $extractedStyle . ''], $unformattedText); + + return str_replace(['' . $extractedStyle . '', '', ''], ['', '', ''], $result); + } + + /** + * Returns true if string contains a macro that is not in it's own w:r. + * + * @param string $text + * + * @return bool + */ + protected function textNeedsSplitting($text) + { + $escapedMacroOpeningChars = preg_quote(self::$macroOpeningChars); + $escapedMacroClosingChars = preg_quote(self::$macroClosingChars); + + return 1 === preg_match('/[^>]' . $escapedMacroOpeningChars . '|' . $escapedMacroClosingChars . '[^<]/i', $text); + } + + public function setMacroOpeningChars(string $macroOpeningChars): void + { + self::$macroOpeningChars = $macroOpeningChars; + } + + public function setMacroClosingChars(string $macroClosingChars): void + { + self::$macroClosingChars = $macroClosingChars; + } + + public function setMacroChars(string $macroOpeningChars, string $macroClosingChars): void + { + self::$macroOpeningChars = $macroOpeningChars; + self::$macroClosingChars = $macroClosingChars; + } + + public function getTempDocumentFilename(): string + { + return $this->tempDocumentFilename; + } +} diff --git a/tests/PhpWordTests/TemplateProcessorTest.php b/tests/PhpWordTests/TemplateProcessorTest.php new file mode 100644 index 0000000..7f8cd44 --- /dev/null +++ b/tests/PhpWordTests/TemplateProcessorTest.php @@ -0,0 +1,1868 @@ +templateProcessor = new TemplateProcessor($filename); + + return $this->templateProcessor; + } + + protected function tearDown(): void + { + if ($this->templateProcessor !== null) { + $filename = $this->templateProcessor->getTempDocumentFilename(); + $this->templateProcessor = null; + if (file_exists($filename)) { + @unlink($filename); + } + } + } + + /** + * Construct test. + * + * @covers ::__construct + * @covers ::__destruct + * @covers \PhpOffice\PhpWord\Shared\ZipArchive::close + */ + public function testTheConstruct(): void + { + $object = $this->getTemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + self::assertEquals([], $object->getVariables()); + $object->save(); + + try { + $object->zip()->close(); + self::fail('Expected exception for double close'); + } catch (Throwable $e) { + // nothing to do here + } + } + + /** + * Template can be saved in temporary location. + * + * @covers ::save + * @covers ::zip + */ + public function xtestTemplateCanBeSavedInTemporaryLocation(string $templateFqfn, TemplateProcessor $templateProcessor): string + { + $xslDomDocument = new DOMDocument(); + $xslDomDocument->load(__DIR__ . '/_files/xsl/remove_tables_by_needle.xsl'); + foreach (['${employee.', '${scoreboard.', '${reference.'] as $needle) { + $templateProcessor->applyXslStyleSheet($xslDomDocument, ['needle' => $needle]); + } + + $embeddingText = 'The quick Brown Fox jumped over the lazy^H^H^H^Htired unitTester'; + $templateProcessor->zip()->AddFromString('word/embeddings/fox.bin', $embeddingText); + $documentFqfn = $templateProcessor->save(); + + self::assertNotEmpty($documentFqfn, 'FQFN of the saved document is empty.'); + self::assertFileExists($documentFqfn, "The saved document \"{$documentFqfn}\" doesn't exist."); + + $templateZip = new ZipArchive(); + $templateZip->open($templateFqfn); + $templateHeaderXml = $templateZip->getFromName('word/header1.xml'); + $templateMainPartXml = $templateZip->getFromName('word/document.xml'); + $templateFooterXml = $templateZip->getFromName('word/footer1.xml'); + if (false === $templateZip->close()) { + throw new Exception("Could not close zip file \"{$templateZip}\"."); + } + + $documentZip = new ZipArchive(); + $documentZip->open($documentFqfn); + $documentHeaderXml = $documentZip->getFromName('word/header1.xml'); + $documentMainPartXml = $documentZip->getFromName('word/document.xml'); + $documentFooterXml = $documentZip->getFromName('word/footer1.xml'); + $documentEmbedding = $documentZip->getFromName('word/embeddings/fox.bin'); + if (false === $documentZip->close()) { + throw new Exception("Could not close zip file \"{$documentZip}\"."); + } + + self::assertNotEquals($templateHeaderXml, $documentHeaderXml); + self::assertNotEquals($templateMainPartXml, $documentMainPartXml); + self::assertNotEquals($templateFooterXml, $documentFooterXml); + self::assertEquals($embeddingText, $documentEmbedding); + + return $documentFqfn; + } + + /** + * XSL stylesheet can be applied. + * + * @covers ::applyXslStyleSheet + */ + public function testXslStyleSheetCanBeApplied(): void + { + $templateFqfn = __DIR__ . '/_files/templates/with_table_macros.docx'; + $templateProcessor = $this->getTemplateProcessor($templateFqfn); + + $actualDocumentFqfn = $this->xtestTemplateCanBeSavedInTemporaryLocation($templateFqfn, $templateProcessor); + $expectedDocumentFqfn = __DIR__ . '/_files/documents/without_table_macros.docx'; + + $actualDocumentZip = new ZipArchive(); + $actualDocumentZip->open($actualDocumentFqfn); + $actualHeaderXml = $actualDocumentZip->getFromName('word/header1.xml'); + $actualMainPartXml = $actualDocumentZip->getFromName('word/document.xml'); + $actualFooterXml = $actualDocumentZip->getFromName('word/footer1.xml'); + if (false === $actualDocumentZip->close()) { + throw new Exception("Could not close zip file \"{$actualDocumentFqfn}\"."); + } + + $expectedDocumentZip = new ZipArchive(); + $expectedDocumentZip->open($expectedDocumentFqfn); + $expectedHeaderXml = $expectedDocumentZip->getFromName('word/header1.xml'); + $expectedMainPartXml = $expectedDocumentZip->getFromName('word/document.xml'); + $expectedFooterXml = $expectedDocumentZip->getFromName('word/footer1.xml'); + if (false === $expectedDocumentZip->close()) { + throw new Exception("Could not close zip file \"{$expectedDocumentFqfn}\"."); + } + + self::assertSame($expectedHeaderXml, $actualHeaderXml); + self::assertSame($expectedMainPartXml, $actualMainPartXml); + self::assertSame($expectedFooterXml, $actualFooterXml); + } + + /** + * XSL stylesheet cannot be applied on failure in setting parameter value. + * + * @covers ::applyXslStyleSheet + */ + public function testXslStyleSheetCanNotBeAppliedOnFailureOfSettingParameterValue(): void + { + if (\PHP_VERSION_ID >= 80000) { + // PHP 8+ internal validation throws TypeError. + $this->expectException(TypeError::class); + $this->expectExceptionMessage('must contain only string keys'); + } else { + $this->expectException(\PhpOffice\PhpWord\Exception\Exception::class); + $this->expectExceptionMessage('Could not set values for the given XSL style sheet parameters.'); + } + + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + + $xslDomDocument = new DOMDocument(); + $xslDomDocument->load(__DIR__ . '/_files/xsl/passthrough.xsl'); + + /* + * We have to use error control below, because \XSLTProcessor::setParameter omits warning on failure. + * This warning fails the test. + */ + @$templateProcessor->applyXslStyleSheet($xslDomDocument, [1 => 'somevalue']); + } + + /** + * XSL stylesheet can be applied on failure of loading XML from template. + * + * @covers ::applyXslStyleSheet + */ + public function testXslStyleSheetCanNotBeAppliedOnFailureOfLoadingXmlFromTemplate(): void + { + $this->expectException(\PhpOffice\PhpWord\Exception\Exception::class); + $this->expectExceptionMessage('Could not load the given XML document.'); + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/corrupted_main_document_part.docx'); + + $xslDomDocument = new DOMDocument(); + $xslDomDocument->load(__DIR__ . '/_files/xsl/passthrough.xsl'); + + /* + * We have to use error control below, because \DOMDocument::loadXML omits warning on failure. + * This warning fails the test. + */ + @$templateProcessor->applyXslStyleSheet($xslDomDocument); + } + + /** + * @covers ::deleteRow + * @covers ::getVariables + * @covers ::saveAs + */ + public function testDeleteRow(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/delete-row.docx'); + + self::assertEquals( + ['deleteMe', 'deleteMeToo'], + $templateProcessor->getVariables() + ); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'delete-row-test-result.docx'; + $templateProcessor->deleteRow('deleteMe'); + self::assertEquals( + [], + $templateProcessor->getVariables() + ); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::cloneRow + * @covers ::saveAs + * @covers ::setValue + */ + public function testCloneRow(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + + self::assertEquals( + ['tableHeader', 'userId', 'userName', 'userLocation'], + $templateProcessor->getVariables() + ); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'clone-test-result.docx'; + $templateProcessor->setValue('tableHeader', 'ééé'); + $templateProcessor->cloneRow('userId', 1); + $templateProcessor->setValue('userId#1', 'Test'); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::cloneRow + * @covers ::saveAs + * @covers ::setValue + */ + public function testCloneRowWithCustomMacro(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/clone-merge-with-custom-macro.docx'); + + $templateProcessor->setMacroOpeningChars('{#'); + $templateProcessor->setMacroClosingChars('#}'); + + self::assertEquals( + ['tableHeader', 'userId', 'userName', 'userLocation'], + $templateProcessor->getVariables() + ); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'clone-test-result.docx'; + $templateProcessor->setValue('tableHeader', 'ééé'); + $templateProcessor->cloneRow('userId', 1); + $templateProcessor->setValue('userId#1', 'Test'); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::cloneRow + * @covers ::saveAs + * @covers ::setValue + */ + public function testCloneRowAndSetValues(): void + { + $mainPart = ' + + + + + + + + ${userId} + + + + + + + ${userName} + + + + + + + + + + + + + + + ${userLocation} + + + + + '; + $templateProcessor = new TestableTemplateProcesor($mainPart); + + self::assertEquals( + ['userId', 'userName', 'userLocation'], + $templateProcessor->getVariables() + ); + + $values = [ + ['userId' => 1, 'userName' => 'Batman', 'userLocation' => 'Gotham City'], + ['userId' => 2, 'userName' => 'Superman', 'userLocation' => 'Metropolis'], + ]; + $templateProcessor->setValue('tableHeader', 'My clonable table'); + $templateProcessor->cloneRowAndSetValues('userId', $values); + self::assertStringContainsString('Superman', $templateProcessor->getMainPart()); + self::assertStringContainsString('Metropolis', $templateProcessor->getMainPart()); + } + + public function testCloneNotExistingRowShouldThrowException(): void + { + $this->expectException(Exception::class); + $mainPart = 'text'; + $templateProcessor = new TestableTemplateProcesor($mainPart); + + $templateProcessor->cloneRow('fake_search', 2); + } + + /** + * @covers ::cloneRow + * @covers ::saveAs + * @covers ::setValue + */ + public function testCloneRowAndSetValuesWithCustomMacro(): void + { + $mainPart = ' + + + + + + + + {{userId}} + + + + + + + {{userName}} + + + + + + + + + + + + + + + {{userLocation}} + + + + + '; + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroOpeningChars('{{'); + $templateProcessor->setMacroClosingChars('}}'); + + self::assertEquals( + ['userId', 'userName', 'userLocation'], + $templateProcessor->getVariables() + ); + + $values = [ + ['userId' => 1, 'userName' => 'Batman', 'userLocation' => 'Gotham City'], + ['userId' => 2, 'userName' => 'Superman', 'userLocation' => 'Metropolis'], + ]; + $templateProcessor->setValue('tableHeader', 'My clonable table'); + $templateProcessor->cloneRowAndSetValues('userId', $values); + self::assertStringContainsString('Superman', $templateProcessor->getMainPart()); + self::assertStringContainsString('Metropolis', $templateProcessor->getMainPart()); + } + + /** + * @covers ::saveAs + * @covers ::setValue + */ + public function testMacrosCanBeReplacedInHeaderAndFooter(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/header-footer.docx'); + + self::assertEquals(['documentContent', 'headerValue:100:100', 'footerValue'], $templateProcessor->getVariables()); + + $macroNames = ['headerValue', 'documentContent', 'footerValue']; + $macroValues = ['Header Value', 'Document text.', 'Footer Value']; + $templateProcessor->setValue($macroNames, $macroValues); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'header-footer-test-result.docx'; + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::saveAs + * @covers ::setValue + */ + public function testCustomMacrosCanBeReplacedInHeaderAndFooter(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/header-footer-with-custom-macro.docx'); + $templateProcessor->setMacroOpeningChars('{{'); + $templateProcessor->setMacroClosingChars('}}'); + + self::assertEquals(['documentContent', 'headerValue:100:100', 'footerValue'], $templateProcessor->getVariables()); + + $macroNames = ['headerValue', 'documentContent', 'footerValue']; + $macroValues = ['Header Value', 'Document text.', 'Footer Value']; + $templateProcessor->setValue($macroNames, $macroValues); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'header-footer-test-result.docx'; + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::setValue + */ + public function testSetValue(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + Settings::setOutputEscapingEnabled(true); + $helloworld = "hello\nworld"; + $templateProcessor->setValue('userName', $helloworld); + self::assertEquals( + ['tableHeader', 'userId', 'userLocation'], + $templateProcessor->getVariables() + ); + } + + /** + * @covers ::setValue + */ + public function testSetValueWithCustomMacro(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/clone-merge-with-custom-macro.docx'); + $templateProcessor->setMacroChars('{#', '#}'); + Settings::setOutputEscapingEnabled(true); + $helloworld = "hello\nworld"; + $templateProcessor->setValue('userName', $helloworld); + self::assertEquals( + ['tableHeader', 'userId', 'userLocation'], + $templateProcessor->getVariables() + ); + } + + public function testSetComplexValue(): void + { + $title = new TextRun(); + $title->addText('This is my title'); + + $firstname = new Text('Donald'); + $lastname = new Text('Duck'); + + $mainPart = ' + + + Hello ${document-title} + + + + + Hello ${firstname} ${lastname} + + '; + + $result = ' + + + + + This is my title + + + + + Hello + + + + Donald + + + + + + + Duck + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setComplexBlock('document-title', $title); + $templateProcessor->setComplexValue('firstname', $firstname); + $templateProcessor->setComplexValue('lastname', $lastname); + + self::assertEquals(preg_replace('/>\s+<', $result), preg_replace('/>\s+<', $templateProcessor->getMainPart())); + } + + public function testSetComplexValueWithCustomMacro(): void + { + $title = new TextRun(); + $title->addText('This is my title'); + + $firstname = new Text('Donald'); + $lastname = new Text('Duck'); + + $mainPart = ' + + + Hello {{document-title}} + + + + + Hello {{firstname}} {{lastname}} + + '; + + $result = ' + + + + + This is my title + + + + + Hello + + + + Donald + + + + + + + Duck + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + $templateProcessor->setComplexBlock('document-title', $title); + $templateProcessor->setComplexValue('firstname', $firstname); + $templateProcessor->setComplexValue('lastname', $lastname); + + self::assertEquals(preg_replace('/>\s+<', $result), preg_replace('/>\s+<', $templateProcessor->getMainPart())); + } + + /** + * @covers ::setValues + */ + public function testSetValues(): void + { + $mainPart = ' + + + Hello ${firstname} ${lastname} + + + + + Hello ${firstname} ${lastname} + + + + + Hello ${firstname} ${lastname} + + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setValues(['firstname' => 'John', 'lastname' => 'Doe']); + self::assertStringContainsString('Hello John Doe', $templateProcessor->getMainPart()); + self::assertStringNotContainsString('Hello ${firstname} ${lastname}', $templateProcessor->getMainPart()); + + // test with a specific limit that is lower than the number of replacements + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setValues(['firstname' => 'Jane', 'lastname' => 'Smith'], 2); + $variablesCounts = $templateProcessor->getVariableCount(); + + self::assertStringContainsString('Hello Jane Smith', $templateProcessor->getMainPart()); + self::assertStringContainsString('Hello ${firstname} ${lastname}', $templateProcessor->getMainPart()); + self::assertEquals(1, $variablesCounts['firstname']); + self::assertEquals(1, $variablesCounts['lastname']); + + // test with a limit for only one replacement + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setValues(['firstname' => 'Alice', 'lastname' => 'Wonderland'], 1); + $variablesCounts = $templateProcessor->getVariableCount(); + + self::assertStringContainsString('Hello Alice Wonderland', $templateProcessor->getMainPart()); + self::assertStringContainsString('Hello ${firstname} ${lastname}', $templateProcessor->getMainPart()); + self::assertEquals(2, $variablesCounts['firstname']); + self::assertEquals(2, $variablesCounts['lastname']); + + // Test with a limit of 0 for a result with no replacements + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setValues(['firstname' => 'Test', 'lastname' => 'User'], 0); + $variablesCounts = $templateProcessor->getVariableCount(); + + self::assertStringContainsString('Hello ${firstname} ${lastname}', $templateProcessor->getMainPart()); + self::assertStringNotContainsString('Hello Test User', $templateProcessor->getMainPart()); + self::assertEquals(3, $variablesCounts['firstname']); + self::assertEquals(3, $variablesCounts['lastname']); + } + + /** + * @covers ::setValues + */ + public function testSetValuesMultiLine(): void + { + $mainPart = ' + + + Address: ${address} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setValues(['address' => "Peter Pan\nNeverland"]); + + self::assertStringContainsString('Address: Peter PanNeverland', $templateProcessor->getMainPart()); + } + + /** + * @covers ::setValues + */ + public function testSetValuesWithCustomMacro(): void + { + $mainPart = ' + + + Hello {#firstname#} {#lastname#} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{#', '#}'); + $templateProcessor->setValues(['firstname' => 'John', 'lastname' => 'Doe']); + + self::assertStringContainsString('Hello John Doe', $templateProcessor->getMainPart()); + } + + /** + * @covers ::setCheckbox + */ + public function testSetCheckbox(): void + { + $mainPart = ' + + + + + + + + + + + ☐ + + + + + + + + + + + + + + + ☒ + + + + '; + + $result = ' + + + + + + + + + + + ☒ + + + + + + + + + + + + + + + ☐ + + + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setCheckbox('checkbox', true); + $templateProcessor->setCheckbox('checkbox2', false); + + self::assertEquals(preg_replace('/>\s+<', $result), preg_replace('/>\s+<', $templateProcessor->getMainPart())); + } + + /** + * @covers ::setCheckbox + */ + public function testSetCheckboxWithCustomMacro(): void + { + $mainPart = ' + + + + + + + + + + + ☐ + + + + + + + + + + + + + + + ☒ + + + + '; + + $result = ' + + + + + + + + + + + ☒ + + + + + + + + + + + + + + + ☐ + + + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{#', '#}'); + $templateProcessor->setCheckbox('checkbox', true); + $templateProcessor->setCheckbox('checkbox2', false); + + self::assertEquals(preg_replace('/>\s+<', $result), preg_replace('/>\s+<', $templateProcessor->getMainPart())); + } + + /** + * @covers ::setImageValue + */ + public function testSetImageValue(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/header-footer.docx'); + $imagePath = __DIR__ . '/_files/images/earth.jpg'; + + $variablesReplace = [ + 'headerValue' => function () use ($imagePath) { + return $imagePath; + }, + 'documentContent' => ['path' => $imagePath, 'width' => 500, 'height' => 500], + 'footerValue' => ['path' => $imagePath, 'width' => 100, 'height' => 50, 'ratio' => false], + ]; + $templateProcessor->setImageValue(array_keys($variablesReplace), $variablesReplace); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'header-footer-images-test-result.docx'; + $templateProcessor->saveAs($docName); + + self::assertFileExists($docName, "Generated file '{$docName}' not found!"); + + $expectedDocumentZip = new ZipArchive(); + $expectedDocumentZip->open($docName); + $expectedContentTypesXml = $expectedDocumentZip->getFromName('[Content_Types].xml'); + $expectedDocumentRelationsXml = $expectedDocumentZip->getFromName('word/_rels/document.xml.rels'); + $expectedHeaderRelationsXml = $expectedDocumentZip->getFromName('word/_rels/header1.xml.rels'); + $expectedFooterRelationsXml = $expectedDocumentZip->getFromName('word/_rels/footer1.xml.rels'); + $expectedMainPartXml = $expectedDocumentZip->getFromName('word/document.xml'); + $expectedHeaderPartXml = $expectedDocumentZip->getFromName('word/header1.xml'); + $expectedFooterPartXml = $expectedDocumentZip->getFromName('word/footer1.xml'); + $expectedImage = $expectedDocumentZip->getFromName('word/media/image_rId11_document.jpeg'); + if (false === $expectedDocumentZip->close()) { + throw new Exception("Could not close zip file \"{$docName}\"."); + } + + self::assertNotEmpty($expectedImage, 'Embed image doesn\'t found.'); + self::assertStringContainsString('/word/media/image_rId11_document.jpeg', $expectedContentTypesXml, '[Content_Types].xml missed "/word/media/image5_document.jpeg"'); + self::assertStringContainsString('/word/_rels/header1.xml.rels', $expectedContentTypesXml, '[Content_Types].xml missed "/word/_rels/header1.xml.rels"'); + self::assertStringContainsString('/word/_rels/footer1.xml.rels', $expectedContentTypesXml, '[Content_Types].xml missed "/word/_rels/footer1.xml.rels"'); + self::assertStringNotContainsString('${documentContent}', $expectedMainPartXml, 'word/document.xml has no image.'); + self::assertStringNotContainsString('${headerValue}', $expectedHeaderPartXml, 'word/header1.xml has no image.'); + self::assertStringNotContainsString('${footerValue}', $expectedFooterPartXml, 'word/footer1.xml has no image.'); + self::assertStringContainsString('media/image_rId11_document.jpeg', $expectedDocumentRelationsXml, 'word/_rels/document.xml.rels missed "media/image5_document.jpeg"'); + self::assertStringContainsString('media/image_rId11_document.jpeg', $expectedHeaderRelationsXml, 'word/_rels/header1.xml.rels missed "media/image5_document.jpeg"'); + self::assertStringContainsString('media/image_rId11_document.jpeg', $expectedFooterRelationsXml, 'word/_rels/footer1.xml.rels missed "media/image5_document.jpeg"'); + + unlink($docName); + + // dynamic generated doc + $testFileName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'images-test-sample.docx'; + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $section->addText('${Test:width=100:ratio=true}'); + $objWriter = IOFactory::createWriter($phpWord, 'Word2007'); + $objWriter->save($testFileName); + self::assertFileExists($testFileName, "Generated file '{$testFileName}' not found!"); + + $resultFileName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'images-test-result.docx'; + $templateProcessor = new TemplateProcessor($testFileName); + unlink($testFileName); + $templateProcessor->setImageValue('Test', $imagePath); + $templateProcessor->setImageValue('Test1', $imagePath); + $templateProcessor->setImageValue('Test2', $imagePath); + $templateProcessor->saveAs($resultFileName); + self::assertFileExists($resultFileName, "Generated file '{$resultFileName}' not found!"); + + $expectedDocumentZip = new ZipArchive(); + $expectedDocumentZip->open($resultFileName); + $expectedMainPartXml = $expectedDocumentZip->getFromName('word/document.xml'); + if (false === $expectedDocumentZip->close()) { + throw new Exception("Could not close zip file \"{$resultFileName}\"."); + } + unlink($resultFileName); + + self::assertStringNotContainsString('${Test}', $expectedMainPartXml, 'word/document.xml has no image.'); + } + + /** + * @covers ::setImageValue + */ + public function testSetImageValueMetafile(): void + { + $testFileName = 'images-metafile-test-sample.docx'; + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $section->addText('${WMF}'); + $section->addText('${EMF}'); + $objWriter = IOFactory::createWriter($phpWord, 'Word2007'); + $objWriter->save($testFileName); + + $resultFileName = 'images-metafile-test-result.docx'; + $templateProcessor = new TemplateProcessor($testFileName); + unlink($testFileName); + $templateProcessor->setImageValue('WMF', __DIR__ . '/_files/images/fish.wmf'); + $templateProcessor->setImageValue('EMF', __DIR__ . '/_files/images/inkscape_shapes_emfplus.emf'); + $templateProcessor->saveAs($resultFileName); + + $resultZip = new ZipArchive(); + $resultZip->open($resultFileName); + $contentTypesXml = (string) $resultZip->getFromName('[Content_Types].xml'); + $relationsXml = (string) $resultZip->getFromName('word/_rels/document.xml.rels'); + $mainPartXml = (string) $resultZip->getFromName('word/document.xml'); + // The relation ids depend on the template : the images are found by their extension + $images = []; + for ($index = 0; $index < $resultZip->numFiles; ++$index) { + $name = (string) $resultZip->getNameIndex($index); + if (preg_match('#^word/media/(image_rId\d+_document\.(wmf|emf))$#', $name, $matches)) { + $images[$matches[2]] = [$matches[1], (string) $resultZip->getFromName($name)]; + } + } + $resultZip->close(); + unlink($resultFileName); + + self::assertArrayHasKey('wmf', $images); + self::assertArrayHasKey('emf', $images); + self::assertStringEqualsFile(__DIR__ . '/_files/images/fish.wmf', $images['wmf'][1]); + self::assertStringEqualsFile(__DIR__ . '/_files/images/inkscape_shapes_emfplus.emf', $images['emf'][1]); + self::assertStringContainsString('', $contentTypesXml); + self::assertStringContainsString('', $contentTypesXml); + self::assertStringContainsString('media/' . $images['wmf'][0], $relationsXml); + self::assertStringContainsString('media/' . $images['emf'][0], $relationsXml); + self::assertStringNotContainsString('${WMF}', $mainPartXml); + self::assertStringNotContainsString('${EMF}', $mainPartXml); + } + + /** + * @covers ::setImageValue + */ + public function testSetImageValueInvalidImage(): void + { + $templateProcessor = $this->getImageTemplateProcessor(); + + $this->expectException(\PhpOffice\PhpWord\Exception\Exception::class); + $this->expectExceptionMessage('Invalid image: ' . __FILE__); + $templateProcessor->setImageValue('Image', __FILE__); + } + + /** + * @covers ::setImageValue + */ + public function testSetImageValueUnsupportedImage(): void + { + $templateProcessor = $this->getImageTemplateProcessor(); + + $this->expectException(\PhpOffice\PhpWord\Exception\Exception::class); + $this->expectExceptionMessage('Unsupported image type image/tiff'); + $templateProcessor->setImageValue('Image', __DIR__ . '/_files/images/angela_merkel.tif'); + } + + /** + * Template processor of a document containing the variable ${Image}. + */ + private function getImageTemplateProcessor(): TemplateProcessor + { + $phpWord = new PhpWord(); + $phpWord->addSection()->addText('${Image}'); + $testFileName = (string) tempnam(Settings::getTempDir(), 'PHPWordTemplate'); + IOFactory::createWriter($phpWord, 'Word2007')->save($testFileName); + $templateProcessor = new TemplateProcessor($testFileName); + unlink($testFileName); + + return $templateProcessor; + } + + /** + * @covers ::cloneBlock + * @covers ::deleteBlock + * @covers ::saveAs + */ + public function testCloneDeleteBlock(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/clone-delete-block.docx'); + + self::assertEquals( + ['DELETEME', '/DELETEME', 'CLONEME', 'blockVariable', '/CLONEME'], + $templateProcessor->getVariables() + ); + + $docName = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'clone-delete-block-result.docx'; + $templateProcessor->cloneBlock('CLONEME', 3); + $templateProcessor->deleteBlock('DELETEME'); + $templateProcessor->setValue('blockVariable#3', 'Test'); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + self::assertTrue($docFound); + } + + /** + * @covers ::getVariableCount + */ + public function testGetVariableCountCountsHowManyTimesEachPlaceholderIsPresent(): void + { + // create template with placeholders + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $header = $section->addHeader(); + $header->addText('${a_field_that_is_present_three_times}'); + $footer = $section->addFooter(); + $footer->addText('${a_field_that_is_present_twice}'); + $section2 = $phpWord->addSection(); + $section2->addText(' + ${a_field_that_is_present_one_time} + ${a_field_that_is_present_three_times} + ${a_field_that_is_present_twice} + ${a_field_that_is_present_three_times} + '); + $objWriter = IOFactory::createWriter($phpWord); + $templatePath = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'test.docx'; + $objWriter->save($templatePath); + + $templateProcessor = $this->getTemplateProcessor($templatePath); + $variableCount = $templateProcessor->getVariableCount(); + unlink($templatePath); + + self::assertEquals( + [ + 'a_field_that_is_present_three_times' => 3, + 'a_field_that_is_present_twice' => 2, + 'a_field_that_is_present_one_time' => 1, + ], + $variableCount + ); + } + + /** + * @covers ::getVariableCount + */ + public function testGetVariableCountCountsHowManyTimesEachPlaceholderIsPresentWithCustomMacro(): void + { + // create template with placeholders + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $header = $section->addHeader(); + $header->addText('{{a_field_that_is_present_three_times}}'); + $footer = $section->addFooter(); + $footer->addText('{{a_field_that_is_present_twice}}'); + $section2 = $phpWord->addSection(); + $section2->addText(' + {{a_field_that_is_present_one_time}} + {{a_field_that_is_present_three_times}} + {{a_field_that_is_present_twice}} + {{a_field_that_is_present_three_times}} + '); + $objWriter = IOFactory::createWriter($phpWord); + $templatePath = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'test.docx'; + $objWriter->save($templatePath); + + $templateProcessor = $this->getTemplateProcessor($templatePath); + $templateProcessor->setMacroChars('{{', '}}'); + $variableCount = $templateProcessor->getVariableCount(); + unlink($templatePath); + + self::assertEquals( + [ + 'a_field_that_is_present_three_times' => 3, + 'a_field_that_is_present_twice' => 2, + 'a_field_that_is_present_one_time' => 1, + ], + $variableCount + ); + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlockCanCloneABlockTwice(): void + { + // create template with placeholders and block + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $documentElements = [ + 'Title: ${title}', + '${subreport}', + '${subreport.id}: ${subreport.text}. ', + '${/subreport}', + ]; + foreach ($documentElements as $documentElement) { + $section->addText($documentElement); + } + + $objWriter = IOFactory::createWriter($phpWord); + $templatePath = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'test.docx'; + $objWriter->save($templatePath); + + // replace placeholders and save the file + $templateProcessor = $this->getTemplateProcessor($templatePath); + $templateProcessor->setValue('title', 'Some title'); + $templateProcessor->cloneBlock('subreport', 2); + $templateProcessor->setValue('subreport.id', '123', 1); + $templateProcessor->setValue('subreport.text', 'Some text', 1); + $templateProcessor->setValue('subreport.id', '456', 1); + $templateProcessor->setValue('subreport.text', 'Some other text', 1); + $templateProcessor->saveAs($templatePath); + + // assert the block has been cloned twice + // and the placeholders have been replaced correctly + $phpWord = IOFactory::load($templatePath); + $sections = $phpWord->getSections(); + /** @var TextRun[] $actualElements */ + $actualElements = $sections[0]->getElements(); + unlink($templatePath); + $expectedElements = [ + 'Title: Some title', + '123: Some text. ', + '456: Some other text. ', + ]; + self::assertCount(count($expectedElements), $actualElements); + foreach ($expectedElements as $i => $expectedElement) { + self::assertEquals( + $expectedElement, + $actualElements[$i]->getElement(0)->getText() + ); + } + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlockCanCloneABlockTwiceWithCustomMacro(): void + { + // create template with placeholders and block + $phpWord = new PhpWord(); + $section = $phpWord->addSection(); + $documentElements = [ + 'Title: {{title}}', + '{{subreport}}', + '{{subreport.id}}: {{subreport.text}}. ', + '{{/subreport}}', + ]; + foreach ($documentElements as $documentElement) { + $section->addText($documentElement); + } + + $objWriter = IOFactory::createWriter($phpWord); + $templatePath = PHPWORD_TEST_TEMP_DIR . DIRECTORY_SEPARATOR . 'test.docx'; + $objWriter->save($templatePath); + + // replace placeholders and save the file + $templateProcessor = $this->getTemplateProcessor($templatePath); + $templateProcessor->setMacroChars('{{', '}}'); + $templateProcessor->setValue('title', 'Some title'); + $templateProcessor->cloneBlock('subreport', 2); + $templateProcessor->setValue('subreport.id', '123', 1); + $templateProcessor->setValue('subreport.text', 'Some text', 1); + $templateProcessor->setValue('subreport.id', '456', 1); + $templateProcessor->setValue('subreport.text', 'Some other text', 1); + $templateProcessor->saveAs($templatePath); + + // assert the block has been cloned twice + // and the placeholders have been replaced correctly + $phpWord = IOFactory::load($templatePath); + $sections = $phpWord->getSections(); + /** @var TextRun[] $actualElements */ + $actualElements = $sections[0]->getElements(); + + unlink($templatePath); + $expectedElements = [ + 'Title: Some title', + '123: Some text. ', + '456: Some other text. ', + ]; + self::assertCount(count($expectedElements), $actualElements); + foreach ($expectedElements as $i => $expectedElement) { + self::assertEquals( + $expectedElement, + $actualElements[$i]->getElement(0)->getText() + ); + } + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlock(): void + { + $mainPart = ' + + + + ${CLONEME} + + + + + This block will be cloned with ${variable} + + + + + ${/CLONEME} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->cloneBlock('CLONEME', 3); + + self::assertEquals(3, substr_count($templateProcessor->getMainPart(), 'This block will be cloned with ${variable}')); + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlockWithCustomMacro(): void + { + $mainPart = ' + + + + {{CLONEME}} + + + + + This block will be cloned with {{variable}} + + + + + {{/CLONEME}} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + $templateProcessor->cloneBlock('CLONEME', 3); + + self::assertEquals(3, substr_count($templateProcessor->getMainPart(), 'This block will be cloned with {{variable}}')); + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlockWithVariables(): void + { + $mainPart = ' + + + + ${CLONEME} + + + + + Address ${address}, Street ${street} + + + + + ${/CLONEME} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->cloneBlock('CLONEME', 3, true, true); + + self::assertStringContainsString('Address ${address#1}, Street ${street#1}', $templateProcessor->getMainPart()); + self::assertStringContainsString('Address ${address#2}, Street ${street#2}', $templateProcessor->getMainPart()); + self::assertStringContainsString('Address ${address#3}, Street ${street#3}', $templateProcessor->getMainPart()); + } + + /** + * @covers ::cloneBlock + */ + public function testCloneBlockWithVariablesAndCustomMacro(): void + { + $mainPart = ' + + + + {{CLONEME}} + + + + + Address {{address}}, Street {{street}} + + + + + {{/CLONEME}} + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + $templateProcessor->cloneBlock('CLONEME', 3, true, true); + + self::assertStringContainsString('Address {{address#1}}, Street {{street#1}}', $templateProcessor->getMainPart()); + self::assertStringContainsString('Address {{address#2}}, Street {{street#2}}', $templateProcessor->getMainPart()); + self::assertStringContainsString('Address {{address#3}}, Street {{street#3}}', $templateProcessor->getMainPart()); + } + + public function testCloneBlockWithVariableReplacements(): void + { + $mainPart = ' + + + + ${CLONEME} + + + + + City: ${city}, Street: ${street} + + + + + ${/CLONEME} + + '; + + $replacements = [ + ['city' => 'London', 'street' => 'Baker Street'], + ['city' => 'New York', 'street' => '5th Avenue'], + ['city' => 'Rome', 'street' => 'Via della Conciliazione'], + ]; + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->cloneBlock('CLONEME', 0, true, false, $replacements); + + self::assertStringContainsString('City: London, Street: Baker Street', $templateProcessor->getMainPart()); + self::assertStringContainsString('City: New York, Street: 5th Avenue', $templateProcessor->getMainPart()); + self::assertStringContainsString('City: Rome, Street: Via della Conciliazione', $templateProcessor->getMainPart()); + } + + public function testCloneBlockWithVariableReplacementsAndCustomMacro(): void + { + $mainPart = ' + + + + {{CLONEME}} + + + + + City: {{city}}, Street: {{street}} + + + + + {{/CLONEME}} + + '; + + $replacements = [ + ['city' => 'London', 'street' => 'Baker Street'], + ['city' => 'New York', 'street' => '5th Avenue'], + ['city' => 'Rome', 'street' => 'Via della Conciliazione'], + ]; + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + $templateProcessor->cloneBlock('CLONEME', 0, true, false, $replacements); + + self::assertStringContainsString('City: London, Street: Baker Street', $templateProcessor->getMainPart()); + self::assertStringContainsString('City: New York, Street: 5th Avenue', $templateProcessor->getMainPart()); + self::assertStringContainsString('City: Rome, Street: Via della Conciliazione', $templateProcessor->getMainPart()); + } + + /** + * Template macros can be fixed. + * + * @covers ::fixBrokenMacros + */ + public function testFixBrokenMacros(): void + { + $templateProcessor = new TestableTemplateProcesor(); + + $fixed = $templateProcessor->fixBrokenMacros('normal text'); + self::assertEquals('normal text', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('${documentContent}'); + self::assertEquals('${documentContent}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('${documentContent}'); + self::assertEquals('${documentContent}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$1500${documentContent}'); + self::assertEquals('$1500${documentContent}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$1500${documentContent}'); + self::assertEquals('$1500${documentContent}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('25$ plus some info {hint}'); + self::assertEquals('25$ plus some info {hint}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$15,000.00. ${variable_name}'); + self::assertEquals('$15,000.00. ${variable_name}', $fixed); + } + + /** + * Template macros can be fixed even with cutome macro. + * + * @covers ::fixBrokenMacros + */ + public function testFixBrokenMacrosWithCustomMacro(): void + { + $templateProcessor = new TestableTemplateProcesor(); + $templateProcessor->setMacroChars('{{', '}}'); + + $fixed = $templateProcessor->fixBrokenMacros('normal text'); + self::assertEquals('normal text', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('{{documentContent}}'); + self::assertEquals('{{documentContent}}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('{{documentContent}}'); + self::assertEquals('{{documentContent}}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$1500{{documentContent}}'); + self::assertEquals('$1500{{documentContent}}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$1500{{documentContent}}'); + self::assertEquals('$1500{{documentContent}}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('25$ plus some info {hint}'); + self::assertEquals('25$ plus some info {hint}', $fixed); + + $fixed = $templateProcessor->fixBrokenMacros('$15,000.00. {{variable_name}}'); + self::assertEquals('$15,000.00. {{variable_name}}', $fixed); + } + + /** + * @covers ::getMainPartName + */ + public function testMainPartNameDetection(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/document22-xml.docx'); + + $variables = ['test']; + + self::assertEquals($variables, $templateProcessor->getVariables()); + } + + /** + * @covers ::getMainPartName + */ + public function testMainPartNameDetectionWithCustomMacro(): void + { + $templateProcessor = $this->getTemplateProcessor(__DIR__ . '/_files/templates/document22-with-custom-macro-xml.docx'); + $templateProcessor->setMacroOpeningChars('{#'); + $templateProcessor->setMacroClosingChars('#}'); + $variables = ['test']; + + self::assertEquals($variables, $templateProcessor->getVariables()); + } + + /** + * @covers ::getVariables + */ + public function testGetVariables(): void + { + $templateProcessor = new TestableTemplateProcesor(); + + $variables = $templateProcessor->getVariablesForPart('normal text'); + self::assertEquals([], $variables); + + $variables = $templateProcessor->getVariablesForPart('${documentContent}'); + self::assertEquals(['documentContent'], $variables); + + $variables = $templateProcessor->getVariablesForPart('$15,000.00. ${variable_name}'); + self::assertEquals(['variable_name'], $variables); + } + + /** + * @covers ::getVariables + */ + public function testGetVariablesWithCustomMacro(): void + { + $templateProcessor = new TestableTemplateProcesor(); + $templateProcessor->setMacroOpeningChars('{{'); + $templateProcessor->setMacroClosingChars('}}'); + + $variables = $templateProcessor->getVariablesForPart('normal text'); + self::assertEquals([], $variables); + + $variables = $templateProcessor->getVariablesForPart('{{documentContent}}'); + self::assertEquals(['documentContent'], $variables); + + $variables = $templateProcessor->getVariablesForPart('{15,000.00. {{variable_name}}'); + self::assertEquals(['variable_name'], $variables); + } + + /** + * @covers ::textNeedsSplitting + */ + public function testTextNeedsSplitting(): void + { + $templateProcessor = new TestableTemplateProcesor(); + + self::assertFalse($templateProcessor->textNeedsSplitting('${nothing-to-replace}')); + + $text = 'Hello ${firstname} ${lastname}'; + self::assertTrue($templateProcessor->textNeedsSplitting($text)); + $splitText = $templateProcessor->splitTextIntoTexts($text); + self::assertFalse($templateProcessor->textNeedsSplitting($splitText)); + } + + /** + * @covers ::textNeedsSplitting + */ + public function testTextNeedsSplittingWithCustomMacro(): void + { + $templateProcessor = new TestableTemplateProcesor(); + $templateProcessor->setMacroChars('{{', '}}'); + + self::assertFalse($templateProcessor->textNeedsSplitting('{{nothing-to-replace}}')); + + $text = 'Hello {{firstname}} {{lastname}}'; + self::assertTrue($templateProcessor->textNeedsSplitting($text)); + $splitText = $templateProcessor->splitTextIntoTexts($text); + self::assertFalse($templateProcessor->textNeedsSplitting($splitText)); + } + + /** + * @covers ::splitTextIntoTexts + */ + public function testSplitTextIntoTexts(): void + { + $templateProcessor = new TestableTemplateProcesor(); + + $splitText = $templateProcessor->splitTextIntoTexts('${nothing-to-replace}'); + self::assertEquals('${nothing-to-replace}', $splitText); + + $splitText = $templateProcessor->splitTextIntoTexts('Hello ${firstname} ${lastname}'); + self::assertEquals('Hello ${firstname} ${lastname}', $splitText); + } + + /** + * @covers ::splitTextIntoTexts + */ + public function testSplitTextIntoTextsWithCustomMacro(): void + { + $templateProcessor = new TestableTemplateProcesor(); + $templateProcessor->setMacroChars('{{', '}}'); + + $splitText = $templateProcessor->splitTextIntoTexts('{{nothing-to-replace}}'); + self::assertEquals('{{nothing-to-replace}}', $splitText); + + $splitText = $templateProcessor->splitTextIntoTexts('Hello {{firstname}} {{lastname}}'); + self::assertEquals('Hello {{firstname}} {{lastname}}', $splitText); + } + + public function testFindXmlBlockStart(): void + { + $toFind = ' + + + + + This whole paragraph will be replaced with my ${title} + '; + $mainPart = ' + + + + + + + ${value1} ${value2} + + + + + + + . + + + + + + + + + + ' . $toFind . ' + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $position = $templateProcessor->findContainingXmlBlockForMacro('${title}', 'w:r'); + + self::assertEquals($toFind, $templateProcessor->getSlice($position['start'], $position['end'])); + } + + public function testFindXmlBlockStartWithCustomMacro(): void + { + $toFind = ' + + + + + This whole paragraph will be replaced with my {{title}} + '; + $mainPart = ' + + + + + + + {{value1}} {{value2}} + + + + + + + . + + + + + + + + + + ' . $toFind . ' + + '; + + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + $position = $templateProcessor->findContainingXmlBlockForMacro('{{title}}', 'w:r'); + + self::assertEquals($toFind, $templateProcessor->getSlice($position['start'], $position['end'])); + } + + public function testShouldReturnFalseIfXmlBlockNotFound(): void + { + $mainPart = ' + + + + + + this is my text containing a ${macro} + + + '; + $templateProcessor = new TestableTemplateProcesor($mainPart); + + //non-existing macro + $result = $templateProcessor->findContainingXmlBlockForMacro('${fake-macro}', 'w:p'); + self::assertFalse($result); + + //existing macro but not inside node looked for + $result = $templateProcessor->findContainingXmlBlockForMacro('${macro}', 'w:fake-node'); + self::assertFalse($result); + + //existing macro but end tag not found after macro + $result = $templateProcessor->findContainingXmlBlockForMacro('${macro}', 'w:rPr'); + self::assertFalse($result); + } + + public function testShouldReturnFalseIfXmlBlockNotFoundWithCustomMacro(): void + { + $mainPart = ' + + + + + + this is my text containing a ${macro} + + + '; + $templateProcessor = new TestableTemplateProcesor($mainPart); + $templateProcessor->setMacroChars('{{', '}}'); + + //non-existing macro + $result = $templateProcessor->findContainingXmlBlockForMacro('{{fake-macro}}', 'w:p'); + self::assertFalse($result); + + //existing macro but not inside node looked for + $result = $templateProcessor->findContainingXmlBlockForMacro('{{macro}}', 'w:fake-node'); + self::assertFalse($result); + + //existing macro but end tag not found after macro + $result = $templateProcessor->findContainingXmlBlockForMacro('{{macro}}', 'w:rPr'); + self::assertFalse($result); + } + + public function testShouldMakeFieldsUpdateOnOpen(): void + { + $settingsPart = ' + + '; + $templateProcessor = new TestableTemplateProcesor(null, $settingsPart); + + $templateProcessor->setUpdateFields(true); + self::assertStringContainsString('', $templateProcessor->getSettingsPart()); + + $templateProcessor->setUpdateFields(false); + self::assertStringContainsString('', $templateProcessor->getSettingsPart()); + } + + public function testShouldMakeFieldsUpdateOnOpenWithCustomMacro(): void + { + $settingsPart = ' + + '; + $templateProcessor = new TestableTemplateProcesor(null, $settingsPart); + $templateProcessor->setMacroChars('{{', '}}'); + + $templateProcessor->setUpdateFields(true); + self::assertStringContainsString('', $templateProcessor->getSettingsPart()); + + $templateProcessor->setUpdateFields(false); + self::assertStringContainsString('', $templateProcessor->getSettingsPart()); + } + + public function testEnsureUtf8Encoded(): void + { + $mainPart = ' + + + + + + + + ${stringZero} + + + + + + + ${intZero} + + + + + + + ${stringTest} + + + + + + + ${null} + + + + + + + ${floatZero} + + + + + + + ${intTen} + + + + + + + ${boolFalse} + + + + + + + ${boolTrue} + + + + + '; + $templateProcessor = new TestableTemplateProcesor($mainPart); + + self::assertEquals( + ['stringZero', 'intZero', 'stringTest', 'null', 'floatZero', 'intTen', 'boolFalse', 'boolTrue'], + $templateProcessor->getVariables() + ); + + $templateProcessor->setValue('stringZero', '0'); + self::assertStringContainsString('0', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('intZero', 0); + self::assertStringContainsString('0', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('stringTest', 'test'); + self::assertStringContainsString('test', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('null', null); + self::assertStringContainsString('', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('floatZero', 0.00); + self::assertStringContainsString('0', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('intTen', 10); + self::assertStringContainsString('10', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('boolFalse', false); + self::assertStringContainsString('', $templateProcessor->getMainPart()); + + $templateProcessor->setValue('boolTrue', true); + self::assertStringContainsString('1', $templateProcessor->getMainPart()); + } +}