Ods   F
last analyzed

Complexity

Total Complexity 177

Size/Duplication

Total Lines 1062
Duplicated Lines 0 %

Test Coverage

Coverage 86.47%

Importance

Changes 0
Metric Value
wmc 177
eloc 594
dl 0
loc 1062
ccs 537
cts 621
cp 0.8647
rs 2
c 0
b 0
f 0

21 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
B listWorksheetNames() 0 43 10
B canRead() 0 43 11
A loadSpreadsheetFromFile() 0 8 1
D listWorksheetInfo() 0 82 19
B processMergedCells() 0 31 7
B scanElementForText() 0 26 9
B processTableColumnGroup() 0 42 6
F processTableRow() 0 276 50
A processSettings() 0 14 2
A lookForActiveSheet() 0 12 4
A processTableColumn() 0 25 4
A processTableHeaderColumns() 0 21 3
A setSelected() 0 6 4
A processTableHeaderRows() 0 27 3
B processTableRowGroup() 0 54 6
A getMultiplier() 0 9 2
A parseRichText() 0 6 1
B lookForSelectedCells() 0 21 7
F loadIntoExisting() 0 210 25
A extractNodeName() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like Ods often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Ods, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace PhpOffice\PhpSpreadsheet\Reader;
4
5
use DOMAttr;
6
use DOMDocument;
7
use DOMElement;
8
use DOMNode;
9
use DOMText;
10
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
11
use PhpOffice\PhpSpreadsheet\Cell\DataType;
12
use PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension;
13
use PhpOffice\PhpSpreadsheet\Reader\Ods\AutoFilter;
14
use PhpOffice\PhpSpreadsheet\Reader\Ods\DefinedNames;
15
use PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
16
use PhpOffice\PhpSpreadsheet\Reader\Ods\PageSettings;
17
use PhpOffice\PhpSpreadsheet\Reader\Ods\Properties as DocumentProperties;
18
use PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner;
19
use PhpOffice\PhpSpreadsheet\RichText\RichText;
20
use PhpOffice\PhpSpreadsheet\Shared\Date;
21
use PhpOffice\PhpSpreadsheet\Shared\File;
22
use PhpOffice\PhpSpreadsheet\Spreadsheet;
23
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
24
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
25
use Throwable;
26
use XMLReader;
27
use ZipArchive;
28
29
class Ods extends BaseReader
30
{
31
    const INITIAL_FILE = 'content.xml';
32
33
    /**
34
     * Create a new Ods Reader instance.
35
     */
36 111
    public function __construct()
37
    {
38 111
        parent::__construct();
39 111
        $this->securityScanner = XmlScanner::getInstance($this);
40
    }
41
42
    /**
43
     * Can the current IReader read the file?
44
     */
45 19
    public function canRead(string $filename): bool
46
    {
47 19
        $mimeType = 'UNKNOWN';
48
49
        // Load file
50
51 19
        if (File::testFileNoThrow($filename, '')) {
52 3
            $zip = new ZipArchive();
53 3
            if ($zip->open($filename) === true) {
54
                // check if it is an OOXML archive
55 3
                $stat = $zip->statName('mimetype');
56 3
                if (!empty($stat) && ($stat['size'] <= 255)) {
57 2
                    $mimeType = $zip->getFromName($stat['name']);
58 1
                } elseif ($zip->statName('META-INF/manifest.xml')) {
59 1
                    $xml = simplexml_load_string(
60 1
                        $this->getSecurityScannerOrThrow()
61 1
                            ->scan(
62 1
                                $zip->getFromName(
63 1
                                    'META-INF/manifest.xml'
64 1
                                )
65 1
                            )
66 1
                    );
67 1
                    if ($xml !== false) {
68 1
                        $namespacesContent = $xml->getNamespaces(true);
69 1
                        if (isset($namespacesContent['manifest'])) {
70 1
                            $manifest = $xml->children($namespacesContent['manifest']);
71 1
                            foreach ($manifest as $manifestDataSet) {
72 1
                                $manifestAttributes = $manifestDataSet->attributes($namespacesContent['manifest']);
73 1
                                if ($manifestAttributes && $manifestAttributes->{'full-path'} == '/') {
74 1
                                    $mimeType = (string) $manifestAttributes->{'media-type'};
75
76 1
                                    break;
77
                                }
78
                            }
79
                        }
80
                    }
81
                }
82
83 3
                $zip->close();
84
            }
85
        }
86
87 19
        return $mimeType === 'application/vnd.oasis.opendocument.spreadsheet';
88
    }
89
90
    /**
91
     * Reads names of the worksheets from a file, without parsing the whole file to a PhpSpreadsheet object.
92
     *
93
     * @return string[]
94
     */
95 6
    public function listWorksheetNames(string $filename): array
96
    {
97 6
        File::assertFile($filename, self::INITIAL_FILE);
98
99 2
        $worksheetNames = [];
100
101 2
        $xml = new XMLReader();
102 2
        $xml->xml(
103 2
            $this->getSecurityScannerOrThrow()
104 2
                ->scanFile(
105 2
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
106 2
                )
107 2
        );
108 2
        $xml->setParserProperty(2, true);
109
110
        // Step into the first level of content of the XML
111 2
        $xml->read();
112 2
        while ($xml->read()) {
113
            // Quickly jump through to the office:body node
114 2
            while ($xml->name !== 'office:body') {
115 2
                if ($xml->isEmptyElement) {
116 2
                    $xml->read();
117
                } else {
118 2
                    $xml->next();
119
                }
120
            }
121
            // Now read each node until we find our first table:table node
122 2
            while ($xml->read()) {
123 2
                $xmlName = $xml->name;
124 2
                if ($xmlName == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
125
                    // Loop through each table:table node reading the table:name attribute for each worksheet name
126
                    do {
127 2
                        $worksheetName = $xml->getAttribute('table:name');
128 2
                        if (!empty($worksheetName)) {
129 2
                            $worksheetNames[] = $worksheetName;
130
                        }
131 2
                        $xml->next();
132 2
                    } while ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT);
133
                }
134
            }
135
        }
136
137 2
        return $worksheetNames;
138
    }
