Tinebase_Export_Doc - replace block replace implementation
[tine20] / tine20 / Tinebase / Export / Richtext / TemplateProcessor.php
1 <?php
2 /**
3  * Tinebase Doc/Docx template processor class
4  *
5  * @package     Tinebase
6  * @subpackage  Export
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @author      Paul Mehrer <p.mehrer@metaways.de>
9  * @copyright   Copyright (c) 2017-2017 Metaways Infosystems GmbH (http://www.metaways.de)
10  */
11
12 /**
13  * Tinebase Doc/Docx template processor class
14  *
15  * @package     Tinebase
16  * @subpackage    Export
17  */
18
19
20 class Tinebase_Export_Richtext_TemplateProcessor extends \PhpOffice\PhpWord\TemplateProcessor
21 {
22     const TYPE_STANDARD = 'standard';
23     const TYPE_DATASOURCE = 'datasource';
24     const TYPE_GROUP = 'group';
25     const TYPE_SUBGROUP = 'subgroup';
26     const TYPE_RECORD = 'record';
27     const TYPE_SUBRECORD = 'subrecord';
28
29     /**
30      * Content of document rels (in XML format) of the temporary document.
31      *
32      * @var string
33      */
34     protected $_temporaryDocumentRels = null;
35
36     protected $_tempHeaderRels = array();
37
38     protected $_tempFooterRels = array();
39
40     protected $_type = null;
41
42     protected $_parent = null;
43
44     protected $_config = array();
45
46     /**
47      * @param string $documentTemplate The fully qualified template filename.
48      * @param bool   $inMemory
49      * @param string $type
50      * @param Tinebase_Export_Richtext_TemplateProcessor|null $parent
51      *
52      * @throws \PhpOffice\PhpWord\Exception\CreateTemporaryFileException
53      * @throws \PhpOffice\PhpWord\Exception\CopyFileException
54      */
55     public function __construct($documentTemplate, $inMemory = false, $type = self::TYPE_STANDARD, Tinebase_Export_Richtext_TemplateProcessor $parent = null)
56     {
57         $this->_type = $type;
58         $this->_parent = $parent;
59
60         if (true === $inMemory) {
61             $this->tempDocumentMainPart = $documentTemplate;
62             return;
63         }
64
65         parent::__construct($documentTemplate);
66
67         $index = 1;
68         while (false !== $this->zipClass->locateName($this->getHeaderName($index))) {
69             $fileName = 'word/_rels/header' . $index . '.xml.rels';
70             if (false !== $this->zipClass->locateName($fileName)) {
71                 $this->_tempHeaderRels[$index] = $this->fixBrokenMacros(
72                     $this->zipClass->getFromName($fileName)
73                 );
74             }
75             $index++;
76         }
77         $index = 1;
78         while (false !== $this->zipClass->locateName($this->getFooterName($index))) {
79             $fileName = 'word/_rels/footer' . $index . '.xml.rels';
80             if (false !== $this->zipClass->locateName($fileName)) {
81                 $this->_tempFooterRels[$index] = $this->fixBrokenMacros(
82                     $this->zipClass->getFromName($fileName)
83                 );
84             }
85             $index++;
86         }
87
88         if (false !== $this->zipClass->locateName('word/_rels/document.xml.rels')) {
89             $this->_temporaryDocumentRels = $this->fixBrokenMacros(
90                 $this->zipClass->getFromName('word/_rels/document.xml.rels'));
91         }
92     }
93
94     /**
95      * @return string
96      */
97     public function getType()
98     {
99         return $this->_type;
100     }
101
102     /**
103      * @return Tinebase_Export_Richtext_TemplateProcessor|null
104      */
105     public function getParent()
106     {
107         return $this->_parent;
108     }
109
110     public function replaceTine20ImagePaths()
111     {
112         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
113             Tinebase_Core::getLogger()->debug(__METHOD__ . ' ' . __LINE__ . ' replacing images...');
114
115         if (null !== $this->_temporaryDocumentRels) {
116             $this->_replaceTine20ImagePaths($this->tempDocumentMainPart, $this->_temporaryDocumentRels);
117         }
118         foreach($this->_tempHeaderRels as $index => $data) {
119             $this->_replaceTine20ImagePaths($this->tempDocumentHeaders[$index], $data);
120         }
121         foreach($this->_tempFooterRels as $index => $data) {
122             $this->_replaceTine20ImagePaths($this->tempDocumentFooters[$index], $data);
123         }
124     }
125
126     protected function _replaceTine20ImagePaths($xmlData, $relData)
127     {
128         if (preg_match_all('#<wp:docPr[^>]+"(\w+://[^"]+)".*?r:embed="([^"]+)"#is', $xmlData, $matches, PREG_SET_ORDER)) {
129             foreach($matches as $match) {
130
131                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
132                     Tinebase_Core::getLogger()->debug(__METHOD__ . ' ' . __LINE__ . ' found url: ' . $match[1]);
133
134                 if (!in_array(mb_strtolower(pathinfo($match[1], PATHINFO_EXTENSION)), array('jpg', 'jpeg', 'png', 'gif'))) {
135                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO))
136                         Tinebase_Core::getLogger()->info(__METHOD__ . ' ' . __LINE__ . ' unsupported file extension: ' . $match[1]);
137                     continue;
138                 }
139                 if (preg_match('#Relationship Id="' . $match[2] . '"[^>]+Target="(media/[^"]+)"#', $relData, $relMatch)) {
140                     $fileContent = file_get_contents($match[1]);
141                     if (!empty($fileContent)) {
142                         $this->zipClass->deleteName('word/' . $relMatch[1]);
143                         $this->zipClass->addFromString('word/' . $relMatch[1], $fileContent);
144                     } else {
145                         if (Tinebase_Core::isLogLevel(Zend_Log::WARN))
146                             Tinebase_Core::getLogger()->warn(__METHOD__ . ' ' . __LINE__ . ' could not get file content: ' . $match[1]);
147                     }
148                 } else {
149                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO))
150                         Tinebase_Core::getLogger()->info(__METHOD__ . ' ' . __LINE__ . ' could not find relation matching found url: ' . $match[1]);
151                 }
152             }
153         }
154     }
155
156     /**
157      * Saves the result document.
158      *
159      * @return string
160      *
161      * @throws \PhpOffice\PhpWord\Exception\Exception
162      */
163     public function save()
164     {
165         if (null !== $this->_temporaryDocumentRels) {
166             $this->zipClass->addFromString('word/_rels/document.xml.rels', $this->_temporaryDocumentRels);
167         }
168
169         foreach($this->_tempHeaderRels as $index => $data) {
170             $this->zipClass->addFromString('word/_rels/header' . $index . '.xml.rels', $data);
171         }
172
173         foreach($this->_tempFooterRels as $index => $data) {
174             $this->zipClass->addFromString('word/_rels/footer' . $index . '.xml.rels', $data);
175         }
176
177         return parent::save();
178     }
179
180     /**
181      * @param string $data
182      */
183     public function setMainPart($data)
184     {
185         $this->tempDocumentMainPart = $data;
186     }
187
188     /**
189      * @return string
190      */
191     public function getMainPart()
192     {
193         return $this->tempDocumentMainPart;
194     }
195
196     /**
197      * replace a table row in a template document and return replaced row
198      *
199      * @param string $search
200      * @param string $replacement
201      *
202      * @return string
203      *
204      * @throws \PhpOffice\PhpWord\Exception\Exception
205      */
206     public function replaceRow($search, $replacement)
207     {
208         if ('${' !== substr($search, 0, 2) && '}' !== substr($search, -1)) {
209             $search = '${' . $search . '}';
210         }
211
212         $tagPos = strpos($this->tempDocumentMainPart, $search);
213         if (!$tagPos) {
214             throw new \PhpOffice\PhpWord\Exception\Exception("Can not clone row, template variable not found or variable contains markup.");
215         }
216
217         $rowStart = $this->findRowStart($tagPos);
218         $rowEnd = $this->findRowEnd($tagPos);
219         $xmlRow = $this->getSlice($rowStart, $rowEnd);
220
221         $result = $this->getSlice(0, $rowStart) . $replacement;
222         $result .= $this->getSlice($rowEnd);
223
224         $this->tempDocumentMainPart = $result;
225
226         return $xmlRow;
227     }
228
229     /**
230      * @param $data
231      */
232     public function append($data)
233     {
234         $this->tempDocumentMainPart .= $data;
235     }
236
237     /**
238      * Find the start position of the nearest table row before $offset.
239      *
240      * @param integer $offset
241      * @return integer
242      */
243     protected function findRowStart($offset)
244     {
245         return $this->findTag('<w:tr', $offset, false);
246     }
247
248     /**
249      * @param string $tag
250      * @param int $offset
251      * @param bool $forward
252      * @return int
253      * @throws Tinebase_Exception_NotFound
254      */
255     protected function findTag($tag, $offset, $forward = true)
256     {
257         if (true === $forward) {
258             $strpos = 'strpos';
259             $minmax = 'min';
260         } else {
261             $strpos = 'strrpos';
262             $minmax = 'max';
263             $offset = (strlen($this->tempDocumentMainPart) - $offset) * -1;
264         }
265
266         $result1 = $strpos($this->tempDocumentMainPart, $tag . ' ', $offset);
267         $result2 = $strpos($this->tempDocumentMainPart, $tag . '>', $offset);
268
269         if (false === $result1) {
270             if (false === $result2) {
271                 throw new Tinebase_Exception_NotFound('Can not find the start position of the tag: ' . $tag);
272             }
273             return (int)$result2;
274         }
275         if (false === $result2) {
276             return (int)$result1;
277         }
278
279         return (int)$minmax($result1, $result2);
280     }
281
282     /**
283      * Find a block (optionally replace it)
284      *
285      * @param string $blockName
286      * @param string $replacement
287      *
288      * @return string|null
289      */
290     public function findBlock($blockName, $replacement = null)
291     {
292         $openBlock = '${' . $blockName . '}';
293         if (false === ($openBlockPos = strpos($this->tempDocumentMainPart, $openBlock))) {
294             return null;
295         }
296         $openBlockPos = $this->findTag('<w:p', $openBlockPos, false);
297         if (false === ($endOpenBlockPos = strpos($this->tempDocumentMainPart, '</w:p>', $openBlockPos))) {
298             return null;
299         }
300         $endOpenBlockPos += 6;
301
302         $closeBlock = '${/' . $blockName . '}';
303         if (false === ($closeBlockPos = strpos($this->tempDocumentMainPart, $closeBlock, $endOpenBlockPos))) {
304             return null;
305         }
306         $closeBlockPos = $this->findTag('<w:p', $closeBlockPos, false);
307         if (false === ($endCloseBlockPos = strpos($this->tempDocumentMainPart, '</w:p>', $closeBlockPos))) {
308             return null;
309         }
310         $endCloseBlockPos += 6;
311
312         $xmlBlock = substr($this->tempDocumentMainPart, $endOpenBlockPos, $closeBlockPos - $endOpenBlockPos);
313
314         if (null !== $replacement) {
315             $this->tempDocumentMainPart = substr($this->tempDocumentMainPart, 0, $openBlockPos) . $replacement .
316                 substr($this->tempDocumentMainPart, $endCloseBlockPos);
317         }
318
319         return $xmlBlock;
320     }
321
322     /**
323      * Clone a block.
324      *
325      * @param string $blockname
326      * @param integer $clones
327      * @param boolean $replace
328      * @return string|null
329      * @throws Tinebase_Exception_NotImplemented
330      */
331     public function cloneBlock($blockname, $clones = 1, $replace = true)
332     {
333         throw new Tinebase_Exception_NotImplemented('do not use this function! ' . __METHOD__);
334     }
335
336     /**
337      * Replace a block.
338      *
339      * @param string $blockname
340      * @param string $replacement
341      * @throws Tinebase_Exception_NotImplemented
342      */
343     public function replaceBlock($blockname, $replacement)
344     {
345         throw new Tinebase_Exception_NotImplemented('do not use this function! ' . __METHOD__);
346     }
347
348     /**
349      * @param array $config
350      */
351     public function setConfig(array $config)
352     {
353         $this->_config = $config;
354         if (Tinebase_Export_Richtext_TemplateProcessor::TYPE_RECORD === $this->_type &&
355                 isset($this->_config['recordXml'])) {
356
357         }
358     }
359
360     /**
361      * @param $key
362      * @return bool
363      */
364     public function hasConfig($key)
365     {
366         return isset($this->_config[$key]);
367     }
368
369     /**
370      * @param string|null $key
371      * @return mixed
372      */
373     public function getConfig($key = null)
374     {
375         if (null === $key) {
376             return $this->_config;
377         }
378         return $this->_config[$key];
379     }
380
381     /**
382      * Returns array of all variables in template.
383      *
384      * @return string[]
385      */
386     public function getVariables()
387     {
388         $result = parent::getVariables();
389
390         switch($this->_type) {
391             /** @noinspection PhpMissingBreakStatementInspection */
392             case self::TYPE_DATASOURCE:
393                 if (isset($this->_config['group'])) {
394                     $result = array_merge($result, $this->_config['group']->getVariables());
395                 }
396             case self::TYPE_GROUP:
397                 if (isset($this->_config['record'])) {
398                     $result = array_merge($result, $this->_config['record']->getVariables());
399                 }
400                 if (isset($this->_config['recordRow']) && isset($this->_config['recordRow']['recordRowProcessor'])) {
401                     /** @noinspection PhpUndefinedMethodInspection */
402                     $result = array_merge($result, $this->_config['recordRow']['recordRowProcessor']->getVariables());
403                 }
404                 // DO NOT return variables of sub groups or sub records
405                 break;
406             case self::TYPE_SUBGROUP:
407             case self::TYPE_SUBRECORD:
408                 if (isset($this->_config['recordXml'])) {
409                     $result = array_merge($result, $this->getVariablesForPart($this->_config['recordXml']));
410                 }
411                 break;
412         }
413
414         return array_unique($result);
415     }
416 }