diff --git a/src/PhpWord/Shared/Converter.php b/src/PhpWord/Shared/Converter.php index e5cb5b25f0..f5b4b84bb7 100644 --- a/src/PhpWord/Shared/Converter.php +++ b/src/PhpWord/Shared/Converter.php @@ -17,6 +17,8 @@ namespace PhpOffice\PhpWord\Shared; +use PhpOffice\PhpWord\Exception\InvalidStyleException; + /** * Common converter functions */ @@ -227,6 +229,121 @@ public static function emuToPixel($emu = 1) return round($emu / self::PIXEL_TO_EMU); } + /** + * Convert an absolute CSS measurement to pixels + * + * Units for absolute CSS measurements are cm, mm, in, px, pt and pc + * + * Note that the result will be rounded to the nearest pixel + * + * @param string $cssMeasurement If no measurement unit is included then cm + * is assumed + * + * @throws \PHPOffice\PhpWord\Exception\InvalidStyleException + * + * @return float + */ + public static function cssToPixel($cssMeasurement = '1cm') + { + $units = trim(preg_replace('/^-?(?:\\d+\\.\\d+|\\.?\\d+)/', '', trim($cssMeasurement))); + $value = preg_replace('/\\D+$/', '', trim($cssMeasurement)); + if ((strlen($value) > 0) && ($value[0] == '.')) { + $value = '0' . $value; + } + switch (strtolower($units)) { + case 'in': + $pixel = $value * static::INCH_TO_PIXEL; + break; + case 'cm': + case '': + $pixel = ($value / static::INCH_TO_CM) * static::INCH_TO_PIXEL; + break; + case 'mm': + $pixel = ($value / (10 * static::INCH_TO_CM)) * static::INCH_TO_PIXEL; + break; + case 'pt': + $pixel = ($value / static::INCH_TO_POINT) * static::INCH_TO_PIXEL; + break; + case 'pc': + $pixel = ($value / (12 * static::INCH_TO_POINT)) * static::INCH_TO_PIXEL; + break; + case 'px': + $pixel = floatval($value); + break; + default: + throw new InvalidStyleException($cssMeasurement . ' is an unsupported CSS measurement'); + } + return $pixel; + } + + /** + * Convert an absolute CSS measurement to EMU + * + * Units for absolute CSS measurements are cm, mm, in, px, pt and pc + * + * @param string $cssMeasurement If no measurement unit is included then cm + * is assumed + * + * @throws \PHPOffice\PhpWord\Exception\InvalidStyleException + * + * @return float + */ + public static function cssToEmu($cssMeasurement = '1cm') + { + return static::cssToPixel($cssMeasurement) * static::PIXEL_TO_EMU; + } + + /** + * Convert an absolute CSS measurement to point + * + * Units for absolute CSS measurements are cm, mm, in, px, pt and pc + * + * @param string $cssMeasurement If no measurement unit is included then cm + * is assumed + * + * @throws \PHPOffice\PhpWord\Exception\InvalidStyleException + * + * @return float + */ + public static function cssToPoint($cssMeasurement = '1cm') + { + return static::cssToPixel($cssMeasurement) * static::INCH_TO_POINT / static::INCH_TO_PIXEL; + } + + /** + * Convert an absolute CSS measurement to twip + * + * Units for absolute CSS measurements are cm, mm, in, px, pt and pc + * + * @param string $cssMeasurement If no measurement unit is included then cm + * is assumed + * + * @throws \PHPOffice\PhpWord\Exception\InvalidStyleException + * + * @return float + */ + public static function cssToTwip($cssMeasurement = '1cm') + { + return static::cssToPixel($cssMeasurement) * static::INCH_TO_TWIP / static::INCH_TO_PIXEL; + } + + /** + * Convert an absolute CSS measurement to centimeter + * + * Units for absolute CSS measurements are cm, mm, in, px, pt and pc + * + * @param string $cssMeasurement If no measurement unit is included then cm + * is assumed + * + * @throws \PHPOffice\PhpWord\Exception\InvalidStyleException + * + * @return float + */ + public static function cssToCm($cssMeasurement = '1cm') + { + return static::cssToPixel($cssMeasurement) * static::INCH_TO_CM / static::INCH_TO_PIXEL; + } + /** * Convert degree to angle * 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/Shared/ConverterTest.php b/tests/PhpWord/Shared/ConverterTest.php index e307f09b1e..8ed251d666 100644 --- a/tests/PhpWord/Shared/ConverterTest.php +++ b/tests/PhpWord/Shared/ConverterTest.php @@ -112,4 +112,84 @@ public function testHtmlToRGB() $this->assertEquals($value[1], $result); } } + + /** + * @covers ::cssToPixel + * @test + */ + public function testCssToPixel() + { + // Prepare test values [ original, expected ] + $values[] = array('1pt', (1 / 72) * 96); + $values[] = array('10.5pc', (10.5 / (72 * 12)) * 96); + $values[] = array('4cm', (4 / 2.54) * 96); + $values[] = array('1', (1 / 2.54) * 96); + $values[] = array('2mm', (0.2 / 2.54) * 96); + $values[] = array('.1', (0.1 / 2.54) * 96); + $values[] = array('2in', 2 * 96); + foreach ($values as $value) { + $result = Converter::cssToPixel($value[0]); + $this->assertEquals($value[1], $result); + } + } + + /** + * @covers ::cssToEmu + * @test + */ + public function testCssToEmu() + { + // Prepare test values [ original, expected ] + $values[] = array('1pt', (1 / 72) * 96 * 9525); + $values[] = array('10.5pc', (10.5 / (72 * 12)) * 96 * 9525); + $values[] = array('4cm', (4 / 2.54) * 96 * 9525); + $values[] = array('1', (1 / 2.54) * 96 * 9525); + $values[] = array('2mm', (0.2 / 2.54) * 96 * 9525); + $values[] = array('.1', (0.1 / 2.54) * 96 * 9525); + $values[] = array('2in', 2 * 96 * 9525); + foreach ($values as $value) { + $result = Converter::cssToEmu($value[0]); + $this->assertEquals($value[1], $result); + } + } + + /** + * @covers ::cssToCm + * @test + */ + public function testCssToCm() + { + // Prepare test values [ original, expected ] + $values[] = array('1pt', (1 / 72) * 2.54); + $values[] = array('10.5pc', (10.5 / (72 * 12)) * 2.54); + $values[] = array('4cm', 4); + $values[] = array('1', 1); + $values[] = array('2mm', 2 / 10); + $values[] = array('.1', 1 / 10); + $values[] = array('2in', 2 * 2.54); + foreach ($values as $value) { + $result = Converter::cssToCm($value[0]); + $this->assertEquals($value[1], $result); + } + } + + /** + * @covers ::cssToTwip + * @test + */ + public function testCssToTwip() + { + // Prepare test values [ original, expected ] + $values[] = array('1pt', (1 / 72) * 1440); + $values[] = array('10.5pc', (10.5 / (72 * 12)) * 1440); + $values[] = array('4cm', (4 / 2.54) * 1440); + $values[] = array('1', (1 / 2.54) * 1440); + $values[] = array('2mm', (0.2 / 2.54) * 1440); + $values[] = array('.1', (0.1 / 2.54) * 1440); + $values[] = array('2in', 2 * 1440); + foreach ($values as $value) { + $result = Converter::cssToTwip($value[0]); + $this->assertEquals($value[1], $result); + } + } } diff --git a/tests/PhpWord/TemplateProcessorTest.php b/tests/PhpWord/TemplateProcessorTest.php index 11b43cf454..ac81573d2f 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,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, '')); + $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); + // preg_match_all requires >= 3 parameters in PHP5.3 + $dummy = null; + $numMatches = preg_match_all('//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('/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