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