139
140
    /**
141
     * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
142
     *
143
     * @return array<int, array{worksheetName: string, lastColumnLetter: string, lastColumnIndex: int, totalRows: int, totalColumns: int, sheetState: string}>
144
     */
145 6
    public function listWorksheetInfo(string $filename): array
146
    {
147 6
        File::assertFile($filename, self::INITIAL_FILE);
148
149 2
        $worksheetInfo = [];
150
151 2
        $xml = new XMLReader();
152 2
        $xml->xml(
153 2
            $this->getSecurityScannerOrThrow()
154 2
                ->scanFile(
155 2
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
156 2
                )
157 2
        );
158 2
        $xml->setParserProperty(2, true);
159
160
        // Step into the first level of content of the XML
161 2
        $xml->read();
162 2
        $tableVisibility = [];
163 2
        $lastTableStyle = '';
164
165 2
        while ($xml->read()) {
166 2
            if ($xml->name === 'style:style') {
167 2
                $styleType = $xml->getAttribute('style:family');
168 2
                if ($styleType === 'table') {
169 2
                    $lastTableStyle = $xml->getAttribute('style:name');
170
                }
171 2
            } elseif ($xml->name === 'style:table-properties') {
172 2
                $visibility = $xml->getAttribute('table:display');
173 2
                $tableVisibility[$lastTableStyle] = ($visibility === 'false') ? Worksheet::SHEETSTATE_HIDDEN : Worksheet::SHEETSTATE_VISIBLE;
174 2
            } elseif ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
175 2
                $worksheetNames[] = $xml->getAttribute('table:name');
176
177 2
                $styleName = $xml->getAttribute('table:style-name') ?? '';
178 2
                $visibility = $tableVisibility[$styleName] ?? '';
179 2
                $tmpInfo = [
180 2
                    'worksheetName' => (string) $xml->getAttribute('table:name'),
181 2
                    'lastColumnLetter' => 'A',
182 2
                    'lastColumnIndex' => 0,
183 2
                    'totalRows' => 0,
184 2
                    'totalColumns' => 0,
185 2
                    'sheetState' => $visibility,
186 2
                ];
187
188
                // Loop through each child node of the table:table element reading
189 2
                $currCells = 0;
190
                do {
191 2
                    $xml->read();
192 2
                    if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) {
193 2
                        $rowspan = $xml->getAttribute('table:number-rows-repeated');
194 2
                        $rowspan = empty($rowspan) ? 1 : (int) $rowspan;
195 2
                        $tmpInfo['totalRows'] += $rowspan;
196 2
                        $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells);
197 2
                        $currCells = 0;
198
                        // Step into the row
199 2
                        $xml->read();
200
                        do {
201 2
                            $doread = true;
202 2
                            if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
203 2
                                if (!$xml->isEmptyElement) {
204 2
                                    ++$currCells;
205 2
                                    $xml->next();
206 2
                                    $doread = false;
207
                                }
208 1
                            } elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
209 1
                                $mergeSize = $xml->getAttribute('table:number-columns-repeated');
210 1
                                $currCells += (int) $mergeSize;
211
                            }
212 2
                            if ($doread) {
213 1
                                $xml->read();
214
                            }
215 2
                        } while ($xml->name != 'table:table-row');
216
                    }
217 2
                } while ($xml->name != 'table:table');
218
219 2
                $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells);
220 2
                $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1;
221 2
                $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1);
222 2
                $worksheetInfo[] = $tmpInfo;
223
            }
224
        }
225
226 2
        return $worksheetInfo;
227
    }
228
229
    /**
230
     * Loads PhpSpreadsheet from file.
231
     */
