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

PHPOffice / PhpSpreadsheet / 22332518505

24 Feb 2026 01:23AM UTC coverage: 96.496% (+0.08%) from 96.416%
22332518505

Pull #4815

github

web-flow
Merge 015afae32 into 2920b2ae1
Pull Request #4815: Ods Reader Style Support (Last) - Column Styles (Partial)

117 of 124 new or added lines in 1 file covered. (94.35%)

2 existing lines in 1 file now uncovered.

47610 of 49339 relevant lines covered (96.5%)

381.63 hits per line

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

95.1
/src/PhpSpreadsheet/Reader/Ods.php
1
<?php
2

3
namespace PhpOffice\PhpSpreadsheet\Reader;
4

5
use Closure;
6
use Composer\Pcre\Preg;
7
use DateTime;
8
use DateTimeZone;
9
use DOMAttr;
10
use DOMDocument;
11
use DOMElement;
12
use DOMNode;
13
use DOMText;
14
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
15
use PhpOffice\PhpSpreadsheet\Cell\DataType;
16
use PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension;
17
use PhpOffice\PhpSpreadsheet\Reader\Ods\AutoFilter;
18
use PhpOffice\PhpSpreadsheet\Reader\Ods\DefinedNames;
19
use PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
20
use PhpOffice\PhpSpreadsheet\Reader\Ods\PageSettings;
21
use PhpOffice\PhpSpreadsheet\Reader\Ods\Properties as DocumentProperties;
22
use PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner;
23
use PhpOffice\PhpSpreadsheet\RichText\RichText;
24
use PhpOffice\PhpSpreadsheet\Shared\Date;
25
use PhpOffice\PhpSpreadsheet\Shared\File;
26
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
27
use PhpOffice\PhpSpreadsheet\Spreadsheet;
28
use PhpOffice\PhpSpreadsheet\Style\Alignment;
29
use PhpOffice\PhpSpreadsheet\Style\Border;
30
use PhpOffice\PhpSpreadsheet\Style\Borders;
31
use PhpOffice\PhpSpreadsheet\Style\Fill;
32
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
33
use PhpOffice\PhpSpreadsheet\Style\Protection;
34
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
35
use Throwable;
36
use XMLReader;
37
use ZipArchive;
38

39
class Ods extends BaseReader
40
{
41
    const INITIAL_FILE = 'content.xml';
42

43
    /**
44
     * Create a new Ods Reader instance.
45
     */
46
    public function __construct()
136✔
47
    {
48
        parent::__construct();
136✔
49
        $this->securityScanner = XmlScanner::getInstance($this);
136✔
50
    }
51

52
    /**
53
     * Can the current IReader read the file?
54
     */
55
    public function canRead(string $filename): bool
21✔
56
    {
57
        $mimeType = 'UNKNOWN';
21✔
58

59
        // Load file
60

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

86
                                    break;
1✔
87
                                }
88
                            }
89
                        }
90
                    }
91
                }
92

93
                $zip->close();
5✔
94
            }
95
        }
96

97
        return $mimeType === 'application/vnd.oasis.opendocument.spreadsheet';
21✔
98
    }
99

100
    /**
101
     * Reads names of the worksheets from a file, without parsing the whole file to a PhpSpreadsheet object.
102
     *
103
     * @return string[]
104
     */
105
    public function listWorksheetNames(string $filename): array
6✔
106
    {
107
        File::assertFile($filename, self::INITIAL_FILE);
6✔
108

109
        $worksheetNames = [];
2✔
110

111
        $xml = new XMLReader();
2✔
112
        $xml->xml(
2✔
113
            $this->getSecurityScannerOrThrow()
2✔
114
                ->scanFile(
2✔
115
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
2✔
116
                )
2✔
117
        );
2✔
118
        $xml->setParserProperty(2, true);
2✔
119

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

147
        return $worksheetNames;
2✔
148
    }
149

150
    /**
151
     * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
152
     *
153
     * @return array<int, array{
154
     *   worksheetName: string,
155
     *   lastColumnLetter: string,
156
     *   lastColumnIndex: int,
157
     *   totalRows: int,
158
     *   totalColumns: int,
159
     *   sheetState: string
160
     * }>
161
     */
162
    public function listWorksheetInfo(string $filename): array
8✔
163
    {
164
        File::assertFile($filename, self::INITIAL_FILE);
8✔
165

166
        $worksheetInfo = [];
4✔
167

168
        $xml = new XMLReader();
4✔
169
        $xml->xml(
4✔
170
            $this->getSecurityScannerOrThrow()
4✔
171
                ->scanFile(
4✔
172
                    'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
4✔
173
                )
4✔
174
        );
4✔
175
        $xml->setParserProperty(2, true);
4✔
176

177
        // Step into the first level of content of the XML
178
        $xml->read();
4✔
179
        $tableVisibility = [];
4✔
180
        $lastTableStyle = '';
4✔
181

182
        while ($xml->read()) {
4✔
183
            if ($xml->name === 'style:style') {
4✔
184
                $styleType = $xml->getAttribute('style:family');
4✔
185
                if ($styleType === 'table') {
4✔
186
                    $lastTableStyle = $xml->getAttribute('style:name');
4✔
187
                }
188
            } elseif ($xml->name === 'style:table-properties') {
4✔
189
                $visibility = $xml->getAttribute('table:display');
4✔
190
                $tableVisibility[$lastTableStyle] = ($visibility === 'false') ? Worksheet::SHEETSTATE_HIDDEN : Worksheet::SHEETSTATE_VISIBLE;
4✔
191
            } elseif ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
192
                $worksheetNames[] = $xml->getAttribute('table:name');
4✔
193

194
                $styleName = $xml->getAttribute('table:style-name') ?? '';
4✔
195
                $visibility = $tableVisibility[$styleName] ?? '';
4✔
196
                $tmpInfo = [
4✔
197
                    'worksheetName' => (string) $xml->getAttribute('table:name'),
4✔
198
                    'lastColumnLetter' => 'A',
4✔
199
                    'lastColumnIndex' => 0,
4✔
200
                    'totalRows' => 0,
4✔
201
                    'totalColumns' => 0,
4✔
202
                    'sheetState' => $visibility,
4✔
203
                ];
4✔
204

205
                // Loop through each child node of the table:table element reading
206
                $currRow = 0;
4✔
207
                do {
208
                    $xml->read();
4✔
209
                    if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
210
                        $rowspan = $xml->getAttribute('table:number-rows-repeated');
4✔
211
                        $rowspan = empty($rowspan) ? 1 : (int) $rowspan;
4✔
212
                        $currRow += $rowspan;
4✔
213
                        $currCol = 0;
4✔
214
                        // Step into the row
215
                        $xml->read();
4✔
216
                        do {
217
                            $doread = true;
4✔
218
                            if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
4✔
219
                                $mergeSize = $xml->getAttribute('table:number-columns-repeated');
4✔
220
                                $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize;
4✔
221
                                $currCol += $mergeSize;
4✔
222
                                if (!$xml->isEmptyElement) {
4✔
223
                                    $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCol);
4✔
224
                                    $tmpInfo['totalRows'] = $currRow;
4✔
225
                                    $xml->next();
4✔
226
                                    $doread = false;
4✔
227
                                }
228
                            } elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
2✔
229
                                $mergeSize = $xml->getAttribute('table:number-columns-repeated');
2✔
230
                                $currCol += (int) $mergeSize;
2✔
231
                            }
232
                            if ($doread) {
4✔
233
                                $xml->read();
3✔
234
                            }
235
                        } while ($xml->name != 'table:table-row');
4✔
236
                    }
237
                } while ($xml->name != 'table:table');
4✔
238

239
                $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1;
4✔
240
                $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1);
4✔
241
                $worksheetInfo[] = $tmpInfo;
4✔
242
            }
243
        }
244

245
        return $worksheetInfo;
4✔
246
    }
247

248
    /**
249
     * Loads PhpSpreadsheet from file.
250
     */
251
    protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
100✔
252
    {
253
        $spreadsheet = $this->newSpreadsheet();
100✔
254
        $spreadsheet->setValueBinder($this->valueBinder);
100✔
255
        $spreadsheet->removeSheetByIndex(0);
100✔
256

257
        // Load into this instance
258
        return $this->loadIntoExisting($filename, $spreadsheet);
100✔
259
    }
260

