• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PhpSpreadsheet / 21013444139

14 Jan 2026 11:20PM UTC coverage: 96.199% (+0.2%) from 95.962%
21013444139

Pull #4657

github

web-flow
Merge dcaff346a into 48f2fe37d
Pull Request #4657: Handling Unions as Function Arguments

19 of 20 new or added lines in 1 file covered. (95.0%)

359 existing lines in 16 files now uncovered.

46287 of 48116 relevant lines covered (96.2%)

387.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.6
/src/PhpSpreadsheet/Reader/Ods.php
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\Shared\StringHelper;
23
use PhpOffice\PhpSpreadsheet\Spreadsheet;
24
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
25
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
26
use Throwable;
27
use XMLReader;
28
use ZipArchive;
29

30
class Ods extends BaseReader
31
{
32
    const INITIAL_FILE = 'content.xml';
33

34
    /**
35
     * Create a new Ods Reader instance.
36
     */
37
    public function __construct()
115✔
38
    {
39
        parent::__construct();
115✔
40
        $this->securityScanner = XmlScanner::getInstance($this);
115✔
41
    }
42

43
    /**
44
     * Can the current IReader read the file?
45
     */
46
    public function canRead(string $filename): bool
21✔
47
    {
48
        $mimeType = 'UNKNOWN';
21✔
49

50
        // Load file
51

52
        if (File::testFileNoThrow($filename, '')) {
21✔
53
            $zip = new ZipArchive();
5✔
54
            if ($zip->open($filename) === true) {
5✔
55
                // check if it is an OOXML archive
56
                $stat = $zip->statName('mimetype');
5✔
57
                if (!empty($stat) && ($stat['size'] <= 255)) {
5✔
58
                    $mimeType = $zip->getFromName($stat['name']);
4✔
59
                } elseif ($zip->statName('META-INF/manifest.xml')) {
1✔
60
                    $xml = simplexml_load_string(
1✔
61
                        $this->getSecurityScannerOrThrow()
1✔
62
                            ->scan(
1✔
63
                                $zip->getFromName(
1✔
64
                                    'META-INF/manifest.xml'
1✔
65
                                )
1✔
66
                            )
1✔
67
                    );
1✔
68
                    if ($xml !== false) {
1✔
69
                        $namespacesContent = $xml->getNamespaces(true);
1✔
70
                        if (isset($namespacesContent['manifest'])) {
1✔
71
                            $manifest = $xml->children($namespacesContent['manifest']);
1✔
72
                            foreach ($manifest as $manifestDataSet) {
1✔
73
                                $manifestAttributes = $manifestDataSet->attributes($namespacesContent['manifest']);
1✔
74
                                if ($manifestAttributes && $manifestAttributes->{'full-path'} == '/') {
1✔
75
                                    $mimeType = (string) $manifestAttributes->{'media-type'};
1✔
76

77
                                    break;
1✔
78
                                }
79
                            }
80
                        }
81
                    }
82
                }
83

84
                $zip->close();
5✔
85
            }
86
        }
87

88
        return $mimeType === 'application/vnd.oasis.opendocument.spreadsheet';
21✔
89
    }
90

91
    /**
92
     * Reads names of the worksheets from a file, without parsing the whole file to a PhpSpreadsheet object.
93
     *
94
     * @return string[]
95
     */
96
    public function listWorksheetNames(string $filename): array
6✔
97
    {
98
        File::assertFile($filename, self::INITIAL_FILE);
6✔
99

100
        $worksheetNames = [];
2✔
101

102
        $xml = new XMLReader();
2✔
103
        $xml->xml(
2✔
104
            $this->getSecurityScannerOrThrow()
2✔
105
                ->scanFile(
2✔
106
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
2✔
107
                )
2✔
108
        );
2✔
109
        $xml->setParserProperty(2, true);
2✔
110

111
        // Step into the first level of content of the XML
112
        $xml->read();
2✔
113
        while ($xml->read()) {
2✔
114
            // Quickly jump through to the office:body node
115
            while ($xml->name !== 'office:body') {
2✔
116
                if ($xml->isEmptyElement) {
2✔
117
                    $xml->read();
2✔
118
                } else {
119
                    $xml->next();
2✔
120
                }
121
            }
122
            // Now read each node until we find our first table:table node
123
            while ($xml->read()) {
2✔
124
                $xmlName = $xml->name;
2✔
125
                if ($xmlName == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
2✔
126
                    // Loop through each table:table node reading the table:name attribute for each worksheet name
127
                    do {
128
                        $worksheetName = $xml->getAttribute('table:name');
2✔
129
                        if (!empty($worksheetName)) {
2✔
130
                            $worksheetNames[] = $worksheetName;
2✔
131
                        }
132
                        $xml->next();
2✔
133
                    } while ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT);
2✔
134
                }
135
            }
136
        }
137

138
        return $worksheetNames;
2✔
139
    }
140

141
    /**
142
     * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
143
     *
144
     * @return array<int, array{worksheetName: string, lastColumnLetter: string, lastColumnIndex: int, totalRows: int, totalColumns: int, sheetState: string}>
145
     */
146
    public function listWorksheetInfo(string $filename): array
