Failed Conditions
Pull Request — master (#393)
by Stefan
02:59
created

Worksheet::updateColumnWidth()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 7.9062

Importance

Changes 0
Metric Value
dl 0
loc 9
ccs 3
cts 8
cp 0.375
rs 9.2
c 0
b 0
f 0
cc 4
eloc 5
nc 3
nop 3
crap 7.9062
1
<?php
2
3
namespace Box\Spout\Writer\XLSX\Internal;
4
5
use Box\Spout\Common\Exception\InvalidArgumentException;
6
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
7
use Box\Spout\Writer\XLSX\Helper\SizeCalculator;
8
use Box\Spout\Writer\Common\Helper\CellHelper;
9
use Box\Spout\Common\Exception\IOException;
10
use Box\Spout\Common\Helper\StringHelper;
11
use Box\Spout\Writer\Style\Style;
12
13
/**
14
 * Class Worksheet
15
 * Represents a worksheet within a XLSX file. The difference with the Sheet object is
16
 * that this class provides an interface to write data
17
 *
18
 * @package Box\Spout\Writer\XLSX\Internal
19
 */
20
class Worksheet implements WorksheetInterface
21
{
22
    /**
23
     * Maximum number of characters a cell can contain
24
     * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-16c69c74-3d6a-4aaf-ba35-e6eb276e8eaa [Excel 2007]
25
     * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3 [Excel 2010]
26
     * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-ca36e2dc-1f09-4620-b726-67c00b05040f [Excel 2013/2016]
27
     */
28
    const MAX_CHARACTERS_PER_CELL = 32767;
29
30
    const SHEET_XML_FILE_HEADER = <<<EOD
31
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
32
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
33
EOD;
34
35
    /** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
36
    protected $externalSheet;
37
38
    /** @var string Path to the XML file that will contain the sheet data */
39
    protected $worksheetFilePath;
40
41
    /** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
42
    protected $sharedStringsHelper;
43
44
    /** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles */
45
    protected $styleHelper;
46
47
    /** @var bool Whether inline or shared strings should be used */
48
    protected $shouldUseInlineStrings;
49
50
    /** @var bool Determine whether cell widths should be calculated */
51
    protected $shouldUseCellAutosizing;
52
53
    /** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */
54
    protected $stringsEscaper;
55
56
    /** @var \Box\Spout\Common\Helper\StringHelper String helper */
57
    protected $stringHelper;
58
59
    /** @var \Box\Spout\Writer\XLSX\Helper\SizeCalculator */
60
    protected $sizeCalculator;
61
62
    /** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */
63
    protected $sheetFilePointer;
64
65
    /** @var int Index of the last written row */
66
    protected $lastWrittenRowIndex = 0;
67
68
    /** @var array Holds the column widths for cell sizing */
69
    protected $columnWidths = [];
70
71
    /**
72
     * @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
73
     * @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
74
     * @param \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper $sharedStringsHelper Helper for shared strings
75
     * @param \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles
76
     * @param \Box\Spout\Writer\XLSX\Helper\SizeCalculator $sizeCalculator To calculate cell sizes
77
     * @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
78
     * @param bool $shouldUseCellAutosizing Whether cell sizes should be calculated or not
79
     * @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
80
     */
81 132
    public function __construct(
82
        $externalSheet,
83
        $worksheetFilesFolder,
84
        $sharedStringsHelper,
85
        $styleHelper,
86
        $sizeCalculator,
87
        $shouldUseInlineStrings,
88
        $shouldUseCellAutosizing
89
    ) {
90 132
        $this->externalSheet = $externalSheet;
91 132
        $this->sharedStringsHelper = $sharedStringsHelper;
92 132
        $this->styleHelper = $styleHelper;
93 132
        $this->sizeCalculator = $sizeCalculator;
94 132
        $this->shouldUseInlineStrings = $shouldUseInlineStrings;
95 132
        $this->shouldUseCellAutosizing = $shouldUseCellAutosizing;
96
97
        /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
98 132
        $this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
99 132
        $this->stringHelper = new StringHelper();
100
101 132
        $this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
102 132
        $this->startSheet();
103 132
    }
104
105
    /**
106
     * Prepares the worksheet to accept data and preserves free space at the beginning
107
     * of the sheet file to prepend header xml and optional column size data.
108
     *
109
     * @return void
110
     * @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
111
     */
112 132
    protected function startSheet()
113
    {
114 132
        $this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
115 132
        $this->throwIfSheetFilePointerIsNotAvailable();
116
117 132
        $spaceToPreserve = $this->shouldUseCellAutosizing ? 1024 * 1024 : 512;
118 132
        fwrite($this->sheetFilePointer, str_repeat(' ', $spaceToPreserve));
119 132
        fwrite($this->sheetFilePointer, '<sheetData>');
120 132
    }
121
122
    /**
123
     * Checks if the book has been created. Throws an exception if not created yet.
124
     *
125
     * @return void
126
     * @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
127
     */
128 132
    protected function throwIfSheetFilePointerIsNotAvailable()
129
    {
130 132
        if (!$this->sheetFilePointer) {
131
            throw new IOException('Unable to open sheet for writing.');
132
        }
133 132
    }
134
135
    /**
136
     * @return \Box\Spout\Writer\Common\Sheet The "external" sheet
137
     */
138 102
    public function getExternalSheet()
139
    {
140 102
        return $this->externalSheet;
141
    }
142
143
    /**
144
     * @return int The index of the last written row
145
     */
146 93
    public function getLastWrittenRowIndex()
147
    {
148 93
        return $this->lastWrittenRowIndex;
149
    }
150
151
    /**
152
     * @return int The ID of the worksheet
153
     */
154 99
    public function getId()
155
    {
156
        // sheet index is zero-based, while ID is 1-based
157 99
        return $this->externalSheet->getIndex() + 1;
158
    }
159
160
    /**
161
     * Adds data to the worksheet.
162
     *
163
     * @param array $dataRow Array containing data to be written. Cannot be empty.
164
     *          Example $dataRow = ['data1', 1234, null, '', 'data5'];
165
     * @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
166
     * @return void
167
     * @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
168
     * @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
169
     */
170 93
    public function addRow($dataRow, $style)
171
    {
172 93
        if (!$this->isEmptyRow($dataRow)) {
173 93
            $this->addNonEmptyRow($dataRow, $style);
174 87
        }
175
176 87
        $this->lastWrittenRowIndex++;
177 87
    }
178
179
    /**
180
     * Returns whether the given row is empty
181
     *
182
     * @param array $dataRow Array containing data to be written. Cannot be empty.
183
     *          Example $dataRow = ['data1', 1234, null, '', 'data5'];
184
     * @return bool Whether the given row is empty
185
     */
186 93
    private function isEmptyRow($dataRow)
187
    {
188 93
        $numCells = count($dataRow);
189
        // using "reset()" instead of "$dataRow[0]" because $dataRow can be an associative array
190 93
        return ($numCells === 1 && CellHelper::isEmpty(reset($dataRow)));
191
    }
192
193
    /**
194
     * Adds non empty row to the worksheet.
195
     *
196
     * @param array $dataRow Array containing data to be written. Cannot be empty.
197
     *          Example $dataRow = ['data1', 1234, null, '', 'data5'];
198
     * @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
199
     * @return void
200
     * @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
201
     * @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
202
     */
203 93
    private function addNonEmptyRow($dataRow, $style)
204
    {
205 93
        $cellNumber = 0;
206 93
        $rowIndex = $this->lastWrittenRowIndex + 1;
207 93
        $numCells = count($dataRow);
208
209 93
        $rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
210
211 93
        if ($this->shouldUseCellAutosizing) {
212
            $this->sizeCalculator->setFont($style->getFontName(), $style->getFontSize());
213
        }
214
215 93
        foreach($dataRow as $cellValue) {
216 93
            $rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style);
217 87
            $cellNumber++;
218 87
        }
219
220 87
        $rowXML .= '</row>';
221
222 87
        $wasWriteSuccessful = fwrite($this->sheetFilePointer, $rowXML);
223 87
        if ($wasWriteSuccessful === false) {
224
            throw new IOException("Unable to write data in {$this->worksheetFilePath}");
225
        }
226 87
    }
227
228
    /**
229
     * Build and return xml for a single cell.
230
     *
231
     * @param int   $rowIndex
232
     * @param int   $cellNumber
233
     * @param mixed $cellValue
234
     * @param Style $style Style to be applied to the row. NULL means use default style.
235
     *
236
     * @return string
237
     * @throws InvalidArgumentException If the given value cannot be processed
238
     */
239 93
    private function getCellXML($rowIndex, $cellNumber, $cellValue, Style $style)
240
    {
241 93
        $columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
242 93
        $cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
243 93
        $cellXML .= ' s="' . $style->getId() . '"';
244
245 93
        if (CellHelper::isNonEmptyString($cellValue)) {
246 90
            $cellXML .= $this->getCellXMLFragmentForNonEmptyString($cellValue);
247 90
        } else if (CellHelper::isBoolean($cellValue)) {
248 3
            $cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
249 12
        } else if (CellHelper::isNumeric($cellValue)) {
250 3
            $cellXML .= '><v>' . $cellValue . '</v></c>';
251 12
        } else if (empty($cellValue)) {
252 6
            if ($this->styleHelper->shouldApplyStyleOnEmptyCell($style->getId())) {
253 3
                $cellXML .= '/>';
254 3
            } else {
255
                // don't write empty cells that do no need styling
256
                // NOTE: not appending to $cellXML is the right behavior!!
257 6
                $cellXML = '';
258
            }
259 6
        } else {
260 6
            throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
261
        }
262
263 87
        $this->updateColumnWidth($cellNumber, $cellValue, $style);
264
265 87
        return $cellXML;
266
    }
267
268
    /**
269
     * Returns the XML fragment for a cell containing a non empty string
270
     *
271
     * @param string $cellValue The cell value
272
     * @return string The XML fragment representing the cell
273
     * @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
274
     */
275 90
    private function getCellXMLFragmentForNonEmptyString($cellValue)
276
    {
277 90
        if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
278 3
            throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
279
        }
280
281 87
        if ($this->shouldUseInlineStrings) {
282 78
            $cellXMLFragment = ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
283 78
        } else {
284 9
            $sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
285 9
            $cellXMLFragment = ' t="s"><v>' . $sharedStringId . '</v></c>';
286
        }
287
288 87
        return $cellXMLFragment;
289
    }