261
    /** @var array<string,
262
     *  array{
263
     *     font?:array{
264
     *       autoColor?: true,
265
     *       bold?: true,
266
     *       color?: array{rgb: string},
267
     *       italic?: true,
268
     *       name?: non-empty-string,
269
     *       size?: float|int,
270
     *       strikethrough?: true,
271
     *       underline?: 'double'|'single',
272
     *    },
273
     *    fill?:array{
274
     *      fillType?: string,
275
     *      startColor?: array{rgb: string},
276
     *    },
277
     *    alignment?:array{
278
     *      horizontal?: string,
279
     *      readOrder?: int,
280
     *      shrinkToFit?: bool,
281
     *      textRotation?: int,
282
     *      vertical?: string,
283
     *      wrapText?: bool,
284
     *    },
285
     *    protection?:array{
286
     *      locked?: string,
287
     *      hidden?: string,
288
     *    },
289
     *    borders?:array{
290
     *      bottom?: array{borderStyle:string, color:array{rgb: string}},
291
     *      left?: array{borderStyle:string, color:array{rgb: string}},
292
     *      right?: array{borderStyle:string, color:array{rgb: string}},
293
     *      top?: array{borderStyle:string, color:array{rgb: string}},
294
     *      diagonal?: array{borderStyle:string, color:array{rgb: string}},
295
     *      diagonalDirection?: int,
296
     *    },
297
     *  }>
298
     */
299
    private array $allStyles;
300

301
    private int $highestDataIndex;
302

303
    /**
304
     * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
305
     */
306
    public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Spreadsheet
105✔
307
    {
308
        File::assertFile($filename, self::INITIAL_FILE);
105✔
309

310
        $zip = new ZipArchive();
101✔
311
        $zip->open($filename);
101✔
312

313
        // Meta
314

315
        $xml = @simplexml_load_string(
101✔
316
            $this->getSecurityScannerOrThrow()
101✔
317
                ->scan($zip->getFromName('meta.xml'))
101✔
318
        );
101✔
319
        if ($xml === false) {
101✔
320
            throw new Exception('Unable to read data from {$pFilename}');
1✔
321
        }
322

323
        /** @var array{meta?: string, office?: string, dc?: string} */
324
        $namespacesMeta = $xml->getNamespaces(true);
100✔
325

326
        (new DocumentProperties($spreadsheet))->load($xml, $namespacesMeta);
100✔
327

328
        // Styles
329

330
        $this->allStyles = [];
100✔
331
        $dom = new DOMDocument('1.01', 'UTF-8');
100✔
332
        $dom->loadXML(
100✔
333
            $this->getSecurityScannerOrThrow()
100✔
334
                ->scan($zip->getFromName('styles.xml'))
100✔
335
        );
100✔
336
        $officeNs = (string) $dom->lookupNamespaceUri('office');
100✔
337
        $styleNs = (string) $dom->lookupNamespaceUri('style');
100✔
338
        $fontNs = (string) $dom->lookupNamespaceUri('fo');
100✔
339

340
        $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'styles')->item(0);
100✔
341
        $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'default-style');
100✔
342
        foreach ($automaticStyles as $automaticStyle) {
100✔
343
            $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
92✔
344
            if ($styleFamily === 'table-cell') {
92✔
345
                $fonts = [];
87✔
346
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
87✔
347
                    $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
87✔
348
                }
349
                if (!empty($fonts)) {
87✔
350
                    $spreadsheet->getDefaultStyle()
87✔
351
                        ->getFont()
87✔
352
                        ->applyFromArray($fonts);
87✔
353
                }
354
            }
355
        }
356
        $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
100✔
357
        foreach ($automaticStyles as $automaticStyle) {
100✔
358
            $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
97✔
359
            $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
97✔
360
            if ($styleFamily === 'table-cell') {
97✔
361
                $fills = $fonts = [];
97✔
362
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
97✔
363
                    $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
50✔
364
                }
365
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
97✔
366
                    $fills = $this->getFillStyles($tableCellProperty, $fontNs);
97✔
367
                }
368
                if ($styleName !== '') {
97✔
369
                    if (!empty($fonts)) {
97✔
370
                        $this->allStyles[$styleName]['font'] = $fonts;
50✔
371
                        if ($styleName === 'Default') {
50✔
372
                            $spreadsheet->getDefaultStyle()
14✔
373
                                ->getFont()
14✔
374
                                ->applyFromArray($fonts);
14✔
375
                        }
376
                    }
377
                    if (!empty($fills)) {
97✔
378
                        $this->allStyles[$styleName]['fill'] = $fills;
89✔
379
                        if ($styleName === 'Default') {
89✔
380
                            $spreadsheet->getDefaultStyle()
56✔
381
                                ->getFill()
56✔
382
                                ->applyFromArray($fills);
56✔
383
                        }
384
                    }
385
                }
386
            }
387
        }
388

389
        $pageSettings = new PageSettings($dom);
100✔
390

391
        // Main Content
392

393
        $dom = new DOMDocument('1.01', 'UTF-8');
100✔
394
        $dom->loadXML(
100✔
395
            $this->getSecurityScannerOrThrow()
100✔
396
                ->scan($zip->getFromName(self::INITIAL_FILE))
100✔
397
        );
100✔
398

399
        $tableNs = (string) $dom->lookupNamespaceUri('table');
100✔
400
        $textNs = (string) $dom->lookupNamespaceUri('text');
100✔
401
        $xlinkNs = (string) $dom->lookupNamespaceUri('xlink');
100✔
402

403
        $pageSettings->readStyleCrossReferences($dom);
100✔
404

405
        $autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
100✔
406
        $definedNameReader = new DefinedNames($spreadsheet, $tableNs);
100✔
407
        $columnWidths = [];
100✔
408
        $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
100✔
409
        $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
100✔
410
        foreach ($automaticStyles as $automaticStyle) {
100✔
411
            $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
97✔
412
            $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
97✔
413
            if ($styleFamily === 'table-column') {
97✔
414
                $tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
52✔
415
                $tcprop = $tcprops->item(0);
52✔
416
                if ($tcprop !== null) {
52✔
417
                    $columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
52✔
418
                    $columnWidths[$styleName] = $columnWidth;
52✔
419
                }
420
            }
421
            if ($styleFamily === 'table-cell') {
97✔
422
                $fonts = $fills = $alignment1 = $alignment2 = $protection = $borders = [];
82✔
423
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
82✔
424
                    $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
62✔
425
                }
426
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
82✔
427
                    $fills = $this->getFillStyles($tableCellProperty, $fontNs);
74✔
428
                    $borders = $this->getBorderStyles($tableCellProperty, $fontNs, $styleNs);
74✔
429
                    $protection = $this->getProtectionStyles($tableCellProperty, $styleNs);
74✔
430
                }
431
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
82✔
432
                    $alignment1 = $this->getAlignment1Styles($tableCellProperty, $styleNs, $fontNs);
74✔
433
                }
434
                foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'paragraph-properties') as $paragraphProperty) {
82✔
435
                    $alignment2 = $this->getAlignment2Styles($paragraphProperty, $styleNs, $fontNs);
23✔
436
                }
437
                if ($styleName !== '') {
82✔
438
                    if (!empty($fonts)) {
82✔
439
                        $this->allStyles[$styleName]['font'] = $fonts;
61✔
440
                    }
441
                    if (!empty($fills)) {
82✔
442
                        $this->allStyles[$styleName]['fill'] = $fills;
58✔
443
                    }
444
                    $alignment = array_merge($alignment1, $alignment2);
82✔
445
                    if (!empty($alignment)) {
82✔
446
                        $this->allStyles[$styleName]['alignment'] = $alignment;
71✔
447
                    }
448
                    if (!empty($protection)) {
82✔
449
                        $this->allStyles[$styleName]['protection'] = $protection;
10✔
450
                    }
451
                    if (!empty($borders)) {
82✔
452
                        $this->allStyles[$styleName]['borders'] = $borders;
12✔
453
                    }
454
                }
455
            }
456
        }
457

458
        // Content
459
        $item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
100✔
460
        $spreadsheets = ($item0 === null) ? [] : $item0->getElementsByTagNameNS($officeNs, 'spreadsheet');
100✔
461

