From d5da80b56e3d2defe0fc0b2c83f3ded4c6c5f16c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 26 Dec 2018 15:35:21 +0200 Subject: [PATCH] Support adding images in Templates (#1170) * setImageValue() + fix adding files via ZipArchive * fix phpdoc variable name * Changed logic that determines extension image file extension for document to depend on MIME type. This same logic is used in Element/Image.php * support tags with arguments * allow setup size of image into template variable * support of 'ratio' replace attribute + documentation --- .travis.yml | 2 - CHANGELOG.md | 1 + docs/templates-processing.rst | 18 + src/PhpWord/Shared/ZipArchive.php | 10 +- src/PhpWord/TemplateProcessor.php | 367 +++++++++++++++++- tests/PhpWord/TemplateProcessorTest.php | 79 +++- .../_files/templates/header-footer.docx | Bin 4495 -> 15730 bytes 7 files changed, 462 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 881decfe..1d32cfda 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,8 +24,6 @@ matrix: - php: 5.3 - php: 7.0 - php: 7.3 - allow_failures: - - php: 7.3 cache: directories: diff --git a/CHANGELOG.md b/CHANGELOG.md index 220d2eeb..ae41bd4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ v0.16.0 (xx dec 2018) - Add setting Chart Title and Legend visibility @Tom-Magill #1433 - Add ability to pass a Style object in Section constructor @ndench #1416 - Add support for hidden text @Alexmg86 #1527 +- Add support for setting images in TemplateProcessor @SailorMax #1170 ### Fixed - Fix regex in `cloneBlock` function @nicoder #1269 diff --git a/docs/templates-processing.rst b/docs/templates-processing.rst index af03b245..0cc5683a 100644 --- a/docs/templates-processing.rst +++ b/docs/templates-processing.rst @@ -7,14 +7,32 @@ You can create an OOXML document template with included search-patterns (macros) To deal with a template file, use ``new TemplateProcessor`` statement. After TemplateProcessor instance creation the document template is copied into the temporary directory. Then you can use ``TemplateProcessor::setValue`` method to change the value of a search pattern. The search-pattern model is: ``${search-pattern}``. +The search-pattern model for images can be like: +- ``${search-image-pattern}`` +- ``${search-image-pattern:[width]:[height]:[ratio]}`` +- ``${search-image-pattern:[width]x[height]}`` +- ``${search-image-pattern:size=[width]x[height]}`` +- ``${search-image-pattern:width=[width]:height=[height]:ratio=false}`` +Where: +- [width] and [height] can be just numbers or numbers with measure, which supported by Word (cm|mm|in|pt|pc|px|%|em|ex) +- [ratio] uses only for ``false``, ``-`` or ``f`` to turn off respect aspect ration of image. By default template image size uses as 'container' size. + Example: +.. code-block:: doc + + ${CompanyLogo} + ${UserLogo:50:50} ${Name} - ${City} - ${Street} + .. code-block:: php $templateProcessor = new TemplateProcessor('Template.docx'); $templateProcessor->setValue('Name', 'John Doe'); $templateProcessor->setValue(array('City', 'Street'), array('Detroit', '12th Street')); + $templateProcessor->setImageValue('CompanyLogo', 'path/to/company/logo.png'); + $templateProcessor->setImageValue('UserLogo', array('path' => 'path/to/logo.png', 'width' => 100, 'height' => 100, 'ratio' => false)); + It is not possible to directly add new OOXML elements to the template file being processed, but it is possible to transform headers, main document part, and footers of the template using XSLT (see ``TemplateProcessor::applyXslStyleSheet``). See ``Sample_07_TemplateCloneRow.php`` for example on how to create diff --git a/src/PhpWord/Shared/ZipArchive.php b/src/PhpWord/Shared/ZipArchive.php index 2783e17e..a9419165 100644 --- a/src/PhpWord/Shared/ZipArchive.php +++ b/src/PhpWord/Shared/ZipArchive.php @@ -129,6 +129,7 @@ class ZipArchive { $result = true; $this->filename = $filename; + $this->tempDir = Settings::getTempDir(); if (!$this->usePclzip) { $zip = new \ZipArchive(); @@ -139,7 +140,6 @@ class ZipArchive $this->numFiles = $zip->numFiles; } else { $zip = new \PclZip($this->filename); - $this->tempDir = Settings::getTempDir(); $zipContent = $zip->listContent(); $this->numFiles = is_array($zipContent) ? count($zipContent) : 0; } @@ -245,7 +245,13 @@ class ZipArchive $pathRemoved = $filenameParts['dirname']; $pathAdded = $localnameParts['dirname']; - $res = $zip->add($filename, PCLZIP_OPT_REMOVE_PATH, $pathRemoved, PCLZIP_OPT_ADD_PATH, $pathAdded); + if (!$this->usePclzip) { + $pathAdded = $pathAdded . '/' . ltrim(str_replace('\\', '/', substr($filename, strlen($pathRemoved))), '/'); + //$res = $zip->addFile($filename, $pathAdded); + $res = $zip->addFromString($pathAdded, file_get_contents($filename)); // addFile can't use subfolders in some cases + } else { + $res = $zip->add($filename, PCLZIP_OPT_REMOVE_PATH, $pathRemoved, PCLZIP_OPT_ADD_PATH, $pathAdded); + } if ($tempFile) { // Remove temp file, if created diff --git a/src/PhpWord/TemplateProcessor.php b/src/PhpWord/TemplateProcessor.php index 7996b7f8..4ec9e2cc 100644 --- a/src/PhpWord/TemplateProcessor.php +++ b/src/PhpWord/TemplateProcessor.php @@ -62,6 +62,27 @@ class TemplateProcessor */ protected $tempDocumentFooters = array(); + /** + * Document relations (in XML format) of the temporary document. + * + * @var string[] + */ + protected $tempDocumentRelations = array(); + + /** + * Document content types (in XML format) of the temporary document. + * + * @var string + */ + protected $tempDocumentContentTypes = ''; + + /** + * new inserted images list + * + * @var string[] + */ + protected $tempDocumentNewImages = array(); + /** * @since 0.12.0 Throws CreateTemporaryFileException and CopyFileException instead of Exception * @@ -88,19 +109,33 @@ class TemplateProcessor $this->zipClass->open($this->tempDocumentFilename); $index = 1; while (false !== $this->zipClass->locateName($this->getHeaderName($index))) { - $this->tempDocumentHeaders[$index] = $this->fixBrokenMacros( - $this->zipClass->getFromName($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->fixBrokenMacros( - $this->zipClass->getFromName($this->getFooterName($index)) - ); + $this->tempDocumentFooters[$index] = $this->readPartWithRels($this->getFooterName($index)); $index++; } - $this->tempDocumentMainPart = $this->fixBrokenMacros($this->zipClass->getFromName($this->getMainPartName())); + + $this->tempDocumentMainPart = $this->readPartWithRels($this->getMainPartName()); + $this->tempDocumentContentTypes = $this->zipClass->getFromName($this->getDocumentContentTypesName()); + } + + /** + * @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)); } /** @@ -233,6 +268,274 @@ class TemplateProcessor $this->tempDocumentFooters = $this->setValueForPart($search, $replace, $this->tempDocumentFooters, $limit); } + private function getImageArgs($varNameWithArgs) + { + $varElements = explode(':', $varNameWithArgs); + array_shift($varElements); // first element is name of variable => remove it + + $varInlineArgs = array(); + // 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 + list($argName, $argValue) = explode('=', $varArg, 2); + $argName = strtolower($argName); + if ($argName == 'size') { + list($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 + list($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 (is_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 (is_null($value)) { + $value = $defaultValue; + } + if (is_numeric($value)) { + $value .= 'px'; + } + + return $value; + } + + private function fixImageWidthHeightRatio(&$width, &$height, $actualWidth, $actualHeight) + { + $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 = array(); + preg_match("/\d([a-z%]+)$/", $height, $matches); + $width = $widthFloat . $matches[1]; + } elseif ($height === '') { // defined height is empty + $widthFloat = (float) $width; + $heightFloat = $widthFloat / $imageRatio; + $matches = array(); + preg_match("/\d([a-z%]+)$/", $width, $matches); + $height = $heightFloat . $matches[1]; + } else { // we have defined size, but we need also check it aspect ratio + $widthMatches = array(); + preg_match("/\d([a-z%]+)$/", $width, $widthMatches); + $heightMatches = array(); + preg_match("/\d([a-z%]+)$/", $height, $heightMatches); + // try to fix only if dimensions are same + if ($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; + 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, isset($varInlineArgs['width']) ? $varInlineArgs['width'] : null, 115); + $height = $this->chooseImageDimension($height, isset($varInlineArgs['height']) ? $varInlineArgs['height'] : null, 70); + + $imageData = @getimagesize($imgPath); + if (!is_array($imageData)) { + throw new Exception(sprintf('Invalid image: %s', $imgPath)); + } + list($actualWidth, $actualHeight, $imageType) = $imageData; + + // fix aspect ratio (by default) + if (is_null($ratio) && isset($varInlineArgs['ratio'])) { + $ratio = $varInlineArgs['ratio']; + } + if (is_null($ratio) || !in_array(strtolower($ratio), array('', '-', 'f', 'false'))) { + $this->fixImageWidthHeightRatio($width, $height, $actualWidth, $actualHeight); + } + + $imageAttrs = array( + 'src' => $imgPath, + 'mime' => image_type_to_mime_type($imageType), + 'width' => $width, + 'height' => $height, + ); + + return $imageAttrs; + } + + private function addImageToRelations($partFileName, $rid, $imgPath, $imageMimeType) + { + // define templates + $typeTpl = ''; + $relationTpl = ''; + $newRelationsTpl = '' . "\n" . ''; + $newRelationsTypeTpl = ''; + $extTransform = array( + 'image/jpeg' => 'jpeg', + 'image/png' => 'png', + 'image/bmp' => 'bmp', + 'image/gif' => 'gif', + ); + + // 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(array('{IMG}', '{EXT}'), array($imgName, $imgExt), $typeTpl); + $this->tempDocumentContentTypes = str_replace('', $xmlImageType, $this->tempDocumentContentTypes) . ''; + } + + $xmlImageRelation = str_replace(array('{RID}', '{IMG}'), array($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) + { + // prepare $search_replace + if (!is_array($search)) { + $search = array($search); + } + + $replacesList = array(); + if (!is_array($replace) || isset($replace['path'])) { + $replacesList[] = $replace; + } else { + $replacesList = array_values($replace); + } + + $searchReplace = array(); + foreach ($search as $searchIdx => $searchString) { + $searchReplace[$searchString] = isset($replacesList[$searchIdx]) ? $replacesList[$searchIdx] : $replacesList[0]; + } + + // collect document parts + $searchParts = array( + $this->getMainPartName() => &$this->tempDocumentMainPart, + ); + foreach (array_keys($this->tempDocumentHeaders) as $headerIndex) { + $searchParts[$this->getHeaderName($headerIndex)] = &$this->tempDocumentHeaders[$headerIndex]; + } + foreach (array_keys($this->tempDocumentFooters) as $headerIndex) { + $searchParts[$this->getFooterName($headerIndex)] = &$this->tempDocumentFooters[$headerIndex]; + } + + // 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 = ''; + + 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(array('{RID}', '{WIDTH}', '{HEIGHT}'), array($rid, $preparedImageAttrs['width'], $preparedImageAttrs['height']), $imgTpl); + + // replace variable + $varNameWithArgsFixed = self::ensureMacroCompleted($varNameWithArgs); + $matches = array(); + if (preg_match('/(<[^<]+>)([^<]*)(' . preg_quote($varNameWithArgsFixed) . ')([^>]*)(<[^>]+>)/Uu', $partContent, $matches)) { + $wholeTag = $matches[0]; + array_shift($matches); + list($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); + } + } + } + } + } + /** * Returns count of all variables in template. * @@ -406,15 +709,17 @@ class TemplateProcessor public function save() { foreach ($this->tempDocumentHeaders as $index => $xml) { - $this->zipClass->addFromString($this->getHeaderName($index), $xml); + $this->savePartWithRels($this->getHeaderName($index), $xml); } - $this->zipClass->addFromString($this->getMainPartName(), $this->tempDocumentMainPart); + $this->savePartWithRels($this->getMainPartName(), $this->tempDocumentMainPart); foreach ($this->tempDocumentFooters as $index => $xml) { - $this->zipClass->addFromString($this->getFooterName($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.'); @@ -423,6 +728,19 @@ class TemplateProcessor return $this->tempDocumentFilename; } + /** + * @param string $fileName + * @param string $xml + */ + protected function savePartWithRels($fileName, $xml) + { + $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. * @@ -542,6 +860,35 @@ class TemplateProcessor 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])) { + return substr_count($this->tempDocumentRelations[$documentPartName], 'assertEquals(array('documentContent', 'headerValue', 'footerValue'), $templateProcessor->getVariables()); + $this->assertEquals(array('documentContent', 'headerValue:100:100', 'footerValue'), $templateProcessor->getVariables()); $macroNames = array('headerValue', 'documentContent', 'footerValue'); $macroValues = array('Header Value', 'Document text.', 'Footer Value'); @@ -200,6 +200,83 @@ final class TemplateProcessorTest extends \PHPUnit\Framework\TestCase $this->assertTrue($docFound); } + /** + * @covers ::setImageValue + * @test + */ + public function testSetImageValue() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/header-footer.docx'); + $imagePath = __DIR__ . '/_files/images/earth.jpg'; + + $variablesReplace = array( + 'headerValue' => $imagePath, + 'documentContent' => array('path' => $imagePath, 'width' => 500, 'height' => 500), + 'footerValue' => array('path' => $imagePath, 'width' => 100, 'height' => 50, 'ratio' => false), + ); + $templateProcessor->setImageValue(array_keys($variablesReplace), $variablesReplace); + + $docName = 'header-footer-images-test-result.docx'; + $templateProcessor->saveAs($docName); + + $this->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}\"."); + } + + $this->assertNotEmpty($expectedImage, 'Embed image doesn\'t found.'); + $this->assertContains('/word/media/image_rId11_document.jpeg', $expectedContentTypesXml, '[Content_Types].xml missed "/word/media/image5_document.jpeg"'); + $this->assertContains('/word/_rels/header1.xml.rels', $expectedContentTypesXml, '[Content_Types].xml missed "/word/_rels/header1.xml.rels"'); + $this->assertContains('/word/_rels/footer1.xml.rels', $expectedContentTypesXml, '[Content_Types].xml missed "/word/_rels/footer1.xml.rels"'); + $this->assertNotContains('${documentContent}', $expectedMainPartXml, 'word/document.xml has no image.'); + $this->assertNotContains('${headerValue}', $expectedHeaderPartXml, 'word/header1.xml has no image.'); + $this->assertNotContains('${footerValue}', $expectedFooterPartXml, 'word/footer1.xml has no image.'); + $this->assertContains('media/image_rId11_document.jpeg', $expectedDocumentRelationsXml, 'word/_rels/document.xml.rels missed "media/image5_document.jpeg"'); + $this->assertContains('media/image_rId11_document.jpeg', $expectedHeaderRelationsXml, 'word/_rels/header1.xml.rels missed "media/image5_document.jpeg"'); + $this->assertContains('media/image_rId11_document.jpeg', $expectedFooterRelationsXml, 'word/_rels/footer1.xml.rels missed "media/image5_document.jpeg"'); + + unlink($docName); + + // dynamic generated doc + $testFileName = 'images-test-sample.docx'; + $phpWord = new \PhpOffice\PhpWord\PhpWord(); + $section = $phpWord->addSection(); + $section->addText('${Test:width=100:ratio=true}'); + $objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007'); + $objWriter->save($testFileName); + $this->assertFileExists($testFileName, "Generated file '{$testFileName}' not found!"); + + $resultFileName = 'images-test-result.docx'; + $templateProcessor = new \PhpOffice\PhpWord\TemplateProcessor($testFileName); + unlink($testFileName); + $templateProcessor->setImageValue('Test', $imagePath); + $templateProcessor->setImageValue('Test1', $imagePath); + $templateProcessor->setImageValue('Test2', $imagePath); + $templateProcessor->saveAs($resultFileName); + $this->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); + + $this->assertNotContains('${Test}', $expectedMainPartXml, 'word/document.xml has no image.'); + } + /** * @covers ::cloneBlock * @covers ::deleteBlock diff --git a/tests/PhpWord/_files/templates/header-footer.docx b/tests/PhpWord/_files/templates/header-footer.docx index 647d52222d612ef33a0113e8ea3ad84ad3c96085..dc2e9e8beaabaefe8ddc10384cdd747ce335cc10 100644 GIT binary patch literal 15730 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 L)>RFzpP&90bK-2! literal 4495 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