8✔
147
    {
148
        File::assertFile($filename, self::INITIAL_FILE);
8✔
149

150
        $worksheetInfo = [];
4✔
151

152
        $xml = new XMLReader();
4✔
153
        $xml->xml(
4✔
154
            $this->getSecurityScannerOrThrow()
4✔
155
                ->scanFile(
4✔
156
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
4✔
157
                )
4✔
158
        );
4✔
159
        $xml->setParserProperty(2, true);
4✔
160

161
        // Step into the first level of content of the XML
162
        $xml->read();
4✔
163
        $tableVisibility = [];
4✔
164
        $lastTableStyle = '';
4✔
165

166
        while ($xml->read()) {
4✔
167
            if ($xml->name === 'style:style') {
4✔
168
                $styleType = $xml->getAttribute('style:family');
4✔
169
                if ($styleType === 'table') {
4✔
170
                    $lastTableStyle = $xml->getAttribute('style:name');
4✔
171
                }
172
            } elseif ($xml->name === 'style:table-properties') {
4✔
173
                $visibility = $xml->getAttribute('table:display');
4✔
174
                $tableVisibility[$lastTableStyle] = ($visibility === 'false') ? Worksheet::SHEETSTATE_HIDDEN : Worksheet::SHEETSTATE_VISIBLE;
4✔
175
            } elseif ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
176
                $worksheetNames[] = $xml->getAttribute('table:name');
4✔
177

178
                $styleName = $xml->getAttribute('table:style-name') ?? '';
4✔
179
                $visibility = $tableVisibility[$styleName] ?? '';
4✔
180
                $tmpInfo = [
4✔
181
                    'worksheetName' => (string) $xml->getAttribute('table:name'),
4✔
182
                    'lastColumnLetter' => 'A',
4✔
183
                    'lastColumnIndex' => 0,
4✔
184
                    'totalRows' => 0,
4✔
185
                    'totalColumns' => 0,
4✔
186
                    'sheetState' => $visibility,
4✔
187
                ];
4✔
188

189
                // Loop through each child node of the table:table element reading
190
                $currRow = 0;
4✔
191
                do {
192
                    $xml->read();
4✔
193
                    if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
194
                        $rowspan = $xml->getAttribute('table:number-rows-repeated');
4✔
195
                        $rowspan = empty($rowspan) ? 1 : (int) $rowspan;
4✔
196
                        $currRow += $rowspan;
4✔
197
                        $currCol = 0;
4✔
198
                        // Step into the row
199
                        $xml->read();
4✔
200
                        do {
201
                            $doread = true;
4✔
202
                            if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
203
                                $mergeSize = $xml->getAttribute('table:number-columns-repeated');
4✔
204
                                $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize;
4✔
205
                                $currCol += $mergeSize;
4✔
206
                                if (!$xml->isEmptyElement) {
4✔
207
                                    $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCol);
4✔
208
                                    $tmpInfo['totalRows'] = $currRow;
4✔
209
                                    $xml->next();
4✔
210
                                    $doread = false;
4✔
211
                                }
212
                            } elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
2✔
213
                                $mergeSize = $xml->getAttribute('table:number-columns-repeated');
2✔
214
                                $currCol += (int) $mergeSize;
2✔
215
                            }
216
                            if ($doread) {
4✔
217
                                $xml->read();
3✔
218
                            }
219
                        } while ($xml->name != 'table:table-row');
4✔
220
                    }
221
                } while ($xml->name != 'table:table');
4✔
222

223
                $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1;
4✔
224
                $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1);
4✔
225
                $worksheetInfo[] = $tmpInfo;
4✔
226
            }
227
        }
228

229
        return $worksheetInfo;
4✔
230
    }
231

232
    /**
233
     * Loads PhpSpreadsheet from file.
234
     */
235
    protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
80✔
236
    {
237
        $spreadsheet = $this->newSpreadsheet();
80✔
238
        $spreadsheet->setValueBinder($this->valueBinder);
80✔
239
        $spreadsheet->removeSheetByIndex(0);
80✔
240

241
        // Load into this instance
242
        return $this->loadIntoExisting($filename, $spreadsheet);
80✔
243
    }
244

245
    /**
246
     * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
247
     */
248
    public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Spreadsheet