462
        foreach ($spreadsheets as $workbookData) {
100✔
463
            /** @var DOMElement $workbookData */
464
            $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
100✔
465

466
            $worksheetID = 0;
100✔
467
            $sheetCreated = false;
100✔
468
            foreach ($tables as $worksheetDataSet) {
100✔
469
                /** @var DOMElement $worksheetDataSet */
470
                $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
100✔
471

472
                // Check loadSheetsOnly
473
                if (
474
                    $this->loadSheetsOnly !== null
100✔
475
                    && $worksheetName
476
                    && !in_array($worksheetName, $this->loadSheetsOnly)
100✔
477
                ) {
478
                    continue;
5✔
479
                }
480

481
                $worksheetStyleName = $worksheetDataSet->getAttributeNS($tableNs, 'style-name');
97✔
482

483
                // Create sheet
484
                $spreadsheet->createSheet();
97✔
485
                $sheetCreated = true;
97✔
486
                $spreadsheet->setActiveSheetIndex($worksheetID);
97✔
487

488
                if ($worksheetName || is_numeric($worksheetName)) {
97✔
489
                    // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in
490
                    // formula cells... during the load, all formulae should be correct, and we're simply
491
                    // bringing the worksheet name in line with the formula, not the reverse
492
                    $spreadsheet->getActiveSheet()
97✔
493
                        ->setTitle((string) $worksheetName, false, false);
97✔
494
                }
495

496
                // Go through every child of table element
497
                $rowID = 1;
97✔
498
                $tableColumnIndex = 1;
97✔
499
                $this->highestDataIndex = 16384;
97✔
500
                foreach ($worksheetDataSet->childNodes as $childNode) {
97✔
501
                    /** @var DOMElement $childNode */
502

503
                    // Filter elements which are not under the "table" ns
504
                    if ($childNode->namespaceURI != $tableNs) {
97✔
505
                        continue;
61✔
506
                    }
507

508
                    $key = self::extractNodeName($childNode->nodeName);
97✔
509

510
                    switch ($key) {
511
                        case 'table-header-rows':
97✔
512
                        case 'table-rows':
97✔
513
                            $this->processTableHeaderRows(
1✔
514
                                $childNode,
1✔
515
                                $tableNs,
1✔
516
                                $rowID,
1✔
517
                                $worksheetName,
1✔
518
                                $officeNs,
1✔
519
                                $textNs,
1✔
520
                                $xlinkNs,
1✔
521
                                $spreadsheet
1✔
522
                            );
1✔
523

524
                            break;
1✔
525
                        case 'table-row-group':
97✔
526
                            $this->processTableRowGroup(
1✔
527
                                $childNode,
1✔
528
                                $tableNs,
1✔
529
                                $rowID,
1✔
530
                                $worksheetName,
1✔
531
                                $officeNs,
1✔
532
                                $textNs,
1✔
533
                                $xlinkNs,
1✔
534
                                $spreadsheet
1✔
535
                            );
1✔
536

537
                            break;
1✔
538
                        case 'table-row':
97✔
539
                            $this->processTableRow(
96✔
540
                                $childNode,
96✔
541
                                $tableNs,
96✔
542
                                $rowID,
96✔
543
                                $worksheetName,
96✔
544
                                $officeNs,
96✔
545
                                $textNs,
96✔
546
                                $xlinkNs,
96✔
547
                                $spreadsheet
96✔
548
                            );
96✔
549

550
                            break;
96✔
551
                        case 'table-header-columns':
52✔
552
                        case 'table-columns':
52✔
553
                            $this->processTableColumnHeader(
1✔
554
                                $childNode,
1✔
555
                                $tableNs,
1✔
556
                                $columnWidths,
1✔
557
                                $tableColumnIndex,
1✔
558
                                $spreadsheet,
1✔
559
                                $this->readEmptyCells,
1✔
560
                                true
1✔
561
                            );
1✔
562

563
                            break;
1✔
564
                        case 'table-column-group':
52✔
565
                            $this->processTableColumnGroup(
1✔
566
                                $childNode,
1✔
567
                                $tableNs,
1✔
568
                                $columnWidths,
1✔
569
                                $tableColumnIndex,
1✔
570
                                $spreadsheet,
1✔
571
                                $this->readEmptyCells,
1✔
572
                                true
1✔
573
                            );
1✔
574

575
                            break;
1✔
576
                        case 'table-column':
51✔
577
                            $this->processTableColumn(
51✔
578
                                $childNode,
51✔
579
                                $tableNs,
51✔
580
                                $columnWidths,
51✔
581
                                $tableColumnIndex,
51✔
582
                                $spreadsheet,
51✔
583
                                $this->readEmptyCells,
51✔
584
                                true
51✔
585
                            );
51✔
586

587
                            break;
51✔
588
                    }
589
                }
590
                $pageSettings->setVisibilityForWorksheet(
97✔
591
                    $spreadsheet->getActiveSheet(),
97✔
592
                    $worksheetStyleName
97✔
593
                );
97✔
594
                $pageSettings->setPrintSettingsForWorksheet(
97✔
595
                    $spreadsheet->getActiveSheet(),
97✔
596
                    $worksheetStyleName
97✔
597
                );
97✔
598
                ++$worksheetID;
97✔
599
            }
600
            if ($this->createBlankSheetIfNoneRead && !$sheetCreated) {
100✔
601
                $spreadsheet->createSheet();
1✔
602
            }
603
        }
604

605
        foreach ($spreadsheets as $workbookData) {
100✔
606
            /** @var DOMElement $workbookData */
607
            $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
100✔
608

609
            $worksheetID = 0;
100✔
610
            foreach ($tables as $worksheetDataSet) {
100✔
611
                /** @var DOMElement $worksheetDataSet */
612
                $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
100✔
613

614
                // Check loadSheetsOnly
615
                if (
616
                    $this->loadSheetsOnly !== null
100✔
617
                    && $worksheetName
618
                    && !in_array($worksheetName, $this->loadSheetsOnly)
100✔
619
                ) {
620
                    continue;
5✔
621
                }
622

623
                // Create sheet
624
                $spreadsheet->setActiveSheetIndex($worksheetID);
97✔
625
                $highestDataColumn = $spreadsheet->getActiveSheet()->getHighestDataColumn();
97✔
626
                $this->highestDataIndex = Coordinate::columnIndexFromString($highestDataColumn);
97✔
627

628
                // Go through every child of table element processing column widths
629
                $rowID = 1;
97✔
630
                $tableColumnIndex = 1;
97✔
631
                foreach ($worksheetDataSet->childNodes as $childNode) {
97✔
632
                    /** @var DOMElement $childNode */
633
                    if (empty($columnWidths) || $this->readEmptyCells) {
97✔
634
                        break;
94✔
635
                    }
636

637
                    // Filter elements which are not under the "table" ns
638
                    if ($childNode->namespaceURI != $tableNs) {
3✔
639
                        continue;
1✔
640
                    }
641

642
                    $key = self::extractNodeName($childNode->nodeName);
3✔
643

644
                    switch ($key) {
645
                        case 'table-header-columns':
3✔
646
                        case 'table-columns':
3✔
647
                            $this->processTableColumnHeader(
1✔
648
                                $childNode,
1✔
649
                                $tableNs,
1✔
650
                                $columnWidths,
1✔
651
                                $tableColumnIndex,
1✔
652
                                $spreadsheet,
1✔
653
                                true,
1✔
654
                                false
1✔
655
                            );
1✔
656

657
                            break;
1✔
658
                        case 'table-column-group':
3✔
659
                            $this->processTableColumnGroup(
1✔
660
                                $childNode,
1✔
661
                                $tableNs,
1✔
662
                                $columnWidths,
1✔
663
                                $tableColumnIndex,
1✔
664
                                $spreadsheet,
1✔
665
                                true,
1✔
666
                                false
1✔
667
                            );
1✔
668

669
                            break;
1✔
670
                        case 'table-column':
3✔
671
                            $this->processTableColumn(
2✔
672
                                $childNode,
2✔
673
                                $tableNs,
2✔
674
                                $columnWidths,
2✔
675
                                $tableColumnIndex,
2✔
676
                                $spreadsheet,
2✔
677
                                true,
2✔
678
                                false
2✔
679
                            );
2✔
680

681
                            break;
2✔
682
                    }
683
                }
684
                ++$worksheetID;
97✔
685
            }
686

687
            $autoFilterReader->read($workbookData);
100✔
688
            $definedNameReader->read($workbookData);
100✔
689
        }
690

691
        $spreadsheet->setActiveSheetIndex(0);
100✔
692

693
        if ($zip->locateName('settings.xml') !== false) {
98✔
694
            $this->processSettings($zip, $spreadsheet);
90✔
695
        }
696

697
        // Return
698
        return $spreadsheet;
98✔
699
    }
700

701
    private function processTableHeaderRows(
1✔
702
        DOMElement $childNode,
703
        string $tableNs,
704
        int &$rowID,
705
        string $worksheetName,
706
        string $officeNs,
707
        string $textNs,
708
        string $xlinkNs,
709
        Spreadsheet $spreadsheet
710
    ): void {
711
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
712
            /** @var DOMElement $grandchildNode */
713
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
714
            switch ($grandkey) {
715
                case 'table-row':
1✔
716
                    $this->processTableRow(
1✔
717
                        $grandchildNode,
1✔
718
                        $tableNs,
1✔
719
                        $rowID,
1✔
720
                        $worksheetName,
1✔
721
                        $officeNs,
1✔
722
                        $textNs,
1✔
723
                        $xlinkNs,
1✔
724
                        $spreadsheet
1✔
725
                    );
1✔
726

727
                    break;
1✔
728
            }
729
        }
730
    }
731

