Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Templating tables #1086

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Additional template processing for tables
- insertion of a new table at a placeholder
- deletion of a table containing a placeholder
- deletion of a table row containing a placeholder
  • Loading branch information
ejn committed Jun 27, 2017
commit e1cf170ddb92ac3eef59f9275e61393b9c5071a4
217 changes: 216 additions & 1 deletion src/PhpWord/TemplateProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,40 @@
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;

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 = '<w:tbl><w:tblPr><w:tblStyle w:val="Tabellenraster"/><w:tblW w:w="0" w:type="auto"/><w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/></w:tblPr><w:tblGrid>%s</w:tblGrid><w:tr>%s</w:tr></w:tbl>';

/**
* sprintf Template for a gridCol element in a table definition
*
* sprintf arguments:
* 1: d, width of the column in Twips
*/
const TABLEGRIDCOL_TEMPLATE = '<w:gridCol w:w="%d"/>';

/**
* 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 = '<w:tc><w:tcPr><w:tcW w:w="%d" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>%s</w:t></w:r></w:p></w:tc>';

/**
* ZipArchive object.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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. <w:p>...${macro}...</w:p>
*
* 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. <w:p>...${macro}...${/macro}...</w:p>
*
* 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, '</' . $blockType . '>', $offset);
// return position of end of tag if found, otherwise -1
return ($blockEndStart === false) ? -1 : $blockEndStart + 3 + strlen($blockType);
}
}
104 changes: 104 additions & 0 deletions tests/PhpWord/TemplateProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

namespace PhpOffice\PhpWord;

use PhpOffice\PhpWord\Shared\ZipArchive;

/**
* @covers \PhpOffice\PhpWord\TemplateProcessor
* @coversDefaultClass \PhpOffice\PhpWord\TemplateProcessor
Expand Down Expand Up @@ -223,4 +225,106 @@ 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, '<w:gridCol w:w="6000"/><w:gridCol w:w="1440"/><w:gridCol w:w="1440"/>'));
$this->assertTrue(false !== strpos($mainDoc, '<w:tc><w:tcPr><w:tcW w:w="6000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>${myCol1#}</w:t></w:r></w:p></w:tc>'));
$this->assertTrue(false !== strpos($mainDoc, '<w:tc><w:tcPr><w:tcW w:w="1440" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>${myCol2#}</w:t></w:r></w:p></w:tc>'));
$this->assertTrue(false !== strpos($mainDoc, '<w:tc><w:tcPr><w:tcW w:w="1440" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>${myCol3#}</w:t></w:r></w:p></w:tc>'));
}

/**
* @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);
// preg_match_all requires >= 3 parameters in PHP5.3
$dummy = null;
$numMatches = preg_match_all('/<w:tbl>/u', $mainDoc, $dummy);
$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);
// preg_match_all requires >= 3 parameters in PHP5.3
$dummy = null;
$numMatches = preg_match_all('/<w:tr /u', $mainDoc, $dummy);
$this->assertTrue($numMatches === 2);
}
}
Binary file not shown.
Binary file added tests/PhpWord/_files/templates/table-delete.docx
Binary file not shown.
Binary file added tests/PhpWord/_files/templates/table-insert.docx
Binary file not shown.