84✔
249
    {
250
        File::assertFile($filename, self::INITIAL_FILE);
84✔
251

252
        $zip = new ZipArchive();
80✔
253
        $zip->open($filename);
80✔
254

255
        // Meta
256

257
        $xml = @simplexml_load_string(
80✔
258
            $this->getSecurityScannerOrThrow()
80✔
259
                ->scan($zip->getFromName('meta.xml'))
80✔
260
        );
80✔
261
        if ($xml === false) {
80✔
262
            throw new Exception('Unable to read data from {$pFilename}');
1✔
263
        }
264

265
        /** @var array{meta?: string, office?: string, dc?: string} */
266
        $namespacesMeta = $xml->getNamespaces(true);
79✔
267

268
        (new DocumentProperties($spreadsheet))->load($xml, $namespacesMeta);
79✔
269

270
        // Styles
271

272
        $dom = new DOMDocument('1.01', 'UTF-8');
79✔
273
        $dom->loadXML(
79✔
274
            $this->getSecurityScannerOrThrow()
79✔
275
                ->scan($zip->getFromName('styles.xml'))
79✔
276
        );
79✔
277

278
        $pageSettings = new PageSettings($dom);
79✔
279

280
        // Main Content
281

282
        $dom = new DOMDocument('1.01', 'UTF-8');
79✔
283
        $dom->loadXML(
79✔
284
            $this->getSecurityScannerOrThrow()
79✔
285
                ->scan($zip->getFromName(self::INITIAL_FILE))
79✔
286
        );
79✔
287

288
        $officeNs = (string) $dom->lookupNamespaceUri('office');
79✔
289
        $tableNs = (string) $dom->lookupNamespaceUri('table');
79✔
290
        $textNs = (string) $dom->lookupNamespaceUri('text');
79✔
291
        $xlinkNs = (string) $dom->lookupNamespaceUri('xlink');
79✔
292
        $styleNs = (string) $dom->lookupNamespaceUri('style');
79✔
293

294
        $pageSettings->readStyleCrossReferences($dom);
79✔
295

296
        $autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
79✔
297
        $definedNameReader = new DefinedNames($spreadsheet, $tableNs);
79✔
298
        $columnWidths = [];
79✔
299
        $automaticStyle0 = $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
79✔
300
        $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
79✔
301
        foreach ($automaticStyles as $automaticStyle) {
79✔
302
            $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
79✔
303
            $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
79✔
304
            if ($styleFamily === 'table-column') {
79✔
305
                $tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
48✔
306
                $tcprop = $tcprops->item(0);
48✔
307
                if ($tcprop !== null) {
48✔
308
                    $columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
48✔
309
                    $columnWidths[$styleName] = $columnWidth;
48✔
310
                }
311
            }
312
        }
313

314
        // Content
315
        $item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
79✔
316
        $spreadsheets = ($item0 === null) ? [] : $item0->getElementsByTagNameNS($officeNs, 'spreadsheet');
79✔
317

318
        foreach ($spreadsheets as $workbookData) {
79✔
319
            /** @var DOMElement $workbookData */
320
            $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
79✔
321

322
            $worksheetID = 0;
79✔
323
            $sheetCreated = false;
79✔
324
            foreach ($tables as $worksheetDataSet) {
79✔
325
                /** @var DOMElement $worksheetDataSet */
326
                $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
79✔
327

328
                // Check loadSheetsOnly
329
                if (
330
                    $this->loadSheetsOnly !== null
79✔
331
                    && $worksheetName
332
                    && !in_array($worksheetName, $this->loadSheetsOnly)
79✔
333
                ) {
334
                    continue;
5✔
335
                }
336

337
                $worksheetStyleName = $worksheetDataSet->getAttributeNS($tableNs, 'style-name');
76✔
338

339
                // Create sheet
340
                $spreadsheet->createSheet();
76✔
341
                $sheetCreated = true;
76✔
342
                $spreadsheet->setActiveSheetIndex($worksheetID);
76✔
343

344
                if ($worksheetName || is_numeric($worksheetName)) {
76✔
345
                    // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in
346
                    // formula cells... during the load, all formulae should be correct, and we're simply
347
                    // bringing the worksheet name in line with the formula, not the reverse
348
                    $spreadsheet->getActiveSheet()->setTitle((string) $worksheetName, false, false);
76✔
349
                }
350

351
                // Go through every child of table element
352
                $rowID = 1;
76✔
353
                $tableColumnIndex = 1;
76✔
354
                foreach ($worksheetDataSet->childNodes as $childNode) {
76✔
355
                    /** @var DOMElement $childNode */
356

357
                    // Filter elements which are not under the "table" ns
358
                    if ($childNode->namespaceURI != $tableNs) {
76✔
359
                        continue;
45✔
360
                    }
361

362
                    $key = self::extractNodeName($childNode->nodeName);
76✔
363

364
                    switch ($key) {
365
                        case 'table-header-rows':
76✔
366
                        case 'table-rows':
76✔
367
                            $this->processTableHeaderRows(
1✔
368
                                $childNode,
1✔
369
                                $tableNs,
1✔
370
                                $rowID,
1✔
371
                                $worksheetName,
1✔
372
                                $officeNs,
1✔
373
                                $textNs,
1✔
374
                                $xlinkNs,
1✔
375
                                $spreadsheet
1✔
376
                            );
1✔
377

378
                            break;
1✔
379
                        case 'table-row-group':
76✔
380
                            $this->processTableRowGroup(
1✔
381
                                $childNode,
1✔
382
                                $tableNs,
1✔
383
                                $rowID,
1✔
384
                                $worksheetName,
1✔
385
                                $officeNs,
1✔
386
                                $textNs,
1✔
387
                                $xlinkNs,
1✔
388
                                $spreadsheet
1✔
389
                            );
1✔
390

391
                            break;
1✔
392
                        case 'table-header-columns':
76✔
393
                        case 'table-columns':
76✔
394
                            $this->processTableHeaderColumns(
×
395
                                $childNode,
×
396
                                $tableNs,
×
397
                                $columnWidths,
×
398
                                $tableColumnIndex,
×
UNCOV
399
                                $spreadsheet
×
400
                            );
×
401

402
                            break;
×
403
                        case 'table-column-group':
76✔
404
                            $this->processTableColumnGroup(
×
405
                                $childNode,
×
406
                                $tableNs,
×
407
                                $columnWidths,
×
408
                                $tableColumnIndex,
×
UNCOV
409
                                $spreadsheet
×
410
                            );
×
411

UNCOV
412
                            break;
×
413
                        case 'table-column':
76✔
414
                            $this->processTableColumn(
45✔
415
                                $childNode,
45✔
416
                                $tableNs,
45✔
417
                                $columnWidths,
45✔
418
                                $tableColumnIndex,
45✔
419
                                $spreadsheet
45✔
420
                            );
45✔
421

422
                            break;
45✔
423
                        case 'table-row':
75✔
424
                            $this->processTableRow(
75✔
425
                                $childNode,
75✔
426
                                $tableNs,
75✔
427
                                $rowID,
75✔
428
                                $worksheetName,
75✔
429
                                $officeNs,
75✔
430
                                $textNs,
75✔
431
                                $xlinkNs,
75✔
432
                                $spreadsheet
75✔
433
                            );
75✔
434

435
                            break;
75✔
436
                    }
437
                }
438
                $pageSettings->setVisibilityForWorksheet(
76✔
439
                    $spreadsheet->getActiveSheet(),
76✔
440
                    $worksheetStyleName
76✔
441
                );
76✔
442
                $pageSettings->setPrintSettingsForWorksheet(
76✔
443
                    $spreadsheet->getActiveSheet(),
76✔
444
                    $worksheetStyleName
76✔
445
                );
76✔
446
                ++$worksheetID;
76✔
447
            }
448
            if ($this->createBlankSheetIfNoneRead && !$sheetCreated) {
79✔
449
                $spreadsheet->createSheet();
1✔
450
            }
451

452
            $autoFilterReader->read($workbookData);
79✔
453
            $definedNameReader->read($workbookData);
79✔
454
        }
455
        $spreadsheet->setActiveSheetIndex(0);
79✔
456

457
        if ($zip->locateName('settings.xml') !== false) {
77✔
458
            $this->processSettings($zip, $spreadsheet);
70✔
459
        }
460

461
        // Return
462
        return $spreadsheet;
77✔
463
    }