732
    private function processTableRowGroup(
1✔
733
        DOMElement $childNode,
734
        string $tableNs,
735
        int &$rowID,
736
        string $worksheetName,
737
        string $officeNs,
738
        string $textNs,
739
        string $xlinkNs,
740
        Spreadsheet $spreadsheet
741
    ): void {
742
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
743
            /** @var DOMElement $grandchildNode */
744
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
745
            switch ($grandkey) {
746
                case 'table-row':
1✔
747
                    $this->processTableRow(
1✔
748
                        $grandchildNode,
1✔
749
                        $tableNs,
1✔
750
                        $rowID,
1✔
751
                        $worksheetName,
1✔
752
                        $officeNs,
1✔
753
                        $textNs,
1✔
754
                        $xlinkNs,
1✔
755
                        $spreadsheet
1✔
756
                    );
1✔
757

758
                    break;
1✔
759
                case 'table-header-rows':
×
760
                case 'table-rows':
×
761
                    $this->processTableHeaderRows(
×
762
                        $grandchildNode,
×
763
                        $tableNs,
×
764
                        $rowID,
×
765
                        $worksheetName,
×
766
                        $officeNs,
×
767
                        $textNs,
×
768
                        $xlinkNs,
×
769
                        $spreadsheet
×
770
                    );
×
771

772
                    break;
×
773
                case 'table-row-group':
×
774
                    $this->processTableRowGroup(
×
775
                        $grandchildNode,
×
776
                        $tableNs,
×
777
                        $rowID,
×
778
                        $worksheetName,
×
779
                        $officeNs,
×
780
                        $textNs,
×
781
                        $xlinkNs,
×
782
                        $spreadsheet
×
783
                    );
×
784

785
                    break;
×
786
            }
787
        }
788
    }
789

790
    private function processTableRow(
96✔
791
        DOMElement $childNode,
792
        string $tableNs,
793
        int &$rowID,
794
        string $worksheetName,
795
        string $officeNs,
796
        string $textNs,
797
        string $xlinkNs,
798
        Spreadsheet $spreadsheet
799
    ): void {
800
        if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {
96✔
801
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
30✔
802
        } else {
803
            $rowRepeats = 1;
96✔
804
        }
805
        $worksheet = $spreadsheet->getSheetByName($worksheetName);
96✔
806

807
        $columnID = 'A';
96✔
808
        /** @var DOMElement|DOMText $cellData */
809
        foreach ($childNode->childNodes as $cellData) {
96✔
810
            if ($cellData instanceof DOMText) {
96✔
811
                continue; // should just be whitespace
3✔
812
            }
813
            if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
96✔
814
                $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
41✔
815
            } else {
816
                $colRepeats = 1;
96✔
817
            }
818
            $styleName = $cellData->getAttributeNS($tableNs, 'style-name');
96✔
819

820
            // When a cell has number-columns-repeated, check if ANY column in the
821
            // repeated range passes the read filter. If not, skip the entire group.
822
            // If some columns pass, we need to fall through to the processing block
823
            // which will handle per-column filtering.
824
            if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
96✔
825
                if ($colRepeats <= 1) {
3✔
826
                    StringHelper::stringIncrement($columnID);
3✔
827

828
                    continue;
3✔
829
                }
830

831
                // Check if any column within this repeated group passes the filter
832
                $anyColumnPasses = false;
2✔
833
                $tempCol = $columnID;
2✔
834
                for ($i = 0; $i < $colRepeats; ++$i) {
2✔
835
                    if ($i > 0) {
2✔
836
                        StringHelper::stringIncrement($tempCol);
2✔
837
                    }
838
                    if ($this->getReadFilter()->readCell($tempCol, $rowID, $worksheetName)) {
2✔
839
                        $anyColumnPasses = true;
2✔
840

841
                        break;
2✔
842
                    }
843
                }
844

845
                if (!$anyColumnPasses) {
2✔
846
                    for ($i = 0; $i < $colRepeats; ++$i) {
2✔
847
                        StringHelper::stringIncrement($columnID);
2✔
848
                    }
849

850
                    continue;
2✔
851
                }
852
                // Fall through to process the cell, with per-column filter checks
853
            }
854
            if ($worksheet !== null && ($cellData->hasChildNodes() || ($cellData->nextSibling !== null)) && isset($this->allStyles[$styleName])) {
96✔
855
                $spannedRange = "$columnID$rowID";
69✔
856
                // the following is sufficient for ods,
857
                // and does no harm for xlsx/xls.
858
                $worksheet->getStyle($spannedRange)
69✔
859
                    ->applyFromArray($this->allStyles[$styleName]);
69✔
860
                // the rest of this block is needed for xlsx/xls,
861
                // and does no harm for ods.
862
                if (isset($this->allStyles[$styleName]['borders'])) {
69✔
863
                    $spannedRows = $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
10✔
864
                    $spannedColumns = $cellData->getAttributeNS($tableNs, 'number-rows-spanned');
10✔
865
                    $spannedRows = max((int) $spannedRows, 1);
10✔
866
                    $spannedColumns = max((int) $spannedColumns, 1);
10✔
867
                    if ($spannedRows > 1 || $spannedColumns > 1) {
10✔
868
                        $endRow = $rowID + $spannedRows - 1;
7✔
869
                        $endCol = $columnID;
7✔
870
                        while ($spannedColumns > 1) {
7✔
871
                            StringHelper::stringIncrement($endCol);
7✔
872
                            --$spannedColumns;
7✔
873
                        }
874
                        $spannedRange .= ":$endCol$endRow";
7✔
875
                        $worksheet->getStyle($spannedRange)
7✔
876
                            ->getBorders()
7✔
877
                            ->applyFromArray(
7✔
878
                                $this->allStyles[$styleName]['borders']
7✔
879
                            );
7✔
880
                    }
881
                }
882
            }
883

884
            // Initialize variables
885
            $formatting = $hyperlink = null;
96✔
886
            $hasCalculatedValue = false;
96✔
887
            $cellDataFormula = '';
96✔
888
            $cellDataType = '';
96✔
889
            $cellDataRef = '';
96✔
890

891
            if ($cellData->hasAttributeNS($tableNs, 'formula')) {
96✔
892
                $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
39✔
893
                $hasCalculatedValue = true;
39✔
894
            }
895
            if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
96✔
896
                if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
12✔
897
                    $cellDataType = 'array';
12✔
898
                    $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
12✔
899
                    $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
12✔
900
                    $lastRow = $rowID + $arrayRow - 1;
12✔
901
                    $lastCol = $columnID;
12✔
902
                    while ($arrayCol > 1) {
12✔
903
                        StringHelper::stringIncrement($lastCol);
7✔
904
                        --$arrayCol;
7✔
905
                    }
906
                    $cellDataRef = "$columnID$rowID:$lastCol$lastRow";
12✔
907
                }
908
            }
909

910
            // Annotations
911
            $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');
96✔
912

913
            if ($annotation->length > 0 && $annotation->item(0) !== null) {
96✔
914
                $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p');
12✔
915
                $textNodeLength = $textNode->length;
12✔
916
                $newLineOwed = false;
12✔
917
                for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) {
12✔
918
                    $textNodeItem = $textNode->item($textNodeIndex);
12✔
919
                    if ($textNodeItem !== null) {
12✔
920
                        $text = $this->scanElementForText($textNodeItem);
12✔
921
                        if ($newLineOwed) {
12✔
922
                            $spreadsheet->getActiveSheet()
1✔
923
                                ->getComment($columnID . $rowID)
1✔
924
                                ->getText()
1✔
925
                                ->createText("\n");
1✔
926
                        }
927
                        $newLineOwed = true;
12✔
928

929
                        $spreadsheet->getActiveSheet()
12✔
930
                            ->getComment($columnID . $rowID)
12✔
931
                            ->getText()
12✔
932
                            ->createText(
12✔
933
                                $this->parseRichText($text)
12✔
934
                            );
12✔
935
                    }
936
                }
937
            }
938

939
            // Content
940

941
            /** @var DOMElement[] $paragraphs */
942
            $paragraphs = [];
96✔
943

944
            foreach ($cellData->childNodes as $item) {
96✔
945
                /** @var DOMElement $item */
946

947
                // Filter text:p elements
948
                if ($item->nodeName == 'text:p') {
96✔
949
                    $paragraphs[] = $item;
96✔
950
                }
951
            }
952