232 78
    protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
233
    {
234 78
        $spreadsheet = $this->newSpreadsheet();
235 78
        $spreadsheet->setValueBinder($this->valueBinder);
236 78
        $spreadsheet->removeSheetByIndex(0);
237
238
        // Load into this instance
239 78
        return $this->loadIntoExisting($filename, $spreadsheet);
240
    }
241
242
    /**
243
     * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
244
     */
245 82
    public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Spreadsheet
246
    {
247 82
        File::assertFile($filename, self::INITIAL_FILE);
248
249 78
        $zip = new ZipArchive();
250 78
        $zip->open($filename);
251
252
        // Meta
253
254 78
        $xml = @simplexml_load_string(
255 78
            $this->getSecurityScannerOrThrow()
256 78
                ->scan($zip->getFromName('meta.xml'))
257 78
        );
258 78
        if ($xml === false) {
259 1
            throw new Exception('Unable to read data from {$pFilename}');
260
        }
261
262
        /** @var array{meta?: string, office?: string, dc?: string} */
263 77
        $namespacesMeta = $xml->getNamespaces(true);
264
265 77
        (new DocumentProperties($spreadsheet))->load($xml, $namespacesMeta);
266
267
        // Styles
268
269 77
        $dom = new DOMDocument('1.01', 'UTF-8');
270 77
        $dom->loadXML(
271 77
            $this->getSecurityScannerOrThrow()
272 77
                ->scan($zip->getFromName('styles.xml'))
273 77
        );
274
275 77
        $pageSettings = new PageSettings($dom);
276
277
        // Main Content
278
279 77
        $dom = new DOMDocument('1.01', 'UTF-8');
280 77
        $dom->loadXML(
281 77
            $this->getSecurityScannerOrThrow()
282 77
                ->scan($zip->getFromName(self::INITIAL_FILE))
283 77
        );
284
285 77
        $officeNs = (string) $dom->lookupNamespaceUri('office');
286 77
        $tableNs = (string) $dom->lookupNamespaceUri('table');
287 77
        $textNs = (string) $dom->lookupNamespaceUri('text');
288 77
        $xlinkNs = (string) $dom->lookupNamespaceUri('xlink');
289 77
        $styleNs = (string) $dom->lookupNamespaceUri('style');
290
291 77
        $pageSettings->readStyleCrossReferences($dom);
292
293 77
        $autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
294 77
        $definedNameReader = new DefinedNames($spreadsheet, $tableNs);
295 77
        $columnWidths = [];
296 77
        $automaticStyle0 = $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
297 77
        $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
298 77
        foreach ($automaticStyles as $automaticStyle) {
299 77
            $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
300 77
            $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
301 77
            if ($styleFamily === 'table-column') {
302 46
                $tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
303 46
                $tcprop = $tcprops->item(0);
304 46
                if ($tcprop !== null) {
305 46
                    $columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
306 46
                    $columnWidths[$styleName] = $columnWidth;
307
                }
308
            }
309
        }
310
311
        // Content
312 77
        $item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
313 77
        $spreadsheets = ($item0 === null) ? [] : $item0->getElementsByTagNameNS($officeNs, 'spreadsheet');
314
315 77
        foreach ($spreadsheets as $workbookData) {
316
            /** @var DOMElement $workbookData */
317 77
            $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
318
319 77
            $worksheetID = 0;
320 77
            foreach ($tables as $worksheetDataSet) {
321
                /** @var DOMElement $worksheetDataSet */
322 77
                $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
323
324
                // Check loadSheetsOnly
325
                if (
326 77
                    $this->loadSheetsOnly !== null
327
                    && $worksheetName
328 77
                    && !in_array($worksheetName, $this->loadSheetsOnly)
329
                ) {
330 3
                    continue;
331
                }
332
333 76
                $worksheetStyleName = $worksheetDataSet->getAttributeNS($tableNs, 'style-name');
334
335
                // Create sheet
336 76
                $spreadsheet->createSheet();
337 76
                $spreadsheet->setActiveSheetIndex($worksheetID);
338
339 76
                if ($worksheetName || is_numeric($worksheetName)) {
340
                    // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in
341
                    // formula cells... during the load, all formulae should be correct, and we're simply
342
                    // bringing the worksheet name in line with the formula, not the reverse
343 76
                    $spreadsheet->getActiveSheet()->setTitle((string) $worksheetName, false, false);
344
                }
345
346
                // Go through every child of table element
347 76
                $rowID = 1;
348 76
                $tableColumnIndex = 1;
349 76
                foreach ($worksheetDataSet->childNodes as $childNode) {
350
                    /** @var DOMElement $childNode */
351
352
                    // Filter elements which are not under the "table" ns
353 76
                    if ($childNode->namespaceURI != $tableNs) {
354 45
                        continue;
355
                    }
356
357 76
                    $key = self::extractNodeName($childNode->nodeName);
358
359
                    switch ($key) {
360 76
                        case 'table-header-rows':
361 76
                        case 'table-rows':
362 1
                            $this->processTableHeaderRows(
363 1
                                $childNode,
364 1
                                $tableNs,
365 1
                                $rowID,
366 1
                                $worksheetName,
367 1
                                $officeNs,
368 1
                                $textNs,
369 1
                                $xlinkNs,
370 1
                                $spreadsheet
371 1
                            );
372
373 1
                            break;
374 76
                        case 'table-row-group':
375 1
                            $this->processTableRowGroup(
376 1
                                $childNode,
377 1
                                $tableNs,
378 1
                                $rowID,
379 1
                                $worksheetName,
380 1
                                $officeNs,
381 1
                                $textNs,
382 1
                                $xlinkNs,
383 1
                                $spreadsheet
384 1
                            );
385
386 1
                            break;
387 76
                        case 'table-header-columns':
388 76
                        case 'table-columns':
389
                            $this->processTableHeaderColumns(
390
                                $childNode,
391
                                $tableNs,
392
                                $columnWidths,
393
                                $tableColumnIndex,
394
                                $spreadsheet
395
                            );
396
397
                            break;
398 76
                        case 'table-column-group':
399
                            $this->processTableColumnGroup(
400
                                $childNode,
401
                                $tableNs,
402
                                $columnWidths,
403
                                $tableColumnIndex,
404
                                $spreadsheet
405
                            );
406
407
                            break;
408 76
                        case 'table-column':
409 45
                            $this->processTableColumn(
410 45
                                $childNode,
411 45
                                $tableNs,
412 45
                                $columnWidths,
413 45
                                $tableColumnIndex,
414 45
                                $spreadsheet
415 45
                            );
416
417 45
                            break;
418 75
                        case 'table-row':
419 75
                            $this->processTableRow(
420 75
                                $childNode,
421 75
                                $tableNs,
422 75
                                $rowID,
423 75
                                $worksheetName,
424 75
                                $officeNs,
425 75
                                $textNs,
426 75
                                $xlinkNs,
427 75
                                $spreadsheet
428 75
                            );
429
430 75
                            break;
431
                    }
432
                }
433 76
                $pageSettings->setVisibilityForWorksheet(
434 76
                    $spreadsheet->getActiveSheet(),
435 76
                    $worksheetStyleName
436 76
                );
437 76
                $pageSettings->setPrintSettingsForWorksheet(
438 76
                    $spreadsheet->getActiveSheet(),
439 76
                    $worksheetStyleName
440 76
                );
441 76
                ++$worksheetID;
442
            }
443
444 77
            $autoFilterReader->read($workbookData);
445 77
            $definedNameReader->read($workbookData);
446
        }
447 77
        $spreadsheet->setActiveSheetIndex(0);
448
449 76
        if ($zip->locateName('settings.xml') !== false) {
450 69
            $this->processSettings($zip, $spreadsheet);
451
        }
452
453
        // Return
454 76
        return $spreadsheet;
455
    }