290
291
    /**
292
     * Update the width of the current cellNumber, if cell autosizing is enabled
293
     * and the width of the current value exceeds a previously calculated one.
294
     *
295
     * @param int    $cellNumber
296
     * @param string $cellValue
297
     * @param Style  $style
298
     */
299 87
    private function updateColumnWidth($cellNumber, $cellValue, $style)
300
    {
301 87
        if ($this->shouldUseCellAutosizing) {
302
            $cellWidth = $this->sizeCalculator->getCellWidth($cellValue, $style->getFontSize());
303
            if (!isset($this->columnWidths[$cellNumber]) || $cellWidth > $this->columnWidths[$cellNumber]) {
304
                $this->columnWidths[$cellNumber] = $cellWidth;
305
            }
306
        }
307 87
    }
308
309
    /**
310
     * Return writable <cols> xml string, if column widths have been
311
     * calculated or custom widths have been set.
312
     *
313
     * @return string
314
     */
315 99
    private function getColsXML()
316
    {
317 99
        if (0 === count($this->columnWidths)) {
318 99
            return '';
319
        }
320
321
        $colsXml = '<cols>';
322
        $colTemplate = '<col min="%d" max="%d" width="%s" customWidth="1"/>';
323
324
        foreach ($this->columnWidths as $columnIndex => $columnWidth) {
325
            $colsXml .= sprintf($colTemplate, $columnIndex+1, $columnIndex+1, $columnWidth);
326
        }
327
328
        $colsXml .= '</cols>';
329
330
        return $colsXml;
331
    }
332
333
    /**
334
     * Closes the worksheet
335
     *
336
     * @return void
337
     */
338 99
    public function close()
339
    {
340 99
        if (!is_resource($this->sheetFilePointer)) {
341
            return;
342
        }
343
344 99
        fwrite($this->sheetFilePointer, '</sheetData></worksheet>');
345 99
        rewind($this->sheetFilePointer);
346
347 99
        fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER);
348 99
        fwrite($this->sheetFilePointer, $this->getColsXML());
349
350 99
        fclose($this->sheetFilePointer);
351 99
    }
352
}
353