Tinebase_Export_Xls - clone row styles too
[tine20] / tine20 / Tinebase / Export / Xls.php
1 <?php
2 /**
3  * Tinebase Xls/Xlsx generation 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 Metaways Infosystems GmbH (http://www.metaways.de)
10  */
11
12 /**
13  * Tinebase Xls/Xlsx generation class
14  *
15  * @package     Tinebase
16  * @subpackage  Export
17  */
18
19 class Tinebase_Export_Xls extends Tinebase_Export_Abstract implements Tinebase_Record_IteratableInterface {
20
21     /**
22      * the document
23      *
24      * @var PHPExcel
25      */
26     protected $_excelObject;
27
28     /**
29      * format strings
30      *
31      * @var string
32      */
33     protected $_format = 'xls';
34
35     protected $_rowOffset = 0;
36
37     protected $_rowCount = 0;
38
39     protected $_columnCount = 0;
40
41     protected $_cloneRow = null;
42
43     protected $_cloneRowStyles = array();
44
45     protected $_excelVersion = null;
46
47
48     /**
49      * the constructor
50      *
51      * @param Tinebase_Model_Filter_FilterGroup $_filter
52      * @param Tinebase_Controller_Record_Interface $_controller (optional)
53      * @param array $_additionalOptions (optional) additional options
54      */
55     public function __construct(Tinebase_Model_Filter_FilterGroup $_filter, Tinebase_Controller_Record_Interface $_controller = NULL, $_additionalOptions = array())
56     {
57         parent::__construct($_filter, $_controller, $_additionalOptions);
58
59         if (empty($this->_config->writer)) {
60             $this->_excelVersion = 'Excel2007';
61         } else {
62             $this->_excelVersion = $this->_config->writer;
63         }
64     }
65
66     public static function getDefaultFormat()
67     {
68         return 'xlsx';
69     }
70
71     /**
72      * get excel object
73      *
74      * @return PHPExcel
75      */
76     public function getDocument()
77     {
78         return $this->_excelObject;
79     }
80
81     /**
82      * get export content type
83      *
84      * @return string
85      */
86     public function getDownloadContentType()
87     {
88         $contentType = ('Excel2007' === $this->_excelVersion)
89             // Excel 2007 content type
90             ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
91             // Excel 5 content type or other
92             : 'application/vnd.ms-excel';
93
94         return $contentType;
95     }
96
97     /**
98      * return download filename
99      * @param string $_appName
100      * @param string $_format
101      * @return string
102      */
103     public function getDownloadFilename($_appName, $_format)
104     {
105         $result = parent::getDownloadFilename($_appName, $_format);
106
107         if ('Excel2007' === $this->_excelVersion) {
108             // excel2007 extension is .xlsx
109             $result .= 'x';
110         }
111
112         return $result;
113     }
114
115     /**
116      * output result
117      */
118     public function write()
119     {
120         Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Creating and sending xls to client (Format: ' . $this->_excelVersion . ').');
121         $xlswriter = PHPExcel_IOFactory::createWriter($this->_excelObject, $this->_excelVersion);
122
123         // precalculating formula values costs tons of time, because sum formulas are like SUM C1:C65000
124         /** @noinspection PhpUndefinedMethodInspection */
125         $xlswriter->setPreCalculateFormulas(FALSE);
126
127         $xlswriter->save('php://output');
128     }
129
130     /**
131      * generate export
132      */
133     public function generate()
134     {
135         $this->_rowCount = 0;
136         $this->_columnCount = 0;
137         $this->_createDocument();
138         $this->_exportRecords();
139         $this->_replaceTine20ImagePaths();
140     }
141
142     protected function _startRow()
143     {
144         $this->_rowCount += 1;
145         $this->_columnCount = 0;
146
147         //insert cloned row
148         if ($this->_rowOffset > 0) {
149             $newRowOffset = $this->_rowOffset + $this->_rowCount - 1;
150             $sheet = $this->_excelObject->getActiveSheet();
151
152             // this doesn't work sadly...
153             //if ($sheet->getHighestRow() >= $newRowOffset) {
154                 if ($this->_rowCount > 1) {
155                     $sheet->insertNewRowBefore($newRowOffset + 1);
156                 }
157             //}
158
159             foreach($this->_cloneRow as $newRow) {
160                 $cell = $sheet->getCell($newRow['column'] . $newRowOffset);
161                 $cell->setValue($newRow['value'] . '#' . $this->_rowCount);
162                 $cell->setXfIndex($newRow['XFIndex']);
163             }
164
165             $rowDimension = $sheet->getRowDimension($newRowOffset);
166             foreach($this->_cloneRowStyles as $func => $value) {
167                 call_user_func(array($rowDimension, $func), $value);
168             }
169         }
170     }
171
172     protected function _createDocument()
173     {
174         $templateFile = $this->_getTemplateFilename();
175
176         if ($templateFile !== NULL) {
177
178             $tmpFile = Tinebase_TempFile::getTempPath();
179             if (false === copy($templateFile, $tmpFile)) {
180                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' could not copy template file to temp path');
181                 throw new Tinebase_Exception('could not copy template file to temp path');
182             }
183
184             if (! $this->_config->reader || 'autodetection' === $this->_config->reader) {
185                 $this->_excelObject = PHPExcel_IOFactory::load($tmpFile);
186             } else {
187                 $reader = PHPExcel_IOFactory::createReader($this->_config->reader);
188                 $this->_excelObject = $reader->load($tmpFile);
189             }
190
191             // need to unregister the zip stream wrapper because it is overwritten by PHPExcel!
192             // TODO file a bugreport to PHPExcel
193             @stream_wrapper_restore("zip");
194
195             $activeSheet = isset($this->_config->sheet) ? $this->_config->sheet : 0;
196             $this->_excelObject->setActiveSheetIndex($activeSheet);
197
198             $this->_hasTemplate = true;
199             $this->_dumpRecords = true;
200             $this->_writeGenericHeader = true;
201         } else {
202             Tinebase_Core::getLogger()->info(__METHOD__ . '::' . __LINE__ . ' Creating new PHPExcel object.');
203             $this->_excelObject = new PHPExcel();
204         }
205     }
206
207     /**
208      * TODO performance?
209      * TODO performance: cache cells with replacement tokens, update cache when cloning a row
210      * TODO not just replace first, replace all!
211      *
212      * @param string $_key
213      * @param string $_value
214      */
215     protected function _setValue($_key, $_value)
216     {
217         if (true !== $this->_iterationDone) {
218             $_key = $_key . '#' . $this->_rowCount;
219         }
220
221         if (null !== ($cell = $this->_findCell($_key))) {
222             $cell->setValue(str_replace($_key, $_value, $cell->getValue()));
223         }
224
225         foreach($this->_excelObject->getActiveSheet()->getDrawingCollection() as $drawing) {
226             $desc = $drawing->getDescription();
227             if (\strpos($desc, $_key) !== false) {
228                 $drawing->setDescription(str_replace($_key, $_value, $desc));
229             }
230         }
231     }
232
233     /**
234      * @param string $_search
235      * @return PHPExcel_Cell
236      */
237     protected function _findCell($_search)
238     {
239         $sheet = $this->_excelObject->getActiveSheet();
240
241         $rowIter = $sheet->getRowIterator();
242         /** @var PHPExcel_Worksheet_Row $row */
243         foreach($rowIter as $row) {
244             $cellIter = $row->getCellIterator();
245             try {
246                 $cellIter->setIterateOnlyExistingCells(true);
247             } catch (PHPExcel_Exception $pe) {
248                 continue;
249             }
250             /** @var PHPExcel_Cell $cell */
251             foreach($cellIter as $cell) {
252                 if (false !== strpos($cell->getValue(), $_search)) {
253                     return $cell;
254                 }
255             }
256         }
257
258         return null;
259     }
260
261     /**
262      * TODO pass value type? for dates etc.?
263      *
264      * @param string $_value
265      * @throws Tinebase_Exception_NotImplemented
266      */
267     protected function _writeValue($_value)
268     {
269         $sheet = $this->_excelObject->getActiveSheet();
270
271         $cell = $sheet->getCellByColumnAndRow($this->_columnCount++, $this->_rowCount);
272
273         $cell->setValue($_value);
274     }
275
276     /**
277      * TODO build up cache of replacement tokens so that _setValue can be implemented faster!
278      *
279      * @return string
280      */
281     public function _getTwigSource()
282     {
283         $i = 0;
284         $source = '[';
285
286         $sheet = $this->_excelObject->getActiveSheet();
287
288         $rowIter = $sheet->getRowIterator();
289         /** @var PHPExcel_Worksheet_Row $row */
290         foreach($rowIter as $row) {
291             $cellIter = $row->getCellIterator();
292             try {
293                 $cellIter->setIterateOnlyExistingCells(true);
294             } catch (PHPExcel_Exception $pe) {
295                 continue;
296             }
297             /** @var PHPExcel_Cell $cell */
298             foreach($cellIter as $cell) {
299                 if (false !== strpos($cell->getValue(), '${twig:') &&
300                         preg_match_all('/\${twig:([^}]+?)}/s', $cell->getValue(), $matches, PREG_SET_ORDER)) {
301                     foreach($matches as $match) {
302                         $this->_twigMapping[$i] = $match[0];
303                         $source .= ($i === 0 ? '' : ',') . '{{' . $match[1] . '}}';
304                         ++$i;
305                     }
306                 }
307             }
308         }
309
310         foreach($this->_excelObject->getActiveSheet()->getDrawingCollection() as $drawing) {
311             $desc = $drawing->getDescription();
312             if (false !== strpos($desc, '${twig:') &&
313                 preg_match_all('/\${twig:([^}]+?)}/s', $desc, $matches, PREG_SET_ORDER)
314             ) {
315                 foreach ($matches as $match) {
316                     $this->_twigMapping[$i] = $match[0];
317                     $source .= ($i === 0 ? '' : ',') . '{{' . $match[1] . '}}';
318                     ++$i;
319                 }
320             }
321         }
322
323         $source .= ']';
324
325         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
326             . ' returning twig template source: ' . $source);
327
328         return $source;
329     }
330
331     protected function _findFirstFreeRow()
332     {
333         $sheet = $this->_excelObject->getActiveSheet();
334
335         $rowIter = $sheet->getRowIterator();
336         /** @var PHPExcel_Worksheet_Row $row */
337         /** @noinspection PhpUnusedLocalVariableInspection */
338         foreach($rowIter as $row) {
339             ++$this->_rowCount;
340         }
341     }
342
343     protected function _onBeforeExportRecords()
344     {
345         // TODO header row?
346
347         if (null === ($block = $this->_findCell('${ROW}'))) {
348             $this->_findFirstFreeRow();
349             return;
350         }
351         $startColumn = $block->getColumn();
352         $this->_rowOffset = $block->getRow();
353
354         if (null === ($block = $this->_findCell('${/ROW}'))) {
355             $this->_findFirstFreeRow();
356             return;
357         }
358
359         $this->_dumpRecords = false;
360         $this->_writeGenericHeader = false;
361
362         $endColumn = $block->getColumn();
363         if ($block->getRow() !== $this->_rowOffset) {
364             if (Tinebase_Core::isLogLevel(Zend_Log::WARN))
365                 Tinebase_Core::getLogger()->warn(__METHOD__ . ' ' . __LINE__ . ' block tags need to be in the same row');
366             throw new Tinebase_Exception_UnexpectedValue('block tags need to be in the same row');
367         }
368
369         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
370             Tinebase_Core::getLogger()->debug(__METHOD__ . ' ' . __LINE__ . ' found block...');
371
372         $sheet = $this->_excelObject->getActiveSheet();
373
374         /** @var  $rowIterator */
375         $rowIterator = $sheet->getRowIterator($this->_rowOffset);
376         $row = $rowIterator->current();
377         $rowDimension = $sheet->getRowDimension($row->getRowIndex());
378         $this->_cloneRowStyles = array(
379             'setCollapsed'      => $rowDimension->getCollapsed(),
380             'setOutlineLevel'   => $rowDimension->getOutlineLevel(),
381             'setRowHeight'      => $rowDimension->getRowHeight(),
382             'setVisible'        => $rowDimension->getVisible(),
383             'setXfIndex'        => $rowDimension->getXfIndex(),
384             'setZeroHeight'     => $rowDimension->getZeroHeight()
385         );
386         $cellIterator = $row->getCellIterator($startColumn, $endColumn);
387
388         $replace = array('${ROW}', '${/ROW}');
389         /** @var PHPExcel_Cell $cell */
390         foreach($cellIterator as $cell) {
391             $this->_cloneRow[] = array(
392                 'column'        => $cell->getColumn(),
393                 'value'         => str_replace($replace, '', $cell->getValue()),
394                 'XFIndex'       => $cell->getXfIndex()
395             );
396             $cell->setValue();
397             // TODO update replacement cache in case we implement it
398         }
399     }
400
401     /**
402      * @throws Tinebase_Exception_UnexpectedValue
403      */
404     protected function _replaceTine20ImagePaths()
405     {
406         /** @var PHPExcel_Worksheet_Drawing $drawing */
407         foreach($this->_excelObject->getActiveSheet()->getDrawingCollection() as $drawing) {
408             $desc = $drawing->getDescription();
409             if (strpos($desc, '://') !== false) {
410                 $desc = trim($desc);
411                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
412                     Tinebase_Core::getLogger()->debug(__METHOD__ . ' ' . __LINE__ . ' found url: ' . $desc);
413
414                 if (!in_array(mb_strtolower(pathinfo($desc, PATHINFO_EXTENSION)), array('jpg', 'jpeg', 'png', 'gif'))) {
415                     if (Tinebase_Core::isLogLevel(Zend_Log::INFO))
416                         Tinebase_Core::getLogger()->info(__METHOD__ . ' ' . __LINE__ . ' unsupported file extension: ' . $desc);
417                     continue;
418                 }
419
420                 $fileContent = file_get_contents($desc);
421                 if (!empty($fileContent)) {
422
423                     $tempFile = Tinebase_TempFile::getTempPath();
424                     if (strlen($fileContent) !== file_put_contents($tempFile, $fileContent)) {
425                         throw new Tinebase_Exception('could not store filecontents in a temp file');
426                     }
427
428                     $drawing->setPath($tempFile);
429
430                 } else {
431                     if (Tinebase_Core::isLogLevel(Zend_Log::WARN))
432                         Tinebase_Core::getLogger()->warn(__METHOD__ . ' ' . __LINE__ . ' could not get file content: ' . $desc);
433
434                     throw new Tinebase_Exception_UnexpectedValue('could not get file content: ' . $desc);
435                 }
436             }
437         }
438     }
439 }