diff --git a/src/PhpWord/TemplateProcessor.php b/src/PhpWord/TemplateProcessor.php index e7a8d039c4..cbc238ceb3 100644 --- a/src/PhpWord/TemplateProcessor.php +++ b/src/PhpWord/TemplateProcessor.php @@ -22,6 +22,7 @@ use PhpOffice\PhpWord\Exception\CopyFileException; use PhpOffice\PhpWord\Exception\CreateTemporaryFileException; use PhpOffice\PhpWord\Exception\Exception; +use PhpOffice\PhpWord\Shared\Converter; use PhpOffice\PhpWord\Shared\ZipArchive; use Zend\Stdlib\StringUtils; @@ -29,6 +30,32 @@ class TemplateProcessor { const MAXIMUM_REPLACEMENTS_DEFAULT = -1; + /** + * sprintf Template for a table + * + * sprintf arguments: + * 1: s, gridCol definition elements + * 2: s, table cell elements + */ + const TABLE_TEMPLATE = '%s%s'; + + /** + * sprintf Template for a gridCol element in a table definition + * + * sprintf arguments: + * 1: d, width of the column in Twips + */ + const TABLEGRIDCOL_TEMPLATE = ''; + + /** + * sprintf Template for a table cell element in a table definition + * + * sprintf arguments: + * 1: d, width of the cell in Twips + * 2: s, cell text content + */ + const TABLECELL_TEMPLATE = '%s'; + /** * ZipArchive object. * @@ -147,7 +174,7 @@ protected function transformXml($xml, $xsltProcessor) /** * Applies XSL style sheet to template's parts. - * + * * Note: since the method doesn't make any guess on logic of the provided XSL style sheet, * make sure that output is correctly escaped. Otherwise you may get broken document. * @@ -256,6 +283,44 @@ public function getVariables() return array_unique($variables); } + /** + * Insert a table at the place marked by the block template + * @param string $name Name of block template + * @param array $columns Array keyed on column (template) name, containing + * column width in CSS units (string) or twips (integer) + * + * @return \PhpOffice\PhpWord\TemplateProcessor + */ + public function insertTable($name, array $columns) + { + $gridCols = ''; + $cells = ''; + + foreach ($columns as $variable => $width) { + $twipWidth = is_string($width) ? Converter::cssToTwip($width) : $width; + $gridCols .= sprintf(static::TABLEGRIDCOL_TEMPLATE, $twipWidth); + $cells .= sprintf(static::TABLECELL_TEMPLATE, $twipWidth, static::ensureMacroCompleted($variable)); + } + + return $this->replaceXmlBlock($name, sprintf(static::TABLE_TEMPLATE, $gridCols, $cells)); + } + + /** + * Delete a table containing the given variable + * + * @param string $search + * + * @return \PhpOffice\PhpWord\TemplateProcessor + */ + public function deleteTable($search) + { + $block = $this->findContainingXmlBlockForMacro($search, 'w:tbl'); + + $this->tempDocumentMainPart = $this->getSlice(0, $block['start']) . $this->getSlice($block['end']); + + return $this; + } + /** * Clone a table row in a template document. * @@ -315,6 +380,22 @@ public function cloneRow($search, $numberOfClones) $this->tempDocumentMainPart = $result; } + /** + * Delete a row containing the given variable + * + * @param string $search + * + * @return \PhpOffice\PhpWord\Template + */ + public function deleteRow($search) + { + $block = $this->findContainingXmlBlockForMacro($search, 'w:tr'); + + $this->tempDocumentMainPart= $this->getSlice(0, $block['start']) . $this->getSlice($block['end']); + + return $this; + } + /** * Clone a block. * @@ -584,4 +665,138 @@ protected function getSlice($startPosition, $endPosition = 0) return substr($this->tempDocumentMainPart, $startPosition, ($endPosition - $startPosition)); } + + /** + * Replace an XML block surrounding a macro with a new block + * + * @param string $macro Name of macro + * @param string $block New block content + * @param string $blockType XML tag type of block + * @return \PhpOffice\PhpWord\TemplateProcessor Fluent interface + */ + protected function replaceXmlBlock($macro, $block, $blockType = 'w:p') + { + $where = $this->findContainingXmlBlockForMacro($macro, $blockType); + if (false !== $where) { + $this->tempDocumentMainPart = $this->getSlice(0, $where['start']) . $block . $this->getSlice($where['end']); + } + return $this; + } + + /** + * Find start and end of XML block containing the given macro + * e.g. ...${macro}... + * + * Note that only the first instance of the macro will be found + * + * @param string $macro Name of macro + * @param string $blockType XML tag for block + * @return boolean|integer[] FALSE if not found, otherwise array with start and end + */ + protected function findContainingXmlBlockForMacro($macro, $blockType = 'w:p') + { + $macroPos = $this->findMacro($macro); + if (false === $macroPos) { + return false; + } + + $start = $this->findXmlBlockStart($macroPos, $blockType); + if (0 > $start) { + return false; + } + + $end = $this->findXmlBlockEnd($macroPos, $blockType); + if (0 > $end) { + return false; + } + + return array('start' => $start, 'end' => $end); + } + + /** + * Find start and end of XML block containing the given block macro + * e.g. ...${macro}...${/macro}... + * + * Note that only the first instance of the macro will be found + * + * @param string $macro Name of macro + * @param string $blockType XML tag for block + * @return boolean|integer[] FALSE if not found, otherwise array with start and end + */ + protected function findContainingXmlBlockForBlockMacro($macro, $blockType = 'w:p') + { + $macroStartPos = $this->findMacro($macro); + if (0 > $macroStartPos) { + return false; + } + + $macroEndPos = $this->findMacro('/' . $macro, $macroStartPos); + if (0 > $macroEndPos) { + return false; + } + + $start = $this->findXmlBlockStart($macroStartPos, $blockType); + if (0 > $start) { + return false; + } + + $end = $this->findXmlBlockEnd($macroEndPos, $blockType); + if (0 > $end) { + return false; + } + + return array('start' => $start, 'end' => $end); + } + + /** + * Find the position of (the start of) a macro + * + * Returns -1 if not found, otherwise position of opening $ + * + * Note that only the first instance of the macro will be found + * + * @param string $search Macro name + * @param string $offset Offset from which to start searching + * @return integer -1 if macro not found + */ + protected function findMacro($search, $offset = 0) + { + $search = static::ensureMacroCompleted($search); + + $pos = strpos($this->tempDocumentMainPart, $search, $offset); + + return ($pos === false) ? -1 : $pos; + } + + /** + * Find the start position of the nearest XML block start before $offset + * + * @param integer $offset Search position + * @param string $blockType XML Block tag + * @return integer -1 if block start not found + */ + protected function findXmlBlockStart($offset, $blockType) + { + // first try XML tag with attributes + $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . ' ', ((strlen($this->tempDocumentMainPart) - $offset) * -1)); + if (false === $blockStart) { + // also try XML tag without attributes + $blockStart = strrpos($this->tempDocumentMainPart, '<' . $blockType . '>', ((strlen($this->tempDocumentMainPart) - $offset) * -1)); + } + return ($blockStart === false) ? -1 : $blockStart; + } + + /** + * Find the nearest block end position after $offset + * + * @param integer $offset Search position + * @param string $blockType XML Block tag + * @return integer -1 if block end not found + */ + protected function findXmlBlockEnd($offset, $blockType) + { + $blockEndStart = strpos($this->tempDocumentMainPart, '', $offset); + // return position of end of tag if found, otherwise -1 + return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType); + } } diff --git a/tests/PhpWord/TemplateProcessorTest.php b/tests/PhpWord/TemplateProcessorTest.php index 11b43cf454..91b881a8ce 100644 --- a/tests/PhpWord/TemplateProcessorTest.php +++ b/tests/PhpWord/TemplateProcessorTest.php @@ -17,6 +17,8 @@ namespace PhpOffice\PhpWord; +use PhpOffice\PhpWord\Shared\ZipArchive; + /** * @covers \PhpOffice\PhpWord\TemplateProcessor * @coversDefaultClass \PhpOffice\PhpWord\TemplateProcessor @@ -223,4 +225,102 @@ public function testCloneDeleteBlock() unlink($docName); $this->assertTrue($docFound); } + + /** + * @covers ::insertTable + * @covers ::saveAs + * @test + */ + public function testInsertTable() + { + $docName = 'table-insert.docx'; + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/' . $docName); + + $this->assertEquals( + array('myTable', 'otherContent'), + $templateProcessor->getVariables() + ); + + $templateProcessor->insertTable('myTable', array('myCol1#' => 6000, 'myCol2#' => '1in', 'myCol3#' => '72pt')); + $this->assertEquals( + array('myCol1#', 'myCol2#', 'myCol3#', 'otherContent'), + $templateProcessor->getVariables() + ); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + $zip = new ZipArchive(); + $zip->open($docName); + $mainDoc = $zip->getFromName('word/document.xml'); + $zip->close(); + unlink($docName); + $this->assertTrue($docFound); + $this->assertTrue(false !== strpos($mainDoc, '')); + $this->assertTrue(false !== strpos($mainDoc, '${myCol1#}')); + $this->assertTrue(false !== strpos($mainDoc, '${myCol2#}')); + $this->assertTrue(false !== strpos($mainDoc, '${myCol3#}')); + } + + /** + * @covers ::deleteTable + * @covers ::saveAs + * @test + */ + public function testDeleteTable() + { + $docName = 'table-delete.docx'; + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/' . $docName); + + $this->assertEquals( + array('KEEP_THIS_TABLE', 'DELETE_THIS_TABLE'), + $templateProcessor->getVariables() + ); + + $templateProcessor->deleteTable('DELETE_THIS_TABLE'); + $this->assertEquals( + array('KEEP_THIS_TABLE'), + $templateProcessor->getVariables() + ); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + $zip = new ZipArchive(); + $zip->open($docName); + $mainDoc = $zip->getFromName('word/document.xml'); + $zip->close(); + unlink($docName); + $this->assertTrue($docFound); + $numMatches = preg_match_all('//u', $mainDoc); + $this->assertTrue($numMatches === 1); + } + + /** + * @covers ::deleteRow + * @covers ::saveAs + * @test + */ + public function testDeleteRow() + { + $docName = 'table-delete-row.docx'; + $templateProcessor = new TemplateProcessor(__DIR__ . '/_files/templates/' . $docName); + + $this->assertEquals( + array('KEEP_THE_HEADER', 'DELETE_THIS_ROW', 'RETAIN_THIS_ROW'), + $templateProcessor->getVariables() + ); + + $templateProcessor->deleteRow('DELETE_THIS_ROW'); + $this->assertEquals( + array('KEEP_THE_HEADER', 'RETAIN_THIS_ROW'), + $templateProcessor->getVariables() + ); + $templateProcessor->saveAs($docName); + $docFound = file_exists($docName); + $zip = new ZipArchive(); + $zip->open($docName); + $mainDoc = $zip->getFromName('word/document.xml'); + $zip->close(); + unlink($docName); + $this->assertTrue($docFound); + $numMatches = preg_match_all('/assertTrue($numMatches === 2); + } } diff --git a/tests/PhpWord/_files/templates/table-delete-row.docx b/tests/PhpWord/_files/templates/table-delete-row.docx new file mode 100644 index 0000000000..738d044d67 Binary files /dev/null and b/tests/PhpWord/_files/templates/table-delete-row.docx differ diff --git a/tests/PhpWord/_files/templates/table-delete.docx b/tests/PhpWord/_files/templates/table-delete.docx new file mode 100644 index 0000000000..203a214e53 Binary files /dev/null and b/tests/PhpWord/_files/templates/table-delete.docx differ diff --git a/tests/PhpWord/_files/templates/table-insert.docx b/tests/PhpWord/_files/templates/table-insert.docx new file mode 100644 index 0000000000..82fd58c885 Binary files /dev/null and b/tests/PhpWord/_files/templates/table-insert.docx differ