953
            if (count($paragraphs) > 0) {
96✔
954
                $dataValue = null;
96✔
955
                // Consolidate if there are multiple p records (maybe with spans as well)
956
                $dataArray = [];
96✔
957

958
                // Text can have multiple text:p and within those, multiple text:span.
959
                // text:p newlines, but text:span does not.
960
                // Also, here we assume there is no text data is span fields are specified, since
961
                // we have no way of knowing proper positioning anyway.
962

963
                foreach ($paragraphs as $pData) {
96✔
964
                    $dataArray[] = $this->scanElementForText($pData);
96✔
965
                }
966
                $allCellDataText = implode("\n", $dataArray);
96✔
967

968
                $type = $cellData->getAttributeNS($officeNs, 'value-type');
96✔
969
                $symbol = '';
96✔
970
                $leftHandCurrency = Preg::isMatch('/\$|£|¥/', $allCellDataText, $matches);
96✔
971
                if ($leftHandCurrency) {
96✔
972
                    $type = str_replace('float', 'currency', $type);
8✔
973
                    $symbol = (string) $matches[0];
8✔
974
                }
975
                $customFormatting = '';
96✔
976
                if ($this->formatCallback !== null) {
96✔
977
                    $temp = ($this->formatCallback)($type, $allCellDataText);
1✔
978
                    if ($temp !== '') {
1✔
979
                        $customFormatting = $temp;
1✔
980
                    }
981
                }
982

983
                switch ($type) {
984
                    case 'string':
96✔
985
                        $type = DataType::TYPE_STRING;
58✔
986
                        $dataValue = $allCellDataText;
58✔
987

988
                        foreach ($paragraphs as $paragraph) {
58✔
989
                            $link = $paragraph->getElementsByTagNameNS($textNs, 'a');
58✔
990
                            if ($link->length > 0 && $link->item(0) !== null) {
58✔
991
                                $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href');
8✔
992
                            }
993
                        }
994

995
                        break;
58✔
996
                    case 'boolean':
66✔
997
                        $type = DataType::TYPE_BOOL;
11✔
998
                        $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false;
11✔
999

1000
                        break;
11✔
1001
                    case 'percentage':
63✔
1002
                        if (!str_contains($allCellDataText, '.')) {
6✔
1003
                            $formatting = NumberFormat::FORMAT_PERCENTAGE;
4✔
1004
                        } elseif (substr($allCellDataText, -3, 1) === '.') {
6✔
1005
                            $formatting = NumberFormat::FORMAT_PERCENTAGE_0;
1✔
1006
                        } else {
1007
                            $formatting = NumberFormat::FORMAT_PERCENTAGE_00;
5✔
1008
                        }
1009
                        $type = DataType::TYPE_NUMERIC;
6✔
1010
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
6✔
1011

1012
                        break;
6✔
1013
                    case 'currency':
62✔
1014
                        $type = DataType::TYPE_NUMERIC;
8✔
1015
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
8✔
1016

1017
                        $currency = $cellData->getAttributeNS($officeNs, 'currency');
8✔
1018
                        if ($leftHandCurrency) {
8✔
1019
                            $typeValue = 'currency';
8✔
1020
                            $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER;
8✔
1021
                            if ($symbol !== '$') {
8✔
1022
                                $formatting = str_replace('$', $symbol, $formatting);
1✔
1023
                            }
1024
                        } elseif (str_contains($allCellDataText, '€')) {
5✔
1025
                            $typeValue = 'currency';
5✔
1026
                            $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_EUR : NumberFormat::FORMAT_CURRENCY_EUR_INTEGER;
5✔
1027
                        }
1028

1029
                        break;
8✔
1030
                    case 'float':
56✔
1031
                        $type = DataType::TYPE_NUMERIC;
54✔
1032
                        $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
54✔
1033

1034
                        if ($dataValue !== floor($dataValue)) {
54✔
1035
                            // do nothing
1036
                        } elseif (substr($allCellDataText, -2, 1) === '.') {
48✔
1037
                            $formatting = NumberFormat::FORMAT_NUMBER_0;
1✔
1038
                        } elseif (substr($allCellDataText, -3, 1) === '.') {
47✔
1039
                            $formatting = NumberFormat::FORMAT_NUMBER_00;
9✔
1040
                        }
1041
                        if (floor($dataValue) == $dataValue) {
54✔
1042
                            if ($dataValue == (int) $dataValue) {
48✔
1043
                                $dataValue = (int) $dataValue;
48✔
1044
                            }
1045
                        }
1046

1047
                        break;
54✔
1048
                    case 'date':
14✔
1049
                        $type = DataType::TYPE_NUMERIC;
12✔
1050
                        $value = $cellData->getAttributeNS($officeNs, 'date-value');
12✔
1051
                        $dataValue = Date::convertIsoDate($value);
12✔
1052

1053
                        if (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d$/', $allCellDataText)) {
12✔
1054
                            $formatting = 'yyyy-mm-dd';
2✔
1055
                        } elseif (Preg::isMatch('/^\d\d?-[a-zA-Z]+-\d\d\d\d$/', $allCellDataText)) {
10✔
1056
                            $formatting = 'd-mmm-yyyy';
7✔
1057
                        } elseif ($dataValue != floor($dataValue)) {
10✔
1058
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15
7✔
1059
                                . ' '
7✔
1060
                                . NumberFormat::FORMAT_DATE_TIME4;
7✔
1061
                        } else {
1062
                            $formatting = NumberFormat::FORMAT_DATE_XLSX15;
10✔
1063
                        }
1064

1065
                        break;
12✔
1066
                    case 'time':
10✔
1067
                        $type = DataType::TYPE_NUMERIC;
9✔
1068

1069
                        $timeValue = $cellData->getAttributeNS($officeNs, 'time-value');
9✔
1070
                        $minus = '';
9✔
1071
                        if (str_starts_with($timeValue, '-')) {
9✔
1072
                            $minus = '-';
1✔
1073
                            $timeValue = substr($timeValue, 1);
1✔
1074
                        }
1075
                        $timeArray = sscanf($timeValue, 'PT%dH%dM%dS');
9✔
1076
                        if (is_array($timeArray)) {
9✔
1077
                            /** @var array{int, int, int} $timeArray */
1078
                            $days = intdiv($timeArray[0], 24);
9✔
1079
                            $hours = $timeArray[0] % 24;
9✔
1080
                            $dt = new DateTime("1899-12-30 $hours:{$timeArray[1]}:{$timeArray[2]}", new DateTimeZone('UTC'));
9✔
1081
                            $dt->modify("+$days days");
9✔
1082
                            $dataValue = Date::PHPToExcel($dt);
9✔
1083
                            if ($minus === '-') {
9✔
1084
                                $dataValue *= -1;
1✔
1085
                                $formatting = '[hh]:mm:ss';
1✔
1086
                            } else {
1087
                                $formatting = NumberFormat::FORMAT_DATE_TIME4;
9✔
1088
                            }
1089
                        }
1090

1091
                        break;
9✔
1092
                    default:
1093
                        $dataValue = null;
1✔
1094
                }
1095
                if ($customFormatting !== '') {
96✔
1096
                    $formatting = $customFormatting;
1✔
1097
                }
1098
            } else {
1099
                $type = DataType::TYPE_NULL;
48✔
1100
                $dataValue = null;
48✔
1101
            }
1102

1103
            if ($hasCalculatedValue) {
96✔
1104
                $type = DataType::TYPE_FORMULA;
39✔
1105
                $cellDataFormula = substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1);
39✔
1106
                $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula);
39✔
1107
            }
1108

1109
            for ($i = 0; $i < $colRepeats; ++$i) {
96✔
1110
                if ($i > 0) {
96✔
1111
                    StringHelper::stringIncrement($columnID);
41✔
1112
                }
1113

1114
                if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) {
96✔
1115
                    continue;
2✔
1116
                }
1117

1118
                if ($type !== DataType::TYPE_NULL) {
96✔
1119
                    for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) {
96✔
1120
                        $rID = $rowID + $rowAdjust;
96✔
1121

1122
                        $cell = $spreadsheet->getActiveSheet()
96✔
1123
                            ->getCell($columnID . $rID);
96✔
1124

1125
                        // Set value
1126
                        if ($hasCalculatedValue) {
96✔
1127
                            $cell->setValueExplicit($cellDataFormula, $type);
39✔
1128
                            if ($cellDataType === 'array') {
39✔
1129
                                $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
12✔
1130
                            }
1131
                        } elseif ($type !== '' || $dataValue !== null) {
84✔
1132
                            $cell->setValueExplicit($dataValue, $type);
84✔
1133
                        }
1134

1135
                        if ($hasCalculatedValue) {
96✔
1136
                            $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC);
39✔
1137
                        }
1138

1139
                        // Set other properties
1140
                        if ($formatting !== null) {
96✔
1141
                            $spreadsheet->getActiveSheet()
23✔
1142
                                ->getStyle($columnID . $rID)
23✔
1143
                                ->getNumberFormat()
23✔
1144
                                ->setFormatCode($formatting);
23✔
1145
                        } else {
1146
                            $spreadsheet->getActiveSheet()
91✔
1147
                                ->getStyle($columnID . $rID)
91✔
1148
                                ->getNumberFormat()
91✔
1149
                                ->setFormatCode(NumberFormat::FORMAT_GENERAL);
91✔
1150
                        }
1151

1152
                        if ($hyperlink !== null) {
96✔
1153
                            if ($hyperlink[0] === '#') {
8✔
1154
                                $hyperlink = 'sheet://' . substr($hyperlink, 1);
1✔
1155
                            }
1156
                            $cell->getHyperlink()
8✔
1157
                                ->setUrl($hyperlink);
8✔
1158
                        }
1159
                    }
1160
                }
1161
            }
1162

1163
            // Merged cells