456
457 1
    private function processTableHeaderRows(
458
        DOMElement $childNode,
459
        string $tableNs,
460
        int &$rowID,
461
        string $worksheetName,
462
        string $officeNs,
463
        string $textNs,
464
        string $xlinkNs,
465
        Spreadsheet $spreadsheet
466
    ): void {
467 1
        foreach ($childNode->childNodes as $grandchildNode) {
468
            /** @var DOMElement $grandchildNode */
469 1
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
470
            switch ($grandkey) {
471 1
                case 'table-row':
472 1
                    $this->processTableRow(
473 1
                        $grandchildNode,
474 1
                        $tableNs,
475 1
                        $rowID,
476 1
                        $worksheetName,
477 1
                        $officeNs,
478 1
                        $textNs,
479 1
                        $xlinkNs,
480 1
                        $spreadsheet
481 1
                    );
482
483 1
                    break;
484
            }
485
        }
486
    }
487
488 1
    private function processTableRowGroup(
489
        DOMElement $childNode,
490
        string $tableNs,
491
        int &$rowID,
492
        string $worksheetName,
493
        string $officeNs,
494
        string $textNs,
495
        string $xlinkNs,
496
        Spreadsheet $spreadsheet
497
    ): void {
498 1
        foreach ($childNode->childNodes as $grandchildNode) {
499
            /** @var DOMElement $grandchildNode */
500 1
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
501
            switch ($grandkey) {
502 1
                case 'table-row':
503 1
                    $this->processTableRow(
504 1
                        $grandchildNode,
505 1
                        $tableNs,
506 1
                        $rowID,
507 1
                        $worksheetName,
508 1
                        $officeNs,
509 1
                        $textNs,
510 1
                        $xlinkNs,
511 1
                        $spreadsheet
512 1
                    );
513
514 1
                    break;
515
                case 'table-header-rows':
516
                case 'table-rows':
517
                    $this->processTableHeaderRows(
518
                        $grandchildNode,
519
                        $tableNs,
520
                        $rowID,
521
                        $worksheetName,
522
                        $officeNs,
523
                        $textNs,
524
                        $xlinkNs,
525
                        $spreadsheet
526
                    );
527
528
                    break;
529
                case 'table-row-group':
530
                    $this->processTableRowGroup(
531
                        $grandchildNode,
532
                        $tableNs,
533
                        $rowID,
534
                        $worksheetName,
535
                        $officeNs,
536
                        $textNs,
537
                        $xlinkNs,
538
                        $spreadsheet
539
                    );
540
541
                    break;
542
            }
543
        }
544
    }