464

465
    private function processTableHeaderRows(
1✔
466
        DOMElement $childNode,
467
        string $tableNs,
468
        int &$rowID,
469
        string $worksheetName,
470
        string $officeNs,
471
        string $textNs,
472
        string $xlinkNs,
473
        Spreadsheet $spreadsheet
474
    ): void {
475
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
476
            /** @var DOMElement $grandchildNode */
477
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
478
            switch ($grandkey) {
479
                case 'table-row':
1✔
480
                    $this->processTableRow(
1✔
481
                        $grandchildNode,
1✔
482
                        $tableNs,
1✔
483
                        $rowID,
1✔
484
                        $worksheetName,
1✔
485
                        $officeNs,
1✔
486
                        $textNs,
1✔
487
                        $xlinkNs,
1✔
488
                        $spreadsheet
1✔
489
                    );
1✔
490

491
                    break;
1✔
492
            }
493
        }
494
    }
495

496
    private function processTableRowGroup(
1✔
497
        DOMElement $childNode,
498
        string $tableNs,
499
        int &$rowID,
500
        string $worksheetName,
501
        string $officeNs,
502
        string $textNs,
503
        string $xlinkNs,
504
        Spreadsheet $spreadsheet
505
    ): void {
506
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
507
            /** @var DOMElement $grandchildNode */
508
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
509
            switch ($grandkey) {
510
                case 'table-row':
1✔
511
                    $this->processTableRow(
1✔
512
                        $grandchildNode,
1✔
513
                        $tableNs,
1✔
514
                        $rowID,
1✔
515
                        $worksheetName,
1✔
516
                        $officeNs,
1✔
517
                        $textNs,
1✔
518
                        $xlinkNs,
1✔
519
                        $spreadsheet
1✔
520
                    );
1✔
521

522
                    break;
1✔
523
                case 'table-header-rows':
×
524
                case 'table-rows':
×
525
                    $this->processTableHeaderRows(
×
526
                        $grandchildNode,
×
527
                        $tableNs,
×
528
                        $rowID,
×
529
                        $worksheetName,
×
530
                        $officeNs,
×
531
                        $textNs,
×
532
                        $xlinkNs,
×
UNCOV
533
                        $spreadsheet
×
534
                    );
×
535

536
                    break;
×
537
                case 'table-row-group':
×
538
                    $this->processTableRowGroup(
×
539
                        $grandchildNode,
×
540
                        $tableNs,
×
541
                        $rowID,
×
542
                        $worksheetName,
×
543
                        $officeNs,
×
544
                        $textNs,
×
545
                        $xlinkNs,
×
UNCOV
546
                        $spreadsheet
×
547
                    );
×
548

UNCOV
549
                    break;
×
550
            }
551
        }
552
    }
553