1164
            $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet);
96✔
1165

1166
            StringHelper::stringIncrement($columnID);
96✔
1167
        }
1168
        $rowID += $rowRepeats;
96✔
1169
    }
1170

1171
    private static function extractNodeName(string $key): string
97✔
1172
    {
1173
        // Remove ns from node name
1174
        if (str_contains($key, ':')) {
97✔
1175
            $keyChunks = explode(':', $key);
97✔
1176
            $key = array_pop($keyChunks);
97✔
1177
        }
1178

1179
        return $key;
97✔
1180
    }
1181

1182
    /**
1183
     * @param string[] $columnWidths
1184
     */
1185
    private function processTableColumnHeader(
1✔
1186
        DOMElement $childNode,
1187
        string $tableNs,
1188
        array $columnWidths,
1189
        int &$tableColumnIndex,
1190
        Spreadsheet $spreadsheet,
1191
        bool $processWidths = true,
1192
        bool $processStyles = true
1193
    ): void {
1194
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
1195
            /** @var DOMElement $grandchildNode */
1196
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
1197
            switch ($grandkey) {
1198
                case 'table-column':
1✔
1199
                    $this->processTableColumn(
1✔
1200
                        $grandchildNode,
1✔
1201
                        $tableNs,
1✔
1202
                        $columnWidths,
1✔
1203
                        $tableColumnIndex,
1✔
1204
                        $spreadsheet,
1✔
1205
                        $processWidths,
1✔
1206
                        $processStyles
1✔
1207
                    );
1✔
1208

1209
                    break;
1✔
1210
            }
1211
        }
1212
    }
1213

1214
    /**
1215
     * @param string[] $columnWidths
1216
     */
1217
    private function processTableColumnGroup(
1✔
1218
        DOMElement $childNode,
1219
        string $tableNs,
1220
        array $columnWidths,
1221
        int &$tableColumnIndex,
1222
        Spreadsheet $spreadsheet,
1223
        bool $processWidths = true,
1224
        bool $processStyles = true
1225
    ): void {
1226
        foreach ($childNode->childNodes as $grandchildNode) {
1✔
1227
            /** @var DOMElement $grandchildNode */
1228
            $grandkey = self::extractNodeName($grandchildNode->nodeName);
1✔
1229
            switch ($grandkey) {
1230
                case 'table-column':
1✔
1231
                    $this->processTableColumn(
1✔
1232
                        $grandchildNode,
1✔
1233
                        $tableNs,
1✔
1234
                        $columnWidths,
1✔
1235
                        $tableColumnIndex,
1✔
1236
                        $spreadsheet,
1✔
1237
                        $processWidths,
1✔
1238
                        $processStyles
1✔
1239
                    );
1✔
1240

1241
                    break;
1✔
1242
                case 'table-header-columns':
×
1243
                case 'table-columns':
×
NEW
1244
                    $this->processTableColumnHeader(
×
1245
                        $grandchildNode,
×
1246
                        $tableNs,
×
1247
                        $columnWidths,
×
1248
                        $tableColumnIndex,
×
NEW
1249
                        $spreadsheet,
×
NEW
1250
                        $processWidths,
×
NEW
1251
                        $processStyles
×
UNCOV
1252
                    );
×
1253

1254
                    break;
×
1255
                case 'table-column-group':
×
1256
                    $this->processTableColumnGroup(
×
1257
                        $grandchildNode,
×
1258
                        $tableNs,
×
1259
                        $columnWidths,
×
1260
                        $tableColumnIndex,
×
NEW
1261
                        $spreadsheet,
×
NEW
1262
                        $processWidths,
×
NEW
1263
                        $processStyles
×
UNCOV
1264
                    );
×
1265

1266
                    break;
×
1267
            }
1268
        }
1269
    }
1270

1271
    /**
1272
     * @param string[] $columnWidths
1273
     */
1274
    private function processTableColumn(
52✔
1275
        DOMElement $childNode,
1276
        string $tableNs,
1277
        array $columnWidths,
1278
        int &$tableColumnIndex,
1279
        Spreadsheet $spreadsheet,
1280
        bool $processWidths = true,
1281
        bool $processStyles = true
1282
    ): void {
1283
        if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
52✔
1284
            $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
44✔
1285
        } else {
1286
            $rowRepeats = 1;
25✔
1287
        }
1288
        $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
52✔
1289
        if ($processWidths) {
52✔
1290
            if (isset($columnWidths[$tableStyleName])) {
50✔
1291
                $columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
49✔
1292
                $tableColumnIndex2 = $tableColumnIndex;
49✔
1293
                $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
49✔
1294
                for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0 && $tableColumnIndex2 <= 16384; --$rowRepeats2) {
49✔
1295
                    if (!$this->readEmptyCells && $tableColumnIndex2 > $this->highestDataIndex) {
49✔
1296
                        break;
1✔
1297
                    }
1298
                    $spreadsheet->getActiveSheet()
49✔
1299
                        ->getColumnDimension($tableColumnString)
49✔
1300
                        ->setWidth($columnWidth->toUnit('cm'), 'cm');
49✔
1301
                    StringHelper::stringIncrement(
49✔
1302
                        $tableColumnString
49✔
1303
                    );
49✔
1304
                    ++$tableColumnIndex2;
49✔
1305
                }
1306
            }
1307
        }
1308
        if ($processStyles) {
52✔
1309
            $defaultStyleName = $childNode->getAttributeNS($tableNs, 'default-cell-style-name');
52✔
1310
            if ($defaultStyleName !== 'Default' && isset($this->allStyles[$defaultStyleName])) {
52✔
1311
                $tableColumnIndex2 = $tableColumnIndex;
9✔
1312
                $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
9✔
1313
                for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0 && $tableColumnIndex2 <= 16384; --$rowRepeats2) {
9✔
1314
                    $spreadsheet->getActiveSheet()
9✔
1315
                        ->getStyle($tableColumnString)
9✔
1316
                        ->applyFromArray(
9✔
1317
                            $this->allStyles[$defaultStyleName]
9✔
1318
                        );
9✔
1319
                    StringHelper::stringIncrement(
9✔
1320
                        $tableColumnString
9✔
1321
                    );
9✔
1322
                    ++$tableColumnIndex2;
9✔
1323
                }
1324
            }
1325
        }
1326
        $tableColumnIndex += $rowRepeats;
52✔
1327
    }
1328

1329
    private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void
90✔
1330
    {
1331
        $dom = new DOMDocument('1.01', 'UTF-8');
90✔
1332
        $dom->loadXML(
90✔
1333
            $this->getSecurityScannerOrThrow()
90✔
1334
                ->scan($zip->getFromName('settings.xml'))
90✔
1335
        );
90✔
1336
        $configNs = (string) $dom->lookupNamespaceUri('config');
90✔
1337
        $officeNs = (string) $dom->lookupNamespaceUri('office');
90✔
1338
        $settings = $dom->getElementsByTagNameNS($officeNs, 'settings')
90✔
1339
            ->item(0);
90✔
1340
        if ($settings !== null) {
90✔
1341
            $this->lookForActiveSheet($settings, $spreadsheet, $configNs);
90✔
1342
            $this->lookForSelectedCells($settings, $spreadsheet, $configNs);
90✔
1343
        }
1344
    }
1345

1346
    private function lookForActiveSheet(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
90✔
1347
    {
1348
        /** @var DOMElement $t */
1349
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item') as $t) {
90✔
1350
            if ($t->getAttributeNs($configNs, 'name') === 'ActiveTable') {
89✔
1351
                try {
1352
                    $spreadsheet->setActiveSheetIndexByName($t->nodeValue ?? '');
89✔
1353
                } catch (Throwable) {
2✔
1354
                    // do nothing
1355
                }
1356

1357
                break;
89✔
1358
            }
1359
        }
1360
    }
1361