545
546 75
    private function processTableRow(
547
        DOMElement $childNode,
548
        string $tableNs,
549
        int &$rowID,
550
        string $worksheetName,
551
        string $officeNs,
552
        string $textNs,
553
        string $xlinkNs,
554
        Spreadsheet $spreadsheet
555
    ): void {
556 75
        if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {
557 26
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
558
        } else {
559 75
            $rowRepeats = 1;
560
        }
561
562 75
        $columnID = 'A';
563
        /** @var DOMElement|DOMText $cellData */
564 75
        foreach ($childNode->childNodes as $cellData) {
565 75
            if ($cellData instanceof DOMText) {
566 2
                continue; // should just be whitespace
567
            }
568 75
            if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
569 2
                if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
570 1
                    $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
571
                } else {
572 2
                    $colRepeats = 1;
573
                }
574
575 2
                for ($i = 0; $i < $colRepeats; ++$i) {
576 2
                    ++$columnID;
577
                }
578
579 2
                continue;
580
            }
581
582
            // Initialize variables
583 75
            $formatting = $hyperlink = null;
584 75
            $hasCalculatedValue = false;
585 75
            $cellDataFormula = '';
586 75
            $cellDataType = '';
587 75
            $cellDataRef = '';
588
589 75
            if ($cellData->hasAttributeNS($tableNs, 'formula')) {
590 29
                $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
591 29
                $hasCalculatedValue = true;
592
            }
593 75
            if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
594 12
                if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
595 12
                    $cellDataType = 'array';
596 12
                    $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
597 12
                    $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
598 12
                    $lastRow = $rowID + $arrayRow - 1;
599 12
                    $lastCol = $columnID;
600 12
                    while ($arrayCol > 1) {
601 7
                        ++$lastCol;
602 7
                        --$arrayCol;
603
                    }
604 12
                    $cellDataRef = "$columnID$rowID:$lastCol$lastRow";
605
                }
606
            }
607
608
            // Annotations
609 75
            $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');
610
611 75
            if ($annotation->length > 0 && $annotation->item(0) !== null) {
612 11
                $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p');
613 11
                $textNodeLength = $textNode->length;
614 11
                $newLineOwed = false;
615 11
                for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) {
616 11
                    $textNodeItem = $textNode->item($textNodeIndex);
617 11
                    if ($textNodeItem !== null) {
618 11
                        $text = $this->scanElementForText($textNodeItem);
619 11
                        if ($newLineOwed) {
620 1
                            $spreadsheet->getActiveSheet()
621 1
                                ->getComment($columnID . $rowID)
622 1
                                ->getText()
623 1
                                ->createText("\n");
624
                        }
625 11
                        $newLineOwed = true;
626
627 11
                        $spreadsheet->getActiveSheet()
628 11
                            ->getComment($columnID . $rowID)
629 11
                            ->getText()
630 11
                            ->createText(
631 11
                                $this->parseRichText($text)
632 11
                            );
633
                    }
634
                }
635
            }
636
637
            // Content
638
639
            /** @var DOMElement[] $paragraphs */
640 75
            $paragraphs = [];
641
642 75
            foreach ($cellData->childNodes as $item) {
643
                /** @var DOMElement $item */
644
645
                // Filter text:p elements
646 75
                if ($item->nodeName == 'text:p') {
647 75
                    $paragraphs[] = $item;
648
                }
649
            }
