| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace TablePress\PhpOffice\PhpSpreadsheet\Reader; |
| 4 | 4 | |
| 5 | 5 | use TablePress\Composer\Pcre\Preg; |
| 6 | +use InvalidArgumentException; | |
| 6 | 7 | use TablePress\PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; |
| 7 | 8 | use TablePress\PhpOffice\PhpSpreadsheet\Cell\Coordinate; |
| 8 | 9 | use TablePress\PhpOffice\PhpSpreadsheet\Cell\DataType; |
| 9 | 10 | use TablePress\PhpOffice\PhpSpreadsheet\Cell\Hyperlink; |
| @@ -17,12 +18,14 @@ | ||
| 17 | 18 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\DataValidations; |
| 18 | 19 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Hyperlinks; |
| 19 | 20 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; |
| 20 | 21 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\PageSetup; |
| 22 | +use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\PivotTableReader; | |
| 21 | 23 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Properties as PropertyReader; |
| 22 | 24 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\SharedFormula; |
| 23 | 25 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\SheetViewOptions; |
| 24 | 26 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\SheetViews; |
| 27 | +use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Sparklines; | |
| 25 | 28 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Styles; |
| 26 | 29 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\TableReader; |
| 27 | 30 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\Theme; |
| 28 | 31 | use TablePress\PhpOffice\PhpSpreadsheet\Reader\Xlsx\WorkbookView; |
| @@ -31,9 +34,11 @@ | ||
| 31 | 34 | use TablePress\PhpOffice\PhpSpreadsheet\Shared\Date; |
| 32 | 35 | use TablePress\PhpOffice\PhpSpreadsheet\Shared\Drawing; |
| 33 | 36 | use TablePress\PhpOffice\PhpSpreadsheet\Shared\File; |
| 34 | 37 | use TablePress\PhpOffice\PhpSpreadsheet\Shared\Font; |
| 38 | +use TablePress\PhpOffice\PhpSpreadsheet\Shared\OLE; | |
| 35 | 39 | use TablePress\PhpOffice\PhpSpreadsheet\Shared\StringHelper; |
| 40 | +use TablePress\PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption; | |
| 36 | 41 | use TablePress\PhpOffice\PhpSpreadsheet\Spreadsheet; |
| 37 | 42 | use TablePress\PhpOffice\PhpSpreadsheet\Style\Color; |
| 38 | 43 | use TablePress\PhpOffice\PhpSpreadsheet\Style\Font as StyleFont; |
| 39 | 44 | use TablePress\PhpOffice\PhpSpreadsheet\Style\NumberFormat; |
| @@ -52,19 +57,40 @@ | ||
| 52 | 57 | |
| 53 | 58 | /** |
| 54 | 59 | * ReferenceHelper instance. |
| 55 | 60 | */ |
| 56 | - private ReferenceHelper $referenceHelper; | |
| 61 | + protected ReferenceHelper $referenceHelper; | |
| 57 | 62 | |
| 58 | - private ZipArchive $zip; | |
| 63 | + protected ZipArchive $zip; | |
| 59 | 64 | |
| 60 | 65 | private Styles $styleReader; |
| 61 | 66 | |
| 62 | 67 | /** @var SharedFormula[] */ |
| 63 | - private array $sharedFormulae = []; | |
| 68 | + protected array $sharedFormulae = []; | |
| 64 | 69 | |
| 65 | - private bool $parseHuge = false; | |
| 70 | + protected bool $parseHuge = false; | |
| 66 | 71 | |
| 72 | + private string $encryptionPassword = ''; | |
| 73 | + | |
| 74 | + private int $maxEncryptionSpinCount = AgileEncryption::MAX_SPIN_COUNT; | |
| 75 | + | |
| 76 | + public function setEncryptionPassword(string $encryptionPassword): self | |
| 77 | + { | |
| 78 | + $this->encryptionPassword = $encryptionPassword; | |
| 79 | + | |
| 80 | + return $this; | |
| 81 | + } | |
| 82 | + | |
| 83 | + public function setMaxEncryptionSpinCount(int $maxEncryptionSpinCount): self | |
| 84 | + { | |
| 85 | + if ($maxEncryptionSpinCount < 0 || $maxEncryptionSpinCount > AgileEncryption::MAX_SPIN_COUNT) { | |
| 86 | + throw new InvalidArgumentException('Maximum encryption spin count must be between 0 and ' . AgileEncryption::MAX_SPIN_COUNT . '.'); | |
| 87 | + } | |
| 88 | + $this->maxEncryptionSpinCount = $maxEncryptionSpinCount; | |
| 89 | + | |
| 90 | + return $this; | |
| 91 | + } | |
| 92 | + | |
| 67 | 93 | /** |
| 68 | 94 | * Allow use of LIBXML_PARSEHUGE. |
| 69 | 95 | * This option can lead to memory leaks and failures, |
| 70 | 96 | * and is not recommended. But some very large spreadsheets |
| @@ -90,9 +116,9 @@ | ||
| 90 | 116 | */ |
| 91 | 117 | public function canRead(string $filename): bool |
| 92 | 118 | { |
| 93 | 119 | if (!File::testFileNoThrow($filename, self::INITIAL_FILE)) { |
| 94 | - return false; | |
| 120 | + return $this->hasEncryptedPackage($filename); | |
| 95 | 121 | } |
| 96 | 122 | |
| 97 | 123 | $result = false; |
| 98 | 124 | $this->zip = $zip = new ZipArchive(); |
| @@ -106,8 +132,77 @@ | ||
| 106 | 132 | |
| 107 | 133 | return $result; |
| 108 | 134 | } |
| 109 | 135 | |
| 136 | + public function load(string $filename, int $flags = 0): Spreadsheet | |
| 137 | + { | |
| 138 | + $temporaryFilename = $this->decryptToTemporaryFile($filename); | |
| 139 | + if ($temporaryFilename === null) { | |
| 140 | + return parent::load($filename, $flags); | |
| 141 | + } | |
| 142 | + | |
| 143 | + try { | |
| 144 | + return parent::load($temporaryFilename, $flags); | |
| 145 | + } finally { | |
| 146 | + @unlink($temporaryFilename); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + private function hasEncryptedPackage(string $filename): bool | |
| 151 | + { | |
| 152 | + try { | |
| 153 | + $ole = new OLE(); | |
| 154 | + $ole->read($filename); | |
| 155 | + | |
| 156 | + return $ole->hasDataByName('EncryptionInfo') && $ole->hasDataByName('EncryptedPackage'); | |
| 157 | + } catch (Throwable $exception) { | |
| 158 | + return false; | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + private function decryptToTemporaryFile(string $filename): ?string | |
| 163 | + { | |
| 164 | + if (File::testFileNoThrow($filename, self::INITIAL_FILE)) { | |
| 165 | + return null; | |
| 166 | + } | |
| 167 | + | |
| 168 | + try { | |
| 169 | + $ole = new OLE(); | |
| 170 | + $ole->read($filename); | |
| 171 | + $encryptionInfo = $ole->getDataByName('EncryptionInfo'); | |
| 172 | + } catch (Throwable $exception) { | |
| 173 | + return null; | |
| 174 | + } | |
| 175 | + | |
| 176 | + $temporaryFilename = File::temporaryFilename(); | |
| 177 | + $encryptedPackageFilename = File::temporaryFilename(); | |
| 178 | + $encryptedPackage = fopen($encryptedPackageFilename, 'wb'); | |
| 179 | + if ($encryptedPackage === false) { | |
| 180 | + @unlink($temporaryFilename); | |
| 181 | + @unlink($encryptedPackageFilename); | |
| 182 | + | |
| 183 | + throw new Exception('Could not create decrypted XLSX package.'); | |
| 184 | + } | |
| 185 | + | |
| 186 | + try { | |
| 187 | + $ole->copyDataByName('EncryptedPackage', $encryptedPackage); | |
| 188 | + fclose($encryptedPackage); | |
| 189 | + $encryptedPackage = null; | |
| 190 | + AgileEncryption::decryptFile(AgileEncryption::parse($encryptionInfo, $this->maxEncryptionSpinCount), $encryptedPackageFilename, $temporaryFilename, $this->encryptionPassword); | |
| 191 | + } catch (Throwable $e) { | |
| 192 | + if ($encryptedPackage !== null) { | |
| 193 | + fclose($encryptedPackage); | |
| 194 | + } | |
| 195 | + @unlink($temporaryFilename); | |
| 196 | + | |
| 197 | + throw $e; | |
| 198 | + } finally { | |
| 199 | + @unlink($encryptedPackageFilename); | |
| 200 | + } | |
| 201 | + | |
| 202 | + return $temporaryFilename; | |
| 203 | + } | |
| 204 | + | |
| 110 | 205 | /** |
| 111 | 206 | * @param mixed $value |
| 112 | 207 | */ |
| 113 | 208 | public static function testSimpleXml($value): SimpleXMLElement |
| @@ -184,8 +279,23 @@ | ||
| 184 | 279 | * @return string[] |
| 185 | 280 | */ |
| 186 | 281 | public function listWorksheetNames(string $filename): array |
| 187 | 282 | { |
| 283 | + $temporaryFilename = $this->decryptToTemporaryFile($filename); | |
| 284 | + if ($temporaryFilename === null) { | |
| 285 | + return $this->listWorksheetNamesFromFile($filename); | |
| 286 | + } | |
| 287 | + | |
| 288 | + try { | |
| 289 | + return $this->listWorksheetNamesFromFile($temporaryFilename); | |
| 290 | + } finally { | |
| 291 | + @unlink($temporaryFilename); | |
| 292 | + } | |
| 293 | + } | |
| 294 | + | |
| 295 | + /** @return string[] */ | |
| 296 | + private function listWorksheetNamesFromFile(string $filename): array | |
| 297 | + { | |
| 188 | 298 | File::assertFile($filename, self::INITIAL_FILE); |
| 189 | 299 | |
| 190 | 300 | $worksheetNames = []; |
| 191 | 301 | |
| @@ -221,8 +331,25 @@ | ||
| 221 | 331 | * @return array<int, array{worksheetName: string, lastColumnLetter: string, lastColumnIndex: int, totalRows: int, totalColumns: int, sheetState: string}> |
| 222 | 332 | */ |
| 223 | 333 | public function listWorksheetInfo(string $filename): array |
| 224 | 334 | { |
| 335 | + $temporaryFilename = $this->decryptToTemporaryFile($filename); | |
| 336 | + if ($temporaryFilename === null) { | |
| 337 | + return $this->listWorksheetInfoFromFile($filename); | |
| 338 | + } | |
| 339 | + | |
| 340 | + try { | |
| 341 | + return $this->listWorksheetInfoFromFile($temporaryFilename); | |
| 342 | + } finally { | |
| 343 | + @unlink($temporaryFilename); | |
| 344 | + } | |
| 345 | + } | |
| 346 | + | |
| 347 | + /** | |
| 348 | + * @return array<int, array{worksheetName: string, lastColumnLetter: string, lastColumnIndex: int, totalRows: int, totalColumns: int, sheetState: string}> | |
| 349 | + */ | |
| 350 | + private function listWorksheetInfoFromFile(string $filename): array | |
| 351 | + { | |
| 225 | 352 | File::assertFile($filename, self::INITIAL_FILE); |
| 226 | 353 | |
| 227 | 354 | $worksheetInfo = []; |
| 228 | 355 | |
| @@ -322,9 +449,9 @@ | ||
| 322 | 449 | |
| 323 | 450 | return $worksheetInfo; |
| 324 | 451 | } |
| 325 | 452 | |
| 326 | - private static function castToBoolean(SimpleXMLElement $c): bool | |
| 453 | + protected static function castToBoolean(SimpleXMLElement $c): bool | |
| 327 | 454 | { |
| 328 | 455 | $value = isset($c->v) ? (string) $c->v : null; |
| 329 | 456 | if ($value == '0') { |
| 330 | 457 | return false; |
| @@ -334,14 +461,14 @@ | ||
| 334 | 461 | |
| 335 | 462 | return (bool) $c->v; |
| 336 | 463 | } |
| 337 | 464 | |
| 338 | - private static function castToError(?SimpleXMLElement $c): ?string | |
| 465 | + protected static function castToError(?SimpleXMLElement $c): ?string | |
| 339 | 466 | { |
| 340 | 467 | return isset($c, $c->v) ? (string) $c->v : null; |
| 341 | 468 | } |
| 342 | 469 | |
| 343 | - private static function castToString(?SimpleXMLElement $c): ?string | |
| 470 | + protected static function castToString(?SimpleXMLElement $c): ?string | |
| 344 | 471 | { |
| 345 | 472 | return isset($c, $c->v) ? (string) $c->v : null; |
| 346 | 473 | } |
| 347 | 474 | |
| @@ -353,9 +480,9 @@ | ||
| 353 | 480 | /** |
| 354 | 481 | * @param mixed $value |
| 355 | 482 | * @param mixed $calculatedValue |
| 356 | 483 | */ |
| 357 | - private function castToFormula(?SimpleXMLElement $c, string $r, string &$cellDataType, &$value, &$calculatedValue, string $castBaseType, bool $updateSharedCells = true): void | |
| 484 | + protected function castToFormula(?SimpleXMLElement $c, string $r, string &$cellDataType, &$value, &$calculatedValue, string $castBaseType, bool $updateSharedCells = true): void | |
| 358 | 485 | { |
| 359 | 486 | if ($c === null) { |
| 360 | 487 | return; |
| 361 | 488 | } |
| @@ -405,9 +532,9 @@ | ||
| 405 | 532 | |
| 406 | 533 | return $contents !== false; |
| 407 | 534 | } |
| 408 | 535 | |
| 409 | - private function getFromZipArchive(ZipArchive $archive, string $fileName = ''): string | |
| 536 | + protected function getFromZipArchive(ZipArchive $archive, string $fileName = ''): string | |
| 410 | 537 | { |
| 411 | 538 | // Root-relative paths |
| 412 | 539 | if (str_contains($fileName, '//')) { |
| 413 | 540 | $fileName = (string) substr($fileName, strpos($fileName, '//') + 1); |
| @@ -586,8 +713,9 @@ | ||
| 586 | 713 | $relsWorkbook = $this->loadZip("$dir/_rels/" . basename($relTarget) . '.rels', Namespaces::RELATIONSHIPS); |
| 587 | 714 | $relsWorkbook->registerXPathNamespace('rel', Namespaces::RELATIONSHIPS); |
| 588 | 715 | |
| 589 | 716 | $worksheets = []; |
| 717 | + $pivotCacheRels = []; | |
| 590 | 718 | $macros = $customUI = null; |
| 591 | 719 | foreach ($relsWorkbook->Relationship as $elex) { |
| 592 | 720 | $ele = self::getAttributes($elex); |
| 593 | 721 | switch ($ele['Type']) { |
| @@ -601,8 +729,12 @@ | ||
| 601 | 729 | $worksheets[(string) $ele['Id']] = $ele['Target']; |
| 602 | 730 | } |
| 603 | 731 | |
| 604 | 732 | break; |
| 733 | + case Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION: | |
| 734 | + $pivotCacheRels[(string) $ele['Id']] = File::realpath("$dir/" . (string) $ele['Target']); | |
| 735 | + | |
| 736 | + break; | |
| 605 | 737 | // a vbaProject ? (: some macros) |
| 606 | 738 | case Namespaces::VBA: |
| 607 | 739 | $macros = $ele['Target']; |
| 608 | 740 | |
| @@ -897,190 +1029,28 @@ | ||
| 897 | 1029 | $sheetViewOptions = new SheetViewOptions($docSheet, $xmlSheetNS); |
| 898 | 1030 | $sheetViewOptions->load($this->readDataOnly, $this->styleReader); |
| 899 | 1031 | |
| 900 | 1032 | (new ColumnAndRowAttributes($docSheet, $xmlSheetNS)) |
| 901 | - ->load($this->getReadFilter(), $this->readDataOnly, $this->ignoreRowsWithNoCells); | |
| 1033 | + ->load($this->readFilter, $this->readDataOnly, $this->ignoreRowsWithNoCells); | |
| 902 | 1034 | } |
| 903 | 1035 | |
| 904 | 1036 | $holdSelectedCells = $docSheet->getSelectedCells(); |
| 905 | - if ($xmlSheetNS && $xmlSheetNS->sheetData && $xmlSheetNS->sheetData->row) { | |
| 906 | - $cIndex = 1; // Cell Start from 1 | |
| 907 | - foreach ($xmlSheetNS->sheetData->row as $row) { | |
| 908 | - $rowIndex = 1; | |
| 909 | - foreach ($row->c as $c) { | |
| 910 | - $cAttr = self::getAttributes($c); | |
| 911 | - $r = (string) $cAttr['r']; | |
| 912 | - if ($r == '') { | |
| 913 | - $r = Coordinate::stringFromColumnIndex($rowIndex) . $cIndex; | |
| 914 | - } | |
| 915 | - $cellDataType = (string) $cAttr['t']; | |
| 916 | - $originalCellDataTypeNumeric = $cellDataType === ''; | |
| 917 | - $value = null; | |
| 918 | - $calculatedValue = null; | |
| 1037 | + /** @var array<object> $styles */ | |
| 1038 | + $this->loadSheetData( | |
| 1039 | + $xmlSheetNS, | |
| 1040 | + $filename, | |
| 1041 | + $dir, | |
| 1042 | + $richData, | |
| 1043 | + $docSheet, | |
| 1044 | + $sharedStrings, | |
| 1045 | + $styles, | |
| 1046 | + [ | |
| 1047 | + // this array can be expanded with additional entries to make it easier to extend | |
| 1048 | + 'mainNS' => $mainNS, | |
| 1049 | + 'fileWorksheetPath' => "$dir/$fileWorksheet", | |
| 1050 | + ], | |
| 1051 | + ); | |
| 919 | 1052 | |
| 920 | - // Read cell? | |
| 921 | - $coordinates = Coordinate::coordinateFromString($r); | |
| 922 | - | |
| 923 | - if (!$this->getReadFilter()->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { | |
| 924 | - // Normally, just testing for the f attribute should identify this cell as containing a formula | |
| 925 | - // that we need to read, even though it is outside of the filter range, in case it is a shared formula. | |
| 926 | - // But in some cases, this attribute isn't set; so we need to delve a level deeper and look at | |
| 927 | - // whether or not the cell has a child formula element that is shared. | |
| 928 | - if (isset($cAttr->f) || (isset($c->f, $c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')) { | |
| 929 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError', false); | |
| 930 | - } | |
| 931 | - ++$rowIndex; | |
| 932 | - | |
| 933 | - continue; | |
| 934 | - } | |
| 935 | - | |
| 936 | - // Read cell! | |
| 937 | - $useFormula = isset($c->f) | |
| 938 | - && ((string) $c->f !== '' || (isset($c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')); | |
| 939 | - switch ($cellDataType) { | |
| 940 | - case DataType::TYPE_STRING: | |
| 941 | - if ((string) $c->v != '') { | |
| 942 | - $value = $sharedStrings[(int) ($c->v)]; | |
| 943 | - | |
| 944 | - if ($value instanceof RichText) { | |
| 945 | - $value = clone $value; | |
| 946 | - } | |
| 947 | - } else { | |
| 948 | - $value = ''; | |
| 949 | - } | |
| 950 | - | |
| 951 | - break; | |
| 952 | - case DataType::TYPE_BOOL: | |
| 953 | - if (!$useFormula) { | |
| 954 | - if (isset($c->v)) { | |
| 955 | - $value = self::castToBoolean($c); | |
| 956 | - } else { | |
| 957 | - $value = null; | |
| 958 | - $cellDataType = DataType::TYPE_NULL; | |
| 959 | - } | |
| 960 | - } else { | |
| 961 | - // Formula | |
| 962 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToBoolean'); | |
| 963 | - self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 964 | - } | |
| 965 | - | |
| 966 | - break; | |
| 967 | - case DataType::TYPE_STRING2: | |
| 968 | - if ($useFormula) { | |
| 969 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); | |
| 970 | - self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 971 | - } else { | |
| 972 | - $value = self::castToString($c); | |
| 973 | - } | |
| 974 | - | |
| 975 | - break; | |
| 976 | - case DataType::TYPE_INLINE: | |
| 977 | - if ($useFormula) { | |
| 978 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); | |
| 979 | - self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 980 | - } else { | |
| 981 | - $value = $this->parseRichText($c->is); | |
| 982 | - } | |
| 983 | - | |
| 984 | - break; | |
| 985 | - case DataType::TYPE_ERROR: | |
| 986 | - if (isset($cAttr->vm, $richData['image']['rId' . $cAttr->vm]) && !$useFormula) { | |
| 987 | - $imagePath = $dir . '/' . str_replace('../', '', $richData['image']['rId' . $cAttr->vm]); | |
| 988 | - $objDrawing = new \TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Drawing(); | |
| 989 | - $objDrawing->setPath( | |
| 990 | - 'zip://' . File::realpath($filename) . '#' . $imagePath, | |
| 991 | - false, | |
| 992 | - $zip | |
| 993 | - ); | |
| 994 | - | |
| 995 | - $objDrawing->setCoordinates($r); | |
| 996 | - $objDrawing->setResizeProportional(false); | |
| 997 | - $objDrawing->setInCell(true); | |
| 998 | - $objDrawing->setWorksheet($docSheet); | |
| 999 | - | |
| 1000 | - $value = $objDrawing; | |
| 1001 | - $cellDataType = DataType::TYPE_DRAWING_IN_CELL; | |
| 1002 | - $c->t = DataType::TYPE_ERROR; | |
| 1003 | - | |
| 1004 | - break; | |
| 1005 | - } | |
| 1006 | - | |
| 1007 | - if (!$useFormula) { | |
| 1008 | - $value = self::castToError($c); | |
| 1009 | - } else { | |
| 1010 | - // Formula | |
| 1011 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); | |
| 1012 | - $eattr = $c->attributes(); | |
| 1013 | - if (isset($eattr['vm'])) { | |
| 1014 | - if ($calculatedValue === ExcelError::VALUE()) { | |
| 1015 | - $calculatedValue = ExcelError::SPILL(); | |
| 1016 | - } | |
| 1017 | - } | |
| 1018 | - } | |
| 1019 | - | |
| 1020 | - break; | |
| 1021 | - default: | |
| 1022 | - if (!$useFormula) { | |
| 1023 | - $value = self::castToString($c); | |
| 1024 | - if (is_numeric($value)) { | |
| 1025 | - $value += 0; | |
| 1026 | - $cellDataType = DataType::TYPE_NUMERIC; | |
| 1027 | - } | |
| 1028 | - } else { | |
| 1029 | - // Formula | |
| 1030 | - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); | |
| 1031 | - if (is_numeric($calculatedValue)) { | |
| 1032 | - $calculatedValue += 0; | |
| 1033 | - } | |
| 1034 | - self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 1035 | - } | |
| 1036 | - | |
| 1037 | - break; | |
| 1038 | - } | |
| 1039 | - | |
| 1040 | - // read empty cells or the cells are not empty | |
| 1041 | - if ($this->readEmptyCells || ($value !== null && $value !== '')) { | |
| 1042 | - // Rich text? | |
| 1043 | - if ($value instanceof RichText && $this->readDataOnly) { | |
| 1044 | - $value = $value->getPlainText(); | |
| 1045 | - } | |
| 1046 | - | |
| 1047 | - $cell = $docSheet->getCell($r); | |
| 1048 | - // Assign value | |
| 1049 | - if ($cellDataType != '') { | |
| 1050 | - // it is possible, that datatype is numeric but with an empty string, which result in an error | |
| 1051 | - if ($cellDataType === DataType::TYPE_NUMERIC && ($value === '' || $value === null)) { | |
| 1052 | - $cellDataType = DataType::TYPE_NULL; | |
| 1053 | - } | |
| 1054 | - if ($cellDataType !== DataType::TYPE_NULL) { | |
| 1055 | - $cell->setValueExplicit($value, $cellDataType); | |
| 1056 | - } | |
| 1057 | - } else { | |
| 1058 | - $cell->setValue($value); | |
| 1059 | - } | |
| 1060 | - if ($calculatedValue !== null) { | |
| 1061 | - $cell->setCalculatedValue($calculatedValue, $originalCellDataTypeNumeric); | |
| 1062 | - } | |
| 1063 | - | |
| 1064 | - // Style information? | |
| 1065 | - if (!$this->readDataOnly) { | |
| 1066 | - $cAttrS = (int) ($cAttr['s'] ?? 0); | |
| 1067 | - // no style index means 0, it seems | |
| 1068 | - $cAttrS = isset($styles[$cAttrS]) ? $cAttrS : 0; | |
| 1069 | - $cell->setXfIndex($cAttrS); | |
| 1070 | - // issue 3495 | |
| 1071 | - if ($cellDataType === DataType::TYPE_FORMULA && $styles[$cAttrS]->quotePrefix === true) { //* @phpstan-ignore-line | |
| 1072 | - $holdSelected = $docSheet->getSelectedCells(); | |
| 1073 | - $cell->getStyle()->setQuotePrefix(false); | |
| 1074 | - $docSheet->setSelectedCells($holdSelected); | |
| 1075 | - } | |
| 1076 | - } | |
| 1077 | - } | |
| 1078 | - ++$rowIndex; | |
| 1079 | - } | |
| 1080 | - ++$cIndex; | |
| 1081 | - } | |
| 1082 | - } | |
| 1083 | 1053 | $docSheet->setSelectedCells($holdSelectedCells); |
| 1084 | 1054 | if (!$this->readDataOnly && $xmlSheetNS && $xmlSheetNS->ignoredErrors) { |
| 1085 | 1055 | foreach ($xmlSheetNS->ignoredErrors->ignoredError as $ignoredError) { |
| 1086 | 1056 | $this->processIgnoredErrors($ignoredError, $docSheet); |
| @@ -1105,8 +1075,12 @@ | ||
| 1105 | 1075 | } |
| 1106 | 1076 | |
| 1107 | 1077 | $this->readTables($xmlSheetNS, $docSheet, $dir, $fileWorksheet, $zip, $mainNS, $tableStyles, $dxfs); |
| 1108 | 1078 | |
| 1079 | + if ($this->readDataOnly === false) { | |
| 1080 | + $this->readPivotTables($docSheet, $dir, $fileWorksheet, $zip, $unparsedLoadedData); | |
| 1081 | + } | |
| 1082 | + | |
| 1109 | 1083 | if ($xmlSheetNS && $xmlSheetNS->mergeCells && $xmlSheetNS->mergeCells->mergeCell && !$this->readDataOnly) { |
| 1110 | 1084 | foreach ($xmlSheetNS->mergeCells->mergeCell as $mergeCellx) { |
| 1111 | 1085 | $mergeCell = $mergeCellx->attributes(); |
| 1112 | 1086 | $mergeRef = (string) ($mergeCell['ref'] ?? ''); |
| @@ -1154,8 +1128,15 @@ | ||
| 1154 | 1128 | if ($xmlSheet && $xmlSheet->dataValidations && !$this->readDataOnly) { |
| 1155 | 1129 | (new DataValidations($docSheet, $xmlSheet))->load(); |
| 1156 | 1130 | } |
| 1157 | 1131 | |
| 1132 | + /* | |
| 1133 | + TablePress: Remove support for Sparklines as they require PHP 8.1 features. | |
| 1134 | + if ($xmlSheet && !$this->readDataOnly) { | |
| 1135 | + (new Sparklines($docSheet, $xmlSheet))->load(); | |
| 1136 | + } | |
| 1137 | + */ | |
| 1138 | + | |
| 1158 | 1139 | // unparsed sheet AlternateContent |
| 1159 | 1140 | if ($xmlSheet && !$this->readDataOnly) { |
| 1160 | 1141 | $mc = $xmlSheet->children(Namespaces::COMPATIBILITY); |
| 1161 | 1142 | if ($mc->AlternateContent) { |
| @@ -1987,8 +1968,24 @@ | ||
| 1987 | 1968 | $excel->addDefinedName(DefinedName::createInstance((string) $definedName['name'], $locatedSheet, $extractedRange, false)); |
| 1988 | 1969 | } |
| 1989 | 1970 | } |
| 1990 | 1971 | } |
| 1972 | + | |
| 1973 | + // Preserve the workbook <pivotCaches> registry (cacheId | |
| 1974 | + // -> cache definition part) so pivot tables survive a | |
| 1975 | + // load/save round-trip. | |
| 1976 | + if (!$this->readDataOnly && $xmlWorkbook->pivotCaches && $xmlWorkbook->pivotCaches->pivotCache) { | |
| 1977 | + foreach ($xmlWorkbook->pivotCaches->pivotCache as $pivotCache) { | |
| 1978 | + $pivotCacheAttributes = self::getAttributes($pivotCache); | |
| 1979 | + $relId = (string) self::getAttributes($pivotCache, Namespaces::SCHEMA_OFFICE_DOCUMENT)['id']; | |
| 1980 | + if (isset($pivotCacheRels[$relId])) { | |
| 1981 | + $unparsedLoadedData['workbookPivotCaches'][] = [ | |
| 1982 | + 'cacheId' => (string) $pivotCacheAttributes['cacheId'], | |
| 1983 | + 'cacheDefinitionPath' => $pivotCacheRels[$relId], | |
| 1984 | + ]; | |
| 1985 | + } | |
| 1986 | + } | |
| 1987 | + } | |
| 1991 | 1988 | } |
| 1992 | 1989 | if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { |
| 1993 | 1990 | $excel->createSheet(); |
| 1994 | 1991 | } |
| @@ -2047,8 +2044,11 @@ | ||
| 2047 | 2044 | break; |
| 2048 | 2045 | |
| 2049 | 2046 | // unparsed |
| 2050 | 2047 | case 'application/vnd.ms-excel.controlproperties+xml': |
| 2048 | + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml': | |
| 2049 | + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml': | |
| 2050 | + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml': | |
| 2051 | 2051 | $unparsedLoadedData['override_content_types'][(string) $contentType['PartName']] = (string) $contentType['ContentType']; |
| 2052 | 2052 | |
| 2053 | 2053 | break; |
| 2054 | 2054 | } |
| @@ -2062,9 +2062,208 @@ | ||
| 2062 | 2062 | |
| 2063 | 2063 | return $excel; |
| 2064 | 2064 | } |
| 2065 | 2065 | |
| 2066 | - private function parseRichText(?SimpleXMLElement $is): RichText | |
| 2066 | + /** | |
| 2067 | + * @param string[][] $richData | |
| 2068 | + * @param Worksheet $docSheet the worksheet to populate | |
| 2069 | + * @param array<int, mixed> $sharedStrings shared string table | |
| 2070 | + * @param object[] $styles style objects array | |
| 2071 | + * @param mixed[] $extraParameters maybe make it a little easier to extend | |
| 2072 | + */ | |
| 2073 | + protected function loadSheetData( | |
| 2074 | + ?SimpleXMLElement $xmlSheetNS, | |
| 2075 | + string $filename, | |
| 2076 | + string $dir, | |
| 2077 | + array $richData, | |
| 2078 | + Worksheet $docSheet, | |
| 2079 | + array $sharedStrings, | |
| 2080 | + array $styles, | |
| 2081 | + array $extraParameters = [] | |
| 2082 | + ): void { | |
| 2083 | + if (!($xmlSheetNS && $xmlSheetNS->sheetData && $xmlSheetNS->sheetData->row)) { | |
| 2084 | + return; // @codeCoverageIgnore | |
| 2085 | + } | |
| 2086 | + | |
| 2087 | + $cIndex = 1; // Cell Start from 1 | |
| 2088 | + foreach ($xmlSheetNS->sheetData->row as $row) { | |
| 2089 | + $rowIndex = 1; | |
| 2090 | + foreach ($row->c as $c) { | |
| 2091 | + $cAttr = self::getAttributes($c); | |
| 2092 | + $r = (string) $cAttr['r']; | |
| 2093 | + if ($r == '') { | |
| 2094 | + $r = Coordinate::stringFromColumnIndex($rowIndex) . $cIndex; | |
| 2095 | + } | |
| 2096 | + $cellDataType = (string) $cAttr['t']; | |
| 2097 | + $originalCellDataTypeNumeric = $cellDataType === ''; | |
| 2098 | + $value = null; | |
| 2099 | + $calculatedValue = null; | |
| 2100 | + | |
| 2101 | + // Read cell? | |
| 2102 | + $coordinates = Coordinate::coordinateFromString($r); | |
| 2103 | + | |
| 2104 | + if (!$this->readFilter->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { | |
| 2105 | + // Normally, just testing for the f attribute should identify this cell as containing a formula | |
| 2106 | + // that we need to read, even though it is outside of the filter range, in case it is a shared formula. | |
| 2107 | + // But in some cases, this attribute isn't set; so we need to delve a level deeper and look at | |
| 2108 | + // whether or not the cell has a child formula element that is shared. | |
| 2109 | + if (isset($cAttr->f) || (isset($c->f, $c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')) { | |
| 2110 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError', false); | |
| 2111 | + } | |
| 2112 | + ++$rowIndex; | |
| 2113 | + | |
| 2114 | + continue; | |
| 2115 | + } | |
| 2116 | + | |
| 2117 | + // Read cell! | |
| 2118 | + $useFormula = isset($c->f) | |
| 2119 | + && ((string) $c->f !== '' || (isset($c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')); | |
| 2120 | + switch ($cellDataType) { | |
| 2121 | + case DataType::TYPE_STRING: | |
| 2122 | + if ((string) $c->v != '') { | |
| 2123 | + $value = $sharedStrings[(int) ($c->v)]; | |
| 2124 | + | |
| 2125 | + if ($value instanceof RichText) { | |
| 2126 | + $value = clone $value; | |
| 2127 | + } | |
| 2128 | + } else { | |
| 2129 | + $value = ''; | |
| 2130 | + } | |
| 2131 | + | |
| 2132 | + break; | |
| 2133 | + case DataType::TYPE_BOOL: | |
| 2134 | + if (!$useFormula) { | |
| 2135 | + if (isset($c->v)) { | |
| 2136 | + $value = self::castToBoolean($c); | |
| 2137 | + } else { | |
| 2138 | + $value = null; | |
| 2139 | + $cellDataType = DataType::TYPE_NULL; | |
| 2140 | + } | |
| 2141 | + } else { | |
| 2142 | + // Formula | |
| 2143 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToBoolean'); | |
| 2144 | + self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 2145 | + } | |
| 2146 | + | |
| 2147 | + break; | |
| 2148 | + case DataType::TYPE_STRING2: | |
| 2149 | + if ($useFormula) { | |
| 2150 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); | |
| 2151 | + self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 2152 | + } else { | |
| 2153 | + $value = self::castToString($c); | |
| 2154 | + } | |
| 2155 | + | |
| 2156 | + break; | |
| 2157 | + case DataType::TYPE_INLINE: | |
| 2158 | + if ($useFormula) { | |
| 2159 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); | |
| 2160 | + self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 2161 | + } else { | |
| 2162 | + $value = $this->parseRichText($c->is); | |
| 2163 | + } | |
| 2164 | + | |
| 2165 | + break; | |
| 2166 | + case DataType::TYPE_ERROR: | |
| 2167 | + if (isset($cAttr->vm, $richData['image']['rId' . $cAttr->vm]) && !$useFormula) { | |
| 2168 | + $imagePath = $dir . '/' . str_replace('../', '', $richData['image']['rId' . $cAttr->vm]); | |
| 2169 | + $objDrawing = new \TablePress\PhpOffice\PhpSpreadsheet\Worksheet\Drawing(); | |
| 2170 | + $objDrawing->setPath( | |
| 2171 | + 'zip://' . File::realpath($filename) . '#' . $imagePath, | |
| 2172 | + false, | |
| 2173 | + $this->zip | |
| 2174 | + ); | |
| 2175 | + | |
| 2176 | + $objDrawing->setCoordinates($r); | |
| 2177 | + $objDrawing->setResizeProportional(false); | |
| 2178 | + $objDrawing->setInCell(true); | |
| 2179 | + $objDrawing->setWorksheet($docSheet); | |
| 2180 | + | |
| 2181 | + $value = $objDrawing; | |
| 2182 | + $cellDataType = DataType::TYPE_DRAWING_IN_CELL; | |
| 2183 | + $c->t = DataType::TYPE_ERROR; | |
| 2184 | + | |
| 2185 | + break; | |
| 2186 | + } | |
| 2187 | + | |
| 2188 | + if (!$useFormula) { | |
| 2189 | + $value = self::castToError($c); | |
| 2190 | + } else { | |
| 2191 | + // Formula | |
| 2192 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); | |
| 2193 | + $eattr = $c->attributes(); | |
| 2194 | + if (isset($eattr['vm'])) { | |
| 2195 | + if ($calculatedValue === ExcelError::VALUE()) { | |
| 2196 | + $calculatedValue = ExcelError::SPILL(); | |
| 2197 | + } | |
| 2198 | + } | |
| 2199 | + } | |
| 2200 | + | |
| 2201 | + break; | |
| 2202 | + default: | |
| 2203 | + if (!$useFormula) { | |
| 2204 | + $value = self::castToString($c); | |
| 2205 | + if (is_numeric($value)) { | |
| 2206 | + $value += 0; | |
| 2207 | + $cellDataType = DataType::TYPE_NUMERIC; | |
| 2208 | + } | |
| 2209 | + } else { | |
| 2210 | + // Formula | |
| 2211 | + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); | |
| 2212 | + if (is_numeric($calculatedValue)) { | |
| 2213 | + $calculatedValue += 0; | |
| 2214 | + } | |
| 2215 | + self::storeFormulaAttributes($c->f, $docSheet, $r); | |
| 2216 | + } | |
| 2217 | + | |
| 2218 | + break; | |
| 2219 | + } | |
| 2220 | + | |
| 2221 | + // read empty cells or the cells are not empty | |
| 2222 | + if ($this->readEmptyCells || ($value !== null && $value !== '')) { | |
| 2223 | + // Rich text? | |
| 2224 | + if ($value instanceof RichText && $this->readDataOnly) { | |
| 2225 | + $value = $value->getPlainText(); | |
| 2226 | + } | |
| 2227 | + | |
| 2228 | + $cell = $docSheet->getCell($r); | |
| 2229 | + // Assign value | |
| 2230 | + if ($cellDataType != '') { | |
| 2231 | + // it is possible, that datatype is numeric but with an empty string, which result in an error | |
| 2232 | + if ($cellDataType === DataType::TYPE_NUMERIC && ($value === '' || $value === null)) { | |
| 2233 | + $cellDataType = DataType::TYPE_NULL; | |
| 2234 | + } | |
| 2235 | + if ($cellDataType !== DataType::TYPE_NULL) { | |
| 2236 | + $cell->setValueExplicit($value, $cellDataType); | |
| 2237 | + } | |
| 2238 | + } else { | |
| 2239 | + $cell->setValue($value); | |
| 2240 | + } | |
| 2241 | + if ($calculatedValue !== null) { | |
| 2242 | + $cell->setCalculatedValue($calculatedValue, $originalCellDataTypeNumeric); | |
| 2243 | + } | |
| 2244 | + | |
| 2245 | + // Style information? | |
| 2246 | + if (!$this->readDataOnly) { | |
| 2247 | + $cAttrS = (int) ($cAttr['s'] ?? 0); | |
| 2248 | + // no style index means 0, it seems | |
| 2249 | + $cAttrS = isset($styles[$cAttrS]) ? $cAttrS : 0; | |
| 2250 | + $cell->setXfIndex($cAttrS); | |
| 2251 | + // issue 3495 | |
| 2252 | + if ($cellDataType === DataType::TYPE_FORMULA && $styles[$cAttrS]->quotePrefix === true) { //* @phpstan-ignore property.notFound (quotePrefix does exist) | |
| 2253 | + $holdSelected = $docSheet->getSelectedCells(); | |
| 2254 | + $cell->getStyle()->setQuotePrefix(false); | |
| 2255 | + $docSheet->setSelectedCells($holdSelected); | |
| 2256 | + } | |
| 2257 | + } | |
| 2258 | + } | |
| 2259 | + ++$rowIndex; | |
| 2260 | + } | |
| 2261 | + ++$cIndex; | |
| 2262 | + } | |
| 2263 | + } | |
| 2264 | + | |
| 2265 | + protected function parseRichText(?SimpleXMLElement $is): RichText | |
| 2067 | 2266 | { |
| 2068 | 2267 | $value = new RichText(); |
| 2069 | 2268 | |
| 2070 | 2269 | if (isset($is->t)) { |
| @@ -2382,9 +2581,9 @@ | ||
| 2382 | 2581 | private static function getLockValue(SimpleXMLElement $protection, string $key): ?bool |
| 2383 | 2582 | { |
| 2384 | 2583 | $returnValue = null; |
| 2385 | 2584 | $protectKey = $protection[$key]; |
| 2386 | - if (!empty($protectKey)) { | |
| 2585 | + if (isset($protectKey)) { | |
| 2387 | 2586 | $protectKey = (string) $protectKey; |
| 2388 | 2587 | $returnValue = $protectKey !== 'false' && (bool) $protectKey; |
| 2389 | 2588 | } |
| 2390 | 2589 | |
| @@ -2599,8 +2798,168 @@ | ||
| 2599 | 2798 | } |
| 2600 | 2799 | } |
| 2601 | 2800 | } |
| 2602 | 2801 | |
| 2802 | + /** | |
| 2803 | + * Discover the pivot table parts referenced by a worksheet, parse them into | |
| 2804 | + * the read-only PivotTable object model, and preserve every associated raw | |
| 2805 | + * XML part (pivot table, cache definition, cache records and their rels) in | |
| 2806 | + * the unparsed loaded data so they can be written back unchanged. | |
| 2807 | + * | |
| 2808 | + * @param mixed[] $unparsedLoadedData | |
| 2809 | + */ | |
| 2810 | + private function readPivotTables( | |
| 2811 | + Worksheet $docSheet, | |
| 2812 | + string $dir, | |
| 2813 | + string $fileWorksheet, | |
| 2814 | + ZipArchive $zip, | |
| 2815 | + array &$unparsedLoadedData | |
| 2816 | + ): void { | |
| 2817 | + $relationsFileName = dirname("$dir/$fileWorksheet") . '/_rels/' . basename($fileWorksheet) . '.rels'; | |
| 2818 | + if ($zip->locateName($relationsFileName) === false) { | |
| 2819 | + return; | |
| 2820 | + } | |
| 2821 | + | |
| 2822 | + $relsWorksheet = $this->loadZip($relationsFileName, Namespaces::RELATIONSHIPS); | |
| 2823 | + foreach ($relsWorksheet->Relationship as $relationship) { | |
| 2824 | + $relAttributes = self::getAttributes($relationship, ''); | |
| 2825 | + if ((string) $relAttributes['Type'] !== Namespaces::RELATIONSHIPS_PIVOT_TABLE) { | |
| 2826 | + continue; | |
| 2827 | + } | |
| 2828 | + | |
| 2829 | + $relTarget = (string) $relAttributes['Target']; | |
| 2830 | + $pivotTablePath = File::realpath(dirname("$dir/$fileWorksheet") . '/' . $relTarget); | |
| 2831 | + if (!$this->fileExistsInArchive($this->zip, $pivotTablePath)) { | |
| 2832 | + continue; | |
| 2833 | + } | |
| 2834 | + | |
| 2835 | + $pivotTableXml = $this->loadZip($pivotTablePath, Namespaces::MAIN); | |
| 2836 | + $cacheDefinitionXml = $this->readPivotCacheDefinition($pivotTablePath, $zip, $unparsedLoadedData); | |
| 2837 | + | |
| 2838 | + (new PivotTableReader($docSheet, $pivotTableXml, $cacheDefinitionXml))->load(); | |
| 2839 | + | |
| 2840 | + // Preserve the raw pivot table part (and its rels) for write-back. | |
| 2841 | + $sheetCodeName = $docSheet->getCodeName(); | |
| 2842 | + if (!isset($unparsedLoadedData['sheets']) || !is_array($unparsedLoadedData['sheets'])) { | |
| 2843 | + $unparsedLoadedData['sheets'] = []; | |
| 2844 | + } | |
| 2845 | + if (!isset($unparsedLoadedData['sheets'][$sheetCodeName]) || !is_array($unparsedLoadedData['sheets'][$sheetCodeName])) { | |
| 2846 | + $unparsedLoadedData['sheets'][$sheetCodeName] = []; | |
| 2847 | + } | |
| 2848 | + /** @var array<string, mixed> $sheetUnparsedData */ | |
| 2849 | + $sheetUnparsedData = &$unparsedLoadedData['sheets'][$sheetCodeName]; | |
| 2850 | + if (!isset($sheetUnparsedData['pivotTables']) || !is_array($sheetUnparsedData['pivotTables'])) { | |
| 2851 | + $sheetUnparsedData['pivotTables'] = []; | |
| 2852 | + } | |
| 2853 | + /** @var array<int, array<string, string>> $sheetPivotTables */ | |
| 2854 | + $sheetPivotTables = &$sheetUnparsedData['pivotTables']; | |
| 2855 | + $sheetPivotTables[] = [ | |
| 2856 | + 'relFilePath' => $relTarget, | |
| 2857 | + 'path' => $pivotTablePath, | |
| 2858 | + 'content' => $this->getSecurityScannerOrThrow()->scan($this->getFromZipArchive($this->zip, $pivotTablePath)), | |
| 2859 | + ]; | |
| 2860 | + unset($sheetPivotTables, $sheetUnparsedData); | |
| 2861 | + $this->preserveRawPart( | |
| 2862 | + dirname($pivotTablePath) . '/_rels/' . basename($pivotTablePath) . '.rels', | |
| 2863 | + $unparsedLoadedData | |
| 2864 | + ); | |
| 2865 | + } | |
| 2866 | + } | |
| 2867 | + | |
| 2868 | + /** | |
| 2869 | + * Follow a pivot table part's relationships to load its cache definition | |
| 2870 | + * part, preserving the cache definition, its records and all of their rels | |
| 2871 | + * as raw parts. Returns the parsed cache definition XML, or null. | |
| 2872 | + * | |
| 2873 | + * @param mixed[] $unparsedLoadedData | |
| 2874 | + */ | |
| 2875 | + private function readPivotCacheDefinition(string $pivotTablePath, ZipArchive $zip, array &$unparsedLoadedData): ?SimpleXMLElement | |
| 2876 | + { | |
| 2877 | + $relsFileName = dirname($pivotTablePath) . '/_rels/' . basename($pivotTablePath) . '.rels'; | |
| 2878 | + if ($zip->locateName($relsFileName) === false) { | |
| 2879 | + return null; | |
| 2880 | + } | |
| 2881 | + | |
| 2882 | + $rels = $this->loadZip($relsFileName, Namespaces::RELATIONSHIPS); | |
| 2883 | + foreach ($rels->Relationship as $relationship) { | |
| 2884 | + $relAttributes = self::getAttributes($relationship, ''); | |
| 2885 | + if ((string) $relAttributes['Type'] === Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION) { | |
| 2886 | + $cachePath = File::realpath( | |
| 2887 | + dirname($pivotTablePath) . '/' . (string) $relAttributes['Target'] | |
| 2888 | + ); | |
| 2889 | + if (!$this->fileExistsInArchive($this->zip, $cachePath)) { | |
| 2890 | + return null; | |
| 2891 | + } | |
| 2892 | + | |
| 2893 | + $cacheDefinitionXml = $this->loadZip($cachePath, Namespaces::MAIN); | |
| 2894 | + $this->preservePivotCache($cachePath, $unparsedLoadedData); | |
| 2895 | + | |
| 2896 | + return $cacheDefinitionXml; | |
| 2897 | + } | |
| 2898 | + } | |
| 2899 | + | |
| 2900 | + return null; | |
| 2901 | + } | |
| 2902 | + | |
| 2903 | + /** | |
| 2904 | + * Preserve a pivot cache definition (keyed by its zip path so a workbook | |
| 2905 | + * relationship can be recreated), along with its rels and any parts they | |
| 2906 | + * reference (typically the cache records). | |
| 2907 | + * | |
| 2908 | + * @param mixed[] $unparsedLoadedData | |
| 2909 | + */ | |
| 2910 | + private function preservePivotCache(string $cachePath, array &$unparsedLoadedData): void | |
| 2911 | + { | |
| 2912 | + if (!isset($unparsedLoadedData['pivotCacheDefinitions']) || !is_array($unparsedLoadedData['pivotCacheDefinitions'])) { | |
| 2913 | + $unparsedLoadedData['pivotCacheDefinitions'] = []; | |
| 2914 | + } | |
| 2915 | + /** @var array<string, array<string, string>> $cacheDefinitions */ | |
| 2916 | + $cacheDefinitions = &$unparsedLoadedData['pivotCacheDefinitions']; | |
| 2917 | + if (!isset($cacheDefinitions[$cachePath])) { | |
| 2918 | + $cacheDefinitions[$cachePath] = [ | |
| 2919 | + 'path' => $cachePath, | |
| 2920 | + 'content' => $this->getSecurityScannerOrThrow()->scan($this->getFromZipArchive($this->zip, $cachePath)), | |
| 2921 | + ]; | |
| 2922 | + unset($cacheDefinitions); | |
| 2923 | + | |
| 2924 | + $relsFileName = dirname($cachePath) . '/_rels/' . basename($cachePath) . '.rels'; | |
| 2925 | + if ($this->zip->locateName($relsFileName) !== false) { | |
| 2926 | + $this->preserveRawPart($relsFileName, $unparsedLoadedData); | |
| 2927 | + | |
| 2928 | + $rels = $this->loadZip($relsFileName, Namespaces::RELATIONSHIPS); | |
| 2929 | + foreach ($rels->Relationship as $relationship) { | |
| 2930 | + $relAttributes = self::getAttributes($relationship, ''); | |
| 2931 | + $target = File::realpath(dirname($cachePath) . '/' . (string) $relAttributes['Target']); | |
| 2932 | + if ($this->fileExistsInArchive($this->zip, $target)) { | |
| 2933 | + $this->preserveRawPart($target, $unparsedLoadedData); | |
| 2934 | + } | |
| 2935 | + } | |
| 2936 | + } | |
| 2937 | + } | |
| 2938 | + } | |
| 2939 | + | |
| 2940 | + /** | |
| 2941 | + * Store a single part verbatim (keyed by its zip path) so the writer can | |
| 2942 | + * re-add it to the archive without modification. | |
| 2943 | + * | |
| 2944 | + * @param mixed[] $unparsedLoadedData | |
| 2945 | + */ | |
| 2946 | + private function preserveRawPart(string $path, array &$unparsedLoadedData): void | |
| 2947 | + { | |
| 2948 | + if ($this->zip->locateName($path) === false) { | |
| 2949 | + return; | |
| 2950 | + } | |
| 2951 | + if (!isset($unparsedLoadedData['pivotCacheParts']) || !is_array($unparsedLoadedData['pivotCacheParts'])) { | |
| 2952 | + $unparsedLoadedData['pivotCacheParts'] = []; | |
| 2953 | + } | |
| 2954 | + /** @var array<string, string> $pivotCacheParts */ | |
| 2955 | + $pivotCacheParts = &$unparsedLoadedData['pivotCacheParts']; | |
| 2956 | + $pivotCacheParts[$path] = $this->getSecurityScannerOrThrow()->scan( | |
| 2957 | + $this->getFromZipArchive($this->zip, $path) | |
| 2958 | + ); | |
| 2959 | + unset($pivotCacheParts); | |
| 2960 | + } | |
| 2961 | + | |
| 2603 | 2962 | /** @return mixed[] */ |
| 2604 | 2963 | private static function extractStyles(?SimpleXMLElement $sxml, string $node1, string $node2): array |
| 2605 | 2964 | { |
| 2606 | 2965 | $array = []; |
| @@ -2640,8 +2999,10 @@ | ||
| 2640 | 2999 | $formula = (string) ($attributes['formula'] ?? ''); |
| 2641 | 3000 | $formulaRange = (string) ($attributes['formulaRange'] ?? ''); |
| 2642 | 3001 | $twoDigitTextYear = (string) ($attributes['twoDigitTextYear'] ?? ''); |
| 2643 | 3002 | $evalError = (string) ($attributes['evalError'] ?? ''); |
| 3003 | + $attributes2 = self::getAttributes($xml, Namespaces::MISLEADING_FORMAT); | |
| 3004 | + $misleadingFormat = (string) ($attributes2['misleadingFormat'] ?? ''); | |
| 2644 | 3005 | if (!empty($sqref)) { |
| 2645 | 3006 | $explodedSqref = explode(' ', $sqref); |
| 2646 | 3007 | $pattern1 = '/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/'; |
| 2647 | 3008 | foreach ($explodedSqref as $sqref1) { |
| @@ -2661,22 +3022,37 @@ | ||
| 2661 | 3022 | if (!$cellCollection->has2("$col$row")) { |
| 2662 | 3023 | continue; |
| 2663 | 3024 | } |
| 2664 | 3025 | if ($numberStoredAsText === '1') { |
| 2665 | - $sheet->getCell("$col$row")->getIgnoredErrors()->setNumberStoredAsText(true); | |
| 3026 | + $sheet->getCell("$col$row") | |
| 3027 | + ->getIgnoredErrors() | |
| 3028 | + ->setNumberStoredAsText(true); | |
| 2666 | 3029 | } |
| 2667 | 3030 | if ($formula === '1') { |
| 2668 | - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormula(true); | |
| 3031 | + $sheet->getCell("$col$row") | |
| 3032 | + ->getIgnoredErrors() | |
| 3033 | + ->setFormula(true); | |
| 2669 | 3034 | } |
| 2670 | 3035 | if ($formulaRange === '1') { |
| 2671 | - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormulaRange(true); | |
| 3036 | + $sheet->getCell("$col$row") | |
| 3037 | + ->getIgnoredErrors() | |
| 3038 | + ->setFormulaRange(true); | |
| 2672 | 3039 | } |
| 2673 | 3040 | if ($twoDigitTextYear === '1') { |
| 2674 | - $sheet->getCell("$col$row")->getIgnoredErrors()->setTwoDigitTextYear(true); | |
| 3041 | + $sheet->getCell("$col$row") | |
| 3042 | + ->getIgnoredErrors() | |
| 3043 | + ->setTwoDigitTextYear(true); | |
| 2675 | 3044 | } |
| 2676 | 3045 | if ($evalError === '1') { |
| 2677 | - $sheet->getCell("$col$row")->getIgnoredErrors()->setEvalError(true); | |
| 3046 | + $sheet->getCell("$col$row") | |
| 3047 | + ->getIgnoredErrors() | |
| 3048 | + ->setEvalError(true); | |
| 2678 | 3049 | } |
| 3050 | + if ($misleadingFormat === '1') { | |
| 3051 | + $sheet->getCell("$col$row") | |
| 3052 | + ->getIgnoredErrors() | |
| 3053 | + ->setMisleadingFormat(true); | |
| 3054 | + } | |
| 2679 | 3055 | } |
| 2680 | 3056 | } |
| 2681 | 3057 | } |
| 2682 | 3058 | } |
| @@ -2682,9 +3058,9 @@ | ||
| 2682 | 3058 | } |
| 2683 | 3059 | } |
| 2684 | 3060 | } |
| 2685 | 3061 | |
| 2686 | - private static function storeFormulaAttributes(SimpleXMLElement $f, Worksheet $docSheet, string $r): void | |
| 3062 | + protected static function storeFormulaAttributes(SimpleXMLElement $f, Worksheet $docSheet, string $r): void | |
| 2687 | 3063 | { |
| 2688 | 3064 | $formulaAttributes = []; |
| 2689 | 3065 | $attributes = $f->attributes(); |
| 2690 | 3066 | if (isset($attributes['t'])) { |