1362
    private function lookForSelectedCells(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
90✔
1363
    {
1364
        /** @var DOMElement $t */
1365
        foreach ($settings->getElementsByTagNameNS($configNs, 'config-item-map-named') as $t) {
90✔
1366
            if ($t->getAttributeNs($configNs, 'name') === 'Tables') {
89✔
1367
                foreach ($t->getElementsByTagNameNS($configNs, 'config-item-map-entry') as $ws) {
89✔
1368
                    $setRow = $setCol = '';
89✔
1369
                    $wsname = $ws->getAttributeNs($configNs, 'name');
89✔
1370
                    foreach ($ws->getElementsByTagNameNS($configNs, 'config-item') as $configItem) {
89✔
1371
                        $attrName = $configItem->getAttributeNs($configNs, 'name');
89✔
1372
                        if ($attrName === 'CursorPositionX') {
89✔
1373
                            $setCol = $configItem->nodeValue;
89✔
1374
                        }
1375
                        if ($attrName === 'CursorPositionY') {
89✔
1376
                            $setRow = $configItem->nodeValue;
89✔
1377
                        }
1378
                    }
1379
                    $this->setSelected($spreadsheet, $wsname, "$setCol", "$setRow");
89✔
1380
                }
1381

1382
                break;
89✔
1383
            }
1384
        }
1385
    }
1386

1387
    private function setSelected(Spreadsheet $spreadsheet, string $wsname, string $setCol, string $setRow): void
89✔
1388
    {
1389
        if (is_numeric($setCol) && is_numeric($setRow)) {
89✔
1390
            $sheet = $spreadsheet->getSheetByName($wsname);
89✔
1391
            if ($sheet !== null) {
89✔
1392
                $sheet->setSelectedCells([(int) $setCol + 1, (int) $setRow + 1]);
88✔
1393
            }
1394
        }
1395
    }
1396

1397
    /**
1398
     * Recursively scan element.
1399
     */
1400
    protected function scanElementForText(DOMNode $element): string
96✔
1401
    {
1402
        $str = '';
96✔
1403
        foreach ($element->childNodes as $child) {
96✔
1404
            /** @var DOMNode $child */
1405
            if ($child->nodeType == XML_TEXT_NODE) {
96✔
1406
                $str .= $child->nodeValue;
96✔
1407
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') {
19✔
1408
                $str .= "\n";
1✔
1409
            } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') {
19✔
1410
                // It's a space
1411

1412
                // Multiple spaces?
1413
                $attributes = $child->attributes;
6✔
1414
                /** @var ?DOMAttr $cAttr */
1415
                $cAttr = ($attributes === null) ? null : $attributes->getNamedItem('c');
6✔
1416
                $multiplier = self::getMultiplier($cAttr);
6✔
1417
                $str .= str_repeat(' ', $multiplier);
6✔
1418
            }
1419

1420
            if ($child->hasChildNodes()) {
96✔
1421
                $str .= $this->scanElementForText($child);
17✔
1422
            }
1423
        }
1424

1425
        return $str;
96✔
1426
    }
1427

1428
    private static function getMultiplier(?DOMAttr $cAttr): int
6✔
1429
    {
1430
        if ($cAttr) {
6✔
1431
            $multiplier = (int) $cAttr->nodeValue;
6✔
1432
        } else {
1433
            $multiplier = 1;
6✔
1434
        }
1435

1436
        return $multiplier;
6✔
1437
    }
1438

1439
    private function parseRichText(string $is): RichText
12✔
1440
    {
1441
        $value = new RichText();
12✔
1442
        $value->createText($is);
12✔
1443

1444
        return $value;
12✔
1445
    }
1446

1447
    private function processMergedCells(
96✔
1448
        DOMElement $cellData,
1449
        string $tableNs,
1450
        string $type,
1451
        string $columnID,
1452
        int $rowID,
1453
        Spreadsheet $spreadsheet
1454
    ): void {
1455
        if (
1456
            $cellData->hasAttributeNS($tableNs, 'number-columns-spanned')
96✔
1457
            || $cellData->hasAttributeNS($tableNs, 'number-rows-spanned')
96✔
1458
        ) {
1459
            if (($type !== DataType::TYPE_NULL) || ($this->readDataOnly === false)) {
19✔
1460
                $columnTo = $columnID;
19✔
1461

1462
                if ($cellData->hasAttributeNS($tableNs, 'number-columns-spanned')) {
19✔
1463
                    $columnIndex = Coordinate::columnIndexFromString($columnID);
18✔
1464
                    $columnIndex += (int) $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
18✔
1465
                    $columnIndex -= 2;
18✔
1466

1467
                    $columnTo = Coordinate::stringFromColumnIndex($columnIndex + 1);
18✔
1468
                }
1469

1470
                $rowTo = $rowID;
19✔
1471

1472
                if ($cellData->hasAttributeNS($tableNs, 'number-rows-spanned')) {
19✔
1473
                    $rowTo = $rowTo + (int) $cellData->getAttributeNS($tableNs, 'number-rows-spanned') - 1;
19✔
1474
                }
1475

1476
                $cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo;
19✔
1477
                $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE);
19✔
1478
            }
1479
        }
1480
    }
1481

1482
    /** @var null|Closure(string, string):string */
1483
    private ?Closure $formatCallback = null;
1484

1485
    /** @param Closure(string, string):string $formatCallback */
1486
    public function setFormatCallback(Closure $formatCallback): void
1✔
1487
    {
1488
        $this->formatCallback = $formatCallback;
1✔
1489
    }
1490

1491
    /** @return array{
1492
     *   autoColor?: true,
1493
     *   bold?: true,
1494
     *   color?: array{rgb: string},
1495
     *   italic?: true,
1496
     *   name?: non-empty-string,
1497
     *   size?: float|int,
1498
     *   strikethrough?: true,
1499
     *   underline?: 'double'|'single',
1500
     * }
1501
     */
1502
    protected function getFontStyles(DOMElement $textProperty, string $styleNs, string $fontNs): array
97✔
1503
    {
1504
        $fonts = [];
97✔
1505
        $temp = $textProperty->getAttributeNs($styleNs, 'font-name') ?: $textProperty->getAttributeNs($fontNs, 'font-family');
97✔
1506
        if ($temp !== '') {
97✔
1507
            $fonts['name'] = $temp;
97✔
1508
        }
1509
        $temp = $textProperty->getAttributeNs($fontNs, 'font-size');
97✔
1510
        if ($temp !== '' && str_ends_with($temp, 'pt')) {
97✔
1511
            $fonts['size'] = (float) substr($temp, 0, -2);
97✔
1512
        }
1513
        $temp = $textProperty->getAttributeNs($fontNs, 'font-style');
97✔
1514
        if ($temp === 'italic') {
97✔
1515
            $fonts['italic'] = true;
45✔
1516
        }
1517
        $temp = $textProperty->getAttributeNs($fontNs, 'font-weight');
97✔
1518
        if ($temp === 'bold') {
97✔
1519
            $fonts['bold'] = true;
47✔
1520
        }
1521
        $temp = $textProperty->getAttributeNs($fontNs, 'color');
97✔
1522
        if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
97✔
1523
            $fonts['color'] = ['rgb' => substr($temp, 1)];
88✔
1524
        }
1525
        $temp = $textProperty->getAttributeNs($styleNs, 'use-window-font-color');
97✔
1526
        if ($temp === 'true') {
97✔
1527
            $fonts['autoColor'] = true;
3✔
1528
        }
1529
        $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-type');
97✔
1530
        if ($temp === '') {
97✔
1531
            $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-style');
97✔
1532
            if ($temp !== '' && $temp !== 'none') {
97✔
1533
                $temp = 'single';
42✔
1534
            }
1535
        }
1536
        if ($temp === 'single' || $temp === 'double') {
97✔
1537
            $fonts['underline'] = $temp;
43✔
1538
        }
1539
        $temp = $textProperty->getAttributeNs($styleNs, 'text-line-through-type');
97✔
1540
        if ($temp !== '' && $temp !== 'none') {
97✔
1541
            $fonts['strikethrough'] = true;
10✔
1542
        }
1543

1544
        return $fonts;
97✔
1545
    }
1546

1547
    /** @return array{
1548
     *   fillType?: string,
1549
     *   startColor?: array{rgb: string},
1550
     * }
1551
     */
1552
    protected function getFillStyles(DOMElement $tableCellProperties, string $fontNs): array
97✔
1553
    {
1554
        $fills = [];
97✔
1555
        $temp = $tableCellProperties->getAttributeNs($fontNs, 'background-color');
97✔
1556
        if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
97✔
1557
            $fills['fillType'] = Fill::FILL_SOLID;
38✔
1558
            $fills['startColor'] = ['rgb' => substr($temp, 1)];
38✔
1559
        } elseif ($temp === 'transparent') {
88✔
1560
            $fills['fillType'] = Fill::FILL_NONE;
64✔
1561
        }
1562

1563
        return $fills;
97✔
1564
    }
1565

1566
    private const MAP_VERTICAL = [
1567
        'top' => Alignment::VERTICAL_TOP,
1568
        'middle' => Alignment::VERTICAL_CENTER,
1569
        'automatic' => Alignment::VERTICAL_JUSTIFY,
1570
        'bottom' => Alignment::VERTICAL_BOTTOM,
1571
    ];
1572
    private const MAP_HORIZONTAL = [
1573
        'center' => Alignment::HORIZONTAL_CENTER,
1574
        'end' => Alignment::HORIZONTAL_RIGHT,
1575
        'justify' => Alignment::HORIZONTAL_FILL,
1576
        'start' => Alignment::HORIZONTAL_LEFT,
1577
    ];
1578

1579
    /** @return array{
1580
     *   shrinkToFit?: bool,
1581
     *   textRotation?: int,
1582
     *   vertical?: string,
1583
     *   wrapText?: bool,
1584
     * }
1585
     */
1586
    protected function getAlignment1Styles(DOMElement $tableCellProperties, string $styleNs, string $fontNs): array