554
    private function processTableRow(
75✔
555
        DOMElement $childNode,
556
        string $tableNs,
557
        int &$rowID,
558
        string $worksheetName,
559
        string $officeNs,
560
        string $textNs,
561
        string $xlinkNs,
562
        Spreadsheet $spreadsheet
563
    ): void {
564
        if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {
75✔
565
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
26✔
566
        } else {
567
            $rowRepeats = 1;
75✔
568
        }
569

570
        $columnID = 'A';
75✔
571
        /** @var DOMElement|DOMText $cellData */
572
        foreach ($childNode->childNodes as $cellData) {
75✔
573
            if ($cellData instanceof DOMText) {
75✔
574
                continue; // should just be whitespace
2✔
575
            }
576
            if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
75✔
577
                if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
2✔
578
                    $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
1✔
579
                } else {
580
                    $colRepeats = 1;
2✔
581
                }
582

583
                for ($i = 0; $i < $colRepeats; ++$i) {
2✔
584
                    StringHelper::stringIncrement($columnID);
2✔
585
                }
586

587
                continue;
2✔
588
            }
589

590
            // Initialize variables
591
            $formatting = $hyperlink = null;
75✔
592
            $hasCalculatedValue = false;
75✔
593
            $cellDataFormula = '';
75✔
594
            $cellDataType = '';
75✔
595
            $cellDataRef = '';
75✔
596

597
            if ($cellData->hasAttributeNS($tableNs, 'formula')) {
75✔
598
                $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
29✔
599
                $hasCalculatedValue = true;
29✔
600
            }
601
            if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
75✔
602
                if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
12✔
603
                    $cellDataType = 'array';
12✔
604
                    $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
12✔
605
                    $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
12✔
606
                    $lastRow = $rowID + $arrayRow - 1;
12✔
607
                    $lastCol = $columnID;
12✔
608
                    while ($arrayCol > 1) {
12✔
609
                        StringHelper::stringIncrement($lastCol);
7✔
610
                        --$arrayCol;
7✔
611
                    }
612
                    $cellDataRef = "$columnID$rowID:$lastCol$lastRow";
12✔
613
                }
614
            }
615

616
            // Annotations
617
            $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');
75✔
618

619
            if ($annotation->length > 0 && $annotation->item(0) !== null) {
75✔
620
                $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p');
11✔
621
                $textNodeLength = $textNode->length;
11✔
622
                $newLineOwed = false;
11✔
623
                for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) {
11✔
624
                    $textNodeItem = $textNode->item($textNodeIndex);
11✔
625
                    if ($textNodeItem !== null) {
11✔
626
                        $text = $this->scanElementForText($textNodeItem);
11✔
627
                        if ($newLineOwed) {
11✔
628
                            $spreadsheet->getActiveSheet()
1✔
629
                                ->getComment($columnID . $rowID)
1✔
630
                                ->getText()
1✔
631
                                ->createText("\n");
1✔
632
                        }
633
                        $newLineOwed = true;
11✔
634

635
                        $spreadsheet->getActiveSheet()
11✔
636
                            ->getComment($columnID . $rowID)
11✔
637
                            ->getText()
11✔
638
                            ->createText(
11✔
639
                                $this->parseRichText($text)
11✔
640
                            );
11✔
641
                    }
642
                }
643
            }
644

645
            // Content
646

647
            /** @var DOMElement[] $paragraphs */
648
            $paragraphs = [];
75✔
649

650
            foreach ($cellData->childNodes as $item) {
75✔
651
                /** @var DOMElement $item */
652

653
                // Filter text:p elements
654
                if ($item->nodeName == 'text:p') {
75✔
655
                    $paragraphs[] = $item;
75✔
656
                }
657
            }
658

