diff --git a/src/PhpWord/TemplateProcessor.php b/src/PhpWord/TemplateProcessor.php index c46038ee9f..97057f405c 100644 --- a/src/PhpWord/TemplateProcessor.php +++ b/src/PhpWord/TemplateProcessor.php @@ -29,6 +29,18 @@ class TemplateProcessor { const MAXIMUM_REPLACEMENTS_DEFAULT = -1; + const SEARCH_LEFT = -1; + const SEARCH_RIGHT = 1; + const SEARCH_AROUND = 0; + + /** + * Enable/disable setValue('key') becoming setValue('${key}') automatically. + * Call it like: TemplateProcessor::$ensureMacroCompletion = false; + * + * @var bool + */ + public static $ensureMacroCompletion = true; + /** * ZipArchive object. * @@ -138,11 +150,12 @@ protected function transformXml($xml, $xsltProcessor) foreach ($xml as &$item) { $item = $this->transformSingleXml($item, $xsltProcessor); } - } else { - $xml = $this->transformSingleXml($xml, $xsltProcessor); + + return (array) $xml; } + $xml = $this->transformSingleXml($xml, $xsltProcessor); - return $xml; + return (string) $xml; } /** @@ -166,20 +179,21 @@ public function applyXslStyleSheet($xslDomDocument, $xslOptions = array(), $xslO 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); + $this->tempDocumentHeaders = (array) $this->transformXml($this->tempDocumentHeaders, $xsltProcessor); + $this->tempDocumentMainPart = (string) $this->transformXml($this->tempDocumentMainPart, $xsltProcessor); + $this->tempDocumentFooters = (array) $this->transformXml($this->tempDocumentFooters, $xsltProcessor); } /** - * @param string $macro + * @param string $macro If written as VALUE it will return ${VALUE} if static::$ensureMacroCompletion + * @param bool $closing False by default, if set to true, will add ${/ } around the macro * * @return string */ - protected static function ensureMacroCompleted($macro) + protected static function ensureMacroCompleted($macro, $closing = false) { - if (substr($macro, 0, 2) !== '${' && substr($macro, -1) !== '}') { - $macro = '${' . $macro . '}'; + if (static::$ensureMacroCompletion && substr($macro, 0, 2) !== '${' && substr($macro, -1) !== '}') { + $macro = '${' . ($closing ? '/' : '') . $macro . '}'; } return $macro; @@ -200,26 +214,28 @@ protected static function ensureUtf8Encoded($subject) } /** - * @param mixed $search - * @param mixed $replace - * @param int $limit + * @param mixed $search macro name you want to replace (or an array of these) + * @param mixed $replace replace string (or an array of these) + * @param int $limit How many times it will have to replace the same variable all over the document */ public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT) { if (is_array($search)) { foreach ($search as &$item) { - $item = self::ensureMacroCompleted($item); + $item = static::ensureMacroCompleted($item); } + unset($item); } else { - $search = self::ensureMacroCompleted($search); + $search = static::ensureMacroCompleted($search); } if (is_array($replace)) { foreach ($replace as &$item) { - $item = self::ensureUtf8Encoded($item); + $item = static::ensureUtf8Encoded($item); } + unset($item); } else { - $replace = self::ensureUtf8Encoded($replace); + $replace = static::ensureUtf8Encoded($replace); } if (Settings::isOutputEscapingEnabled()) { @@ -227,9 +243,78 @@ public function setValue($search, $replace, $limit = self::MAXIMUM_REPLACEMENTS_ $replace = $xmlEscaper->escape($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); + $this->tempDocumentHeaders = (array) $this->setValueForPart( + $search, + $replace, + (array) $this->tempDocumentHeaders, + $limit + ); + $this->tempDocumentMainPart = (string) $this->setValueForPart( + $search, + $replace, + (string) $this->tempDocumentMainPart, + $limit + ); + $this->tempDocumentFooters = (array) $this->setValueForPart( + $search, + $replace, + (array) $this->tempDocumentFooters, + $limit + ); + } + + /** + * Replaces a closed block with text + * + * @param string $blockname The blockname without '${}'. Your macro must end with slash, i.e.: ${value/} + * @param mixed $replace Array or the text can be multiline (contain \n); It will then cloneBlock() + * @param int $limit + */ + public function setBlock($blockname, $replace, $limit = self::MAXIMUM_REPLACEMENTS_DEFAULT) + { + if (is_string($replace) && preg_match('~\R~u', $replace)) { + $replace = preg_split('~\R~u', $replace); + } + if (is_array($replace)) { + $this->processBlock($blockname, count($replace), true, false); + foreach ($replace as $oneVal) { + $this->setValue($blockname, $oneVal, 1); + } + } else { + $this->setValue($blockname, $replace, $limit); + } + } + + /** + * Expose zip class + * + * To replace an image: $templateProcessor->zip()->AddFromString("word/media/image1.jpg", file_get_contents($file)); + * (note that to add an image you also need to add some xml in the document, and a relation from Id to zip-filename) + * To read a file: $templateProcessor->zip()->getFromName("word/media/image1.jpg"); + * + * @return object + */ + public function zip() + { + return $this->zipClass; + } + + /** + * If $throwException is true, it throws an exception, else it returns $elseReturn + * + * @param string $exceptionText + * @param bool $throwException + * @param mixed $elseReturn + * + * @return mixed + */ + private function failGraciously($exceptionText, $throwException, $elseReturn) + { + if ($throwException) { + throw new Exception($exceptionText); + } + + return $elseReturn; } /** @@ -252,131 +337,471 @@ public function getVariables() return array_unique($variables); } + /** + * Clone a string and enumerate ( i.e. ${macro#1} ) + * + * @param string $text Must be a variable as we use references for speed + * @param int $numberOfClones How many times $text needs to be duplicated + * @param bool $incrementVariables If true, the macro's inside the string get numerated + * + * @return string + */ + protected static function cloneSlice(&$text, $numberOfClones = 1, $incrementVariables = true) + { + $result = ''; + for ($i = 1; $i <= $numberOfClones; $i++) { + if ($incrementVariables) { + $result .= preg_replace('/\$\{(.*?)(\/?)\}/', '\${\\1#' . $i . '\\2}', $text); + } else { + $result .= $text; + } + } + + return $result; + } + + /** + * Process a table row in a template document. + * + * @param string $search + * @param int $numberOfClones + * @param mixed $replace (true to clone, or a string to replace) + * @param bool $incrementVariables + * @param bool $throwException + * + * @throws \PhpOffice\PhpWord\Exception\Exception + * @return string|false Returns the row cloned or false if the $search macro is not found + */ + private function processRow( + $search, + $numberOfClones = 1, + $replace = true, + $incrementVariables = true, + $throwException = false + ) { + return $this->processSegment( + static::ensureMacroCompleted($search), + 'w:tr', + 0, + $numberOfClones, + 'MainPart', + function (&$xmlSegment, &$segmentStart, &$segmentEnd, &$part) use (&$replace) { + if (strpos($xmlSegment, '', $extraRowStart); + + if (!$extraRowEnd) { + break; + } + $extraRowEnd += strlen(''); + // If tmpXmlRow doesn't contain continue, this row is no longer part of the spanned row. + $tmpXmlRow = substr($part, $extraRowStart, ($extraRowEnd - $extraRowStart)); + if (!preg_match('##', $tmpXmlRow) + && !preg_match('##', $tmpXmlRow) + ) { + break; + } + // This row was a spanned row, update $segmentEnd and search for the next row. + $segmentEnd = $extraRowEnd; + } + $xmlSegment = substr($part, $segmentStart, ($segmentEnd - $segmentStart)); + } + + return $replace; + }, + $incrementVariables, + $throwException + ); + } + /** * Clone a table row in a template document. * * @param string $search * @param int $numberOfClones + * @param bool $incrementVariables + * @param bool $throwException * * @throws \PhpOffice\PhpWord\Exception\Exception + * @return mixed Returns true if row cloned succesfully or or false if the $search macro is not found + */ + public function cloneRow( + $search, + $numberOfClones = 1, + $incrementVariables = true, + $throwException = false + ) { + return $this->processRow($search, $numberOfClones, true, $incrementVariables, $throwException); + } + + /** + * Get a row. (first block found) + * + * @param string $search + * @param bool $throwException + * + * @return string|null + */ + public function getRow($search, $throwException = false) + { + return $this->processRow($search, 0, false, false, $throwException); + } + + /** + * Replace a row. + * + * @param string $search a macro name in a table row + * @param string $replacement The replacement xml string. Be careful and keep the xml uncorrupted. + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @return mixed true (replaced), false ($search not found) or null (no tags found around $search) + */ + public function replaceRow($search, $replacement = '', $throwException = false) + { + return $this->processRow($search, 1, (string) $replacement, false, $throwException); + } + + /** + * Delete a row containing the given variable + * + * @param string $search + * + * @return bool */ - public function cloneRow($search, $numberOfClones) + public function deleteRow($search) { - if ('${' !== substr($search, 0, 2) && '}' !== substr($search, -1)) { - $search = '${' . $search . '}'; + return $this->processRow($search, 0, '', false, false); + } + + /** + * process a block. + * + * @param string $blockname The blockname without '${}' + * @param int $clones + * @param mixed $replace + * @param bool $incrementVariables + * @param bool $throwException + * + * @throws \PhpOffice\PhpWord\Exception\Exception + * @return mixed The cloned string if successful, false ($blockname not found) or Null (no paragraph found) + */ + private function processBlock( + $blockname, + $clones = 1, + $replace = true, + $incrementVariables = true, + $throwException = false + ) { + $startSearch = static::ensureMacroCompleted($blockname); + $endSearch = static::ensureMacroCompleted($blockname, true); + + if (substr($blockname, -1) == '/') { // singleton/closed block + return $this->processSegment( + $startSearch, + 'w:p', + 0, + $clones, + 'MainPart', + $replace, + $incrementVariables, + $throwException + ); } - $tagPos = strpos($this->tempDocumentMainPart, $search); - if (!$tagPos) { - throw new Exception('Can not clone row, template variable not found or variable contains markup.'); + $startTagPos = strpos($this->tempDocumentMainPart, $startSearch); + $endTagPos = strpos($this->tempDocumentMainPart, $endSearch, $startTagPos); + + if (!$startTagPos || !$endTagPos) { + return $this->failGraciously( + "Can not find block '$blockname', template variable not found or variable contains markup.", + $throwException, + false + ); } - $rowStart = $this->findRowStart($tagPos); - $rowEnd = $this->findRowEnd($tagPos); - $xmlRow = $this->getSlice($rowStart, $rowEnd); + $startBlockStart = $this->findOpenTagLeft($this->tempDocumentMainPart, '', $startTagPos, $throwException); + $startBlockEnd = $this->findCloseTagRight($this->tempDocumentMainPart, '', $startTagPos); - // 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 (!$startBlockStart || !$startBlockEnd) { + return $this->failGraciously( + "Can not find start paragraph around block '$blockname'", + $throwException, + null + ); + } - // If extraRowEnd is lower then 7, there was no next row found. - if ($extraRowEnd < 7) { - break; - } + $endBlockStart = $this->findOpenTagLeft($this->tempDocumentMainPart, '', $endTagPos, $throwException); + $endBlockEnd = $this->findCloseTagRight($this->tempDocumentMainPart, '', $endTagPos); - // 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); + if (!$endBlockStart || !$endBlockEnd) { + return $this->failGraciously( + "Can not find end paragraph around block '$blockname'", + $throwException, + null + ); } - $result = $this->getSlice(0, $rowStart); - for ($i = 1; $i <= $numberOfClones; $i++) { - $result .= preg_replace('/\$\{(.*?)\}/', '\${\\1#' . $i . '}', $xmlRow); + if ($startBlockEnd == $endBlockEnd) { // inline block + $startBlockStart = $startTagPos; + $startBlockEnd = $startTagPos + strlen($startSearch); + $endBlockStart = $endTagPos; + $endBlockEnd = $endTagPos + strlen($endSearch); } - $result .= $this->getSlice($rowEnd); - $this->tempDocumentMainPart = $result; + $xmlBlock = $this->getSlice($this->tempDocumentMainPart, $startBlockEnd, $endBlockStart); + + if ($replace !== false) { + if ($replace === true) { + $replace = static::cloneSlice($xmlBlock, $clones, $incrementVariables); + } + $this->tempDocumentMainPart = + $this->getSlice($this->tempDocumentMainPart, 0, $startBlockStart) + . $replace + . $this->getSlice($this->tempDocumentMainPart, $endBlockEnd); + + return true; + } + + return $xmlBlock; } /** * Clone a block. * + * @param string $blockname The blockname without '${}', it will search for '${BLOCKNAME}' and '${/BLOCKNAME} + * @param int $clones How many times the block needs to be cloned + * @param bool $incrementVariables true by default (variables get appended #1, #2 inside the cloned blocks) + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @throws \PhpOffice\PhpWord\Exception\Exception + * @return mixed True if successful, false ($blockname not found) or null (no paragraph found) + */ + public function cloneBlock( + $blockname, + $clones = 1, + $incrementVariables = true, + $throwException = false + ) { + return $this->processBlock($blockname, $clones, true, $incrementVariables, $throwException); + } + + /** + * Get a block. (first block found) + * + * @param string $blockname The blockname without '${}' + * @param bool $throwException false by default + * + * @return mixed a string when $blockname is found, false ($blockname not found) or null (no paragraph found) + */ + public function getBlock($blockname, $throwException = false) + { + return $this->processBlock($blockname, 0, false, false, $throwException); + } + + /** + * Replace a block. + * + * @param string $blockname The name of the macro start and end (without the macro marker ${}) + * @param string $replacement The replacement xml + * @param bool $throwException false by default + * + * @return mixed false-ish on no replacement, true-ish on replacement + */ + public function replaceBlock($blockname, $replacement = '', $throwException = false) + { + return $this->processBlock($blockname, 0, (string) $replacement, false, $throwException); + } + + /** + * Delete a block of text. + * * @param string $blockname - * @param int $clones - * @param bool $replace * - * @return string|null + * @return mixed true-ish on block found and deleted, falseish on block not found */ - public function cloneBlock($blockname, $clones = 1, $replace = true) + public function deleteBlock($blockname) { - $xmlBlock = null; - preg_match( - '/(<\?xml.*)(\${' . $blockname . '}<\/w:.*?p>)(.*)()/is', - $this->tempDocumentMainPart, - $matches - ); + return $this->replaceBlock($blockname, '', false); + } + + /** + * process a segment. + * + * @param string $needle If this is a macro, you need to add the ${} around it yourself + * @param string $xmltag an xml tag without brackets, for example: w:p + * @param int $direction in which direction should be searched. -1 left, 1 right. Default 0: around + * @param int $clones How many times the segment needs to be cloned + * @param string $docPart 'MainPart' (default) 'Footers:1' (first footer) or 'Headers:1' (first header) + * @param mixed $replace true (default/cloneSegment) false(getSegment) string(replaceSegment) function(callback) + * @param bool $incrementVariables true by default (variables get appended #1, #2 inside the cloned blocks) + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @return mixed The segment(getSegment), false (no $needle), null (no tags), true (clone/replace) + */ + public function processSegment( + $needle, + $xmltag, + $direction = self::SEARCH_AROUND, + $clones = 1, + $docPart = 'MainPart', + $replace = true, + $incrementVariables = true, + $throwException = false + ) { + $docPart = preg_split('/:/', $docPart); + if (count($docPart) > 1) { + $part = &$this->{'tempDocument' . $docPart[0]}[$docPart[1]]; + } else { + $part = &$this->{'tempDocument' . $docPart[0]}; + } + $needlePos = strpos($part, $needle); + + if ($needlePos === false) { + return $this->failGraciously( + "Can not find macro '$needle', text not found or text contains markup.", + $throwException, + false + ); + } - if (isset($matches[3])) { - $xmlBlock = $matches[3]; - $cloned = array(); - for ($i = 1; $i <= $clones; $i++) { - $cloned[] = $xmlBlock; + $directionStart = $direction == self::SEARCH_RIGHT ? 'findOpenTagRight' : 'findOpenTagLeft'; + $directionEnd = $direction == self::SEARCH_LEFT ? 'findCloseTagLeft' : 'findCloseTagRight'; + $segmentStart = $this->{$directionStart}($part, "<$xmltag>", $needlePos, $throwException); + $segmentEnd = $this->{$directionEnd}($part, "", $needlePos, $throwException); + + if ($segmentStart >= $segmentEnd && $segmentEnd) { + if ($direction == self::SEARCH_RIGHT) { + $segmentEnd = $this->findCloseTagRight($part, "", $segmentStart); + } else { + $segmentStart = $this->findOpenTagLeft($part, "<$xmltag>", $segmentEnd - 1, $throwException); } + } - if ($replace) { - $this->tempDocumentMainPart = str_replace( - $matches[2] . $matches[3] . $matches[4], - implode('', $cloned), - $this->tempDocumentMainPart - ); + if (!$segmentStart || !$segmentEnd) { + return $this->failGraciously( + "Can not find <$xmltag> ($segmentStart,$segmentEnd) around segment '$needle'", + $throwException, + null + ); + } + + $xmlSegment = $this->getSlice($part, $segmentStart, $segmentEnd); + + while (is_callable($replace)) { + $replace = $replace($xmlSegment, $segmentStart, $segmentEnd, $part); + } + if ($replace !== false) { + if ($replace === true) { + $replace = static::cloneSlice($xmlSegment, $clones, $incrementVariables); } + $part = + $this->getSlice($part, 0, $segmentStart) + . $replace + . $this->getSlice($part, $segmentEnd); + + return true; } - return $xmlBlock; + return $xmlSegment; } /** - * Replace a block. + * Clone a segment. * - * @param string $blockname - * @param string $replacement + * @param string $needle If this is a macro, you need to add the ${} around it yourself + * @param string $xmltag an xml tag without brackets, for example: w:p + * @param int $direction in which direction should be searched. -1 left, 1 right. Default 0: around + * @param int $clones How many times the segment needs to be cloned + * @param string $docPart 'MainPart' (default) 'Footers:1' (first footer) or 'Headers:1' (first header) + * @param bool $incrementVariables true by default (variables get appended #1, #2 inside the cloned blocks) + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @return mixed Returns true when succesfully cloned, false (no $needle found), null (no tags found) */ - public function replaceBlock($blockname, $replacement) - { - preg_match( - '/(<\?xml.*)(\${' . $blockname . '}<\/w:.*?p>)(.*)()/is', - $this->tempDocumentMainPart, - $matches + public function cloneSegment( + $needle, + $xmltag, + $direction = self::SEARCH_AROUND, + $clones = 1, + $docPart = 'MainPart', + $incrementVariables = true, + $throwException = false + ) { + return $this->processSegment( + $needle, + $xmltag, + $direction, + $clones, + $docPart, + true, + $incrementVariables, + $throwException ); + } - if (isset($matches[3])) { - $this->tempDocumentMainPart = str_replace( - $matches[2] . $matches[3] . $matches[4], - $replacement, - $this->tempDocumentMainPart - ); - } + /** + * Get a segment. (first segment found) + * + * @param string $needle If this is a macro, you need to add the ${} around it yourself + * @param string $xmltag an xml tag without brackets, for example: w:p + * @param int $direction in which direction should be searched. -1 left, 1 right. Default 0: around + * @param string $docPart 'MainPart' (default) 'Footers:1' (first footer) or 'Headers:1' (first header) + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @return mixed Segment String, false ($needle not found) or null (no tags found around $needle) + */ + public function getSegment($needle, $xmltag, $direction = 0, $docPart = 'MainPart', $throwException = false) + { + return $this->processSegment($needle, $xmltag, $direction, 0, $docPart, false, false, $throwException); } /** - * Delete a block of text. + * Replace a segment. * - * @param string $blockname + * @param string $needle If this is a macro, you need to add the ${} around it yourself + * @param string $xmltag an xml tag without brackets, for example: w:p + * @param int $direction in which direction should be searched. -1 left, 1 right. Default 0: around + * @param string $replacement The replacement xml string. Be careful and keep the xml uncorrupted. + * @param string $docPart 'MainPart' (default) 'Footers:1' (first footer) or 'Headers:2' (second header) + * @param bool $throwException false by default (it then returns false or null on errors) + * + * @return mixed true (replaced), false ($needle not found) or null (no tags found around $needle) */ - public function deleteBlock($blockname) + public function replaceSegment( + $needle, + $xmltag, + $direction = self::SEARCH_AROUND, + $replacement = '', + $docPart = 'MainPart', + $throwException = false + ) { + return $this->processSegment( + $needle, + $xmltag, + $direction, + 0, + $docPart, + (string) $replacement, + false, + $throwException + ); + } + + /** + * Delete a segment. + * + * @param string $needle If this is a macro, you need to add the ${} yourself + * @param string $xmltag an xml tag without brackets, for example: w:p + * @param int $direction in which direction should be searched. -1 left, 1 right. Default 0: around + * @param string $docPart 'MainPart' (default) 'Footers:1' (first footer) or 'Headers:1' (second header) + * + * @return mixed true (segment deleted), false ($needle not found) or null (no tags found around $needle) + */ + public function deleteSegment($needle, $xmltag, $direction = self::SEARCH_AROUND, $docPart = 'MainPart') { - $this->replaceBlock($blockname, ''); + return $this->replaceSegment($needle, $xmltag, $direction, '', $docPart, false); } /** @@ -384,7 +809,7 @@ public function deleteBlock($blockname) * * @throws \PhpOffice\PhpWord\Exception\Exception * - * @return string + * @return string The filename of the document */ public function save() { @@ -434,6 +859,7 @@ public function saveAs($fileName) /** * Finds parts of broken macros and sticks them together. * Macros, while being edited, could be implicitly broken by some of the word processors. + * In order to limit side-effects, we limit matches to only inside (inner) paragraphs * * @param string $documentPart The document part in XML representation * @@ -441,17 +867,23 @@ public function saveAs($fileName) */ protected function fixBrokenMacros($documentPart) { - $fixedDocumentPart = $documentPart; - - $fixedDocumentPart = preg_replace_callback( - '|\$[^{]*\{[^}]*\}|U', - function ($match) { - return strip_tags($match[0]); - }, - $fixedDocumentPart + $paragraphs = preg_split( + '@(]*>)@', + $documentPart, + -1, + PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE ); + foreach ($paragraphs as &$paragraph) { + $paragraph = preg_replace_callback( + '|\$(?:<[^\${}]+>)?\{[^{}]*\}|U', + function ($match) { + return strip_tags($match[0]); + }, + $paragraph + ); + } - return $fixedDocumentPart; + return implode('', $paragraphs); } /** @@ -459,13 +891,23 @@ function ($match) { * * @param mixed $search * @param mixed $replace - * @param string $documentPartXML + * @param mixed $documentPartXML Array or string (Header/Footer) * @param int $limit * - * @return string + * @return mixed */ protected function setValueForPart($search, $replace, $documentPartXML, $limit) { + // Shift-Enter + if (is_array($replace)) { + foreach ($replace as &$item) { + $item = preg_replace('~\R~u', '', $item); + } + unset($item); + } else { + $replace = preg_replace('~\R~u', '', $replace); + } + // 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); @@ -482,9 +924,9 @@ protected function setValueForPart($search, $replace, $documentPartXML, $limit) * * @return string[] */ - protected function getVariablesForPart($documentPartXML) + protected static function getVariablesForPart($documentPartXML) { - preg_match_all('/\$\{(.*?)}/i', $documentPartXML, $matches); + preg_match_all('/\$\{([^<>{}]*?)}/i', $documentPartXML, $matches); return $matches[1]; } @@ -522,54 +964,136 @@ protected function getFooterName($index) } /** - * Find the start position of the nearest table row before $offset. + * Find the start position of the nearest tag before $offset. * - * @param int $offset + * @param string $searchString The string we are searching in (the mainbody or an array element of Footers/Headers) + * @param string $tag Fully qualified tag, for example: '' (with brackets!) + * @param int $offset Do not look from the beginning, but starting at $offset + * @param bool $throwException * * @throws \PhpOffice\PhpWord\Exception\Exception + * @return int Zero if not found (due to the nature of xml, your document never starts at 0) + */ + protected function findOpenTagLeft(&$searchString, $tag, $offset = 0, $throwException = false) + { + $tagStart = strrpos( + $searchString, + substr($tag, 0, -1) . ' ', + ((strlen($searchString) - $offset) * -1) + ); + + if ($tagStart === false) { + $tagStart = strrpos( + $searchString, + $tag, + ((strlen($searchString) - $offset) * -1) + ); + + if ($tagStart === false) { + return $this->failGraciously( + 'Can not find the start position of the item to clone.', + $throwException, + 0 + ); + } + } + + return $tagStart; + } + + /** + * Find the start position of the nearest tag before $offset. * - * @return int + * @param string $searchString The string we are searching in (the mainbody or an array element of Footers/Headers) + * @param string $tag Fully qualified tag, for example: '' (with brackets!) + * @param int $offset Do not look from the beginning, but starting at $offset + * @param bool $throwException + * + * @throws \PhpOffice\PhpWord\Exception\Exception + * @return int Zero if not found (due to the nature of xml, your document never starts at 0) */ - protected function findRowStart($offset) + protected function findOpenTagRight(&$searchString, $tag, $offset = 0, $throwException = false) { - $rowStart = strrpos($this->tempDocumentMainPart, 'tempDocumentMainPart) - $offset) * -1)); + $tagStart = strpos( + $searchString, + substr($tag, 0, -1) . ' ', + $offset + ); + + if ($tagStart === false) { + $tagStart = strrpos( + $searchString, + $tag, + $offset + ); - if (!$rowStart) { - $rowStart = strrpos($this->tempDocumentMainPart, '', ((strlen($this->tempDocumentMainPart) - $offset) * -1)); + if ($tagStart === false) { + return $this->failGraciously( + 'Can not find the start position of the item to clone.', + $throwException, + 0 + ); + } } - if (!$rowStart) { - throw new Exception('Can not find the start position of the row to clone.'); + + return $tagStart; + } + + /** + * Find the end position of the nearest $tag after $offset. + * + * @param string $searchString The string we are searching in (the MainPart or an array element of Footers/Headers) + * @param string $tag Fully qualified tag, for example: '' + * @param int $offset Do not look from the beginning, but starting at $offset + * + * @return int Zero if not found + */ + protected function findCloseTagLeft(&$searchString, $tag, $offset = 0) + { + $pos = strrpos($searchString, $tag, ((strlen($searchString) - $offset) * -1)); + + if ($pos !== false) { + return $pos + strlen($tag); } - return $rowStart; + return 0; } /** - * Find the end position of the nearest table row after $offset. + * Find the end position of the nearest $tag after $offset. * - * @param int $offset + * @param string $searchString The string we are searching in (the MainPart or an array element of Footers/Headers) + * @param string $tag Fully qualified tag, for example: '' + * @param int $offset Do not look from the beginning, but starting at $offset * - * @return int + * @return int Zero if not found */ - protected function findRowEnd($offset) + protected function findCloseTagRight(&$searchString, $tag, $offset = 0) { - return strpos($this->tempDocumentMainPart, '', $offset) + 7; + $pos = strpos($searchString, $tag, $offset); + + if ($pos !== false) { + return $pos + strlen($tag); + } + + return 0; } /** * Get a slice of a string. * + * @param string $searchString The string we are searching in (the MainPart or an array element of Footers/Headers) * @param int $startPosition * @param int $endPosition * * @return string */ - protected function getSlice($startPosition, $endPosition = 0) + protected function getSlice(&$searchString, $startPosition, $endPosition = 0) { if (!$endPosition) { - $endPosition = strlen($this->tempDocumentMainPart); + $endPosition = strlen($searchString); } - return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition)); + return substr($searchString, $startPosition, ($endPosition - $startPosition)); } } diff --git a/tests/PhpWord/TemplateProcessorTest.php b/tests/PhpWord/TemplateProcessorTest.php index 7b064ef7b8..01804d877b 100644 --- a/tests/PhpWord/TemplateProcessorTest.php +++ b/tests/PhpWord/TemplateProcessorTest.php @@ -24,10 +24,65 @@ */ final class TemplateProcessorTest extends \PHPUnit\Framework\TestCase { + // http://php.net/manual/en/class.reflectionobject.php + public function poke(&$object, $property, $newValue = null) + { + $refObject = new \ReflectionObject($object); + $refProperty = $refObject->getProperty($property); + $refProperty->setAccessible(true); + + if ($newValue !== null) { + $refProperty->setValue($object, $newValue); + } + + return $refProperty; + } + + public function peek(&$object, $property) + { + $refObject = new \ReflectionObject($object); + $refProperty = $refObject->getProperty($property); + $refProperty->setAccessible(true); + + return $refProperty->getValue($object); + } + + /** + * Helper function to call protected method + * + * @param mixed $object + * @param string $method + * @param array $args + */ + public static function callProtectedMethod($object, $method, array $args = array()) + { + $class = new \ReflectionClass(get_class($object)); + $method = $class->getMethod($method); + $method->setAccessible(true); + + return $method->invokeArgs($object, $args); + } + + /** + * Construct test + * + * @covers ::__construct + + * @test + */ + public function testTheConstruct() + { + $object = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + + $this->assertInstanceOf('PhpOffice\\PhpWord\\TemplateProcessor', $object); + $this->assertEquals(array(), $object->getVariables()); + } + /** * Template can be saved in temporary location. * * @covers ::save + * @covers ::zip * @test */ final public function testTemplateCanBeSavedInTemporaryLocation() @@ -41,6 +96,8 @@ final public function testTemplateCanBeSavedInTemporaryLocation() $templateProcessor->applyXslStyleSheet($xslDomDocument, array('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(); $this->assertNotEmpty($documentFqfn, 'FQFN of the saved document is empty.'); @@ -60,6 +117,7 @@ final public function testTemplateCanBeSavedInTemporaryLocation() $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}\"."); } @@ -68,6 +126,8 @@ final public function testTemplateCanBeSavedInTemporaryLocation() $this->assertNotEquals($templateMainPartXml, $documentMainPartXml); $this->assertNotEquals($templateFooterXml, $documentFooterXml); + $this->assertEquals($embeddingText, $documentEmbedding); + return $documentFqfn; } @@ -104,9 +164,9 @@ final public function testXslStyleSheetCanBeApplied($actualDocumentFqfn) throw new \Exception("Could not close zip file \"{$expectedDocumentFqfn}\"."); } - $this->assertXmlStringEqualsXmlString($expectedHeaderXml, $actualHeaderXml); - $this->assertXmlStringEqualsXmlString($expectedMainPartXml, $actualMainPartXml); - $this->assertXmlStringEqualsXmlString($expectedFooterXml, $actualFooterXml); + $this->assertxmlStringEqualsxmlString($expectedHeaderXml, $actualHeaderXml); + $this->assertxmlStringEqualsxmlString($expectedMainPartXml, $actualMainPartXml); + $this->assertxmlStringEqualsxmlString($expectedFooterXml, $actualFooterXml); } /** @@ -156,7 +216,12 @@ final public function testXslStyleSheetCanNotBeAppliedOnFailureOfLoadingXmlFromT /** * @covers ::setValue * @covers ::cloneRow + * @covers ::deleteRow * @covers ::saveAs + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find macro * @test */ public function testCloneRow() @@ -170,12 +235,93 @@ public function testCloneRow() $docName = 'clone-test-result.docx'; $templateProcessor->setValue('tableHeader', utf8_decode('ééé')); - $templateProcessor->cloneRow('userId', 1); + $templateProcessor->cloneRow('userId', 2, true, true); $templateProcessor->setValue('userId#1', 'Test'); + $this->assertTrue( + $templateProcessor->deleteRow('userId#2') + ); + $this->assertFalse( + $templateProcessor->deleteRow('userId#3') + ); $templateProcessor->saveAs($docName); $docFound = file_exists($docName); unlink($docName); $this->assertTrue($docFound); + $this->assertNull( + $templateProcessor->cloneRow('userId', 2, false, true) + ); + } + + /** + * @covers ::getRow + * @covers ::replaceRow + * @covers ::saveAs + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @test + */ + public function testGetRow() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $initialArray = array('tableHeader', 'userId', 'userName', 'userLocation'); + $midArray = array( + 'tableHeader', + 'userId', 'userName', 'foo', 'userLocation', + ); + $finalArray = array( + 'tableHeader', + 'userId#1', 'userName#1', 'foo#1', + 'userId#2', 'userName#2', 'foo#2', + 'userId', 'userName', 'userLocation', + ); + $row = $templateProcessor->getRow('userId'); + $this->assertNotEmpty($row); + $this->assertEquals( + $initialArray, + $templateProcessor->getVariables() + ); + $this->assertStringStartsWith('assertStringEndsWith('', $row); + + $result = $templateProcessor->cloneRow('userId', 2, false, false); + $this->assertTrue($result); + $templateProcessor->setValue('userLocation', '${foo}', 1); + $this->assertEquals( + $midArray, + array_values($templateProcessor->getVariables()), + implode('|', $templateProcessor->getVariables()) + ); + $row = $templateProcessor->cloneRow('userId', 2, true, false); + $this->assertEquals( + $finalArray, + $templateProcessor->getVariables() + ); + + $docName = 'test-getRow-result.docx'; + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + $this->assertTrue($docFound); + if ($docFound) { + $templateProcessorNewFile = new TemplateProcessor($docName); + $this->assertEquals( + $finalArray, + $templateProcessorNewFile->getVariables() + ); + $row = $templateProcessorNewFile->getRow('userId'); + $this->assertTrue(strlen($row) > 10); + $this->assertTrue( + $templateProcessorNewFile->replaceRow('userId#1', $row) + ); + $this->assertTrue( + $templateProcessorNewFile->replaceRow('userId#2', $row) + ); + // now we have less macro variables (although multiple of them) + $this->assertEquals( + $initialArray, + $templateProcessorNewFile->getVariables() + ); + unlink($docName); + } } /** @@ -203,7 +349,10 @@ public function testMacrosCanBeReplacedInHeaderAndFooter() /** * @covers ::cloneBlock * @covers ::deleteBlock + * @covers ::getBlock * @covers ::saveAs + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight * @test */ public function testCloneDeleteBlock() @@ -214,13 +363,681 @@ public function testCloneDeleteBlock() array('DELETEME', '/DELETEME', 'CLONEME', '/CLONEME'), $templateProcessor->getVariables() ); - + $cloneTimes = 3; $docName = 'clone-delete-block-result.docx'; - $templateProcessor->cloneBlock('CLONEME', 3); + $xmlblock = $templateProcessor->getBlock('CLONEME'); + $this->assertNotEmpty($xmlblock); + $templateProcessor->cloneBlock('CLONEME', $cloneTimes); $templateProcessor->deleteBlock('DELETEME'); $templateProcessor->saveAs($docName); $docFound = file_exists($docName); - unlink($docName); + if ($docFound) { + // Great, so we saved the replaced document, so we open that new document + // note that we need to access private variables, so we use a sub-class + $templateProcessorNewFile = new TemplateProcessor($docName); + // We test that all Block variables have been replaced (thus, getVariables() is empty) + $this->assertEquals( + array(), + $templateProcessorNewFile->getVariables(), + 'All block variables should have been replaced' + ); + // we cloned block CLONEME $cloneTimes times, so let's count to $cloneTimes + $this->assertEquals( + $cloneTimes, + substr_count($this->peek($templateProcessorNewFile, 'tempDocumentMainPart'), $xmlblock), + 'Block should be present $cloneTimes in the document' + ); + unlink($docName); // delete generated file + } + + $this->assertTrue($docFound); + } + + /** + * @covers ::cloneBlock + * @covers ::getVariables + * @covers ::getBlock + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @covers ::setValue + * @test + */ + public function testCloneIndexedBlock() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + // we will fake a block with a variable inside it, as there is no template document yet. + $xmlTxt = 'This ${repeats} a few times'; + $xmlStr = '${MYBLOCK}' . $xmlTxt . '${/MYBLOCK}'; + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + + $this->assertEquals( + $xmlTxt, + $templateProcessor->getBlock('MYBLOCK'), + 'Block should be cut at the right place (using findOpenTagLeft/findCloseTagRight)' + ); + + // detects variables + $this->assertEquals( + array('MYBLOCK', 'repeats', '/MYBLOCK'), + $templateProcessor->getVariables(), + 'Injected document should contain the right initial variables, in the right order' + ); + + $templateProcessor->cloneBlock('MYBLOCK', 4); + // detects new variables + $this->assertEquals( + array('repeats#1', 'repeats#2', 'repeats#3', 'repeats#4'), + $templateProcessor->getVariables(), + 'Injected document should contain the right cloned variables, in the right order' + ); + + $variablesArray = array( + 'repeats#1' => 'ONE', + 'repeats#2' => 'TWO', + 'repeats#3' => 'THREE', + 'repeats#4' => 'FOUR', + ); + $templateProcessor->setValue(array_keys($variablesArray), array_values($variablesArray)); + $this->assertEquals( + array(), + $templateProcessor->getVariables(), + 'Variables have been replaced and should not be present anymore' + ); + + // now we test the order of replacement: ONE,TWO,THREE then FOUR + $tmpStr = ''; + foreach ($variablesArray as $variable) { + $tmpStr .= str_replace('${repeats}', $variable, $xmlTxt); + } + $this->assertEquals( + 1, + substr_count($this->peek($templateProcessor, 'tempDocumentMainPart'), $tmpStr), + 'order of replacement should be: ONE,TWO,THREE then FOUR' + ); + + // Now we try again, but without variable incrementals (old behavior) + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + $templateProcessor->cloneBlock('MYBLOCK', 4, false); + + // detects new variable + $this->assertEquals( + array('repeats'), + $templateProcessor->getVariables(), + 'new variable $repeats should be present' + ); + + // we cloned block CLONEME 4 times, so let's count + $this->assertEquals( + 4, + substr_count($this->peek($templateProcessor, 'tempDocumentMainPart'), $xmlTxt), + 'detects new variable $repeats to be present 4 times' + ); + + // we cloned block CLONEME 4 times, so let's see that there is no space between these blocks + $this->assertEquals( + 1, + substr_count( + $this->peek($templateProcessor, 'tempDocumentMainPart'), + $xmlTxt . $xmlTxt . $xmlTxt . $xmlTxt + ), + 'The four times cloned block should be the same as four times the block' + ); + } + + /** + * @covers ::cloneBlock + * @covers ::getVariables + * @covers ::getBlock + * @covers ::setValue + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @test + */ + public function testClosedBlock() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + $xmlTxt = 'This ${BLOCKCLOSE/} is here.'; + $xmlStr = '${BEFORE}' . $xmlTxt . '${AFTER}'; + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + + $this->assertEquals( + $xmlTxt, + $templateProcessor->getBlock('BLOCKCLOSE/'), + 'Block should be cut at the right place (using findOpenTagLeft/findCloseTagRight)' + ); + + // detects variables + $this->assertEquals( + array('BEFORE', 'BLOCKCLOSE/', 'AFTER'), + $templateProcessor->getVariables(), + 'Injected document should contain the right initial variables, in the right order' + ); + + // inserting itself should result in no change + $oldvalue = $this->peek($templateProcessor, 'tempDocumentMainPart'); + $block = $templateProcessor->getBlock('BLOCKCLOSE/'); + $templateProcessor->replaceBlock('BLOCKCLOSE/', $block); + $this->assertEquals( + $oldvalue, + $this->peek($templateProcessor, 'tempDocumentMainPart'), + 'ReplaceBlock should replace at the right position' + ); + + $templateProcessor->cloneBlock('BLOCKCLOSE/', 4); + // detects new variables + $this->assertEquals( + array('BEFORE', 'BLOCKCLOSE#1/', 'BLOCKCLOSE#2/', 'BLOCKCLOSE#3/', 'BLOCKCLOSE#4/', 'AFTER'), + $templateProcessor->getVariables(), + 'Injected document should contain the right cloned variables, in the right order' + ); + + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + $templateProcessor->deleteBlock('BLOCKCLOSE/'); + $this->assertEquals( + '${BEFORE}${AFTER}', + $this->peek($templateProcessor, 'tempDocumentMainPart'), + 'closedblock should delete properly' + ); + } + + /** + * @covers ::setValue + * @test + */ + public function testSetValue() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + Settings::setOutputEscapingEnabled(true); + $helloworld = "hello\nworld"; + $templateProcessor->setValue('userName', $helloworld); + $this->assertEquals( + array('tableHeader', 'userId', 'userLocation'), + $templateProcessor->getVariables() + ); + } + + /** + * @covers ::setValue + * @covers ::saveAs + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @test + */ + public function testSetValueMultiline() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + + $this->assertEquals( + array('tableHeader', 'userId', 'userName', 'userLocation'), + $templateProcessor->getVariables() + ); + + $docName = 'multiline-test-result.docx'; + $helloworld = "hello\nworld"; + $templateProcessor->setValue('userName', $helloworld); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); $this->assertTrue($docFound); + if ($docFound) { + // We open that new document (and use the OpenTemplateProcessor to access private variables) + $templateProcessorNewFile = new TemplateProcessor($docName); + // We test that all Block variables have been replaced (thus, getVariables() is empty) + $this->assertEquals( + 0, + substr_count($this->peek($templateProcessorNewFile, 'tempDocumentMainPart'), $helloworld), + 'there should be a multiline' + ); + // The block it should be turned into: + $xmlblock = 'helloworld'; + $this->assertEquals( + 1, + substr_count($this->peek($templateProcessorNewFile, 'tempDocumentMainPart'), $xmlblock), + 'multiline should be present 1 in the document' + ); + unlink($docName); // delete generated file + } + } + + /** + * @covers ::replaceBlock + * @covers ::getBlock + * @covers ::findOpenTagLeft + * @covers ::findCloseTagRight + * @test + */ + public function testInlineBlock() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + $xmlStr = '' . + 'This' . + '${inline}' . + ' has been' . + '${/inline}' . + ' block'; + + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + + $this->assertEquals( + $templateProcessor->getBlock('inline'), + '' . + ' has been', + 'When inside the same , cut inside the paragraph' + ); + + $templateProcessor->replaceBlock('inline', 'shows'); + + $this->assertEquals( + $this->peek($templateProcessor, 'tempDocumentMainPart'), + '' . + '' . + 'This' . + 'shows' . + ' block', + 'InlineBlock replace is malformed' + ); + } + + /** + * @covers ::replaceBlock + * @covers ::cloneBlock + * @covers ::setValue + * @test + */ + public function testSetBlock() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + $xmlStr = '' . + '' . + '' . + 'BEFORE' . + '${inline/}' . + 'AFTER' . + '' . + ''; + + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + $templateProcessor->setBlock('inline/', "one\ntwo"); + + // XMLReader::xml($templateProcessor->tempDocumentMainPart)->isValid() + + $this->assertEquals( + '' . + '' . + '' . + 'BEFORE' . + 'one' . + 'AFTER' . + '' . + '' . + 'BEFORE' . + 'two' . + 'AFTER' . + '' . + '', + $this->peek($templateProcessor, 'tempDocumentMainPart') + ); + + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + $templateProcessor->setBlock('inline/', 'simplé`'); + $this->assertEquals( + '' . + '' . + '' . + 'BEFORE' . + 'simplé`' . + 'AFTER' . + '' . + '', + $this->peek($templateProcessor, 'tempDocumentMainPart') + ); + } + + /** + * @covers ::replaceBlock + * @covers ::cloneBlock + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find end paragraph around block + * @test + */ + public function testReplaceBlock() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + $xmlStr = '' . + '${/nostart}' . + '' . + '${malformedblock}' . + '${notABlock}' . + '' . + '${/malformedblock}'; + + $this->poke($templateProcessor, 'tempDocumentMainPart', $xmlStr); + + $this->assertFalse( + $templateProcessor->replaceBlock('notABlock', '') + ); + $this->assertFalse( + $templateProcessor->cloneBlock('notABlock', '', 10) + ); + $this->assertNull( + $templateProcessor->cloneBlock('malformedblock', 11) + ); + $this->assertNull( + $templateProcessor->cloneBlock('nostart', 11) + ); + $this->assertNull( + $templateProcessor->replaceBlock('malformedblock', '', true) + ); + } + + /** + * @covers ::failGraciously + * @covers ::cloneSegment + * @covers ::replaceSegment + * @covers ::deleteSegment + * @test + */ + public function testFailGraciously() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $around = TemplateProcessor::SEARCH_AROUND; + + $this->assertFalse( + $templateProcessor->getSegment('I-DO-NOT-EXIST', 'w:p', $around, 'MainPart', false) + ); + + $this->assertFalse( + $templateProcessor->cloneSegment('I-DO-NOT-EXIST', 'w:p', $around, 1, 'MainPart', true, false) + ); + + $this->assertNull( + $templateProcessor->cloneSegment('tableHeader', 'DESPACITO', $around, 1, 'MainPart', true, false) + ); + + $this->assertFalse( + $templateProcessor->replaceSegment('I-DO-NOT-EXIST', 'w:p', $around, 'IOU', 'Footer:1', false) + ); + + $this->assertNull( + $templateProcessor->replaceSegment('tableHeader', 'we:be', $around, 'BodyMoving', 'MainPart', false) + ); + + $this->assertFalse( + $templateProcessor->deleteSegment('tableHeader', '>sabotage<', 'MainPart', $around, 1, true, true, false) + ); + $left = TemplateProcessor::SEARCH_LEFT; + $right = TemplateProcessor::SEARCH_RIGHT; + + $this->assertFalse( + $templateProcessor->deleteSegment('tableHeader', '>sabotage<', 'MainPart', $right, 1, true, true, false) + ); + + $this->assertFalse( + $templateProcessor->deleteSegment('tableHeader', '>sabotage<', 'MainPart', $left, 1, true, true, false) + ); + } + + /** + * @covers ::cloneSegment + * @covers ::getVariables + * @covers ::setBlock + * @covers ::saveAs + * @test + */ + public function testCloneSegment() + { + $testDocument = __DIR__ . '/_files/templates/header-footer.docx'; + $templateProcessor = new TemplateProcessor($testDocument); + + $this->assertEquals( + array('documentContent', 'headerValue', 'footerValue'), + $templateProcessor->getVariables() + ); + + $zipFile = new \ZipArchive(); + $zipFile->open($testDocument); + $originalFooterXml = $zipFile->getFromName('word/footer1.xml'); + if (false === $zipFile->close()) { + throw new \Exception('Could not close zip file'); + } + + $around = TemplateProcessor::SEARCH_AROUND; + $segment = $templateProcessor->cloneSegment('${footerValue}', 'w:p', $around, 2, 'Footers:1'); + $this->assertNotNull($segment); + $segment = $templateProcessor->cloneSegment('${headerValue}', 'w:p', $around, 2, 'Headers:1'); + $this->assertNotNull($segment); + $segment = $templateProcessor->cloneSegment('${documentContent}', 'w:p', $around, 1, 'MainPart'); + $this->assertNotNull($segment); + $templateProcessor->setBlock('headerValue#1', "In the end, it doesn't even matter."); + + $docName = 'header-footer-test-result.docx'; + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + if ($docFound) { + $zipFile->open($docName); + $updatedFooterXml = $zipFile->getFromName('word/footer1.xml'); + if (false === $zipFile->close()) { + throw new \Exception('Could not close zip file'); + } + + $this->assertNotEquals( + $originalFooterXml, + $updatedFooterXml + ); + + $templateProcessor2 = new TemplateProcessor($docName); + $this->assertEquals( + array('documentContent#1', 'headerValue#2', 'footerValue#1', 'footerValue#2'), + $templateProcessor2->getVariables() + ); + unlink($docName); + } + $this->assertTrue($docFound); + } + + /** + * @covers ::failGraciously + * @covers ::cloneSegment + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find macro 'I-DO-NOT-EXIST', text not found or text contains markup + * @test + */ + final public function testThrowFailGraciously() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $this->assertNull( + $templateProcessor->cloneSegment( + 'I-DO-NOT-EXIST', + 'w:p', + TemplateProcessor::SEARCH_AROUND, + 1, + 'MainPart', + true, + true + ) + ); + } + + /** + * @covers ::failGraciously + * @covers ::replaceSegment + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find macro 'I-DO-NOT-EXIST', text not found or text contains markup + * @test + */ + final public function testAnotherThrowFailGraciously() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $around = TemplateProcessor::SEARCH_AROUND; + $this->assertNull( + $templateProcessor->replaceSegment('I-DO-NOT-EXIST', 'w:p', $around, 'IOU', 'MainPart', true) + ); + } + + /** + * @covers ::save + * @covers ::saveas + * @test1 + */ + public function testSaveAs() + { + $testDocument = __DIR__ . '/_files/templates/header-footer.docx'; + $templateProcessor = new TemplateProcessor($testDocument); + $tempFileName = 'tempfilename.docx'; + $this->assertNull( + $templateProcessor->saveAs($tempFileName) + ); + $this->assertFileExists($tempFileName); + $templateProcessor = new TemplateProcessor($tempFileName); + $this->assertNull( + $templateProcessor->saveAs($tempFileName), + 'Second save should succeed, but does not' + ); + $this->assertFileExists($tempFileName); + unlink($tempFileName); + } + + /** + * @covers ::save + * @test + */ + public function testSave() + { + $testDocument = __DIR__ . '/_files/templates/header-footer.docx'; + $templateProcessor = new TemplateProcessor($testDocument); + + $this->assertNotEquals( + $testDocument, + $tempFileName = $templateProcessor->save(), + 'Do not clobber the original file' + ); + + $this->assertFileExists($tempFileName); + unlink($tempFileName); + } + + /** + * @covers ::replaceBlock + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find block 'I-DO-NOT-EXIST', template variable not found or variable contains + * @test + */ + final public function testReplaceBlockThrow() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $this->assertNull( + $templateProcessor->replaceBlock('I-DO-NOT-EXIST', 'IOU', true) + ); + } + + /** + * @covers ::getSegment + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find macro 'I-DO-NOT-EXIST', text not found or text contains markup. + * @test + */ + final public function testgetSegmentThrow() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $this->assertNull( + $templateProcessor->getSegment('I-DO-NOT-EXIST', 'w:p', TemplateProcessor::SEARCH_AROUND, 'MainPart', true) + ); + } + + /** + * @covers ::cloneBlock + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find block + * @test + */ + final public function testCloneBlockThrow() + { + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/clone-merge.docx'); + $this->assertNull( + $templateProcessor->cloneBlock('I-DO-NOT-EXIST', 'replace', 1, true, true) + ); + } + + /** + * Example of testing protected functions + * + * @covers ::findCloseTagRight + * @covers ::findOpenTagLeft + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find the start position + * @test + */ + public function testfindTagRightAndLeft() + { + $testDocument = __DIR__ . '/_files/templates/header-footer.docx'; + $stub = $this->getMockForAbstractClass('\PhpOffice\PhpWord\TemplateProcessor', array($testDocument)); + + $str = '...abcd${dream}efg...'; + $this->assertEquals(26, self::callProtectedMethod($stub, 'findCloseTagRight', array(&$str, '', 0))); + $this->assertEquals(7, self::callProtectedMethod($stub, 'findOpenTagRight', array(&$str, '', 0))); + $this->assertEquals(07, self::callProtectedMethod($stub, 'findOpenTagLeft', array(&$str, '', 20))); + + $str = '...before...abcd${dream}efg...after...'; + $this->assertEquals(0, self::callProtectedMethod($stub, 'findCloseTagRight', array(&$str, '', 44))); + $this->assertEquals(56, self::callProtectedMethod($stub, 'findCloseTagRight', array(&$str, '', 44))); + $this->assertEquals(88, self::callProtectedMethod($stub, 'findCloseTagRight', array(&$str, '', 56))); + $this->assertEquals(3, self::callProtectedMethod($stub, 'findOpenTagLeft', array(&$str, '', 44))); + $this->assertEquals(30, self::callProtectedMethod($stub, 'findCloseTagLeft', array(&$str, '', 44))); + $this->assertEquals(0, self::callProtectedMethod($stub, 'findCloseTagLeft', array(&$str, '', 20))); + + $snipEnd = self::callProtectedMethod($stub, 'findCloseTagLeft', array(&$str, '', 44)); + $snipStart = self::callProtectedMethod($stub, 'findOpenTagLeft', array(&$str, '', $snipEnd)); + $this->assertEquals( + 'before', + self::callProtectedMethod($stub, 'getSlice', array(&$str, $snipStart, $snipEnd)) + ); + + $want = 'after'; + $snipStart = self::callProtectedMethod($stub, 'findOpenTagRight', array(&$str, '', 44)); + $snipEnd = self::callProtectedMethod($stub, 'findCloseTagRight', array(&$str, '', $snipStart)); + $this->assertEquals( + $want, + self::callProtectedMethod($stub, 'getSlice', array(&$str, $snipStart, $snipEnd)) + ); + + // now throw an exception + $snipStart = self::callProtectedMethod($stub, 'findOpenTagRight', array(&$str, '', $snipStart + 1, true)); + } + + /** + * testing grabbing segments left and right + * + * @covers ::getSegment + * @expectedException \PhpOffice\PhpWord\Exception\Exception + * @expectedExceptionMessage Can not find the start position + * @test + */ + public function testGetSegment() + { + $template = new TemplateProcessor(__DIR__ . '/_files/templates/blank.docx'); + $xmlStr = '' . + 'before' . + '' . + '${middle}' . + '' . + 'after'; + + $this->poke($template, 'tempDocumentMainPart', $xmlStr); + + $this->assertEquals( + 'after', + $template->getSegment('${middle}', 'w:p', 1) + ); + + $this->assertEquals( + '${middle}', + $template->getSegment('${middle}', 'w:p', 0) + ); + + $this->assertEquals( + 'before', + $template->getSegment('${middle}', 'w:p', -1) + ); + + $template->getSegment('after', 'w:p', 1, 'MainPart', true); } } diff --git a/tests/PhpWord/_files/templates/bad-tags.docx b/tests/PhpWord/_files/templates/bad-tags.docx new file mode 100644 index 0000000000..e2ca8e37c2 Binary files /dev/null and b/tests/PhpWord/_files/templates/bad-tags.docx differ