650
651 75
            if (count($paragraphs) > 0) {
652
                // Consolidate if there are multiple p records (maybe with spans as well)
653 75
                $dataArray = [];
654
655
                // Text can have multiple text:p and within those, multiple text:span.
656
                // text:p newlines, but text:span does not.
657
                // Also, here we assume there is no text data is span fields are specified, since
658
                // we have no way of knowing proper positioning anyway.
659
660 75
                foreach ($paragraphs as $pData) {
661 75
                    $dataArray[] = $this->scanElementForText($pData);
662
                }
663 75
                $allCellDataText = implode("\n", $dataArray);
664
665 75
                $type = $cellData->getAttributeNS($officeNs, 'value-type');
666
667
                switch ($type) {
668 75
                    case 'string':
669 49
                        $type = DataType::TYPE_STRING;
670 49
                        $dataValue = $allCellDataText;
671
672 49
                        foreach ($paragraphs as $paragraph) {
673 49
                            $link = $paragraph->getElementsByTagNameNS($textNs, 'a');
674 49
                            if ($link->length > 0 && $link->item(0) !== null) {
675 7
                                $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href');
676
                            }
677
                        }
678
679 49
                        break;
680 51
                    case 'boolean':
681 9
                        $type = DataType::TYPE_BOOL;
682 9
                        $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false;
683
684 9
                        break;
685 49
                    case 'percentage':
686 4
                        $type = DataType::TYPE_NUMERIC;
687 4
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
688
689
                        // percentage should always be float
690
                        //if (floor($dataValue) == $dataValue) {
691
                        //    $dataValue = (int) $dataValue;
692
                        //}
693 4
                        $formatting = NumberFormat::FORMAT_PERCENTAGE_00;
694
695 4
                        break;
696 49
                    case 'currency':
697 4
                        $type = DataType::TYPE_NUMERIC;
698 4
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
699
700 4
                        if (floor($dataValue) == $dataValue) {
701 4
                            $dataValue = (int) $dataValue;
702
                        }
703 4
                        $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER;
704
705 4
                        break;
706 45
                    case 'float':
707 45
                        $type = DataType::TYPE_NUMERIC;
708 45
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
709
710 45
                        if (floor($dataValue) == $dataValue) {
711 41
                            if ($dataValue == (int) $dataValue) {
712 41
                                $dataValue = (int) $dataValue;
713
                            }
714
                        }
715
716 45
                        break;
717 10
                    case 'date':
718 9
                        $type = DataType::TYPE_NUMERIC;
719 9
                        $value = $cellData->getAttributeNS($officeNs, 'date-value');
720 9
                        $dataValue = Date::convertIsoDate($value);
721
722 9
                        if ($dataValue != floor($dataValue)) {
723 6
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15
724 6
                                . ' '
725 6
                                . NumberFormat::FORMAT_DATE_TIME4;
726
                        } else {
727 9
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15;
728
                        }
729
730 9
                        break;
731 7
                    case 'time':
732 6
                        $type = DataType::TYPE_NUMERIC;
733
734 6
                        $timeValue = $cellData->getAttributeNS($officeNs, 'time-value');
735
736 6
                        $dataValue = Date::PHPToExcel(
737 6
                            strtotime(
738 6
                                '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? [])
739 6
                            )
740 6
                        );
741 6
                        $formatting = NumberFormat::FORMAT_DATE_TIME4;
742
743 6
                        break;
744
                    default:
745 1
                        $dataValue = null;
746
                }
747
            } else {
748 43
                $type = DataType::TYPE_NULL;
749 43
                $dataValue = null;
750
            }
751
752 75
            if ($hasCalculatedValue) {
753 29
                $type = DataType::TYPE_FORMULA;
754 29
                $cellDataFormula = substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1);
755 29
                $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula);
756
            }
757
758 75
            if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
759 38
                $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
760
            } else {
761 75
                $colRepeats = 1;
762
            }
763
764 75
            if ($type !== null) { // @phpstan-ignore-line
765 75
                for ($i = 0; $i < $colRepeats; ++$i) {
766 75
                    if ($i > 0) {
767 38
                        ++$columnID;
768
                    }
769
770 75
                    if ($type !== DataType::TYPE_NULL) {
771 75
                        for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) {
772 75
                            $rID = $rowID + $rowAdjust;
773
774 75
                            $cell = $spreadsheet->getActiveSheet()
775 75
                                ->getCell($columnID . $rID);
776
777
                            // Set value
778 75
                            if ($hasCalculatedValue) {
779 29
                                $cell->setValueExplicit($cellDataFormula, $type);
780 29
                                if ($cellDataType === 'array') {
781 12
                                    $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
782
                                }
783 71
                            } elseif ($type !== '' || $dataValue !== null) {
784 71
                                $cell->setValueExplicit($dataValue, $type);
785
                            }
786
787 75
                            if ($hasCalculatedValue) {
788 29
                                $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC);
789
                            }
790
791
                            // Set other properties
792 75
                            if ($formatting !== null) {
793 13
                                $spreadsheet->getActiveSheet()
794 13
                                    ->getStyle($columnID . $rID)
795 13
                                    ->getNumberFormat()
796 13
                                    ->setFormatCode($formatting);
797
                            } else {
798 75
                                $spreadsheet->getActiveSheet()
799 75
                                    ->getStyle($columnID . $rID)
800 75
                                    ->getNumberFormat()
801 75
                                    ->setFormatCode(NumberFormat::FORMAT_GENERAL);
802
                            }
803
804 75
                            if ($hyperlink !== null) {
805 7
                                if ($hyperlink[0] === '#') {
806 1
                                    $hyperlink = 'sheet://' . substr($hyperlink, 1);
807
                                }
808 7
                                $cell->getHyperlink()
809 7
                                    ->setUrl($hyperlink);
810
                            }
811
                        }
812
                    }