659
            if (count($paragraphs) > 0) {
75✔
660
                // Consolidate if there are multiple p records (maybe with spans as well)
661
                $dataArray = [];
75✔
662

663
                // Text can have multiple text:p and within those, multiple text:span.
664
                // text:p newlines, but text:span does not.
665
                // Also, here we assume there is no text data is span fields are specified, since
666
                // we have no way of knowing proper positioning anyway.
667

668
                foreach ($paragraphs as $pData) {
75✔
669
                    $dataArray[] = $this->scanElementForText($pData);
75✔
670
                }
671
                $allCellDataText = implode("\n", $dataArray);
75✔
672

673
                $type = $cellData->getAttributeNS($officeNs, 'value-type');
75✔
674

675
                switch ($type) {
676
                    case 'string':
75✔
677
                        $type = DataType::TYPE_STRING;
49✔
678
                        $dataValue = $allCellDataText;
49✔
679

680
                        foreach ($paragraphs as $paragraph) {
49✔
681
                            $link = $paragraph->getElementsByTagNameNS($textNs, 'a');
49✔
682
                            if ($link->length > 0 && $link->item(0) !== null) {
49✔
683
                                $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href');
7✔
684
                            }
685
                        }
686

687
                        break;
49✔
688
                    case 'boolean':
51✔
689
                        $type = DataType::TYPE_BOOL;
9✔
690
                        $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false;
9✔
691

692
                        break;
9✔
693
                    case 'percentage':
49✔
694
                        $type = DataType::TYPE_NUMERIC;
4✔
695
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
4✔
696

697
                        // percentage should always be float
698
                        //if (floor($dataValue) == $dataValue) {
699
                        //    $dataValue = (int) $dataValue;
700
                        //}
701
                        $formatting = NumberFormat::FORMAT_PERCENTAGE_00;
4✔
702

703
                        break;
4✔
704
                    case 'currency':
49✔
705
                        $type = DataType::TYPE_NUMERIC;
4✔
706
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
4✔
707

708
                        if (floor($dataValue) == $dataValue) {
4✔
709
                            $dataValue = (int) $dataValue;
4✔
710
                        }
711
                        $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER;
4✔
712

713
                        break;
4✔
714
                    case 'float':
45✔
715
                        $type = DataType::TYPE_NUMERIC;
45✔
716
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
45✔
717

718
                        if (floor($dataValue) == $dataValue) {
45✔
719
                            if ($dataValue == (int) $dataValue) {
41✔
720
                                $dataValue = (int) $dataValue;
41✔
721
                            }
722
                        }
723

724
                        break;
45✔
725
                    case 'date':
10✔
726
                        $type = DataType::TYPE_NUMERIC;
9✔
727
                        $value = $cellData->getAttributeNS($officeNs, 'date-value');
9✔
728
                        $dataValue = Date::convertIsoDate($value);
9✔
729

730
                        if ($dataValue != floor($dataValue)) {
9✔
731
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15
6✔
732
                                . ' '
6✔
733
                                . NumberFormat::FORMAT_DATE_TIME4;
6✔
734
                        } else {
735
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15;
9✔
736
                        }
737

738
                        break;
9✔
739
                    case 'time':
7✔
740
                        $type = DataType::TYPE_NUMERIC;
6✔
741

742
                        $timeValue = $cellData->getAttributeNS($officeNs, 'time-value');
6✔
743

744
                        $dataValue = Date::PHPToExcel(
6✔
745
                            strtotime(
6✔
746
                                '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? [])
6✔
747
                            )
6✔
748
                        );
6✔
749
                        $formatting = NumberFormat::FORMAT_DATE_TIME4;
6✔
750

751
                        break;
6✔
752
                    default:
753
                        $dataValue = null;
1✔
754
                }
755
            } else {
756
                $type = DataType::TYPE_NULL;
43✔
757
                $dataValue = null;
43✔
758
            }
759

760
            if ($hasCalculatedValue) {
75✔
761
                $type = DataType::TYPE_FORMULA;
29✔
762
                $cellDataFormula = substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1);
29✔
763
                $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula);
29✔
764
            }
765

766
            if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
75✔
767
                $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
38✔
768
            } else {
769
                $colRepeats = 1;
75✔
770
            }
771

772
            if ($type !== null) { // @phpstan-ignore-line
75✔
773
                for ($i = 0; $i < $colRepeats; ++$i) {
75✔
774
                    if ($i > 0) {
75✔
775
                        StringHelper::stringIncrement($columnID);
38✔
776
                    }
777

778
                    if ($type !== DataType::TYPE_NULL) {
75✔
779
                        for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) {
75✔
780
                            $rID = $rowID + $rowAdjust;
75✔
781

782
                            $cell = $spreadsheet->getActiveSheet()
75✔
783
                                ->getCell($columnID . $rID);
75✔
784

785
                            // Set value
786
                            if ($hasCalculatedValue) {
75✔
787
                                $cell->setValueExplicit($cellDataFormula, $type);
29✔
788
                                if ($cellDataType === 'array') {
29✔
789
                                    $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
12✔
790
                                }
791
                            } elseif ($type !== '' || $dataValue !== null) {
71✔
792
                                $cell->setValueExplicit($dataValue, $type);
71✔
793
                            }
794

795
                            if ($hasCalculatedValue) {
75✔
796
                                $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC);
29✔
797
                            }
798

799
                            // Set other properties
800
                            if ($formatting !== null) {
75✔
801
                                $spreadsheet->getActiveSheet()
13✔
802
                                    ->getStyle($columnID . $rID)
13✔
803
                                    ->getNumberFormat()
13✔
804
                                    ->setFormatCode($formatting);
13✔
805
                            } else {
806
                                $spreadsheet->getActiveSheet()
75✔
807
                                    ->getStyle($columnID . $rID)
75✔
808
                                    ->getNumberFormat()
75✔
809
                                    ->setFormatCode(NumberFormat::FORMAT_GENERAL);
75✔
810
                            }
811

812
                            if ($hyperlink !== null) {
75✔
813
                                if ($hyperlink[0] === '#') {
7✔
814
                                    $hyperlink = 'sheet://' . substr($hyperlink, 1);
1✔
815
                                }
816
                                $cell->getHyperlink()
7✔
817
                                    ->setUrl($hyperlink);
7✔
818
                            }
819
                        }
820
                    }
821
                }
822
            }
823

824
            // Merged cells
825
            $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet);
75✔
826

827
            StringHelper::stringIncrement($columnID);
75✔
828
        }
829
        $rowID += $rowRepeats;
75✔
830
    }
831

832
    private static function extractNodeName(string $key): string
