diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b6326b..1ddd2e89 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This is the changelog between releases of PHPWord. Releases are listed in revers - Table: Add `exactHeight` to row style to define whether row height should be exact or atLeast - @jcarignan GH-168 - Element: New `CheckBox` element for sections and table cells - @ozilion GH-156 - Settings: Ability to use PCLZip as alternative to ZipArchive - @bskrtich @ivanlanin GH-106 GH-140 GH-185 +- Template: Ability to find & replace variables in headers & footers - @dgudgeon GH-190 - Table: Ability to add footnote in table cell - @ivanlanin GH-187 - Footnote: Ability to add image in footnote - @ivanlanin GH-187 - ListItem: Ability to add list item in header/footer - @ivanlanin GH-187 diff --git a/samples/Sample_07_TemplateCloneRow.php b/samples/Sample_07_TemplateCloneRow.php index 4344fafc..72464d84 100755 --- a/samples/Sample_07_TemplateCloneRow.php +++ b/samples/Sample_07_TemplateCloneRow.php @@ -7,6 +7,11 @@ $phpWord = new \PhpOffice\PhpWord\PhpWord(); $document = $phpWord->loadTemplate('resources/Sample_07_TemplateCloneRow.docx'); +// Variables on different parts of document +$document->setValue('weekday', date('l')); // On section/content +$document->setValue('time', date('H:i')); // On footer +$document->setValue('serverName', $_SERVER['SERVER_NAME']); // On header + // Simple table $document->cloneRow('rowValue', 10); @@ -32,9 +37,6 @@ $document->setValue('rowNumber#8', '8'); $document->setValue('rowNumber#9', '9'); $document->setValue('rowNumber#10', '10'); -$document->setValue('weekday', date('l')); -$document->setValue('time', date('H:i')); - // Table with a spanned cell $document->cloneRow('userId', 3); diff --git a/samples/resources/Sample_07_TemplateCloneRow.docx b/samples/resources/Sample_07_TemplateCloneRow.docx index 25a8c418..9ac6c2a1 100755 Binary files a/samples/resources/Sample_07_TemplateCloneRow.docx and b/samples/resources/Sample_07_TemplateCloneRow.docx differ diff --git a/src/PhpWord/Template.php b/src/PhpWord/Template.php index 0711ecd5..142228c3 100644 --- a/src/PhpWord/Template.php +++ b/src/PhpWord/Template.php @@ -23,46 +23,72 @@ class Template * * @var mixed */ - private $_objZip; + private $zipClass; /** * Temporary file name * * @var string */ - private $_tempFileName; + private $tempFileName; /** * Document XML * * @var string */ - private $_documentXML; + private $documentXML; + /** + * Document header XML + * + * @var string[] + */ + private $headerXMLs = array(); + + /** + * Document footer XML + * + * @var string[] + */ + private $footerXMLs = array(); /** * Create a new Template Object * * @param string $strFilename - * @throws \PhpOffice\PhpWord\Exception\Exception + * @throws Exception */ public function __construct($strFilename) { - $this->_tempFileName = tempnam(sys_get_temp_dir(), ''); - if ($this->_tempFileName === false) { + $this->tempFileName = tempnam(sys_get_temp_dir(), ''); + if ($this->tempFileName === false) { throw new Exception('Could not create temporary file with unique name in the default temporary directory.'); } // Copy the source File to the temp File - if (!copy($strFilename, $this->_tempFileName)) { - throw new Exception("Could not copy the template from {$strFilename} to {$this->_tempFileName}."); + if (!copy($strFilename, $this->tempFileName)) { + throw new Exception("Could not copy the template from {$strFilename} to {$this->tempFileName}."); } $zipClass = Settings::getZipClass(); - $this->_objZip = new $zipClass(); - $this->_objZip->open($this->_tempFileName); + $this->zipClass = new $zipClass(); + $this->zipClass->open($this->tempFileName); - $this->_documentXML = $this->_objZip->getFromName('word/document.xml'); + // Find and load headers and footers + $i = 1; + while ($this->zipClass->locateName($this->getHeaderName($i)) !== false) { + $this->headerXMLs[$i] = $this->zipClass->getFromName($this->getHeaderName($i)); + $i++; + } + + $i = 1; + while ($this->zipClass->locateName($this->getFooterName($i)) !== false) { + $this->footerXMLs[$i] = $this->zipClass->getFromName($this->getFooterName($i)); + $i++; + } + + $this->documentXML = $this->zipClass->getFromName('word/document.xml'); } /** @@ -71,7 +97,7 @@ class Template * @param \DOMDocument $xslDOMDocument * @param array $xslOptions * @param string $xslOptionsURI - * @throws \PhpOffice\PhpWord\Exception\Exception + * @throws Exception */ public function applyXslStyleSheet(&$xslDOMDocument, $xslOptions = array(), $xslOptionsURI = '') { @@ -84,7 +110,7 @@ class Template } $xmlDOMDocument = new \DOMDocument(); - if ($xmlDOMDocument->loadXML($this->_documentXML) === false) { + if ($xmlDOMDocument->loadXML($this->documentXML) === false) { throw new Exception('Could not load XML from the given template.'); } @@ -93,7 +119,7 @@ class Template throw new Exception('Could not transform the given XML document.'); } - $this->_documentXML = $xmlTransformed; + $this->documentXML = $xmlTransformed; } /** @@ -104,13 +130,151 @@ class Template * @param integer $limit */ public function setValue($search, $replace, $limit = -1) + { + foreach ($this->headerXMLs as $index => $headerXML) { + $this->headerXMLs[$index] = $this->setValueForPart($this->headerXMLs[$index], $search, $replace, $limit); + } + + $this->documentXML = $this->setValueForPart($this->documentXML, $search, $replace, $limit); + + foreach ($this->footerXMLs as $index => $headerXML) { + $this->footerXMLs[$index] = $this->setValueForPart($this->footerXMLs[$index], $search, $replace, $limit); + } + } + + /** + * Returns array of all variables in template + * @return string[] + */ + public function getVariables() + { + $variables = $this->getVariablesForPart($this->documentXML); + + foreach ($this->headerXMLs as $headerXML) { + $variables = array_merge($variables, $this->getVariablesForPart($headerXML)); + } + + foreach ($this->footerXMLs as $footerXML) { + $variables = array_merge($variables, $this->getVariablesForPart($footerXML)); + } + + return array_unique($variables); + } + + /** + * Clone a table row in a template document + * + * @param string $search + * @param int $numberOfClones + * @throws Exception + */ + public function cloneRow($search, $numberOfClones) + { + if (substr($search, 0, 2) !== '${' && substr($search, -1) !== '}') { + $search = '${' . $search . '}'; + } + + $tagPos = strpos($this->documentXML, $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); + for ($i = 1; $i <= $numberOfClones; $i++) { + $result .= preg_replace('/\$\{(.*?)\}/', '\${\\1#' . $i . '}', $xmlRow); + } + $result .= $this->getSlice($rowEnd); + + $this->documentXML = $result; + } + + /** + * Save XML to temporary file + * + * @return string + * @throws Exception + */ + public function save() + { + foreach ($this->headerXMLs as $index => $headerXML) { + $this->zipClass->addFromString($this->getHeaderName($index), $this->headerXMLs[$index]); + } + + $this->zipClass->addFromString('word/document.xml', $this->documentXML); + + foreach ($this->footerXMLs as $index => $headerXML) { + $this->zipClass->addFromString($this->getFooterName($index), $this->footerXMLs[$index]); + } + + // Close zip file + if ($this->zipClass->close() === false) { + throw new Exception('Could not close zip file.'); + } + + return $this->tempFileName; + } + + /** + * Save XML to defined name + * + * @param string $strFilename + */ + public function saveAs($strFilename) + { + $tempFilename = $this->save(); + + if (\file_exists($strFilename)) { + unlink($strFilename); + } + + rename($tempFilename, $strFilename); + } + + /** + * Find and replace placeholders in the given XML section. + * + * @param string $documentPartXML + * @param string $search + * @param mixed $replace + * @param integer $limit + * @return string + */ + protected function setValueForPart($documentPartXML, $search, $replace, $limit) { $pattern = '|\$\{([^\}]+)\}|U'; - preg_match_all($pattern, $this->_documentXML, $matches); + preg_match_all($pattern, $documentPartXML, $matches); foreach ($matches[0] as $value) { $valueCleaned = preg_replace('/<[^>]+>/', '', $value); $valueCleaned = preg_replace('/<\/[^>]+>/', '', $valueCleaned); - $this->_documentXML = str_replace($value, $valueCleaned, $this->_documentXML); + $documentPartXML = str_replace($value, $valueCleaned, $documentPartXML); } if (substr($search, 0, 2) !== '${' && substr($search, -1) !== '}') { @@ -130,30 +294,53 @@ class Template $regExpDelim = '/'; $escapedSearch = preg_quote($search, $regExpDelim); - $this->_documentXML = preg_replace("{$regExpDelim}{$escapedSearch}{$regExpDelim}u", $replace, $this->_documentXML, $limit); + return preg_replace("{$regExpDelim}{$escapedSearch}{$regExpDelim}u", $replace, $documentPartXML, $limit); } /** - * Returns array of all variables in template + * Find all variables in $documentPartXML + * @param string $documentPartXML + * @return string[] */ - public function getVariables() + protected function getVariablesForPart($documentPartXML) { - preg_match_all('/\$\{(.*?)}/i', $this->_documentXML, $matches); + preg_match_all('/\$\{(.*?)}/i', $documentPartXML, $matches); + return $matches[1]; } + /** + * Get the name of the footer file for $index + * @param integer $index + * @return string + */ + private function getFooterName($index) + { + return sprintf('word/footer%d.xml', $index); + } + + /** + * Get the name of the header file for $index + * @param integer $index + * @return string + */ + private function getHeaderName($index) + { + return sprintf('word/header%d.xml', $index); + } + /** * Find the start position of the nearest table row before $offset * * @param int $offset * @return int - * @throws \PhpOffice\PhpWord\Exception\Exception + * @throws Exception */ - private function _findRowStart($offset) + private function findRowStart($offset) { - $rowStart = strrpos($this->_documentXML, "_documentXML) - $offset) * -1)); + $rowStart = strrpos($this->documentXML, "documentXML) - $offset) * -1)); if (!$rowStart) { - $rowStart = strrpos($this->_documentXML, "", ((strlen($this->_documentXML) - $offset) * -1)); + $rowStart = strrpos($this->documentXML, "", ((strlen($this->documentXML) - $offset) * -1)); } if (!$rowStart) { throw new Exception("Can not find the start position of the row to clone."); @@ -167,9 +354,9 @@ class Template * @param int $offset * @return int */ - private function _findRowEnd($offset) + private function findRowEnd($offset) { - $rowEnd = strpos($this->_documentXML, "", $offset) + 7; + $rowEnd = strpos($this->documentXML, "", $offset) + 7; return $rowEnd; } @@ -180,100 +367,11 @@ class Template * @param int $endPosition * @return string */ - private function _getSlice($startPosition, $endPosition = 0) + private function getSlice($startPosition, $endPosition = 0) { if (!$endPosition) { - $endPosition = strlen($this->_documentXML); + $endPosition = strlen($this->documentXML); } - return substr($this->_documentXML, $startPosition, ($endPosition - $startPosition)); - } - - /** - * Clone a table row in a template document - * - * @param string $search - * @param int $numberOfClones - * @throws \PhpOffice\PhpWord\Exception\Exception - */ - public function cloneRow($search, $numberOfClones) - { - if (substr($search, 0, 2) !== '${' && substr($search, -1) !== '}') { - $search = '${' . $search . '}'; - } - - $tagPos = strpos($this->_documentXML, $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); - for ($i = 1; $i <= $numberOfClones; $i++) { - $result .= preg_replace('/\$\{(.*?)\}/', '\${\\1#' . $i . '}', $xmlRow); - } - $result .= $this->_getSlice($rowEnd); - - $this->_documentXML = $result; - } - - /** - * Save XML to temporary file - * - * @return string - * @throws \PhpOffice\PhpWord\Exception\Exception - */ - public function save() - { - $this->_objZip->addFromString('word/document.xml', $this->_documentXML); - - // Close zip file - if ($this->_objZip->close() === false) { - throw new Exception('Could not close zip file.'); - } - - return $this->_tempFileName; - } - - /** - * Save XML to defined name - * - * @param string $strFilename - */ - public function saveAs($strFilename) - { - $tempFilename = $this->save(); - - if (\file_exists($strFilename)) { - unlink($strFilename); - } - - rename($tempFilename, $strFilename); + return substr($this->documentXML, $startPosition, ($endPosition - $startPosition)); } } diff --git a/tests/PhpWord/Tests/TemplateTest.php b/tests/PhpWord/Tests/TemplateTest.php index eb766772..6929c4fd 100644 --- a/tests/PhpWord/Tests/TemplateTest.php +++ b/tests/PhpWord/Tests/TemplateTest.php @@ -156,4 +156,27 @@ final class TemplateTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expectedVar, $actualVar); $this->assertTrue($docFound); } + + /** + * Replace variables in header and footer + */ + public function testVariablesCanBeReplacedInHeaderAndFooter() + { + $template = __DIR__ . "/_files/templates/header-footer.docx"; + $expectedVar = array('documentContent', 'headerValue', 'footerValue'); + $docName = 'header-footer-test-result.docx'; + + $document = new Template($template); + $actualVar = $document->getVariables(); + $document->setValue('headerValue', 'Header Value'); + $document->setValue('documentContent', 'Document text.'); + $document->setValue('footerValue', 'Footer Value'); + $document->saveAs($docName); + $docFound = file_exists($docName); + unlink($docName); + + $this->assertEquals($expectedVar, $actualVar); + $this->assertTrue($docFound); + + } } diff --git a/tests/PhpWord/Tests/_files/templates/header-footer.docx b/tests/PhpWord/Tests/_files/templates/header-footer.docx new file mode 100644 index 00000000..647d5222 Binary files /dev/null and b/tests/PhpWord/Tests/_files/templates/header-footer.docx differ