spreadsheetreader_xlsx.php (27903B)
1 <?php 2 /** 3 * Class for parsing XLSX files specifically 4 * 5 * @author Martins Pilsetnieks 6 */ 7 class SpreadsheetReader_XLSX implements Iterator, Countable { 8 const CELL_TYPE_BOOL = 'b'; 9 const CELL_TYPE_NUMBER = 'n'; 10 const CELL_TYPE_ERROR = 'e'; 11 const CELL_TYPE_SHARED_STR = 's'; 12 const CELL_TYPE_STR = 'str'; 13 const CELL_TYPE_INLINE_STR = 'inlineStr'; 14 /** 15 * Number of shared strings that can be reasonably cached, i.e., that aren't read from file but stored in memory. 16 * If the total number of shared strings is higher than this, caching is not used. 17 * If this value is null, shared strings are cached regardless of amount. 18 * With large shared string caches there are huge performance gains, however a lot of memory could be used which 19 * can be a problem, especially on shared hosting. 20 */ 21 const SHARED_STRING_CACHE_LIMIT = 50000; 22 private $Options = array( 23 'TempDir' => '', 24 'ReturnDateTimeObjects' => false 25 ); 26 private static $RuntimeInfo = array( 27 'GMPSupported' => false 28 ); 29 private $Valid = false; 30 /** 31 * @var SpreadsheetReader_* Handle for the reader object 32 */ 33 private $Handle = false; 34 // Worksheet file 35 /** 36 * @var string Path to the worksheet XML file 37 */ 38 private $WorksheetPath = false; 39 /** 40 * @var XMLReader XML reader object for the worksheet XML file 41 */ 42 private $Worksheet = false; 43 // Shared strings file 44 /** 45 * @var string Path to shared strings XML file 46 */ 47 private $SharedStringsPath = false; 48 /** 49 * @var XMLReader XML reader object for the shared strings XML file 50 */ 51 private $SharedStrings = false; 52 /** 53 * @var array Shared strings cache, if the number of shared strings is low enough 54 */ 55 private $SharedStringCache = array(); 56 // Workbook data 57 /** 58 * @var SimpleXMLElement XML object for the workbook XML file 59 */ 60 private $WorkbookXML = false; 61 // Style data 62 /** 63 * @var SimpleXMLElement XML object for the styles XML file 64 */ 65 private $StylesXML = false; 66 /** 67 * @var array Container for cell value style data 68 */ 69 private $Styles = array(); 70 private $TempDir = ''; 71 private $TempFiles = array(); 72 private $CurrentRow = false; 73 // Runtime parsing data 74 /** 75 * @var int Current row in the file 76 */ 77 private $Index = 0; 78 /** 79 * @var array Data about separate sheets in the file 80 */ 81 private $Sheets = false; 82 private $SharedStringCount = 0; 83 private $SharedStringIndex = 0; 84 private $LastSharedStringValue = null; 85 private $RowOpen = false; 86 private $SSOpen = false; 87 private $SSForwarded = false; 88 private static $BuiltinFormats = array( 89 0 => '', 90 1 => '0', 91 2 => '0.00', 92 3 => '#,##0', 93 4 => '#,##0.00', 94 9 => '0%', 95 10 => '0.00%', 96 11 => '0.00E+00', 97 12 => '# ?/?', 98 13 => '# ??/??', 99 14 => 'mm-dd-yy', 100 15 => 'd-mmm-yy', 101 16 => 'd-mmm', 102 17 => 'mmm-yy', 103 18 => 'h:mm AM/PM', 104 19 => 'h:mm:ss AM/PM', 105 20 => 'h:mm', 106 21 => 'h:mm:ss', 107 22 => 'm/d/yy h:mm', 108 37 => '#,##0 ;(#,##0)', 109 38 => '#,##0 ;[Red](#,##0)', 110 39 => '#,##0.00;(#,##0.00)', 111 40 => '#,##0.00;[Red](#,##0.00)', 112 45 => 'mm:ss', 113 46 => '[h]:mm:ss', 114 47 => 'mmss.0', 115 48 => '##0.0E+0', 116 49 => '@', 117 // CHT & CHS 118 27 => '[$-404]e/m/d', 119 30 => 'm/d/yy', 120 36 => '[$-404]e/m/d', 121 50 => '[$-404]e/m/d', 122 57 => '[$-404]e/m/d', 123 // THA 124 59 => 't0', 125 60 => 't0.00', 126 61 =>'t#,##0', 127 62 => 't#,##0.00', 128 67 => 't0%', 129 68 => 't0.00%', 130 69 => 't# ?/?', 131 70 => 't# ??/??' 132 ); 133 private $Formats = array(); 134 private static $DateReplacements = array( 135 'All' => array( 136 '\\' => '', 137 'am/pm' => 'A', 138 'yyyy' => 'Y', 139 'yy' => 'y', 140 'mmmmm' => 'M', 141 'mmmm' => 'F', 142 'mmm' => 'M', 143 ':mm' => ':i', 144 'mm' => 'm', 145 'm' => 'n', 146 'dddd' => 'l', 147 'ddd' => 'D', 148 'dd' => 'd', 149 'd' => 'j', 150 'ss' => 's', 151 '.s' => '' 152 ), 153 '24H' => array( 154 'hh' => 'H', 155 'h' => 'G' 156 ), 157 '12H' => array( 158 'hh' => 'h', 159 'h' => 'G' 160 ) 161 ); 162 private static $BaseDate = false; 163 private static $DecimalSeparator = '.'; 164 private static $ThousandSeparator = ''; 165 private static $CurrencyCode = ''; 166 /** 167 * @var array Cache for already processed format strings 168 */ 169 private $ParsedFormatCache = array(); 170 /** 171 * @param string Path to file 172 * @param array Options: 173 * TempDir => string Temporary directory path 174 * ReturnDateTimeObjects => bool True => dates and times will be returned as PHP DateTime objects, false => as strings 175 */ 176 public function __construct($Filepath, array $Options = null) 177 { 178 if (!is_readable($Filepath)) 179 { 180 throw new Exception('SpreadsheetReader_XLSX: File not readable ('.$Filepath.')'); 181 } 182 $this -> TempDir = isset($Options['TempDir']) && is_writable($Options['TempDir']) ? 183 $Options['TempDir'] : 184 sys_get_temp_dir(); 185 $this -> TempDir = rtrim($this -> TempDir, DIRECTORY_SEPARATOR); 186 $this -> TempDir = $this -> TempDir.DIRECTORY_SEPARATOR.uniqid().DIRECTORY_SEPARATOR; 187 $Zip = new ZipArchive; 188 $Status = $Zip -> open($Filepath); 189 if ($Status !== true) 190 { 191 throw new Exception('SpreadsheetReader_XLSX: File not readable ('.$Filepath.') (Error '.$Status.')'); 192 } 193 // Getting the general workbook information 194 if ($Zip -> locateName('xl/workbook.xml') !== false) 195 { 196 $this -> WorkbookXML = new SimpleXMLElement($Zip -> getFromName('xl/workbook.xml')); 197 } 198 // Extracting the XMLs from the XLSX zip file 199 if ($Zip -> locateName('xl/sharedStrings.xml') !== false) 200 { 201 $this -> SharedStringsPath = $this -> TempDir.'xl'.DIRECTORY_SEPARATOR.'sharedStrings.xml'; 202 $Zip -> extractTo($this -> TempDir, 'xl/sharedStrings.xml'); 203 $this -> TempFiles[] = $this -> TempDir.'xl'.DIRECTORY_SEPARATOR.'sharedStrings.xml'; 204 if (is_readable($this -> SharedStringsPath)) 205 { 206 $this -> SharedStrings = new XMLReader; 207 $this -> SharedStrings -> open($this -> SharedStringsPath); 208 $this -> PrepareSharedStringCache(); 209 } 210 } 211 $Sheets = $this -> Sheets(); 212 foreach ($this -> Sheets as $Index => $Name) 213 { 214 if ($Zip -> locateName('xl/worksheets/sheet'.$Index.'.xml') !== false) 215 { 216 $Zip -> extractTo($this -> TempDir, 'xl/worksheets/sheet'.$Index.'.xml'); 217 $this -> TempFiles[] = $this -> TempDir.'xl'.DIRECTORY_SEPARATOR.'worksheets'.DIRECTORY_SEPARATOR.'sheet'.$Index.'.xml'; 218 } 219 } 220 $this -> ChangeSheet(0); 221 // If worksheet is present and is OK, parse the styles already 222 if ($Zip -> locateName('xl/styles.xml') !== false) 223 { 224 $this -> StylesXML = new SimpleXMLElement($Zip -> getFromName('xl/styles.xml')); 225 if ($this -> StylesXML && $this -> StylesXML -> cellXfs && $this -> StylesXML -> cellXfs -> xf) 226 { 227 foreach ($this -> StylesXML -> cellXfs -> xf as $Index => $XF) 228 { 229 // Format #0 is a special case - it is the "General" format that is applied regardless of applyNumberFormat 230 if ($XF -> attributes() -> applyNumberFormat || (0 == (int)$XF -> attributes() -> numFmtId)) 231 { 232 $FormatId = (int)$XF -> attributes() -> numFmtId; 233 // If format ID >= 164, it is a custom format and should be read from styleSheet\numFmts 234 $this -> Styles[] = $FormatId; 235 } 236 else 237 { 238 // 0 for "General" format 239 $this -> Styles[] = 0; 240 } 241 } 242 } 243 244 if ($this -> StylesXML -> numFmts && $this -> StylesXML -> numFmts -> numFmt) 245 { 246 foreach ($this -> StylesXML -> numFmts -> numFmt as $Index => $NumFmt) 247 { 248 $this -> Formats[(int)$NumFmt -> attributes() -> numFmtId] = (string)$NumFmt -> attributes() -> formatCode; 249 } 250 } 251 unset($this -> StylesXML); 252 } 253 $Zip -> close(); 254 // Setting base date 255 if (!self::$BaseDate) 256 { 257 self::$BaseDate = new DateTime; 258 self::$BaseDate -> setTimezone(new DateTimeZone('UTC')); 259 self::$BaseDate -> setDate(1900, 1, 0); 260 self::$BaseDate -> setTime(0, 0, 0); 261 } 262 // Decimal and thousand separators 263 if (!self::$DecimalSeparator && !self::$ThousandSeparator && !self::$CurrencyCode) 264 { 265 $Locale = localeconv(); 266 self::$DecimalSeparator = $Locale['decimal_point']; 267 self::$ThousandSeparator = $Locale['thousands_sep']; 268 self::$CurrencyCode = $Locale['int_curr_symbol']; 269 } 270 if (function_exists('gmp_gcd')) 271 { 272 self::$RuntimeInfo['GMPSupported'] = true; 273 } 274 } 275 /** 276 * Destructor, destroys all that remains (closes and deletes temp files) 277 */ 278 public function __destruct() 279 { 280 foreach ($this -> TempFiles as $TempFile) 281 { 282 @unlink($TempFile); 283 } 284 // Better safe than sorry - shouldn't try deleting '.' or '/', or '..'. 285 if (strlen($this -> TempDir) > 2) 286 { 287 @rmdir($this -> TempDir.'xl'.DIRECTORY_SEPARATOR.'worksheets'); 288 @rmdir($this -> TempDir.'xl'); 289 @rmdir($this -> TempDir); 290 } 291 if ($this -> Worksheet && $this -> Worksheet instanceof XMLReader) 292 { 293 $this -> Worksheet -> close(); 294 unset($this -> Worksheet); 295 } 296 unset($this -> WorksheetPath); 297 if ($this -> SharedStrings && $this -> SharedStrings instanceof XMLReader) 298 { 299 $this -> SharedStrings -> close(); 300 unset($this -> SharedStrings); 301 } 302 unset($this -> SharedStringsPath); 303 if (isset($this -> StylesXML)) 304 { 305 unset($this -> StylesXML); 306 } 307 if ($this -> WorkbookXML) 308 { 309 unset($this -> WorkbookXML); 310 } 311 } 312 /** 313 * Retrieves an array with information about sheets in the current file 314 * 315 * @return array List of sheets (key is sheet index, value is name) 316 */ 317 public function Sheets() 318 { 319 if ($this -> Sheets === false) 320 { 321 $this -> Sheets = array(); 322 foreach ($this -> WorkbookXML -> sheets -> sheet as $Index => $Sheet) 323 { 324 $AttributesWithPrefix = $Sheet -> attributes('r', true); 325 $Attributes = $Sheet -> attributes(); 326 327 $rId = 0; 328 $sheetId = 0; 329 330 foreach ($AttributesWithPrefix as $Name => $Value) 331 { 332 if ($Name == 'id') 333 { 334 $rId = (int)str_replace('rId', '', (string)$Value); 335 break; 336 } 337 } 338 foreach ($Attributes as $Name => $Value) 339 { 340 if ($Name == 'sheetId') { 341 $sheetId = (int)$Value; 342 break; 343 } 344 } 345 346 $this -> Sheets[min($rId, $sheetId)] = (string)$Sheet['name']; 347 } 348 ksort($this -> Sheets); 349 } 350 return array_values($this -> Sheets); 351 } 352 /** 353 * Changes the current sheet in the file to another 354 * 355 * @param int Sheet index 356 * 357 * @return bool True if sheet was successfully changed, false otherwise. 358 */ 359 public function ChangeSheet($Index) 360 { 361 $RealSheetIndex = false; 362 $Sheets = $this -> Sheets(); 363 if (isset($Sheets[$Index])) 364 { 365 $SheetIndexes = array_keys($this -> Sheets); 366 $RealSheetIndex = $SheetIndexes[$Index]; 367 } 368 $TempWorksheetPath = $this -> TempDir.'xl/worksheets/sheet'.$RealSheetIndex.'.xml'; 369 if ($RealSheetIndex !== false && is_readable($TempWorksheetPath)) 370 { 371 $this -> WorksheetPath = $TempWorksheetPath; 372 $this -> rewind(); 373 return true; 374 } 375 return false; 376 } 377 /** 378 * Creating shared string cache if the number of shared strings is acceptably low (or there is no limit on the amount 379 */ 380 private function PrepareSharedStringCache() 381 { 382 while ($this -> SharedStrings -> read()) 383 { 384 if ($this -> SharedStrings -> name == 'sst') 385 { 386 $this -> SharedStringCount = $this -> SharedStrings -> getAttribute('count'); 387 break; 388 } 389 } 390 if (!$this -> SharedStringCount || (self::SHARED_STRING_CACHE_LIMIT < $this -> SharedStringCount && self::SHARED_STRING_CACHE_LIMIT !== null)) 391 { 392 return false; 393 } 394 $CacheIndex = 0; 395 $CacheValue = ''; 396 while ($this -> SharedStrings -> read()) 397 { 398 switch ($this -> SharedStrings -> name) 399 { 400 case 'si': 401 if ($this -> SharedStrings -> nodeType == XMLReader::END_ELEMENT) 402 { 403 $this -> SharedStringCache[$CacheIndex] = $CacheValue; 404 $CacheIndex++; 405 $CacheValue = ''; 406 } 407 break; 408 case 't': 409 if ($this -> SharedStrings -> nodeType == XMLReader::END_ELEMENT) 410 { 411 continue; 412 } 413 $CacheValue .= $this -> SharedStrings -> readString(); 414 break; 415 } 416 } 417 $this -> SharedStrings -> close(); 418 return true; 419 } 420 /** 421 * Retrieves a shared string value by its index 422 * 423 * @param int Shared string index 424 * 425 * @return string Value 426 */ 427 private function GetSharedString($Index) 428 { 429 if ((self::SHARED_STRING_CACHE_LIMIT === null || self::SHARED_STRING_CACHE_LIMIT > 0) && !empty($this -> SharedStringCache)) 430 { 431 if (isset($this -> SharedStringCache[$Index])) 432 { 433 return $this -> SharedStringCache[$Index]; 434 } 435 else 436 { 437 return ''; 438 } 439 } 440 // If the desired index is before the current, rewind the XML 441 if ($this -> SharedStringIndex > $Index) 442 { 443 $this -> SSOpen = false; 444 $this -> SharedStrings -> close(); 445 $this -> SharedStrings -> open($this -> SharedStringsPath); 446 $this -> SharedStringIndex = 0; 447 $this -> LastSharedStringValue = null; 448 $this -> SSForwarded = false; 449 } 450 // Finding the unique string count (if not already read) 451 if ($this -> SharedStringIndex == 0 && !$this -> SharedStringCount) 452 { 453 while ($this -> SharedStrings -> read()) 454 { 455 if ($this -> SharedStrings -> name == 'sst') 456 { 457 $this -> SharedStringCount = $this -> SharedStrings -> getAttribute('uniqueCount'); 458 break; 459 } 460 } 461 } 462 // If index of the desired string is larger than possible, don't even bother. 463 if ($this -> SharedStringCount && ($Index >= $this -> SharedStringCount)) 464 { 465 return ''; 466 } 467 // If an index with the same value as the last already fetched is requested 468 // (any further traversing the tree would get us further away from the node) 469 if (($Index == $this -> SharedStringIndex) && ($this -> LastSharedStringValue !== null)) 470 { 471 return $this -> LastSharedStringValue; 472 } 473 // Find the correct <si> node with the desired index 474 while ($this -> SharedStringIndex <= $Index) 475 { 476 // SSForwarded is set further to avoid double reading in case nodes are skipped. 477 if ($this -> SSForwarded) 478 { 479 $this -> SSForwarded = false; 480 } 481 else 482 { 483 $ReadStatus = $this -> SharedStrings -> read(); 484 if (!$ReadStatus) 485 { 486 break; 487 } 488 } 489 if ($this -> SharedStrings -> name == 'si') 490 { 491 if ($this -> SharedStrings -> nodeType == XMLReader::END_ELEMENT) 492 { 493 $this -> SSOpen = false; 494 $this -> SharedStringIndex++; 495 } 496 else 497 { 498 $this -> SSOpen = true; 499 500 if ($this -> SharedStringIndex < $Index) 501 { 502 $this -> SSOpen = false; 503 $this -> SharedStrings -> next('si'); 504 $this -> SSForwarded = true; 505 $this -> SharedStringIndex++; 506 continue; 507 } 508 else 509 { 510 break; 511 } 512 } 513 } 514 } 515 $Value = ''; 516 // Extract the value from the shared string 517 if ($this -> SSOpen && ($this -> SharedStringIndex == $Index)) 518 { 519 while ($this -> SharedStrings -> read()) 520 { 521 switch ($this -> SharedStrings -> name) 522 { 523 case 't': 524 if ($this -> SharedStrings -> nodeType == XMLReader::END_ELEMENT) 525 { 526 continue; 527 } 528 $Value .= $this -> SharedStrings -> readString(); 529 break; 530 case 'si': 531 if ($this -> SharedStrings -> nodeType == XMLReader::END_ELEMENT) 532 { 533 $this -> SSOpen = false; 534 $this -> SSForwarded = true; 535 break 2; 536 } 537 break; 538 } 539 } 540 } 541 if ($Value) 542 { 543 $this -> LastSharedStringValue = $Value; 544 } 545 return $Value; 546 } 547 /** 548 * Formats the value according to the index 549 * 550 * @param string Cell value 551 * @param int Format index 552 * 553 * @return string Formatted cell value 554 */ 555 private function FormatValue($Value, $Index) 556 { 557 if (!is_numeric($Value)) 558 { 559 return $Value; 560 } 561 if (isset($this -> Styles[$Index]) && ($this -> Styles[$Index] !== false)) 562 { 563 $Index = $this -> Styles[$Index]; 564 } 565 else 566 { 567 return $Value; 568 } 569 // A special case for the "General" format 570 if ($Index == 0) 571 { 572 return $this -> GeneralFormat($Value); 573 } 574 $Format = array(); 575 if (isset($this -> ParsedFormatCache[$Index])) 576 { 577 $Format = $this -> ParsedFormatCache[$Index]; 578 } 579 if (!$Format) 580 { 581 $Format = array( 582 'Code' => false, 583 'Type' => false, 584 'Scale' => 1, 585 'Thousands' => false, 586 'Currency' => false 587 ); 588 if (isset(self::$BuiltinFormats[$Index])) 589 { 590 $Format['Code'] = self::$BuiltinFormats[$Index]; 591 } 592 elseif (isset($this -> Formats[$Index])) 593 { 594 $Format['Code'] = $this -> Formats[$Index]; 595 } 596 // Format code found, now parsing the format 597 if ($Format['Code']) 598 { 599 $Sections = explode(';', $Format['Code']); 600 $Format['Code'] = $Sections[0]; 601 602 switch (count($Sections)) 603 { 604 case 2: 605 if ($Value < 0) 606 { 607 $Format['Code'] = $Sections[1]; 608 } 609 break; 610 case 3: 611 case 4: 612 if ($Value < 0) 613 { 614 $Format['Code'] = $Sections[1]; 615 } 616 elseif ($Value == 0) 617 { 618 $Format['Code'] = $Sections[2]; 619 } 620 break; 621 } 622 } 623 // Stripping colors 624 $Format['Code'] = trim(preg_replace('{^\[[[:alpha:]]+\]}i', '', $Format['Code'])); 625 // Percentages 626 if (substr($Format['Code'], -1) == '%') 627 { 628 $Format['Type'] = 'Percentage'; 629 } 630 elseif (preg_match('{^(\[\$[[:alpha:]]*-[0-9A-F]*\])*[hmsdy]}i', $Format['Code'])) 631 { 632 $Format['Type'] = 'DateTime'; 633 $Format['Code'] = trim(preg_replace('{^(\[\$[[:alpha:]]*-[0-9A-F]*\])}i', '', $Format['Code'])); 634 $Format['Code'] = strtolower($Format['Code']); 635 $Format['Code'] = strtr($Format['Code'], self::$DateReplacements['All']); 636 if (strpos($Format['Code'], 'A') === false) 637 { 638 $Format['Code'] = strtr($Format['Code'], self::$DateReplacements['24H']); 639 } 640 else 641 { 642 $Format['Code'] = strtr($Format['Code'], self::$DateReplacements['12H']); 643 } 644 } 645 elseif ($Format['Code'] == '[$EUR ]#,##0.00_-') 646 { 647 $Format['Type'] = 'Euro'; 648 } 649 else 650 { 651 // Removing skipped characters 652 $Format['Code'] = preg_replace('{_.}', '', $Format['Code']); 653 // Removing unnecessary escaping 654 $Format['Code'] = preg_replace("{\\\\}", '', $Format['Code']); 655 // Removing string quotes 656 $Format['Code'] = str_replace(array('"', '*'), '', $Format['Code']); 657 // Removing thousands separator 658 if (strpos($Format['Code'], '0,0') !== false || strpos($Format['Code'], '#,#') !== false) 659 { 660 $Format['Thousands'] = true; 661 } 662 $Format['Code'] = str_replace(array('0,0', '#,#'), array('00', '##'), $Format['Code']); 663 // Scaling (Commas indicate the power) 664 $Scale = 1; 665 $Matches = array(); 666 if (preg_match('{(0|#)(,+)}', $Format['Code'], $Matches)) 667 { 668 $Scale = pow(1000, strlen($Matches[2])); 669 // Removing the commas 670 $Format['Code'] = preg_replace(array('{0,+}', '{#,+}'), array('0', '#'), $Format['Code']); 671 } 672 $Format['Scale'] = $Scale; 673 if (preg_match('{#?.*\?\/\?}', $Format['Code'])) 674 { 675 $Format['Type'] = 'Fraction'; 676 } 677 else 678 { 679 $Format['Code'] = str_replace('#', '', $Format['Code']); 680 $Matches = array(); 681 if (preg_match('{(0+)(\.?)(0*)}', preg_replace('{\[[^\]]+\]}', '', $Format['Code']), $Matches)) 682 { 683 $Integer = $Matches[1]; 684 $DecimalPoint = $Matches[2]; 685 $Decimals = $Matches[3]; 686 $Format['MinWidth'] = strlen($Integer) + strlen($DecimalPoint) + strlen($Decimals); 687 $Format['Decimals'] = $Decimals; 688 $Format['Precision'] = strlen($Format['Decimals']); 689 $Format['Pattern'] = '%0'.$Format['MinWidth'].'.'.$Format['Precision'].'f'; 690 } 691 } 692 $Matches = array(); 693 if (preg_match('{\[\$(.*)\]}u', $Format['Code'], $Matches)) 694 { 695 $CurrFormat = $Matches[0]; 696 $CurrCode = $Matches[1]; 697 $CurrCode = explode('-', $CurrCode); 698 if ($CurrCode) 699 { 700 $CurrCode = $CurrCode[0]; 701 } 702 if (!$CurrCode) 703 { 704 $CurrCode = self::$CurrencyCode; 705 } 706 $Format['Currency'] = $CurrCode; 707 } 708 $Format['Code'] = trim($Format['Code']); 709 } 710 $this -> ParsedFormatCache[$Index] = $Format; 711 } 712 // Applying format to value 713 if ($Format) 714 { 715 if ($Format['Code'] == '@') 716 { 717 return (string)$Value; 718 } 719 // Percentages 720 elseif ($Format['Type'] == 'Percentage') 721 { 722 if ($Format['Code'] === '0%') 723 { 724 $Value = round(100 * $Value, 0).'%'; 725 } 726 else 727 { 728 $Value = sprintf('%.2f%%', round(100 * $Value, 2)); 729 } 730 } 731 // Dates and times 732 elseif ($Format['Type'] == 'DateTime') 733 { 734 $Days = (int)$Value; 735 // Correcting for Feb 29, 1900 736 if ($Days > 60) 737 { 738 $Days--; 739 } 740 // At this point time is a fraction of a day 741 $Time = ($Value - (int)$Value); 742 $Seconds = 0; 743 if ($Time) 744 { 745 // Here time is converted to seconds 746 // Some loss of precision will occur 747 $Seconds = (int)($Time * 86400); 748 } 749 $Value = clone self::$BaseDate; 750 $Value -> add(new DateInterval('P'.$Days.'D'.($Seconds ? 'T'.$Seconds.'S' : ''))); 751 if (!$this -> Options['ReturnDateTimeObjects']) 752 { 753 $Value = $Value -> format($Format['Code']); 754 } 755 else 756 { 757 // A DateTime object is returned 758 } 759 } 760 elseif ($Format['Type'] == 'Euro') 761 { 762 $Value = 'EUR '.sprintf('%1.2f', $Value); 763 } 764 else 765 { 766 // Fractional numbers 767 if ($Format['Type'] == 'Fraction' && ($Value != (int)$Value)) 768 { 769 $Integer = floor(abs($Value)); 770 $Decimal = fmod(abs($Value), 1); 771 // Removing the integer part and decimal point 772 $Decimal *= pow(10, strlen($Decimal) - 2); 773 $DecimalDivisor = pow(10, strlen($Decimal)); 774 if (self::$RuntimeInfo['GMPSupported']) 775 { 776 $GCD = gmp_strval(gmp_gcd($Decimal, $DecimalDivisor)); 777 } 778 else 779 { 780 $GCD = self::GCD($Decimal, $DecimalDivisor); 781 } 782 $AdjDecimal = $DecimalPart/$GCD; 783 $AdjDecimalDivisor = $DecimalDivisor/$GCD; 784 if ( 785 strpos($Format['Code'], '0') !== false || 786 strpos($Format['Code'], '#') !== false || 787 substr($Format['Code'], 0, 3) == '? ?' 788 ) 789 { 790 // The integer part is shown separately apart from the fraction 791 $Value = ($Value < 0 ? '-' : ''). 792 $Integer ? $Integer.' ' : ''. 793 $AdjDecimal.'/'. 794 $AdjDecimalDivisor; 795 } 796 else 797 { 798 // The fraction includes the integer part 799 $AdjDecimal += $Integer * $AdjDecimalDivisor; 800 $Value = ($Value < 0 ? '-' : ''). 801 $AdjDecimal.'/'. 802 $AdjDecimalDivisor; 803 } 804 } 805 else 806 { 807 // Scaling 808 $Value = $Value / $Format['Scale']; 809 if (!empty($Format['MinWidth']) && $Format['Decimals']) 810 { 811 if ($Format['Thousands']) 812 { 813 $Value = number_format($Value, $Format['Precision'], 814 self::$DecimalSeparator, self::$ThousandSeparator); 815 } 816 else 817 { 818 $Value = sprintf($Format['Pattern'], $Value); 819 } 820 $Value = preg_replace('{(0+)(\.?)(0*)}', $Value, $Format['Code']); 821 } 822 } 823 // Currency/Accounting 824 if ($Format['Currency']) 825 { 826 $Value = preg_replace('', $Format['Currency'], $Value); 827 } 828 } 829 830 } 831 return $Value; 832 } 833 /** 834 * Attempts to approximate Excel's "general" format. 835 * 836 * @param mixed Value 837 * 838 * @return mixed Result 839 */ 840 public function GeneralFormat($Value) 841 { 842 // Numeric format 843 if (is_numeric($Value)) 844 { 845 $Value = (float)$Value; 846 } 847 return $Value; 848 } 849 // !Iterator interface methods 850 /** 851 * Rewind the Iterator to the first element. 852 * Similar to the reset() function for arrays in PHP 853 */ 854 public function rewind() 855 { 856 // Removed the check whether $this -> Index == 0 otherwise ChangeSheet doesn't work properly 857 // If the worksheet was already iterated, XML file is reopened. 858 // Otherwise it should be at the beginning anyway 859 if ($this -> Worksheet instanceof XMLReader) 860 { 861 $this -> Worksheet -> close(); 862 } 863 else 864 { 865 $this -> Worksheet = new XMLReader; 866 } 867 $this -> Worksheet -> open($this -> WorksheetPath); 868 $this -> Valid = true; 869 $this -> RowOpen = false; 870 $this -> CurrentRow = false; 871 $this -> Index = 0; 872 } 873 /** 874 * Return the current element. 875 * Similar to the current() function for arrays in PHP 876 * 877 * @return mixed current element from the collection 878 */ 879 public function current() 880 { 881 if ($this -> Index == 0 && $this -> CurrentRow === false) 882 { 883 $this -> next(); 884 $this -> Index--; 885 } 886 return $this -> CurrentRow; 887 } 888 /** 889 * Move forward to next element. 890 * Similar to the next() function for arrays in PHP 891 */ 892 public function next() 893 { 894 $this -> Index++; 895 $this -> CurrentRow = array(); 896 if (!$this -> RowOpen) 897 { 898 while ($this -> Valid = $this -> Worksheet -> read()) 899 { 900 if ($this -> Worksheet -> name == 'row') 901 { 902 // Getting the row spanning area (stored as e.g., 1:12) 903 // so that the last cells will be present, even if empty 904 $RowSpans = $this -> Worksheet -> getAttribute('spans'); 905 if ($RowSpans) 906 { 907 $RowSpans = explode(':', $RowSpans); 908 $CurrentRowColumnCount = $RowSpans[1]; 909 } 910 else 911 { 912 $CurrentRowColumnCount = 0; 913 } 914 if ($CurrentRowColumnCount > 0) 915 { 916 $this -> CurrentRow = array_fill(0, $CurrentRowColumnCount, ''); 917 } 918 $this -> RowOpen = true; 919 break; 920 } 921 } 922 } 923 // Reading the necessary row, if found 924 if ($this -> RowOpen) 925 { 926 // These two are needed to control for empty cells 927 $MaxIndex = 0; 928 $CellCount = 0; 929 $CellHasSharedString = false; 930 while ($this -> Valid = $this -> Worksheet -> read()) 931 { 932 switch ($this -> Worksheet -> name) 933 { 934 // End of row 935 case 'row': 936 if ($this -> Worksheet -> nodeType == XMLReader::END_ELEMENT) 937 { 938 $this -> RowOpen = false; 939 break 2; 940 } 941 break; 942 // Cell 943 case 'c': 944 // If it is a closing tag, skip it 945 if ($this -> Worksheet -> nodeType == XMLReader::END_ELEMENT) 946 { 947 continue; 948 } 949 $StyleId = (int)$this -> Worksheet -> getAttribute('s'); 950 // Get the index of the cell 951 $Index = $this -> Worksheet -> getAttribute('r'); 952 $Letter = preg_replace('{[^[:alpha:]]}S', '', $Index); 953 $Index = self::IndexFromColumnLetter($Letter); 954 // Determine cell type 955 if ($this -> Worksheet -> getAttribute('t') == self::CELL_TYPE_SHARED_STR) 956 { 957 $CellHasSharedString = true; 958 } 959 else 960 { 961 $CellHasSharedString = false; 962 } 963 $this -> CurrentRow[$Index] = ''; 964 $CellCount++; 965 if ($Index > $MaxIndex) 966 { 967 $MaxIndex = $Index; 968 } 969 break; 970 // Cell value 971 case 'v': 972 case 'is': 973 if ($this -> Worksheet -> nodeType == XMLReader::END_ELEMENT) 974 { 975 continue; 976 } 977 $Value = $this -> Worksheet -> readString(); 978 if ($CellHasSharedString) 979 { 980 $Value = $this -> GetSharedString($Value); 981 } 982 // Format value if necessary 983 if ($Value !== '' && $StyleId && isset($this -> Styles[$StyleId])) 984 { 985 $Value = $this -> FormatValue($Value, $StyleId); 986 } 987 elseif ($Value) 988 { 989 $Value = $this -> GeneralFormat($Value); 990 } 991 $this -> CurrentRow[$Index] = $Value; 992 break; 993 } 994 } 995 // Adding empty cells, if necessary 996 // Only empty cells inbetween and on the left side are added 997 if ($MaxIndex + 1 > $CellCount) 998 { 999 $this -> CurrentRow = $this -> CurrentRow + array_fill(0, $MaxIndex + 1, ''); 1000 ksort($this -> CurrentRow); 1001 } 1002 } 1003 return $this -> CurrentRow; 1004 } 1005 /** 1006 * Return the identifying key of the current element. 1007 * Similar to the key() function for arrays in PHP 1008 * 1009 * @return mixed either an integer or a string 1010 */ 1011 public function key() 1012 { 1013 return $this -> Index; 1014 } 1015 /** 1016 * Check if there is a current element after calls to rewind() or next(). 1017 * Used to check if we've iterated to the end of the collection 1018 * 1019 * @return boolean FALSE if there's nothing more to iterate over 1020 */ 1021 public function valid() 1022 { 1023 return $this -> Valid; 1024 } 1025 // !Countable interface method 1026 /** 1027 * Ostensibly should return the count of the contained items but this just returns the number 1028 * of rows read so far. It's not really correct but at least coherent. 1029 */ 1030 public function count() 1031 { 1032 return $this -> Index + 1; 1033 } 1034 /** 1035 * Takes the column letter and converts it to a numerical index (0-based) 1036 * 1037 * @param string Letter(s) to convert 1038 * 1039 * @return mixed Numeric index (0-based) or boolean false if it cannot be calculated 1040 */ 1041 public static function IndexFromColumnLetter($Letter) 1042 { 1043 $Powers = array(); 1044 $Letter = strtoupper($Letter); 1045 $Result = 0; 1046 for ($i = strlen($Letter) - 1, $j = 0; $i >= 0; $i--, $j++) 1047 { 1048 $Ord = ord($Letter[$i]) - 64; 1049 if ($Ord > 26) 1050 { 1051 // Something is very, very wrong 1052 return false; 1053 } 1054 $Result += $Ord * pow(26, $j); 1055 } 1056 return $Result - 1; 1057 } 1058 /** 1059 * Helper function for greatest common divisor calculation in case GMP extension is 1060 * not enabled 1061 * 1062 * @param int Number #1 1063 * @param int Number #2 1064 * 1065 * @param int Greatest common divisor 1066 */ 1067 public static function GCD($A, $B) 1068 { 1069 $A = abs($A); 1070 $B = abs($B); 1071 if ($A + $B == 0) 1072 { 1073 return 0; 1074 } 1075 else 1076 { 1077 $C = 1; 1078 while ($A > 0) 1079 { 1080 $C = $A; 1081 $A = $B % $A; 1082 $B = $C; 1083 } 1084 return $C; 1085 } 1086 } 1087 } 1088 ?>