76✔
833
    {
834
        // Remove ns from node name
835
        if (str_contains($key, ':')) {
76✔
836
            $keyChunks = explode(':', $key);
76✔
837
            $key = array_pop($keyChunks);
76✔
838
        }
839

840
        return $key;
76✔
841
    }
842

843
    /**
844
     * @param string[] $columnWidths
845
     */
UNCOV
846
    private function processTableHeaderColumns(
×
847
        DOMElement $childNode,
848
        string $tableNs,
849
        array $columnWidths,
850
        int &$tableColumnIndex,
851
        Spreadsheet $spreadsheet
852
    ): void {
853
        foreach ($childNode->childNodes as $grandchildNode) {
×
854
            /** @var DOMElement $grandchildNode */
855
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
×
856
            switch ($grandkey) {
857
                case 'table-column':
×
858
                    $this->processTableColumn(
×
859
                        $grandchildNode,
×
860
                        $tableNs,
×
861
                        $columnWidths,
×
862
                        $tableColumnIndex,
×
UNCOV
863
                        $spreadsheet
×
864
                    );
×
865

UNCOV
866
                    break;
×
867
            }
868
        }
869
    }
870

871
    /**
872
     * @param string[] $columnWidths
873
     */
UNCOV
874
    private function processTableColumnGroup(
×
875
        DOMElement $childNode,
876
        string $tableNs,
877
        array $columnWidths,
878
        int &$tableColumnIndex,
879
        Spreadsheet $spreadsheet
880
    ): void {
881
        foreach ($childNode->childNodes as $grandchildNode) {
×
882
            /** @var DOMElement $grandchildNode */
883
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
×
884
            switch ($grandkey) {
885
                case 'table-column':
×
886
                    $this->processTableColumn(
×
887
                        $grandchildNode,
×
888
                        $tableNs,
×
889
                        $columnWidths,
×
890
                        $tableColumnIndex,
×
UNCOV
891
                        $spreadsheet
×
892
                    );
×
893

894
                    break;
×
895
                case 'table-header-columns':
×
896
                case 'table-columns':
×
897
                    $this->processTableHeaderColumns(
×
898
                        $grandchildNode,
×
899
                        $tableNs,
×
900
                        $columnWidths,
×
901
                        $tableColumnIndex,
×
UNCOV
902
                        $spreadsheet
×
903
                    );
×
904

905
                    break;
×
906
                case 'table-column-group':
×
907
                    $this->processTableColumnGroup(
×
908
                        $grandchildNode,
×
909
                        $tableNs,
×
910
                        $columnWidths,
×
911
                        $tableColumnIndex,
×
UNCOV
912
                        $spreadsheet
×
913
                    );
×
914

UNCOV
915
                    break;
×
916
            }
917
        }
918
    }
919

920
    /**
921
     * @param string[] $columnWidths
922
     */
923
    private function processTableColumn(
45✔
924
        DOMElement $childNode,
925
        string $tableNs,
926
        array $columnWidths,
927
        int &$tableColumnIndex,
928
        Spreadsheet $spreadsheet
929
    ): void {
930
        if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
45✔
931
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
40✔
932
        } else {
933
            $rowRepeats = 1;
19✔
934
        }
935
        $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
45✔
936
        if (isset($columnWidths[$tableStyleName])) {
45✔
937
            $columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
45✔
938
            $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex);
45✔
939
            for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) {
45✔
940
                /** @var string $tableColumnString */
941
                $spreadsheet->getActiveSheet()
45✔
942
                    ->getColumnDimension($tableColumnString)
45✔
943
                    ->setWidth($columnWidth->toUnit('cm'), 'cm');
45✔
944
                StringHelper::stringIncrement($tableColumnString);
45✔
945
            }
946
        }
947
        $tableColumnIndex += $rowRepeats;
45✔
948
    }
949

950
    private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void
70✔
951
    {
952
        $dom = new DOMDocument('1.01', 'UTF-8');
70✔
953
        $dom->loadXML(
70✔
954
            $this->getSecurityScannerOrThrow()
70✔
955
                ->scan($zip->getFromName('settings.xml'))
70✔
956
        );
70✔
957
        $configNs = (string) $dom->lookupNamespaceUri('config');
70✔
958
        $officeNs = (string) $dom->lookupNamespaceUri('office');
70✔
959
        $settings = $dom->getElementsByTagNameNS($officeNs, 'settings')
70✔
960
            ->item(0);
70✔
961
        if ($settings !== null) {
70✔
962
            $this->lookForActiveSheet($settings, $spreadsheet, $configNs);
70✔
963
            $this->lookForSelectedCells($settings, $spreadsheet, $configNs);
70✔
964
        }
965
    }
966

967
    private function lookForActiveSheet(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
70✔
968
    {
969
        /** @var DOMElement $t */
970
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item') as $t) {
70✔
971
            if ($t->getAttributeNs($configNs, 'name') === 'ActiveTable') {
69✔
972
                try {
973
                    $spreadsheet->setActiveSheetIndexByName($t->nodeValue ?? '');
69✔
974
                } catch (Throwable) {
2✔
975
                    // do nothing
976
                }
977

978
                break;
69✔
979
            }
980
        }
981
    }