813
                }
814
            }
815
816
            // Merged cells
817 75
            $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet);
818
819 75
            ++$columnID;
820
        }
821 75
        $rowID += $rowRepeats;
822
    }
823
824 76
    private static function extractNodeName(string $key): string
825
    {
826
        // Remove ns from node name
827 76
        if (str_contains($key, ':')) {
828 76
            $keyChunks = explode(':', $key);
829 76
            $key = array_pop($keyChunks);
830
        }
831
832 76
        return $key;
833
    }
834
835
    /**
836
     * @param string[] $columnWidths
837
     */
838
    private function processTableHeaderColumns(
839
        DOMElement $childNode,
840
        string $tableNs,
841
        array $columnWidths,
842
        int &$tableColumnIndex,
843
        Spreadsheet $spreadsheet
844
    ): void {
845
        foreach ($childNode->childNodes as $grandchildNode) {
846
            /** @var DOMElement $grandchildNode */
847
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
848
            switch ($grandkey) {
849
                case 'table-column':
850
                    $this->processTableColumn(
851
                        $grandchildNode,
852
                        $tableNs,
853
                        $columnWidths,
854
                        $tableColumnIndex,
855
                        $spreadsheet
856
                    );
857
858
                    break;
859
            }
860
        }
861
    }
862
863
    /**
864
     * @param string[] $columnWidths
865
     */
866
    private function processTableColumnGroup(
867
        DOMElement $childNode,
868
        string $tableNs,
869
        array $columnWidths,
870
        int &$tableColumnIndex,
871
        Spreadsheet $spreadsheet
872
    ): void {
873
        foreach ($childNode->childNodes as $grandchildNode) {
874
            /** @var DOMElement $grandchildNode */
875
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
876
            switch ($grandkey) {
877
                case 'table-column':
878
                    $this->processTableColumn(
879
                        $grandchildNode,
880
                        $tableNs,
881
                        $columnWidths,
882
                        $tableColumnIndex,
883
                        $spreadsheet
884
                    );
885
886
                    break;
887
                case 'table-header-columns':
888
                case 'table-columns':
889
                    $this->processTableHeaderColumns(
890
                        $grandchildNode,
891
                        $tableNs,
892
                        $columnWidths,
893
                        $tableColumnIndex,
894
                        $spreadsheet
895
                    );
896
897
                    break;
898
                case 'table-column-group':
899
                    $this->processTableColumnGroup(
900
                        $grandchildNode,
901
                        $tableNs,
902
                        $columnWidths,
903
                        $tableColumnIndex,
904
                        $spreadsheet
905
                    );
906
907
                    break;
908
            }
909
        }
910
    }
911
912
    /**
913
     * @param string[] $columnWidths
914
     */
915 45
    private function processTableColumn(
916
        DOMElement $childNode,
917
        string $tableNs,
918
        array $columnWidths,
919
        int &$tableColumnIndex,
920
        Spreadsheet $spreadsheet
921
    ): void {
922 45
        if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
923 40
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
924
        } else {
925 19
            $rowRepeats = 1;
926
        }
927 45
        $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
928 45
        if (isset($columnWidths[$tableStyleName])) {
929 45
            $columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
930 45
            $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex);
931 45
            for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) {
932
                /** @var string $tableColumnString */
933 45
                $spreadsheet->getActiveSheet()
934 45
                    ->getColumnDimension($tableColumnString)
935 45
                    ->setWidth($columnWidth->toUnit('cm'), 'cm');
936 45
                ++$tableColumnString;
937
            }
938
        }
939 45
        $tableColumnIndex += $rowRepeats;
940
    }
941
942 69
    private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void
943
    {
944 69
        $dom = new DOMDocument('1.01', 'UTF-8');
945 69
        $dom->loadXML(
946 69
            $this->getSecurityScannerOrThrow()
947 69
                ->scan($zip->getFromName('settings.xml'))
948 69
        );
949 69
        $configNs = (string) $dom->lookupNamespaceUri('config');
950 69
        $officeNs = (string) $dom->lookupNamespaceUri('office');
951 69
        $settings = $dom->getElementsByTagNameNS($officeNs, 'settings')
952 69
            ->item(0);
953 69
        if ($settings !== null) {
954 69
            $this->lookForActiveSheet($settings, $spreadsheet, $configNs);
955 69
            $this->lookForSelectedCells($settings, $spreadsheet, $configNs);
956
        }
957
    }
958
959 69
    private function lookForActiveSheet(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
960
    {
961
        /** @var DOMElement $t */
962 69
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item') as $t) {
963 68
            if ($t->getAttributeNs($configNs, 'name') === 'ActiveTable') {
964
                try {
965 68
                    $spreadsheet->setActiveSheetIndexByName($t->nodeValue ?? '');
966 1
                } catch (Throwable) {
967
                    // do nothing
968
                }
969
970 68
                break;
971
            }
972
        }
973
    }