74✔
1587
    {
1588
        $alignment1 = [];
74✔
1589
        $temp = $tableCellProperties->getAttributeNs($styleNs, 'rotation-angle');
74✔
1590
        if (is_numeric($temp)) {
74✔
1591
            $temp2 = (int) $temp;
57✔
1592
            if ($temp2 > 90) {
57✔
1593
                $temp2 -= 360;
9✔
1594
            }
1595
            if ($temp2 >= -90 && $temp2 <= 90) {
57✔
1596
                $alignment1['textRotation'] = (int) $temp2;
57✔
1597
            }
1598
        }
1599
        $temp = $tableCellProperties->getAttributeNs($styleNs, 'vertical-align');
74✔
1600
        $temp2 = self::MAP_VERTICAL[$temp] ?? '';
74✔
1601
        if ($temp2 !== '') {
74✔
1602
            $alignment1['vertical'] = $temp2;
69✔
1603
        }
1604
        $temp = $tableCellProperties->getAttributeNs($fontNs, 'wrap-option');
74✔
1605
        if ($temp === 'wrap') {
74✔
1606
            $alignment1['wrapText'] = true;
12✔
1607
        } elseif ($temp === 'no-wrap') {
73✔
1608
            $alignment1['wrapText'] = false;
9✔
1609
        }
1610
        $temp = $tableCellProperties->getAttributeNs($styleNs, 'shrink-to-fit');
74✔
1611
        if ($temp === 'true' || $temp === 'false') {
74✔
1612
            $alignment1['shrinkToFit'] = $temp === 'true';
10✔
1613
        }
1614

1615
        return $alignment1;
74✔
1616
    }
1617

1618
    /** @return array{
1619
     *   horizontal?: string,
1620
     *   readOrder?: int,
1621
     * }
1622
     */
1623
    protected function getAlignment2Styles(DOMElement $paragraphProperties, string $styleNs, string $fontNs): array
23✔
1624
    {
1625
        $alignment2 = [];
23✔
1626
        $temp = $paragraphProperties->getAttributeNs($fontNs, 'text-align');
23✔
1627
        $temp2 = self::MAP_HORIZONTAL[$temp] ?? '';
23✔
1628
        if ($temp2 !== '') {
23✔
1629
            $alignment2['horizontal'] = $temp2;
23✔
1630
        }
1631
        $temp = $paragraphProperties->getAttributeNs($fontNs, 'margin-left') ?: $paragraphProperties->getAttributeNs($fontNs, 'margin-right');
23✔
1632
        if (Preg::isMatch('/^\d+([.]\d+)?(cm|in|mm|pt)$/', $temp)) {
23✔
1633
            $dimension = new HelperDimension($temp);
14✔
1634
            $alignment2['indent'] = (int) round($dimension->toUnit('px') / Alignment::INDENT_UNITS_TO_PIXELS);
14✔
1635
        }
1636

1637
        $temp = $paragraphProperties->getAttributeNs($styleNs, 'writing-mode');
23✔
1638
        if ($temp === 'rl-tb') {
23✔
1639
            $alignment2['readOrder'] = Alignment::READORDER_RTL;
1✔
1640
        } elseif ($temp === 'lr-tb') {
23✔
1641
            $alignment2['readOrder'] = Alignment::READORDER_LTR;
1✔
1642
        }
1643

1644
        return $alignment2;
23✔
1645
    }
1646

1647
    /** @return array{
1648
     *   locked?: string,
1649
     *   hidden?: string,
1650
     * }
1651
     */
1652
    protected function getProtectionStyles(DOMElement $tableCellProperties, string $styleNs): array
74✔
1653
    {
1654
        $protection = [];
74✔
1655
        $temp = $tableCellProperties->getAttributeNs($styleNs, 'cell-protect');
74✔
1656
        switch ($temp) {
1657
            case 'protected formula-hidden':
74✔
1658
                $protection['locked'] = Protection::PROTECTION_PROTECTED;
1✔
1659
                $protection['hidden'] = Protection::PROTECTION_PROTECTED;
1✔
1660

1661
                break;
1✔
1662
            case 'formula-hidden':
74✔
1663
                $protection['locked'] = Protection::PROTECTION_UNPROTECTED;
1✔
1664
                $protection['hidden'] = Protection::PROTECTION_PROTECTED;
1✔
1665

1666
                break;
1✔
1667
            case 'protected':
74✔
1668
                $protection['locked'] = Protection::PROTECTION_PROTECTED;
10✔
1669
                $protection['hidden'] = Protection::PROTECTION_UNPROTECTED;
10✔
1670

1671
                break;
10✔
1672
            case 'none':
74✔
1673
                $protection['locked'] = Protection::PROTECTION_UNPROTECTED;
1✔
1674
                $protection['hidden'] = Protection::PROTECTION_UNPROTECTED;
1✔
1675

1676
                break;
1✔
1677
        }
1678

1679
        return $protection;
74✔
1680
    }
1681

1682
    private const MAP_BORDER_STYLE = [ // default BORDER_THIN
1683
        'none' => Border::BORDER_NONE,
1684
        'hidden' => Border::BORDER_NONE,
1685
        'dotted' => Border::BORDER_DOTTED,
1686
        'dash-dot' => Border::BORDER_DASHDOT,
1687
        'dash-dot-dot' => Border::BORDER_DASHDOTDOT,
1688
        'dashed' => Border::BORDER_DASHED,
1689
        'double' => Border::BORDER_DOUBLE,
1690
    ];
1691

1692
    private const MAP_BORDER_MEDIUM = [
1693
        Border::BORDER_THIN => Border::BORDER_MEDIUM,
1694
        Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
1695
        Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
1696
        Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
1697
    ];
1698

1699
    private const MAP_BORDER_THICK = [
1700
        Border::BORDER_THIN => Border::BORDER_THICK,
1701
        Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
1702
        Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
1703
        Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
1704
    ];
1705

1706
    /** @return array{
1707
     *   bottom?: array{borderStyle:string, color:array{rgb: string}},
1708
     *   top?: array{borderStyle:string, color:array{rgb: string}},
1709
     *   left?: array{borderStyle:string, color:array{rgb: string}},
1710
     *   right?: array{borderStyle:string, color:array{rgb: string}},
1711
     *   diagonal?: array{borderStyle:string, color:array{rgb: string}},
1712
     *   diagonalDirection?: int,
1713
     * }
1714
     */
1715
    protected function getBorderStyles(DOMElement $tableCellProperties, string $fontNs, string $styleNs): array
74✔
1716
    {
1717
        $borders = [];
74✔
1718
        $temp = $tableCellProperties->getAttributeNs($fontNs, 'border');
74✔
1719
        $diagonalIndex = Borders::DIAGONAL_NONE;
74✔
1720
        foreach (['bottom', 'left', 'right', 'top', 'diagonal-tl-br', 'diagonal-bl-tr'] as $direction) {
74✔
1721
            if (str_starts_with($direction, 'diagonal')) {
74✔
1722
                $directionIndex = 'diagonal';
74✔
1723
                $temp = $tableCellProperties->getAttributeNs($styleNs, $direction);
74✔
1724
            } else {
1725
                $directionIndex = $direction;
74✔
1726
                $temp = $tableCellProperties->getAttributeNs($fontNs, "border-$direction");
74✔
1727
            }
1728
            if (Preg::isMatch('/^(\d+(?:[.]\d+)?)pt\s+([-\w]+)\s+#([0-9a-fA-F]{6})$/', $temp, $matches)) {
74✔
1729
                $style = self::MAP_BORDER_STYLE[$matches[2]] ?? Border::BORDER_THIN;
12✔
1730
                $width = (float) $matches[1];
12✔
1731
                if ($width >= 2.5) {
12✔
1732
                    $style = self::MAP_BORDER_THICK[$style] ?? $style;
12✔
1733
                } elseif ($width >= 1.75) {
10✔
1734
                    $style = self::MAP_BORDER_MEDIUM[$style] ?? $style;
10✔
1735
                }
1736
                $color = $matches[3];
12✔
1737
                $borders[$directionIndex] = ['borderStyle' => $style, 'color' => ['rgb' => $matches[3]]];
12✔
1738
                if ($direction === 'diagonal-tl-br') {
12✔
1739
                    $diagonalIndex = Borders::DIAGONAL_DOWN;
10✔
1740
                } elseif ($direction === 'diagonal-bl-tr') {
12✔
1741
                    $diagonalIndex = ($diagonalIndex === Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_UP : Borders::DIAGONAL_BOTH;
10✔
1742
                }
1743
            }
1744
        }
1745
        if ($diagonalIndex !== Borders::DIAGONAL_NONE) {
74✔
1746
            $borders['diagonalDirection'] = $diagonalIndex;
10✔
1747
        }
1748

1749
        return $borders; // @phpstan-ignore-line
74✔
1750
    }
1751
}
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