PluginProbe
TablePress – Tables in WordPress made easy / 3.4
TablePress – Tables in WordPress made easy v3.4
3.4 3.3.4 3.3.3 3.3.2 3.3.1 trunk 1.12 1.14 1.9.2 2.0.4 2.1.7 2.1.8 2.2 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.3 2.3.1 2.3.2 2.4 2.4.1 2.4.2 2.4.3 All 45 releases
tablepress / libraries / vendor / PhpSpreadsheet / Reader / Ods.php

Ods.php in TablePress – Tables in WordPress made easy 3.4, at libraries/vendor/PhpSpreadsheet/Reader/Ods.php

1,927 lines 58.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace TablePress\PhpOffice\PhpSpreadsheet\Reader;
4
5 use TablePress\Composer\Pcre\Preg;
6 use DateTime;
7 use DateTimeZone;
8 use DOMAttr;
9 use DOMDocument;
10 use DOMElement;
11 use DOMNode;
12 use DOMText;
13 use TablePress\PhpOffice\PhpSpreadsheet\Cell\AddressRange;
14 use TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate;
15 use TablePress\PhpOffice\PhpSpreadsheet\Cell\DataType;
16 use TablePress\PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension;
17 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\AutoFilter;
18 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\DefinedNames;
19 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
20 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\PageSettings;
21 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Ods\Properties as DocumentProperties;
22 use TablePress\PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner;
23 use TablePress\PhpOffice\PhpSpreadsheet\RichText\RichText;
24 use TablePress\PhpOffice\PhpSpreadsheet\Shared\Date;
25 use TablePress\PhpOffice\PhpSpreadsheet\Shared\File;
26 use TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper;
27 use TablePress\PhpOffice\PhpSpreadsheet\Spreadsheet;
28 use TablePress\PhpOffice\PhpSpreadsheet\Style\Alignment;
29 use TablePress\PhpOffice\PhpSpreadsheet\Style\Border;
30 use TablePress\PhpOffice\PhpSpreadsheet\Style\Borders;
31 use TablePress\PhpOffice\PhpSpreadsheet\Style\Fill;
32 use TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat;
33 use TablePress\PhpOffice\PhpSpreadsheet\Style\Protection;
34 use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Drawing;
35 use TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
36 use Throwable;
37 use XMLReader;
38 use ZipArchive;
39
40 class Ods extends BaseReader
41 {
42 const INITIAL_FILE = 'content.xml';
43
44 private ZipArchive $zip;
45
46 private string $filename;
47
48 /**
49 * Create a new Ods Reader instance.
50 */
51 public function __construct()
52 {
53 parent::__construct();
54 $this->securityScanner = XmlScanner::getInstance($this);
55 }
56
57 /**
58 * Can the current IReader read the file?
59 */
60 public function canRead(string $filename): bool
61 {
62 $mimeType = 'UNKNOWN';
63
64 // Load file
65
66 if (File::testFileNoThrow($filename, '')) {
67 $zip = new ZipArchive();
68 if ($zip->open($filename) === true) {
69 // check if it is an OOXML archive
70 $stat = $zip->statName('mimetype');
71 if (!empty($stat) && ($stat['size'] <= 255)) {
72 $mimeType = $zip->getFromName($stat['name']);
73 } elseif ($zip->statName('META-INF/manifest.xml')) {
74 $xml = simplexml_load_string(
75 $this->getSecurityScannerOrThrow()
76 ->scan(
77 $zip->getFromName(
78 'META-INF/manifest.xml'
79 )
80 )
81 );
82 if ($xml !== false) {
83 $namespacesContent = $xml->getNamespaces(true);
84 if (isset($namespacesContent['manifest'])) {
85 $manifest = $xml->children($namespacesContent['manifest']);
86 foreach ($manifest as $manifestDataSet) {
87 $manifestAttributes = $manifestDataSet->attributes($namespacesContent['manifest']);
88 if ($manifestAttributes && $manifestAttributes->{'full-path'} == '/') {
89 $mimeType = (string) $manifestAttributes->{'media-type'};
90
91 break;
92 }
93 }
94 }
95 }
96 }
97
98 $zip->close();
99 }
100 }
101
102 return $mimeType === 'application/vnd.oasis.opendocument.spreadsheet';
103 }
104
105 /**
106 * Reads names of the worksheets from a file, without parsing the whole file to a PhpSpreadsheet object.
107 *
108 * @return string[]
109 */
110 public function listWorksheetNames(string $filename): array
111 {
112 File::assertFile($filename, self::INITIAL_FILE);
113
114 $worksheetNames = [];
115
116 $xml = new XMLReader();
117 $xml->xml(
118 $this->getSecurityScannerOrThrow()
119 ->scanFile(
120 'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
121 )
122 );
123 $xml->setParserProperty(2, true);
124
125 // Step into the first level of content of the XML
126 $xml->read();
127 while ($xml->read()) {
128 // Quickly jump through to the office:body node
129 while ($xml->name !== 'office:body') {
130 if ($xml->isEmptyElement) {
131 $xml->read();
132 } else {
133 $xml->next();
134 }
135 }
136 // Now read each node until we find our first table:table node
137 while ($xml->read()) {
138 $xmlName = $xml->name;
139 if ($xmlName == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
140 // Loop through each table:table node reading the table:name attribute for each worksheet name
141 do {
142 $worksheetName = $xml->getAttribute('table:name');
143 if (!empty($worksheetName)) {
144 $worksheetNames[] = $worksheetName;
145 }
146 $xml->next();
147 } while ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT);
148 }
149 }
150 }
151
152 return $worksheetNames;
153 }
154
155 /**
156 * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
157 *
158 * @return array<int, array{
159 * worksheetName: string,
160 * lastColumnLetter: string,
161 * lastColumnIndex: int,
162 * totalRows: int,
163 * totalColumns: int,
164 * sheetState: string
165 * }>
166 */
167 public function listWorksheetInfo(string $filename): array
168 {
169 File::assertFile($filename, self::INITIAL_FILE);
170
171 $worksheetInfo = [];
172
173 $xml = new XMLReader();
174 $xml->xml(
175 $this->getSecurityScannerOrThrow()
176 ->scanFile(
177 'zip://' . realpath($filename) . '#' . self::INITIAL_FILE
178 )
179 );
180 $xml->setParserProperty(2, true);
181
182 // Step into the first level of content of the XML
183 $xml->read();
184 $tableVisibility = [];
185 $lastTableStyle = '';
186
187 while ($xml->read()) {
188 if ($xml->name === 'style:style') {
189 $styleType = $xml->getAttribute('style:family');
190 if ($styleType === 'table') {
191 $lastTableStyle = $xml->getAttribute('style:name');
192 }
193 } elseif ($xml->name === 'style:table-properties') {
194 $visibility = $xml->getAttribute('table:display');
195 $tableVisibility[$lastTableStyle] = ($visibility === 'false') ? Worksheet::SHEETSTATE_HIDDEN : Worksheet::SHEETSTATE_VISIBLE;
196 } elseif ($xml->name == 'table:table' && $xml->nodeType == XMLReader::ELEMENT) {
197 $worksheetNames[] = $xml->getAttribute('table:name');
198
199 $styleName = $xml->getAttribute('table:style-name') ?? '';
200 $visibility = $tableVisibility[$styleName] ?? '';
201 $tmpInfo = [
202 'worksheetName' => (string) $xml->getAttribute('table:name'),
203 'lastColumnLetter' => 'A',
204 'lastColumnIndex' => 0,
205 'totalRows' => 0,
206 'totalColumns' => 0,
207 'sheetState' => $visibility,
208 ];
209
210 // Loop through each child node of the table:table element reading
211 $currRow = 0;
212 do {
213 $xml->read();
214 if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) {
215 $rowspan = $xml->getAttribute('table:number-rows-repeated');
216 $rowspan = empty($rowspan) ? 1 : (int) $rowspan;
217 self::checkRowsRepeated($currRow, $rowspan);
218 $currRow += $rowspan;
219 $currCol = 0;
220 // Step into the row
221 $xml->read();
222 do {
223 $doread = true;
224 if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
225 $mergeSize = $xml->getAttribute('table:number-columns-repeated');
226 $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize;
227 self::checkColumnsRepeatedInt(
228 $currCol,
229 $mergeSize
230 );
231 $currCol += $mergeSize;
232 if (!$xml->isEmptyElement) {
233 $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCol);
234 $tmpInfo['totalRows'] = $currRow;
235 $xml->next();
236 $doread = false;
237 }
238 } elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) {
239 $mergeSize = $xml->getAttribute('table:number-columns-repeated');
240 $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize;
241 self::checkColumnsRepeatedInt(
242 $currCol,
243 $mergeSize
244 );
245 $currCol += (int) $mergeSize;
246 }
247 if ($doread) {
248 $xml->read();
249 }
250 } while ($xml->name != 'table:table-row');
251 }
252 } while ($xml->name != 'table:table');
253
254 $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1;
255 $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true);
256 $worksheetInfo[] = $tmpInfo;
257 }
258 }
259
260 return $worksheetInfo;
261 }
262
263 /**
264 * Loads PhpSpreadsheet from file.
265 */
266 protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
267 {
268 $spreadsheet = $this->newSpreadsheet();
269 $spreadsheet->setValueBinder($this->valueBinder);
270 $spreadsheet->removeSheetByIndex(0);
271
272 // Load into this instance
273 return $this->loadIntoExisting($filename, $spreadsheet);
274 }
275
276 /** @var array<string,
277 * array{
278 * font?:array{
279 * autoColor?: true,
280 * bold?: true,
281 * color?: array{rgb: string},
282 * italic?: true,
283 * name?: non-empty-string,
284 * size?: float|int,
285 * strikethrough?: true,
286 * underline?: 'double'|'single',
287 * },
288 * fill?:array{
289 * fillType?: string,
290 * startColor?: array{rgb: string},
291 * },
292 * alignment?:array{
293 * horizontal?: string,
294 * readOrder?: int,
295 * shrinkToFit?: bool,
296 * textRotation?: int,
297 * vertical?: string,
298 * wrapText?: bool,
299 * indent?: int,
300 * },
301 * protection?:array{
302 * locked?: string,
303 * hidden?: string,
304 * },
305 * borders?:array{
306 * bottom?: array{borderStyle:string, color:array{rgb: string}},
307 * left?: array{borderStyle:string, color:array{rgb: string}},
308 * right?: array{borderStyle:string, color:array{rgb: string}},
309 * top?: array{borderStyle:string, color:array{rgb: string}},
310 * diagonal?: array{borderStyle:string, color:array{rgb: string}},
311 * diagonalDirection?: int,
312 * },
313 * numberFormat?:array{formatCode: string},
314 * }>
315 */
316 private array $allStyles;
317
318 /** @var string[] */
319 private array $numberFormats;
320
321 private int $highestDataIndex;
322
323 /**
324 * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
325 */
326 public function loadIntoExisting(string $filename, Spreadsheet $spreadsheet): Spreadsheet
327 {
328 File::assertFile($filename, self::INITIAL_FILE);
329
330 $this->zip = $zip = new ZipArchive();
331 $this->filename = $filename;
332 $zip->open($filename);
333
334 // Meta
335
336 $xml = @simplexml_load_string(
337 $this->getSecurityScannerOrThrow()
338 ->scan($zip->getFromName('meta.xml'))
339 );
340 if ($xml === false) {
341 throw new Exception("Unable to read data from {$filename}");
342 }
343
344 /** @var array{meta?: string, office?: string, dc?: string} */
345 $namespacesMeta = $xml->getNamespaces(true);
346
347 (new DocumentProperties($spreadsheet))->load($xml, $namespacesMeta);
348
349 // Styles
350
351 $this->allStyles = $this->numberFormats = [];
352 $dom = $this->loadDom('styles.xml', $zip);
353 $officeNs = (string) $dom->lookupNamespaceUri('office');
354 $styleNs = (string) $dom->lookupNamespaceUri('style');
355 $fontNs = (string) $dom->lookupNamespaceUri('fo');
356 $numberNs = (string) $dom->lookupNamespaceUri('number');
357 $tableNs = (string) $dom->lookupNamespaceUri('table');
358 $textNs = (string) $dom->lookupNamespaceUri('text');
359 $xlinkNs = (string) $dom->lookupNamespaceUri('xlink');
360
361 $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'styles')->item(0);
362 $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs);
363 $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'default-style');
364 foreach ($automaticStyles as $automaticStyle) {
365 $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
366 if ($styleFamily === 'table-cell') {
367 $fonts = [];
368 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
369 $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
370 }
371 if (!empty($fonts)) {
372 $spreadsheet->getDefaultStyle()
373 ->getFont()
374 ->applyFromArray($fonts);
375 }
376 }
377 }
378 $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
379 foreach ($automaticStyles as $automaticStyle) {
380 $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
381 $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
382 if ($styleFamily === 'table-cell') {
383 $fills = $fonts = [];
384 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
385 $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
386 }
387 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
388 $fills = $this->getFillStyles($tableCellProperty, $fontNs);
389 }
390 if ($styleName !== '') {
391 if (!empty($fonts)) {
392 $this->allStyles[$styleName]['font'] = $fonts;
393 if ($styleName === 'Default') {
394 $spreadsheet->getDefaultStyle()
395 ->getFont()
396 ->applyFromArray($fonts);
397 }
398 }
399 if (!empty($fills)) {
400 $this->allStyles[$styleName]['fill'] = $fills;
401 if ($styleName === 'Default') {
402 $spreadsheet->getDefaultStyle()
403 ->getFill()
404 ->applyFromArray($fills);
405 }
406 }
407 }
408 }
409 }
410
411 $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
412 $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs);
413
414 $pageSettings = new PageSettings($dom);
415
416 // Main Content
417
418 $dom = $this->loadDom(self::INITIAL_FILE, $zip);
419
420 $pageSettings->readStyleCrossReferences($dom);
421
422 $autoFilterReader = new AutoFilter($spreadsheet, $tableNs);
423 $definedNameReader = new DefinedNames($spreadsheet, $tableNs);
424 $columnWidths = [];
425 $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0);
426 $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs);
427 $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style');
428 foreach ($automaticStyles as $automaticStyle) {
429 $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
430 $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family');
431 if ($styleFamily === 'table-column') {
432 $tcprops = $automaticStyle->getElementsByTagNameNS($styleNs, 'table-column-properties');
433 $tcprop = $tcprops->item(0);
434 if ($tcprop !== null) {
435 $columnWidth = $tcprop->getAttributeNs($styleNs, 'column-width');
436 $columnWidths[$styleName] = $columnWidth;
437 }
438 }
439 if ($styleFamily === 'table-cell') {
440 $fonts = $fills = $alignment1 = $alignment2 = $protection = $borders = [];
441 $numberFormatName = $automaticStyle->getAttributeNS($styleNs, 'data-style-name');
442 $numberFormat = $this->numberFormats[$numberFormatName] ?? '';
443 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) {
444 $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs);
445 }
446 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
447 $fills = $this->getFillStyles($tableCellProperty, $fontNs);
448 $borders = $this->getBorderStyles($tableCellProperty, $fontNs, $styleNs);
449 $protection = $this->getProtectionStyles($tableCellProperty, $styleNs);
450 }
451 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) {
452 $alignment1 = $this->getAlignment1Styles($tableCellProperty, $styleNs, $fontNs);
453 }
454 foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'paragraph-properties') as $paragraphProperty) {
455 $alignment2 = $this->getAlignment2Styles($paragraphProperty, $styleNs, $fontNs);
456 }
457 if ($styleName !== '') {
458 if (!empty($fonts)) {
459 $this->allStyles[$styleName]['font'] = $fonts;
460 }
461 if (!empty($fills)) {
462 $this->allStyles[$styleName]['fill'] = $fills;
463 }
464 $alignment = array_merge($alignment1, $alignment2);
465 if (!empty($alignment)) {
466 $this->allStyles[$styleName]['alignment'] = $alignment;
467 }
468 if (!empty($protection)) {
469 $this->allStyles[$styleName]['protection'] = $protection;
470 }
471 if (!empty($borders)) {
472 $this->allStyles[$styleName]['borders'] = $borders;
473 }
474 if ($numberFormat !== '') {
475 $this->allStyles[$styleName]['numberFormat']['formatCode'] = $numberFormat;
476 }
477 }
478 }
479 }
480
481 // Content
482 $item0 = $dom->getElementsByTagNameNS($officeNs, 'body')->item(0);
483 $spreadsheets = ($item0 === null) ? [] : $item0->getElementsByTagNameNS($officeNs, 'spreadsheet');
484
485 foreach ($spreadsheets as $workbookData) {
486 /** @var DOMElement $workbookData */
487 $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
488
489 $worksheetID = 0;
490 $sheetCreated = false;
491 foreach ($tables as $worksheetDataSet) {
492 /** @var DOMElement $worksheetDataSet */
493 $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
494
495 // Check loadSheetsOnly
496 if (
497 $this->loadSheetsOnly !== null
498 && $worksheetName
499 && !in_array($worksheetName, $this->loadSheetsOnly)
500 ) {
501 continue;
502 }
503
504 $worksheetStyleName = $worksheetDataSet->getAttributeNS($tableNs, 'style-name');
505
506 // Create sheet
507 $spreadsheet->createSheet();
508 $sheetCreated = true;
509 $spreadsheet->setActiveSheetIndex($worksheetID);
510
511 if ($worksheetName || is_numeric($worksheetName)) {
512 // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in
513 // formula cells... during the load, all formulae should be correct, and we're simply
514 // bringing the worksheet name in line with the formula, not the reverse
515 $spreadsheet->getActiveSheet()
516 ->setTitle((string) $worksheetName, false, false);
517 }
518
519 // Go through every child of table element
520 $rowID = 1;
521 $tableColumnIndex = 1;
522 $this->highestDataIndex = AddressRange::MAX_COLUMN_INT;
523 foreach ($worksheetDataSet->childNodes ?? [] as $childNode) {
524 /** @var DOMElement $childNode */
525
526 // Filter elements which are not under the "table" ns
527 if ($childNode->namespaceURI != $tableNs) {
528 continue;
529 }
530
531 $key = self::extractNodeName($childNode->nodeName);
532
533 switch ($key) {
534 case 'table-header-rows':
535 case 'table-rows':
536 $this->processTableHeaderRows(
537 $childNode,
538 $tableNs,
539 $rowID,
540 $worksheetName,
541 $officeNs,
542 $textNs,
543 $xlinkNs,
544 $spreadsheet
545 );
546
547 break;
548 case 'table-row-group':
549 $this->processTableRowGroup(
550 $childNode,
551 $tableNs,
552 $rowID,
553 $worksheetName,
554 $officeNs,
555 $textNs,
556 $xlinkNs,
557 $spreadsheet
558 );
559
560 break;
561 case 'table-row':
562 $this->processTableRow(
563 $childNode,
564 $tableNs,
565 $rowID,
566 $worksheetName,
567 $officeNs,
568 $textNs,
569 $xlinkNs,
570 $spreadsheet
571 );
572
573 break;
574 case 'table-header-columns':
575 case 'table-columns':
576 $this->processTableColumnHeader(
577 $childNode,
578 $tableNs,
579 $columnWidths,
580 $tableColumnIndex,
581 $spreadsheet,
582 $this->readEmptyCells,
583 true
584 );
585
586 break;
587 case 'table-column-group':
588 $this->processTableColumnGroup(
589 $childNode,
590 $tableNs,
591 $columnWidths,
592 $tableColumnIndex,
593 $spreadsheet,
594 $this->readEmptyCells,
595 true
596 );
597
598 break;
599 case 'table-column':
600 $this->processTableColumn(
601 $childNode,
602 $tableNs,
603 $columnWidths,
604 $tableColumnIndex,
605 $spreadsheet,
606 $this->readEmptyCells,
607 true
608 );
609
610 break;
611 }
612 }
613 $pageSettings->setVisibilityForWorksheet(
614 $spreadsheet->getActiveSheet(),
615 $worksheetStyleName
616 );
617 $pageSettings->setPrintSettingsForWorksheet(
618 $spreadsheet->getActiveSheet(),
619 $worksheetStyleName
620 );
621 ++$worksheetID;
622 }
623 if ($this->createBlankSheetIfNoneRead && !$sheetCreated) {
624 $spreadsheet->createSheet();
625 }
626 }
627
628 foreach ($spreadsheets as $workbookData) {
629 /** @var DOMElement $workbookData */
630 $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table');
631
632 $worksheetID = 0;
633 foreach ($tables as $worksheetDataSet) {
634 /** @var DOMElement $worksheetDataSet */
635 $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name');
636
637 // Check loadSheetsOnly
638 if (
639 $this->loadSheetsOnly !== null
640 && $worksheetName
641 && !in_array($worksheetName, $this->loadSheetsOnly)
642 ) {
643 continue;
644 }
645
646 // Create sheet
647 $spreadsheet->setActiveSheetIndex($worksheetID);
648 $highestDataColumn = $spreadsheet->getActiveSheet()->getHighestDataColumn();
649 $this->highestDataIndex = Coordinate::columnIndexFromString($highestDataColumn);
650
651 // Go through every child of table element processing column widths
652 $rowID = 1;
653 $tableColumnIndex = 1;
654 foreach ($worksheetDataSet->childNodes ?? [] as $childNode) {
655 /** @var DOMElement $childNode */
656 if (empty($columnWidths) || $this->readEmptyCells) {
657 break;
658 }
659
660 // Filter elements which are not under the "table" ns
661 if ($childNode->namespaceURI != $tableNs) {
662 continue;
663 }
664
665 $key = self::extractNodeName($childNode->nodeName);
666
667 switch ($key) {
668 case 'table-header-columns':
669 case 'table-columns':
670 $this->processTableColumnHeader(
671 $childNode,
672 $tableNs,
673 $columnWidths,
674 $tableColumnIndex,
675 $spreadsheet,
676 true,
677 false
678 );
679
680 break;
681 case 'table-column-group':
682 $this->processTableColumnGroup(
683 $childNode,
684 $tableNs,
685 $columnWidths,
686 $tableColumnIndex,
687 $spreadsheet,
688 true,
689 false
690 );
691
692 break;
693 case 'table-column':
694 $this->processTableColumn(
695 $childNode,
696 $tableNs,
697 $columnWidths,
698 $tableColumnIndex,
699 $spreadsheet,
700 true,
701 false
702 );
703
704 break;
705 }
706 }
707 ++$worksheetID;
708 }
709
710 $autoFilterReader->read($workbookData);
711 $definedNameReader->read($workbookData);
712 }
713
714 $spreadsheet->setActiveSheetIndex(0);
715
716 if ($zip->locateName('settings.xml') !== false) {
717 $this->processSettings($zip, $spreadsheet);
718 }
719
720 // Return
721 return $spreadsheet;
722 }
723
724 private function processTableHeaderRows(
725 DOMElement $childNode,
726 string $tableNs,
727 int &$rowID,
728 string $worksheetName,
729 string $officeNs,
730 string $textNs,
731 string $xlinkNs,
732 Spreadsheet $spreadsheet
733 ): void {
734 foreach ($childNode->childNodes ?? [] as $grandchildNode) {
735 /** @var DOMElement $grandchildNode */
736 $grandkey = self::extractNodeName($grandchildNode->nodeName);
737 switch ($grandkey) {
738 case 'table-row':
739 $this->processTableRow(
740 $grandchildNode,
741 $tableNs,
742 $rowID,
743 $worksheetName,
744 $officeNs,
745 $textNs,
746 $xlinkNs,
747 $spreadsheet
748 );
749
750 break;
751 }
752 }
753 }
754
755 private function processTableRowGroup(
756 DOMElement $childNode,
757 string $tableNs,
758 int &$rowID,
759 string $worksheetName,
760 string $officeNs,
761 string $textNs,
762 string $xlinkNs,
763 Spreadsheet $spreadsheet
764 ): void {
765 foreach ($childNode->childNodes ?? [] as $grandchildNode) {
766 /** @var DOMElement $grandchildNode */
767 $grandkey = self::extractNodeName($grandchildNode->nodeName);
768 switch ($grandkey) {
769 case 'table-row':
770 $this->processTableRow(
771 $grandchildNode,
772 $tableNs,
773 $rowID,
774 $worksheetName,
775 $officeNs,
776 $textNs,
777 $xlinkNs,
778 $spreadsheet
779 );
780
781 break;
782 case 'table-header-rows':
783 case 'table-rows':
784 $this->processTableHeaderRows(
785 $grandchildNode,
786 $tableNs,
787 $rowID,
788 $worksheetName,
789 $officeNs,
790 $textNs,
791 $xlinkNs,
792 $spreadsheet
793 );
794
795 break;
796 case 'table-row-group':
797 $this->processTableRowGroup(
798 $grandchildNode,
799 $tableNs,
800 $rowID,
801 $worksheetName,
802 $officeNs,
803 $textNs,
804 $xlinkNs,
805 $spreadsheet
806 );
807
808 break;
809 }
810 }
811 }
812
813 private function processTableRow(
814 DOMElement $childNode,
815 string $tableNs,
816 int &$rowID,
817 string $worksheetName,
818 string $officeNs,
819 string $textNs,
820 string $xlinkNs,
821 Spreadsheet $spreadsheet
822 ): void {
823 if ($childNode->hasAttributeNS($tableNs, 'number-rows-repeated')) {
824 $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-rows-repeated');
825 } else {
826 $rowRepeats = 1;
827 }
828 self::checkRowsRepeated($rowID, $rowRepeats);
829 $worksheet = $spreadsheet->getSheetByName($worksheetName);
830
831 $columnID = 'A';
832 /** @var DOMElement|DOMText $cellData */
833 foreach ($childNode->childNodes ?? [] as $cellData) {
834 if ($cellData instanceof DOMText) {
835 continue; // should just be whitespace
836 }
837 if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) {
838 $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated');
839 } else {
840 $colRepeats = 1;
841 }
842 $columnIndex = Coordinate::columnIndexFromString($columnID);
843 self::checkColumnsRepeated($columnID, $colRepeats);
844 $styleName = $cellData->getAttributeNS($tableNs, 'style-name');
845 if ($styleName === '') {
846 if ($worksheet === null || !$worksheet->columnDimensionExists($columnID)) {
847 $assignedNumberFormat = '';
848 } else {
849 $colStyle = $worksheet->getColumnDimension($columnID)->getXfIndex() ?? 0;
850 $assignedNumberFormat = $spreadsheet
851 ->getCellXfByIndex($colStyle)
852 ->getNumberFormat()->getFormatCode();
853 if ($assignedNumberFormat === NumberFormat::FORMAT_GENERAL) {
854 $assignedNumberFormat = '';
855 }
856 }
857 } else {
858 $assignedNumberFormat = $this->allStyles[$styleName]['numberFormat']['formatCode'] ?? '';
859 }
860
861 // When a cell has number-columns-repeated, check if ANY column in the
862 // repeated range passes the read filter. If not, skip the entire group.
863 // If some columns pass, we need to fall through to the processing block
864 // which will handle per-column filtering.
865 if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) {
866 if ($colRepeats <= 1) {
867 StringHelper::stringIncrement($columnID);
868
869 continue;
870 }
871
872 // Check if any column within this repeated group passes the filter
873 $anyColumnPasses = false;
874 $tempCol = $columnID;
875 for ($i = 0; $i < $colRepeats; ++$i) {
876 if ($i > 0) {
877 StringHelper::stringIncrement($tempCol);
878 }
879 if ($this->readFilter->readCell($tempCol, $rowID, $worksheetName)) {
880 $anyColumnPasses = true;
881
882 break;
883 }
884 }
885
886 if (!$anyColumnPasses) {
887 for ($i = 0; $i < $colRepeats; ++$i) {
888 StringHelper::stringIncrement($columnID);
889 }
890
891 continue;
892 }
893 // Fall through to process the cell, with per-column filter checks
894 }
895 $tempSpannedRange = "$columnID$rowID";
896 $spannedRange = '';
897 if ($worksheet !== null && ($cellData->hasChildNodes() || ($cellData->nextSibling !== null)) && isset($this->allStyles[$styleName])) {
898 $spannedRange = "$columnID$rowID";
899 // the following is sufficient for ods,
900 // and does no harm for xlsx/xls.
901 $worksheet->getStyle($spannedRange)
902 ->applyFromArray($this->allStyles[$styleName]);
903 // the rest of this block is needed for xlsx/xls,
904 // and does no harm for ods.
905 if (isset($this->allStyles[$styleName]['borders'])) {
906 $spannedRows = $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
907 $spannedColumns = $cellData->getAttributeNS($tableNs, 'number-rows-spanned');
908 $spannedRows = max((int) $spannedRows, 1);
909 $spannedColumns = max((int) $spannedColumns, 1);
910 if ($spannedRows > 1 || $spannedColumns > 1) {
911 $endRow = $rowID + $spannedRows - 1;
912 $endCol = $columnID;
913 while ($spannedColumns > 1) {
914 StringHelper::stringIncrement($endCol);
915 --$spannedColumns;
916 }
917 $spannedRange .= ":$endCol$endRow";
918 $worksheet->getStyle($spannedRange)
919 ->getBorders()
920 ->applyFromArray(
921 $this->allStyles[$styleName]['borders']
922 );
923 }
924 }
925 }
926
927 // Initialize variables
928 $formatting = $hyperlink = null;
929 $hasCalculatedValue = false;
930 $cellDataFormula = '';
931 $cellDataType = '';
932 $cellDataRef = '';
933
934 if ($cellData->hasAttributeNS($tableNs, 'formula')) {
935 $cellDataFormula = $cellData->getAttributeNS($tableNs, 'formula');
936 $hasCalculatedValue = true;
937 }
938 if ($cellData->hasAttributeNS($tableNs, 'number-matrix-columns-spanned')) {
939 if ($cellData->hasAttributeNS($tableNs, 'number-matrix-rows-spanned')) {
940 $cellDataType = 'array';
941 $arrayRow = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-rows-spanned');
942 $arrayCol = (int) $cellData->getAttributeNS($tableNs, 'number-matrix-columns-spanned');
943 $lastRow = $rowID + $arrayRow - 1;
944 $lastCol = $columnID;
945 while ($arrayCol > 1) {
946 StringHelper::stringIncrement($lastCol);
947 --$arrayCol;
948 }
949 $cellDataRef = "$columnID$rowID:$lastCol$lastRow";
950 }
951 }
952
953 // Annotations
954 $annotation = $cellData->getElementsByTagNameNS($officeNs, 'annotation');
955
956 if ($annotation->length > 0 && $annotation->item(0) !== null) {
957 $textNode = $annotation->item(0)->getElementsByTagNameNS($textNs, 'p');
958 $textNodeLength = $textNode->length;
959 $newLineOwed = false;
960 for ($textNodeIndex = 0; $textNodeIndex < $textNodeLength; ++$textNodeIndex) {
961 $textNodeItem = $textNode->item($textNodeIndex);
962 if ($textNodeItem !== null) {
963 $text = $this->scanElementForText($textNodeItem);
964 if ($newLineOwed) {
965 $spreadsheet->getActiveSheet()
966 ->getComment($columnID . $rowID)
967 ->getText()
968 ->createText("\n");
969 }
970 $newLineOwed = true;
971
972 $spreadsheet->getActiveSheet()
973 ->getComment($columnID . $rowID)
974 ->getText()
975 ->createText(
976 $this->parseRichText($text)
977 );
978 }
979 }
980 }
981
982 // Content
983
984 /** @var DOMElement[] $paragraphs */
985 $paragraphs = [];
986
987 foreach ($cellData->childNodes ?? [] as $item) {
988 /** @var DOMElement $item */
989
990 // Filter text:p elements
991 if ($item->nodeName == 'text:p') {
992 $paragraphs[] = $item;
993 } elseif ($item->nodeName === 'draw:frame' && $worksheet !== null) {
994 $this->processDrawFrame($spannedRange ?: $tempSpannedRange, $item, $worksheet);
995 }
996 }
997
998 if (count($paragraphs) > 0) {
999 $dataValue = null;
1000 // Consolidate if there are multiple p records (maybe with spans as well)
1001 $dataArray = [];
1002
1003 // Text can have multiple text:p and within those, multiple text:span.
1004 // text:p newlines, but text:span does not.
1005 // Also, here we assume there is no text data is span fields are specified, since
1006 // we have no way of knowing proper positioning anyway.
1007
1008 foreach ($paragraphs as $pData) {
1009 $dataArray[] = $this->scanElementForText($pData);
1010 }
1011 $allCellDataText = implode("\n", $dataArray);
1012
1013 $type = $cellData->getAttributeNS($officeNs, 'value-type');
1014 $symbol = '';
1015 $leftHandCurrency = Preg::isMatch('/\$|£|¥/', $allCellDataText, $matches);
1016 if ($leftHandCurrency) {
1017 $type = str_replace('float', 'currency', $type);
1018 $symbol = (string) $matches[0];
1019 }
1020 $customFormatting = '';
1021 if ($this->formatCallback !== null) {
1022 $temp = ($this->formatCallback)($type, $allCellDataText);
1023 if ($temp !== '') {
1024 $customFormatting = $temp;
1025 }
1026 }
1027
1028 switch ($type) {
1029 case 'string':
1030 $type = DataType::TYPE_STRING;
1031 $dataValue = $allCellDataText;
1032
1033 foreach ($paragraphs as $paragraph) {
1034 $link = $paragraph->getElementsByTagNameNS($textNs, 'a');
1035 if ($link->length > 0 && $link->item(0) !== null) {
1036 $hyperlink = $link->item(0)->getAttributeNS($xlinkNs, 'href');
1037 }
1038 }
1039
1040 break;
1041 case 'boolean':
1042 $type = DataType::TYPE_BOOL;
1043 $dataValue = ($cellData->getAttributeNS($officeNs, 'boolean-value') === 'true') ? true : false;
1044
1045 break;
1046 case 'percentage':
1047 if (!str_contains($allCellDataText, '.')) {
1048 $formatting = NumberFormat::FORMAT_PERCENTAGE;
1049 } elseif (substr($allCellDataText, -3, 1) === '.') {
1050 $formatting = NumberFormat::FORMAT_PERCENTAGE_0;
1051 } else {
1052 $formatting = NumberFormat::FORMAT_PERCENTAGE_00;
1053 }
1054 $type = DataType::TYPE_NUMERIC;
1055 $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
1056
1057 break;
1058 case 'currency':
1059 $type = DataType::TYPE_NUMERIC;
1060 $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
1061
1062 $currency = $cellData->getAttributeNS($officeNs, 'currency');
1063 if ($leftHandCurrency) {
1064 $typeValue = 'currency';
1065 $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER;
1066 if ($symbol !== '$') {
1067 $formatting = str_replace('$', $symbol, $formatting);
1068 }
1069 } elseif (str_contains($allCellDataText, '€')) {
1070 $typeValue = 'currency';
1071 $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_EUR : NumberFormat::FORMAT_CURRENCY_EUR_INTEGER;
1072 }
1073
1074 break;
1075 case 'float':
1076 $type = DataType::TYPE_NUMERIC;
1077 $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value');
1078
1079 if ($assignedNumberFormat !== '') {
1080 $formatting = $assignedNumberFormat;
1081 } elseif ($dataValue !== floor($dataValue)) {
1082 // do nothing
1083 } elseif (substr($allCellDataText, -2, 1) === '.') {
1084 $formatting = NumberFormat::FORMAT_NUMBER_0;
1085 } elseif (substr($allCellDataText, -3, 1) === '.') {
1086 $formatting = NumberFormat::FORMAT_NUMBER_00;
1087 }
1088 if (floor($dataValue) == $dataValue) {
1089 if ($dataValue == (int) $dataValue) {
1090 $dataValue = (int) $dataValue;
1091 }
1092 }
1093
1094 break;
1095 case 'date':
1096 $type = DataType::TYPE_NUMERIC;
1097 $value = $cellData->getAttributeNS($officeNs, 'date-value');
1098 $dataValue = Date::convertIsoDate($value);
1099 $format15 = Preg::isMatch('/\d\d\d\d/', $allCellDataText) ? NumberFormat::FORMAT_DATE_XLSX15_YYYY : NumberFormat::FORMAT_DATE_XLSX15;
1100
1101 if (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d$/', $allCellDataText)) {
1102 $formatting = 'yyyy-mm-dd';
1103 } elseif (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d \d\d:\d\d(:\d\d)?$/', $allCellDataText)) {
1104 $formatting = NumberFormat::FORMAT_DATE_DATETIME_BETTER;
1105 } elseif (Preg::isMatch('/^\d\d?-[a-zA-Z]+-\d\d\d\d$/', $allCellDataText)) {
1106 $formatting = 'd-mmm-yyyy';
1107 } elseif ($dataValue != floor($dataValue) || str_contains($allCellDataText, ':')) {
1108 $formatting = $format15
1109 . ' '
1110 . NumberFormat::FORMAT_DATE_TIME4;
1111 } else {
1112 $formatting = $format15;
1113 }
1114
1115 break;
1116 case 'time':
1117 $type = DataType::TYPE_NUMERIC;
1118
1119 $timeValue = $cellData->getAttributeNS($officeNs, 'time-value');
1120 $minus = '';
1121 if (str_starts_with($timeValue, '-')) {
1122 $minus = '-';
1123 $timeValue = (string) substr($timeValue, 1);
1124 }
1125 $timeArray = sscanf($timeValue, 'PT%dH%dM%dS');
1126 if (is_array($timeArray)) {
1127 /** @var array{int, int, int} $timeArray */
1128 $days = intdiv($timeArray[0], 24);
1129 $hours = $timeArray[0] % 24;
1130 $dt = new DateTime("1899-12-30 $hours:{$timeArray[1]}:{$timeArray[2]}", new DateTimeZone('UTC'));
1131 $dt->modify("+$days days");
1132 $dataValue = Date::PHPToExcel($dt);
1133 if ($minus === '-') {
1134 $dataValue *= -1;
1135 $formatting = '[hh]:mm:ss';
1136 } else {
1137 $formatting = NumberFormat::FORMAT_DATE_TIME4;
1138 }
1139 }
1140
1141 break;
1142 default:
1143 $dataValue = null;
1144 }
1145 if ($customFormatting !== '') {
1146 $formatting = $customFormatting;
1147 }
1148 } else {
1149 $type = DataType::TYPE_NULL;
1150 $dataValue = null;
1151 }
1152
1153 if ($hasCalculatedValue) {
1154 $type = DataType::TYPE_FORMULA;
1155 $cellDataFormula = (string) substr($cellDataFormula, strpos($cellDataFormula, ':=') + 1);
1156 $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula);
1157 }
1158
1159 for ($i = 0; $i < $colRepeats; ++$i) {
1160 if ($i > 0) {
1161 StringHelper::stringIncrement($columnID);
1162 }
1163
1164 if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) {
1165 continue;
1166 }
1167
1168 if ($type !== DataType::TYPE_NULL) {
1169 for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) {
1170 $rID = $rowID + $rowAdjust;
1171
1172 $cell = $spreadsheet->getActiveSheet()
1173 ->getCell($columnID . $rID);
1174
1175 // Set value
1176 if ($hasCalculatedValue) {
1177 $cell->setValueExplicit($cellDataFormula, $type);
1178 if ($cellDataType === 'array') {
1179 $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]);
1180 }
1181 } elseif ($type !== '' || $dataValue !== null) {
1182 $cell->setValueExplicit($dataValue, $type);
1183 }
1184
1185 if ($hasCalculatedValue) {
1186 $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC);
1187 }
1188
1189 // Set other properties
1190 if ($formatting !== null) {
1191 $spreadsheet->getActiveSheet()
1192 ->getStyle($columnID . $rID)
1193 ->getNumberFormat()
1194 ->setFormatCode($formatting);
1195 } else {
1196 $spreadsheet->getActiveSheet()
1197 ->getStyle($columnID . $rID)
1198 ->getNumberFormat()
1199 ->setFormatCode(NumberFormat::FORMAT_GENERAL);
1200 }
1201
1202 if ($hyperlink !== null) {
1203 if ($hyperlink[0] === '#') {
1204 $hyperlink = 'sheet://' . substr($hyperlink, 1);
1205 }
1206 $cell->getHyperlink()
1207 ->setUrl($hyperlink);
1208 }
1209 }
1210 }
1211 }
1212
1213 // Merged cells
1214 $this->processMergedCells($cellData, $tableNs, $type, $columnID, $rowID, $spreadsheet);
1215
1216 StringHelper::stringIncrement($columnID);
1217 }
1218 $rowID += $rowRepeats;
1219 }
1220
1221 private function processDrawFrame(string $spannedRange, DOMElement $item, Worksheet $worksheet): void
1222 {
1223 $drawName = $item->getAttribute('draw:name');
1224 $svgWidth = $item->getAttribute('svg:width');
1225 $svgHeight = $item->getAttribute('svg:height');
1226 $styleName = $item->getAttribute('draw:style-name');
1227 $drawImage = null;
1228 foreach ($item->childNodes ?? [] as $node) {
1229 // Check if the node is a standard element tag
1230 if ($node->nodeType === XML_ELEMENT_NODE && $node->nodeName === 'draw:image') {
1231 /** @var DOMElement */
1232 $drawImage = $node;
1233
1234 break;
1235 }
1236 }
1237
1238 $xlinkHref = $xlinkType = $xlinkShow = '';
1239 if ($drawImage !== null) {
1240 $xlinkHref = $drawImage->getAttribute('xlink:href');
1241 $xlinkType = $drawImage->getAttribute('xlink:type');
1242 $xlinkShow = $drawImage->getAttribute('xlink:show');
1243 }
1244 if (
1245 $drawName !== ''
1246 && Preg::isMatch('/(\d+([.]\d+)?)(cm|in)/', $svgWidth, $matchWidth)
1247 && Preg::isMatch('/(\d+([.]\d+)?)(cm|in)/', $svgHeight, $matchHeight)
1248 //&& $styleName === 'gr1'
1249 && (str_starts_with($xlinkHref, 'Pictures/') || str_starts_with($xlinkHref, 'media/'))
1250 && $xlinkType === 'simple'
1251 && $xlinkShow === 'embed'
1252 ) {
1253 $drawing = new Drawing();
1254 $drawing->setPath(
1255 "zip://{$this->filename}#$xlinkHref",
1256 true,
1257 $this->zip,
1258 false
1259 );
1260 $unit = [
1261 'cm' => HelperDimension::ABSOLUTE_UNITS[HelperDimension::UOM_CENTIMETERS],
1262 'in' => HelperDimension::ABSOLUTE_UNITS[HelperDimension::UOM_INCHES],
1263 ];
1264 $width = ((float) $matchWidth[1]) * $unit[$matchWidth[3]];
1265 $height = ((float) $matchHeight[1]) * $unit[$matchHeight[3]];
1266 if ($drawing->getPath()) {
1267 $drawing->setCoordinates($spannedRange)
1268 ->setWidth((int) $width)
1269 ->setHeight((int) $height)
1270 ->setName($drawName)
1271 ->setWorksheet($worksheet);
1272 }
1273 }
1274 }
1275
1276 private static function extractNodeName(string $key): string
1277 {
1278 // Remove ns from node name
1279 if (str_contains($key, ':')) {
1280 $keyChunks = explode(':', $key);
1281 $key = array_pop($keyChunks);
1282 }
1283
1284 return $key;
1285 }
1286
1287 /**
1288 * @param string[] $columnWidths
1289 */
1290 private function processTableColumnHeader(
1291 DOMElement $childNode,
1292 string $tableNs,
1293 array $columnWidths,
1294 int &$tableColumnIndex,
1295 Spreadsheet $spreadsheet,
1296 bool $processWidths = true,
1297 bool $processStyles = true
1298 ): void {
1299 foreach ($childNode->childNodes ?? [] as $grandchildNode) {
1300 /** @var DOMElement $grandchildNode */
1301 $grandkey = self::extractNodeName($grandchildNode->nodeName);
1302 switch ($grandkey) {
1303 case 'table-column':
1304 $this->processTableColumn(
1305 $grandchildNode,
1306 $tableNs,
1307 $columnWidths,
1308 $tableColumnIndex,
1309 $spreadsheet,
1310 $processWidths,
1311 $processStyles
1312 );
1313
1314 break;
1315 }
1316 }
1317 }
1318
1319 /**
1320 * @param string[] $columnWidths
1321 */
1322 private function processTableColumnGroup(
1323 DOMElement $childNode,
1324 string $tableNs,
1325 array $columnWidths,
1326 int &$tableColumnIndex,
1327 Spreadsheet $spreadsheet,
1328 bool $processWidths = true,
1329 bool $processStyles = true
1330 ): void {
1331 foreach ($childNode->childNodes ?? [] as $grandchildNode) {
1332 /** @var DOMElement $grandchildNode */
1333 $grandkey = self::extractNodeName($grandchildNode->nodeName);
1334 switch ($grandkey) {
1335 case 'table-column':
1336 $this->processTableColumn(
1337 $grandchildNode,
1338 $tableNs,
1339 $columnWidths,
1340 $tableColumnIndex,
1341 $spreadsheet,
1342 $processWidths,
1343 $processStyles
1344 );
1345
1346 break;
1347 case 'table-header-columns':
1348 case 'table-columns':
1349 $this->processTableColumnHeader(
1350 $grandchildNode,
1351 $tableNs,
1352 $columnWidths,
1353 $tableColumnIndex,
1354 $spreadsheet,
1355 $processWidths,
1356 $processStyles
1357 );
1358
1359 break;
1360 case 'table-column-group':
1361 $this->processTableColumnGroup(
1362 $grandchildNode,
1363 $tableNs,
1364 $columnWidths,
1365 $tableColumnIndex,
1366 $spreadsheet,
1367 $processWidths,
1368 $processStyles
1369 );
1370
1371 break;
1372 }
1373 }
1374 }
1375
1376 /**
1377 * @param string[] $columnWidths
1378 */
1379 private function processTableColumn(
1380 DOMElement $childNode,
1381 string $tableNs,
1382 array $columnWidths,
1383 int &$tableColumnIndex,
1384 Spreadsheet $spreadsheet,
1385 bool $processWidths = true,
1386 bool $processStyles = true
1387 ): void {
1388 if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) {
1389 $colRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated');
1390 } else {
1391 $colRepeats = 1;
1392 }
1393 // called routine expects index to be 1 less than it is
1394 self::checkColumnsRepeatedInt($tableColumnIndex - 1, $colRepeats);
1395 $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name');
1396 if ($processWidths) {
1397 if (isset($columnWidths[$tableStyleName])) {
1398 $columnWidth = new HelperDimension($columnWidths[$tableStyleName]);
1399 $tableColumnIndex2 = $tableColumnIndex;
1400 $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
1401 for ($colRepeats2 = $colRepeats; $colRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$colRepeats2) {
1402 if (!$this->readEmptyCells && $tableColumnIndex2 > $this->highestDataIndex) {
1403 break;
1404 }
1405 $spreadsheet->getActiveSheet()
1406 ->getColumnDimension($tableColumnString)
1407 ->setWidth($columnWidth->toUnit('cm'), 'cm');
1408 StringHelper::stringIncrement(
1409 $tableColumnString
1410 );
1411 ++$tableColumnIndex2;
1412 }
1413 }
1414 }
1415 if ($processStyles) {
1416 $defaultStyleName = $childNode->getAttributeNS($tableNs, 'default-cell-style-name');
1417 if ($defaultStyleName !== 'Default' && isset($this->allStyles[$defaultStyleName])) {
1418 $tableColumnIndex2 = $tableColumnIndex;
1419 $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2);
1420 for ($colRepeats2 = $colRepeats; $colRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$colRepeats2) {
1421 $spreadsheet->getActiveSheet()
1422 ->getStyle($tableColumnString)
1423 ->applyFromArray(
1424 $this->allStyles[$defaultStyleName]
1425 );
1426 StringHelper::stringIncrement(
1427 $tableColumnString
1428 );
1429 ++$tableColumnIndex2;
1430 }
1431 }
1432 }
1433 $tableColumnIndex += $colRepeats;
1434 }
1435
1436 private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void
1437 {
1438 $dom = $this->loadDom('settings.xml', $zip);
1439 $configNs = (string) $dom->lookupNamespaceUri('config');
1440 $officeNs = (string) $dom->lookupNamespaceUri('office');
1441 $settings = $dom->getElementsByTagNameNS($officeNs, 'settings')
1442 ->item(0);
1443 if ($settings !== null) {
1444 $this->lookForActiveSheet($settings, $spreadsheet, $configNs);
1445 $this->lookForSelectedCells($settings, $spreadsheet, $configNs);
1446 }
1447 }
1448
1449 private function lookForActiveSheet(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
1450 {
1451 /** @var DOMElement $t */
1452 foreach ($settings->getElementsByTagNameNS($configNs, 'config-item') as $t) {
1453 if ($t->getAttributeNs($configNs, 'name') === 'ActiveTable') {
1454 try {
1455 $spreadsheet->setActiveSheetIndexByName($t->nodeValue ?? '');
1456 } catch (Throwable $exception) {
1457 // do nothing
1458 }
1459
1460 break;
1461 }
1462 }
1463 }
1464
1465 private function lookForSelectedCells(DOMElement $settings, Spreadsheet $spreadsheet, string $configNs): void
1466 {
1467 /** @var DOMElement $t */
1468 foreach ($settings->getElementsByTagNameNS($configNs, 'config-item-map-named') as $t) {
1469 if ($t->getAttributeNs($configNs, 'name') === 'Tables') {
1470 foreach ($t->getElementsByTagNameNS($configNs, 'config-item-map-entry') as $ws) {
1471 $setRow = $setCol = '';
1472 $wsname = $ws->getAttributeNs($configNs, 'name');
1473 foreach ($ws->getElementsByTagNameNS($configNs, 'config-item') as $configItem) {
1474 $attrName = $configItem->getAttributeNs($configNs, 'name');
1475 if ($attrName === 'CursorPositionX') {
1476 $setCol = $configItem->nodeValue;
1477 }
1478 if ($attrName === 'CursorPositionY') {
1479 $setRow = $configItem->nodeValue;
1480 }
1481 }
1482 $this->setSelected($spreadsheet, $wsname, "$setCol", "$setRow");
1483 }
1484
1485 break;
1486 }
1487 }
1488 }
1489
1490 private function setSelected(Spreadsheet $spreadsheet, string $wsname, string $setCol, string $setRow): void
1491 {
1492 if (is_numeric($setCol) && is_numeric($setRow)) {
1493 $sheet = $spreadsheet->getSheetByName($wsname);
1494 if ($sheet !== null) {
1495 $sheet->setSelectedCells([(int) $setCol + 1, (int) $setRow + 1]);
1496 }
1497 }
1498 }
1499
1500 /**
1501 * Recursively scan element.
1502 */
1503 protected function scanElementForText(DOMNode $element): string
1504 {
1505 $str = '';
1506 foreach ($element->childNodes ?? [] as $child) {
1507 /** @var DOMNode $child */
1508 if ($child->nodeType == XML_TEXT_NODE) {
1509 $str .= $child->nodeValue;
1510 } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:line-break') {
1511 $str .= "\n";
1512 } elseif ($child->nodeType == XML_ELEMENT_NODE && $child->nodeName == 'text:s') {
1513 // It's a space
1514
1515 // Multiple spaces?
1516 $attributes = $child->attributes;
1517 /** @var ?DOMAttr $cAttr */
1518 $cAttr = ($attributes === null) ? null : $attributes->getNamedItem('c');
1519 $multiplier = self::getMultiplier($cAttr);
1520 $str .= str_repeat(' ', $multiplier);
1521 }
1522
1523 if ($child->hasChildNodes()) {
1524 $str .= $this->scanElementForText($child);
1525 }
1526 }
1527
1528 return $str;
1529 }
1530
1531 private static function getMultiplier(?DOMAttr $cAttr): int
1532 {
1533 if ($cAttr) {
1534 $multiplier = (int) $cAttr->nodeValue;
1535 } else {
1536 $multiplier = 1;
1537 }
1538
1539 return $multiplier;
1540 }
1541
1542 private function parseRichText(string $is): RichText
1543 {
1544 $value = new RichText();
1545 $value->createText($is);
1546
1547 return $value;
1548 }
1549
1550 private function processMergedCells(
1551 DOMElement $cellData,
1552 string $tableNs,
1553 string $type,
1554 string $columnID,
1555 int $rowID,
1556 Spreadsheet $spreadsheet
1557 ): void {
1558 if (
1559 $cellData->hasAttributeNS($tableNs, 'number-columns-spanned')
1560 || $cellData->hasAttributeNS($tableNs, 'number-rows-spanned')
1561 ) {
1562 if (($type !== DataType::TYPE_NULL) || ($this->readDataOnly === false)) {
1563 $columnTo = $columnID;
1564
1565 if ($cellData->hasAttributeNS($tableNs, 'number-columns-spanned')) {
1566 $columnIndex = Coordinate::columnIndexFromString($columnID);
1567 $columnIndex += (int) $cellData->getAttributeNS($tableNs, 'number-columns-spanned');
1568 $columnIndex -= 2;
1569
1570 $columnTo = Coordinate::stringFromColumnIndex($columnIndex + 1);
1571 }
1572
1573 $rowTo = $rowID;
1574
1575 if ($cellData->hasAttributeNS($tableNs, 'number-rows-spanned')) {
1576 $rowTo = $rowTo + (int) $cellData->getAttributeNS($tableNs, 'number-rows-spanned') - 1;
1577 }
1578
1579 $cellRange = $columnID . $rowID . ':' . $columnTo . $rowTo;
1580 $spreadsheet->getActiveSheet()->mergeCells($cellRange, Worksheet::MERGE_CELL_CONTENT_HIDE);
1581 }
1582 }
1583 }
1584
1585 /** @var null|callable(string, string):string format callback routine */
1586 private $formatCallback;
1587
1588 /** @param callable(string, string):string $formatCallback format callback routine */
1589 public function setFormatCallback(callable $formatCallback): void
1590 {
1591 $this->formatCallback = $formatCallback;
1592 }
1593
1594 /** @return array{
1595 * autoColor?: true,
1596 * bold?: true,
1597 * color?: array{rgb: string},
1598 * italic?: true,
1599 * name?: non-empty-string,
1600 * size?: float|int,
1601 * strikethrough?: true,
1602 * underline?: 'double'|'single',
1603 * }
1604 */
1605 protected function getFontStyles(DOMElement $textProperty, string $styleNs, string $fontNs): array
1606 {
1607 $fonts = [];
1608 $temp = $textProperty->getAttributeNs($styleNs, 'font-name') ?: $textProperty->getAttributeNs($fontNs, 'font-family');
1609 if ($temp !== '') {
1610 $fonts['name'] = $temp;
1611 }
1612 $temp = $textProperty->getAttributeNs($fontNs, 'font-size');
1613 if ($temp !== '' && str_ends_with($temp, 'pt')) {
1614 $fonts['size'] = (float) substr($temp, 0, -2);
1615 }
1616 $temp = $textProperty->getAttributeNs($fontNs, 'font-style');
1617 if ($temp === 'italic') {
1618 $fonts['italic'] = true;
1619 }
1620 $temp = $textProperty->getAttributeNs($fontNs, 'font-weight');
1621 if ($temp === 'bold') {
1622 $fonts['bold'] = true;
1623 }
1624 $temp = $textProperty->getAttributeNs($fontNs, 'color');
1625 if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
1626 $fonts['color'] = ['rgb' => (string) substr($temp, 1)];
1627 }
1628 $temp = $textProperty->getAttributeNs($styleNs, 'use-window-font-color');
1629 if ($temp === 'true') {
1630 $fonts['autoColor'] = true;
1631 }
1632 $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-type');
1633 if ($temp === '') {
1634 $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-style');
1635 if ($temp !== '' && $temp !== 'none') {
1636 $temp = 'single';
1637 }
1638 }
1639 if ($temp === 'single' || $temp === 'double') {
1640 $fonts['underline'] = $temp;
1641 }
1642 $temp = $textProperty->getAttributeNs($styleNs, 'text-line-through-type');
1643 if ($temp !== '' && $temp !== 'none') {
1644 $fonts['strikethrough'] = true;
1645 }
1646
1647 return $fonts;
1648 }
1649
1650 /** @return array{
1651 * fillType?: string,
1652 * startColor?: array{rgb: string},
1653 * }
1654 */
1655 protected function getFillStyles(DOMElement $tableCellProperties, string $fontNs): array
1656 {
1657 $fills = [];
1658 $temp = $tableCellProperties->getAttributeNs($fontNs, 'background-color');
1659 if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) {
1660 $fills['fillType'] = Fill::FILL_SOLID;
1661 $fills['startColor'] = ['rgb' => (string) substr($temp, 1)];
1662 } elseif ($temp === 'transparent') {
1663 $fills['fillType'] = Fill::FILL_NONE;
1664 }
1665
1666 return $fills;
1667 }
1668
1669 private const MAP_VERTICAL = [
1670 'top' => Alignment::VERTICAL_TOP,
1671 'middle' => Alignment::VERTICAL_CENTER,
1672 'automatic' => Alignment::VERTICAL_JUSTIFY,
1673 'bottom' => Alignment::VERTICAL_BOTTOM,
1674 ];
1675 private const MAP_HORIZONTAL = [
1676 'center' => Alignment::HORIZONTAL_CENTER,
1677 'end' => Alignment::HORIZONTAL_RIGHT,
1678 'justify' => Alignment::HORIZONTAL_FILL,
1679 'start' => Alignment::HORIZONTAL_LEFT,
1680 ];
1681
1682 /** @return array{
1683 * shrinkToFit?: bool,
1684 * textRotation?: int,
1685 * vertical?: string,
1686 * wrapText?: bool,
1687 * }
1688 */
1689 protected function getAlignment1Styles(DOMElement $tableCellProperties, string $styleNs, string $fontNs): array
1690 {
1691 $alignment1 = [];
1692 $temp = $tableCellProperties->getAttributeNs($styleNs, 'rotation-angle');
1693 if (is_numeric($temp)) {
1694 $temp2 = (int) $temp;
1695 if ($temp2 > 90) {
1696 $temp2 -= 360;
1697 }
1698 if ($temp2 >= -90 && $temp2 <= 90) {
1699 $alignment1['textRotation'] = (int) $temp2;
1700 }
1701 }
1702 $temp = $tableCellProperties->getAttributeNs($styleNs, 'vertical-align');
1703 $temp2 = self::MAP_VERTICAL[$temp] ?? '';
1704 if ($temp2 !== '') {
1705 $alignment1['vertical'] = $temp2;
1706 }
1707 $temp = $tableCellProperties->getAttributeNs($fontNs, 'wrap-option');
1708 if ($temp === 'wrap') {
1709 $alignment1['wrapText'] = true;
1710 } elseif ($temp === 'no-wrap') {
1711 $alignment1['wrapText'] = false;
1712 }
1713 $temp = $tableCellProperties->getAttributeNs($styleNs, 'shrink-to-fit');
1714 if ($temp === 'true' || $temp === 'false') {
1715 $alignment1['shrinkToFit'] = $temp === 'true';
1716 }
1717
1718 return $alignment1;
1719 }
1720
1721 /** @return array{
1722 * horizontal?: string,
1723 * readOrder?: int,
1724 * indent?: int,
1725 * }
1726 */
1727 protected function getAlignment2Styles(DOMElement $paragraphProperties, string $styleNs, string $fontNs): array
1728 {
1729 $alignment2 = [];
1730 $temp = $paragraphProperties->getAttributeNs($fontNs, 'text-align');
1731 $temp2 = self::MAP_HORIZONTAL[$temp] ?? '';
1732 if ($temp2 !== '') {
1733 $alignment2['horizontal'] = $temp2;
1734 }
1735 $temp = $paragraphProperties->getAttributeNs($fontNs, 'margin-left') ?: $paragraphProperties->getAttributeNs($fontNs, 'margin-right');
1736 if (Preg::isMatch('/^\d+([.]\d+)?(cm|in|mm|pt)$/', $temp)) {
1737 $dimension = new HelperDimension($temp);
1738 $alignment2['indent'] = (int) round($dimension->toUnit('px') / Alignment::INDENT_UNITS_TO_PIXELS);
1739 }
1740
1741 $temp = $paragraphProperties->getAttributeNs($styleNs, 'writing-mode');
1742 if ($temp === 'rl-tb') {
1743 $alignment2['readOrder'] = Alignment::READORDER_RTL;
1744 } elseif ($temp === 'lr-tb') {
1745 $alignment2['readOrder'] = Alignment::READORDER_LTR;
1746 }
1747
1748 return $alignment2;
1749 }
1750
1751 /** @return array{
1752 * locked?: string,
1753 * hidden?: string,
1754 * }
1755 */
1756 protected function getProtectionStyles(DOMElement $tableCellProperties, string $styleNs): array
1757 {
1758 $protection = [];
1759 $temp = $tableCellProperties->getAttributeNs($styleNs, 'cell-protect');
1760 switch ($temp) {
1761 case 'protected formula-hidden':
1762 $protection['locked'] = Protection::PROTECTION_PROTECTED;
1763 $protection['hidden'] = Protection::PROTECTION_PROTECTED;
1764
1765 break;
1766 case 'formula-hidden':
1767 $protection['locked'] = Protection::PROTECTION_UNPROTECTED;
1768 $protection['hidden'] = Protection::PROTECTION_PROTECTED;
1769
1770 break;
1771 case 'protected':
1772 $protection['locked'] = Protection::PROTECTION_PROTECTED;
1773 $protection['hidden'] = Protection::PROTECTION_UNPROTECTED;
1774
1775 break;
1776 case 'none':
1777 $protection['locked'] = Protection::PROTECTION_UNPROTECTED;
1778 $protection['hidden'] = Protection::PROTECTION_UNPROTECTED;
1779
1780 break;
1781 }
1782
1783 return $protection;
1784 }
1785
1786 private const MAP_BORDER_STYLE = [ // default BORDER_THIN
1787 'none' => Border::BORDER_NONE,
1788 'hidden' => Border::BORDER_NONE,
1789 'dotted' => Border::BORDER_DOTTED,
1790 'dash-dot' => Border::BORDER_DASHDOT,
1791 'dash-dot-dot' => Border::BORDER_DASHDOTDOT,
1792 'dashed' => Border::BORDER_DASHED,
1793 'double' => Border::BORDER_DOUBLE,
1794 ];
1795
1796 private const MAP_BORDER_MEDIUM = [
1797 Border::BORDER_THIN => Border::BORDER_MEDIUM,
1798 Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
1799 Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
1800 Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
1801 ];
1802
1803 private const MAP_BORDER_THICK = [
1804 Border::BORDER_THIN => Border::BORDER_THICK,
1805 Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT,
1806 Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT,
1807 Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED,
1808 ];
1809
1810 /** @return array{
1811 * bottom?: array{borderStyle:string, color:array{rgb: string}},
1812 * top?: array{borderStyle:string, color:array{rgb: string}},
1813 * left?: array{borderStyle:string, color:array{rgb: string}},
1814 * right?: array{borderStyle:string, color:array{rgb: string}},
1815 * diagonal?: array{borderStyle:string, color:array{rgb: string}},
1816 * diagonalDirection?: int,
1817 * }
1818 */
1819 protected function getBorderStyles(DOMElement $tableCellProperties, string $fontNs, string $styleNs): array
1820 {
1821 $borders = [];
1822 $temp = $tableCellProperties->getAttributeNs($fontNs, 'border');
1823 $diagonalIndex = Borders::DIAGONAL_NONE;
1824 foreach (['bottom', 'left', 'right', 'top', 'diagonal-tl-br', 'diagonal-bl-tr'] as $direction) {
1825 if ($direction === 'diagonal-tl-br' || $direction === 'diagonal-bl-tr') {
1826 $directionIndex = 'diagonal';
1827 $temp = $tableCellProperties->getAttributeNs($styleNs, $direction);
1828 } else {
1829 $directionIndex = $direction;
1830 $temp = $tableCellProperties->getAttributeNs($fontNs, "border-$direction");
1831 }
1832 if (Preg::isMatch('/^(\d+(?:[.]\d+)?)pt\s+([-\w]+)\s+#([0-9a-fA-F]{6})$/', $temp, $matches)) {
1833 $style = self::MAP_BORDER_STYLE[$matches[2]] ?? Border::BORDER_THIN;
1834 $width = (float) $matches[1];
1835 if ($width >= 2.5) {
1836 $style = self::MAP_BORDER_THICK[$style] ?? $style;
1837 } elseif ($width >= 1.75) {
1838 $style = self::MAP_BORDER_MEDIUM[$style] ?? $style;
1839 }
1840 $color = $matches[3];
1841 $borders[$directionIndex] = ['borderStyle' => $style, 'color' => ['rgb' => $matches[3]]];
1842 if ($direction === 'diagonal-tl-br') {
1843 $diagonalIndex = Borders::DIAGONAL_DOWN;
1844 } elseif ($direction === 'diagonal-bl-tr') {
1845 $diagonalIndex = ($diagonalIndex === Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_UP : Borders::DIAGONAL_BOTH;
1846 }
1847 }
1848 }
1849 if ($diagonalIndex !== Borders::DIAGONAL_NONE) {
1850 $borders['diagonalDirection'] = $diagonalIndex;
1851 }
1852
1853 return $borders;
1854 }
1855
1856 protected function processSomeNumberFormats(?DOMElement $automaticStyle0, string $numberNs, string $styleNs): void
1857 {
1858 $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($numberNs, 'number-style');
1859 foreach ($automaticStyles as $automaticStyle) {
1860 $this->processNumberNumber($automaticStyle, $numberNs, $styleNs);
1861 }
1862 }
1863
1864 protected function processNumberNumber(DOMElement $automaticStyle, string $numberNs, string $styleNs): void
1865 {
1866 $styleName = $automaticStyle->getAttributeNS($styleNs, 'name');
1867 foreach ($automaticStyle->getElementsByTagNameNS($numberNs, 'number') as $numberNumber) {
1868 $decimalPlaces = $numberNumber->getAttributeNs($numberNs, 'decimal-places');
1869 $minIntegerDigits = (int) $numberNumber->getAttributeNs($numberNs, 'min-integer-digits');
1870 if ($decimalPlaces === '0' && $minIntegerDigits > 1) {
1871 $this->numberFormats[$styleName] = str_repeat('0', $minIntegerDigits);
1872 }
1873 }
1874 }
1875
1876 private static function checkRowsRepeated(int $rowID, int $rowRepeats): void
1877 {
1878 if ($rowRepeats < 1 || $rowID + $rowRepeats - 1 > AddressRange::MAX_ROW) {
1879 throw new Exception("Invalid number-rows-repeated $rowRepeats following row $rowID");
1880 }
1881 }
1882
1883 private static function checkColumnsRepeated(string $colID, int $colRepeats): void
1884 {
1885 $colIndex = Coordinate::columnIndexFromString($colID);
1886 if ($colRepeats < 1 || $colIndex + $colRepeats - 1 > AddressRange::MAX_COLUMN_INT) {
1887 throw new Exception("Invalid number-columns-repeated $colRepeats following column $colID");
1888 }
1889 }
1890
1891 private static function checkColumnsRepeatedInt(int $colIndex, int $colRepeats): void
1892 {
1893 // We don't have column string at this point,
1894 // and colIndex is actually 1 less than it should be.
1895 if ($colRepeats < 1 || $colIndex + $colRepeats > AddressRange::MAX_COLUMN_INT) {
1896 throw new Exception("Invalid number-columns-repeated $colRepeats following column index $colIndex");
1897 }
1898 }
1899
1900 private function loadDom(string $file, ZipArchive $zip): DOMDocument
1901 {
1902 $dom = new DOMDocument('1.01', 'UTF-8');
1903 $orig = false;
1904
1905 try {
1906 $orig = libxml_use_internal_errors(true);
1907 $result = $dom->loadXML(
1908 $this->getSecurityScannerOrThrow()
1909 ->scan($zip->getFromName($file))
1910 );
1911 if ($result === false) {
1912 $fatal = false;
1913 foreach (libxml_get_errors() as $err) {
1914 if ($err->level === LIBXML_ERR_FATAL) {
1915 throw new Exception($err->message);
1916 }
1917 }
1918 }
1919 } finally {
1920 libxml_clear_errors();
1921 libxml_use_internal_errors($orig);
1922 }
1923
1924 return $dom;
1925 }
1926 }
1927