974
975 69
    private function lookForSelectedCells(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
976
    {
977
        /** @var DOMElement $t */
978 69
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item-map-named') as $t) {
979 68
            if ($t->getAttributeNs($configNs, 'name') === 'Tables') {
980 68
                foreach ($t->getElementsByTagNameNS($configNs, 'config-item-map-entry') as $ws) {
981 68
                    $setRow = $setCol = '';
982 68
                    $wsname = $ws->getAttributeNs($configNs, 'name');
983 68
                    foreach ($ws->getElementsByTagNameNS($configNs, 'config-item') as $configItem) {
984 68
                        $attrName = $configItem->getAttributeNs($configNs, 'name');
985 68
                        if ($attrName === 'CursorPositionX') {
986 68
                            $setCol = $configItem->nodeValue;
987
                        }
988 68
                        if ($attrName === 'CursorPositionY') {
989 68
                            $setRow = $configItem->nodeValue;
990
                        }
991
                    }
992 68
                    $this->setSelected($spreadsheet, $wsname, "$setCol", "$setRow");
993
                }
994
995 68
                break;
996
            }
997
        }
998
    }
999
1000 68
    private function setSelected(Spreadsheet $spreadsheet, string $wsname, string $setCol, string $setRow): void
1001
    {
1002 68
        if (is_numeric($setCol) && is_numeric($setRow)) {
1003 68
            $sheet = $spreadsheet->getSheetByName($wsname);
1004 68
            if ($sheet !== null) {
1005 68
                $sheet->setSelectedCells([(int) $setCol + 1, (int) $setRow + 1]);
1006
            }
1007
        }
1008
    }
1009
1010
    /**
1011
     * Recursively scan element.
1012
     */
1013 75
    protected function scanElementForText(DOMNode $element): string
1014
    {
1015 75
        $str = '';
1016 75
        foreach ($element->childNodes as $child) {
1017
            /** @var DOMNode $child */
1018 75
            if ($child->nodeType == XML_TEXT_NODE) {
1019 75
                $str .= $child->nodeValue;
1020 18
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') {
1021 1
                $str .= "\n";
1022 18
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') {
1023
                // It's a space
1024
1025
                // Multiple spaces?
1026 6
                $attributes = $child->attributes;
1027
                /** @var ?DOMAttr $cAttr */
1028 6
                $cAttr = ($attributes === null) ? null : $attributes->getNamedItem('c');
1029 6
                $multiplier = self::getMultiplier($cAttr);
1030 6
                $str .= str_repeat(' ', $multiplier);
1031
            }
1032
1033 75
            if ($child->hasChildNodes()) {
1034 16
                $str .= $this->scanElementForText($child);
1035
            }
1036
        }
1037
1038 75
        return $str;
1039
    }
1040
1041 6
    private static function getMultiplier(?DOMAttr $cAttr): int
1042
    {
1043 6
        if ($cAttr) {
1044 6
            $multiplier = (int) $cAttr->nodeValue;
1045
        } else {
1046 6
            $multiplier = 1;
1047
        }
1048
1049 6
        return $multiplier;
1050
    }
1051
1052 11
    private function parseRichText(string $is): RichText
1053
    {
1054 11
        $value = new RichText();
1055 11
        $value->createText($is);
1056
1057 11
        return $value;
1058
    }
1059
1060 75
    private function processMergedCells(
1061
        DOMElement $cellData,
1062
        string $tableNs,
1063
        string $type,
1064
        string $columnID,
1065
        int $rowID,
1066
        Spreadsheet $spreadsheet
1067
    ): void {
1068
        if (
1069 75
            $cellData->hasAttributeNS($tableNs, 'number-columns-spanned')
1070 75
            || $cellData->hasAttributeNS($tableNs, 'number-rows-spanned')
1071
        ) {
1072 18
            if (($type !== DataType::TYPE_NULL) || ($this->readDataOnly === false)) {
1073 18
                $columnTo = $columnID;
1074
1075 18
                if ($cellData->hasAttributeNS($tableNs, 'number-columns-spanned')) {
1076 17
                    $columnIndex = Coordinate::columnIndexFromString($columnID);
1077 17
                    $columnIndex += (int) $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
1078 17
                    $columnIndex -= 2;
1079
1080 17
                    $columnTo = Coordinate::stringFromColumnIndex($columnIndex + 1);
1081
                }
1082
1083 18
                $rowTo = $rowID;
1084
1085 18
                if ($cellData->hasAttributeNS($tableNs, 'number-rows-spanned')) {
1086 18
                    $rowTo = $rowTo + (int) $cellData->getAttributeNS($tableNs, 'number-rows-spanned') - 1;
1087
                }
1088
1089 18
                $cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo;
1090 18
                $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE);
1091
            }
1092
        }
1093
    }
1094
}
1095