982

983
    private function lookForSelectedCells(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
70✔
984
    {
985
        /** @var DOMElement $t */
986
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item-map-named') as $t) {
70✔
987
            if ($t->getAttributeNs($configNs, 'name') === 'Tables') {
69✔
988
                foreach ($t->getElementsByTagNameNS($configNs, 'config-item-map-entry') as $ws) {
69✔
989
                    $setRow = $setCol = '';
69✔
990
                    $wsname = $ws->getAttributeNs($configNs, 'name');
69✔
991
                    foreach ($ws->getElementsByTagNameNS($configNs, 'config-item') as $configItem) {
69✔
992
                        $attrName = $configItem->getAttributeNs($configNs, 'name');
69✔
993
                        if ($attrName === 'CursorPositionX') {
69✔
994
                            $setCol = $configItem->nodeValue;
69✔
995
                        }
996
                        if ($attrName === 'CursorPositionY') {
69✔
997
                            $setRow = $configItem->nodeValue;
69✔
998
                        }
999
                    }
1000
                    $this->setSelected($spreadsheet, $wsname, "$setCol", "$setRow");
69✔
1001
                }
1002

1003
                break;
69✔
1004
            }
1005
        }
1006
    }
1007

1008
    private function setSelected(Spreadsheet $spreadsheet, string $wsname, string $setCol, string $setRow): void
69✔
1009
    {
1010
        if (is_numeric($setCol) && is_numeric($setRow)) {
69✔
1011
            $sheet = $spreadsheet->getSheetByName($wsname);
69✔
1012
            if ($sheet !== null) {
69✔
1013
                $sheet->setSelectedCells([(int) $setCol + 1, (int) $setRow + 1]);
68✔
1014
            }
1015
        }
1016
    }
1017

1018
    /**
1019
     * Recursively scan element.
1020
     */
1021
    protected function scanElementForText(DOMNode $element): string
75✔
1022
    {
1023
        $str = '';
75✔
1024
        foreach ($element->childNodes as $child) {
75✔
1025
            /** @var DOMNode $child */
1026
            if ($child->nodeType == XML_TEXT_NODE) {
75✔
1027
                $str .= $child->nodeValue;
75✔
1028
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') {
18✔
1029
                $str .= "\n";
1✔
1030
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') {
18✔
1031
                // It's a space
1032

1033
                // Multiple spaces?
1034
                $attributes = $child->attributes;
6✔
1035
                /** @var ?DOMAttr $cAttr */
1036
                $cAttr = ($attributes === null) ? null : $attributes->getNamedItem('c');
6✔
1037
                $multiplier = self::getMultiplier($cAttr);
6✔
1038
                $str .= str_repeat(' ', $multiplier);
6✔
1039
            }
1040

1041
            if ($child->hasChildNodes()) {
75✔
1042
                $str .= $this->scanElementForText($child);
16✔
1043
            }
1044
        }
1045

1046
        return $str;
75✔
1047
    }
1048

1049
    private static function getMultiplier(?DOMAttr $cAttr): int
6✔
1050
    {
1051
        if ($cAttr) {
6✔
1052
            $multiplier = (int) $cAttr->nodeValue;
6✔
1053
        } else {
1054
            $multiplier = 1;
6✔
1055
        }
1056

1057
        return $multiplier;
6✔
1058
    }
1059

1060
    private function parseRichText(string $is): RichText
11✔
1061
    {
1062
        $value = new RichText();
11✔
1063
        $value->createText($is);
11✔
1064

1065
        return $value;
11✔
1066
    }
1067

1068
    private function processMergedCells(
75✔
1069
        DOMElement $cellData,
1070
        string $tableNs,
1071
        string $type,
1072
        string $columnID,
1073
        int $rowID,
1074
        Spreadsheet $spreadsheet
1075
    ): void {
1076
        if (
1077
            $cellData->hasAttributeNS($tableNs, 'number-columns-spanned')
75✔
1078
            || $cellData->hasAttributeNS($tableNs, 'number-rows-spanned')
75✔
1079
        ) {
1080
            if (($type !== DataType::TYPE_NULL) || ($this->readDataOnly === false)) {
18✔
1081
                $columnTo = $columnID;
18✔
1082

1083
                if ($cellData->hasAttributeNS($tableNs, 'number-columns-spanned')) {
18✔
1084
                    $columnIndex = Coordinate::columnIndexFromString($columnID);
17✔
1085
                    $columnIndex += (int) $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
17✔
1086
                    $columnIndex -= 2;
17✔
1087

1088
                    $columnTo = Coordinate::stringFromColumnIndex($columnIndex + 1);
17✔
1089
                }
1090

1091
                $rowTo = $rowID;
18✔
1092

1093
                if ($cellData->hasAttributeNS($tableNs, 'number-rows-spanned')) {
18✔
1094
                    $rowTo = $rowTo + (int) $cellData->getAttributeNS($tableNs, 'number-rows-spanned') - 1;
18✔
1095
                }
1096

1097
                $cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo;
18✔
1098
                $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE);
18✔
1099
            }
1100
        }
1101
    }
1102
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc