Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   1  <?php
   2  
   3  namespace PhpOffice\PhpSpreadsheet\Reader;
   4  
   5  use InvalidArgumentException;
   6  use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
   7  use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
   8  use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
   9  use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
  10  use PhpOffice\PhpSpreadsheet\Spreadsheet;
  11  use PhpOffice\PhpSpreadsheet\Style\Border;
  12  use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
  13  
  14  class Slk extends BaseReader
  15  {
  16      /**
  17       * Input encoding.
  18       *
  19       * @var string
  20       */
  21      private $inputEncoding = 'ANSI';
  22  
  23      /**
  24       * Sheet index to read.
  25       *
  26       * @var int
  27       */
  28      private $sheetIndex = 0;
  29  
  30      /**
  31       * Formats.
  32       *
  33       * @var array
  34       */
  35      private $formats = [];
  36  
  37      /**
  38       * Format Count.
  39       *
  40       * @var int
  41       */
  42      private $format = 0;
  43  
  44      /**
  45       * Fonts.
  46       *
  47       * @var array
  48       */
  49      private $fonts = [];
  50  
  51      /**
  52       * Font Count.
  53       *
  54       * @var int
  55       */
  56      private $fontcount = 0;
  57  
  58      /**
  59       * Create a new SYLK Reader instance.
  60       */
  61      public function __construct()
  62      {
  63          parent::__construct();
  64      }
  65  
  66      /**
  67       * Validate that the current file is a SYLK file.
  68       *
  69       * @param string $pFilename
  70       *
  71       * @return bool
  72       */
  73      public function canRead($pFilename)
  74      {
  75          try {
  76              $this->openFile($pFilename);
  77          } catch (InvalidArgumentException $e) {
  78              return false;
  79          }
  80  
  81          // Read sample data (first 2 KB will do)
  82          $data = fread($this->fileHandle, 2048);
  83  
  84          // Count delimiters in file
  85          $delimiterCount = substr_count($data, ';');
  86          $hasDelimiter = $delimiterCount > 0;
  87  
  88          // Analyze first line looking for ID; signature
  89          $lines = explode("\n", $data);
  90          $hasId = substr($lines[0], 0, 4) === 'ID;P';
  91  
  92          fclose($this->fileHandle);
  93  
  94          return $hasDelimiter && $hasId;
  95      }
  96  
  97      private function canReadOrBust(string $pFilename): void
  98      {
  99          if (!$this->canRead($pFilename)) {
 100              throw new ReaderException($pFilename . ' is an Invalid SYLK file.');
 101          }
 102          $this->openFile($pFilename);
 103      }
 104  
 105      /**
 106       * Set input encoding.
 107       *
 108       * @deprecated no use is made of this property
 109       *
 110       * @param string $pValue Input encoding, eg: 'ANSI'
 111       *
 112       * @return $this
 113       *
 114       * @codeCoverageIgnore
 115       */
 116      public function setInputEncoding($pValue)
 117      {
 118          $this->inputEncoding = $pValue;
 119  
 120          return $this;
 121      }
 122  
 123      /**
 124       * Get input encoding.
 125       *
 126       * @deprecated no use is made of this property
 127       *
 128       * @return string
 129       *
 130       * @codeCoverageIgnore
 131       */
 132      public function getInputEncoding()
 133      {
 134          return $this->inputEncoding;
 135      }
 136  
 137      /**
 138       * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns).
 139       *
 140       * @param string $pFilename
 141       *
 142       * @return array
 143       */
 144      public function listWorksheetInfo($pFilename)
 145      {
 146          // Open file
 147          $this->canReadOrBust($pFilename);
 148          $fileHandle = $this->fileHandle;
 149          rewind($fileHandle);
 150  
 151          $worksheetInfo = [];
 152          $worksheetInfo[0]['worksheetName'] = basename($pFilename, '.slk');
 153  
 154          // loop through one row (line) at a time in the file
 155          $rowIndex = 0;
 156          $columnIndex = 0;
 157          while (($rowData = fgets($fileHandle)) !== false) {
 158              $columnIndex = 0;
 159  
 160              // convert SYLK encoded $rowData to UTF-8
 161              $rowData = StringHelper::SYLKtoUTF8($rowData);
 162  
 163              // explode each row at semicolons while taking into account that literal semicolon (;)
 164              // is escaped like this (;;)
 165              $rowData = explode("\t", str_replace('¤', ';', str_replace(';', "\t", str_replace(';;', '¤', rtrim($rowData)))));
 166  
 167              $dataType = array_shift($rowData);
 168              if ($dataType == 'B') {
 169                  foreach ($rowData as $rowDatum) {
 170                      switch ($rowDatum[0]) {
 171                          case 'X':
 172                              $columnIndex = substr($rowDatum, 1) - 1;
 173  
 174                              break;
 175                          case 'Y':
 176                              $rowIndex = substr($rowDatum, 1);
 177  
 178                              break;
 179                      }
 180                  }
 181  
 182                  break;
 183              }
 184          }
 185  
 186          $worksheetInfo[0]['lastColumnIndex'] = $columnIndex;
 187          $worksheetInfo[0]['totalRows'] = $rowIndex;
 188          $worksheetInfo[0]['lastColumnLetter'] = Coordinate::stringFromColumnIndex($worksheetInfo[0]['lastColumnIndex'] + 1);
 189          $worksheetInfo[0]['totalColumns'] = $worksheetInfo[0]['lastColumnIndex'] + 1;
 190  
 191          // Close file
 192          fclose($fileHandle);
 193  
 194          return $worksheetInfo;
 195      }
 196  
 197      /**
 198       * Loads PhpSpreadsheet from file.
 199       *
 200       * @param string $pFilename
 201       *
 202       * @return Spreadsheet
 203       */
 204      public function load($pFilename)
 205      {
 206          // Create new Spreadsheet
 207          $spreadsheet = new Spreadsheet();
 208  
 209          // Load into this instance
 210          return $this->loadIntoExisting($pFilename, $spreadsheet);
 211      }
 212  
 213      private $colorArray = [
 214          'FF00FFFF', // 0 - cyan
 215          'FF000000', // 1 - black
 216          'FFFFFFFF', // 2 - white
 217          'FFFF0000', // 3 - red
 218          'FF00FF00', // 4 - green
 219          'FF0000FF', // 5 - blue
 220          'FFFFFF00', // 6 - yellow
 221          'FFFF00FF', // 7 - magenta
 222      ];
 223  
 224      private $fontStyleMappings = [
 225          'B' => 'bold',
 226          'I' => 'italic',
 227          'U' => 'underline',
 228      ];
 229  
 230      private function processFormula(string $rowDatum, bool &$hasCalculatedValue, string &$cellDataFormula, string $row, string $column): void
 231      {
 232          $cellDataFormula = '=' . substr($rowDatum, 1);
 233          //    Convert R1C1 style references to A1 style references (but only when not quoted)
 234          $temp = explode('"', $cellDataFormula);
 235          $key = false;
 236          foreach ($temp as &$value) {
 237              //    Only count/replace in alternate array entries
 238              if ($key = !$key) {
 239                  preg_match_all('/(R(\[?-?\d*\]?))(C(\[?-?\d*\]?))/', $value, $cellReferences, PREG_SET_ORDER + PREG_OFFSET_CAPTURE);
 240                  //    Reverse the matches array, otherwise all our offsets will become incorrect if we modify our way
 241                  //        through the formula from left to right. Reversing means that we work right to left.through
 242                  //        the formula
 243                  $cellReferences = array_reverse($cellReferences);
 244                  //    Loop through each R1C1 style reference in turn, converting it to its A1 style equivalent,
 245                  //        then modify the formula to use that new reference
 246                  foreach ($cellReferences as $cellReference) {
 247                      $rowReference = $cellReference[2][0];
 248                      //    Empty R reference is the current row
 249                      if ($rowReference == '') {
 250                          $rowReference = $row;
 251                      }
 252                      //    Bracketed R references are relative to the current row
 253                      if ($rowReference[0] == '[') {
 254                          $rowReference = $row + trim($rowReference, '[]');
 255                      }
 256                      $columnReference = $cellReference[4][0];
 257                      //    Empty C reference is the current column
 258                      if ($columnReference == '') {
 259                          $columnReference = $column;
 260                      }
 261                      //    Bracketed C references are relative to the current column
 262                      if ($columnReference[0] == '[') {
 263                          $columnReference = $column + trim($columnReference, '[]');
 264                      }
 265                      $A1CellReference = Coordinate::stringFromColumnIndex($columnReference) . $rowReference;
 266  
 267                      $value = substr_replace($value, $A1CellReference, $cellReference[0][1], strlen($cellReference[0][0]));
 268                  }
 269              }
 270          }
 271          unset($value);
 272          //    Then rebuild the formula string
 273          $cellDataFormula = implode('"', $temp);
 274          $hasCalculatedValue = true;
 275      }
 276  
 277      private function processCRecord(array $rowData, Spreadsheet &$spreadsheet, string &$row, string &$column): void
 278      {
 279          //    Read cell value data
 280          $hasCalculatedValue = false;
 281          $cellDataFormula = $cellData = '';
 282          foreach ($rowData as $rowDatum) {
 283              switch ($rowDatum[0]) {
 284                  case 'C':
 285                  case 'X':
 286                      $column = substr($rowDatum, 1);
 287  
 288                      break;
 289                  case 'R':
 290                  case 'Y':
 291                      $row = substr($rowDatum, 1);
 292  
 293                      break;
 294                  case 'K':
 295                      $cellData = substr($rowDatum, 1);
 296  
 297                      break;
 298                  case 'E':
 299                      $this->processFormula($rowDatum, $hasCalculatedValue, $cellDataFormula, $row, $column);
 300  
 301                      break;
 302              }
 303          }
 304          $columnLetter = Coordinate::stringFromColumnIndex((int) $column);
 305          $cellData = Calculation::unwrapResult($cellData);
 306  
 307          // Set cell value
 308          $this->processCFinal($spreadsheet, $hasCalculatedValue, $cellDataFormula, $cellData, "$columnLetter$row");
 309      }
 310  
 311      private function processCFinal(Spreadsheet &$spreadsheet, bool $hasCalculatedValue, string $cellDataFormula, string $cellData, string $coordinate): void
 312      {
 313          // Set cell value
 314          $spreadsheet->getActiveSheet()->getCell($coordinate)->setValue(($hasCalculatedValue) ? $cellDataFormula : $cellData);
 315          if ($hasCalculatedValue) {
 316              $cellData = Calculation::unwrapResult($cellData);
 317              $spreadsheet->getActiveSheet()->getCell($coordinate)->setCalculatedValue($cellData);
 318          }
 319      }
 320  
 321      private function processFRecord(array $rowData, Spreadsheet &$spreadsheet, string &$row, string &$column): void
 322      {
 323          //    Read cell formatting
 324          $formatStyle = $columnWidth = '';
 325          $startCol = $endCol = '';
 326          $fontStyle = '';
 327          $styleData = [];
 328          foreach ($rowData as $rowDatum) {
 329              switch ($rowDatum[0]) {
 330                  case 'C':
 331                  case 'X':
 332                      $column = substr($rowDatum, 1);
 333  
 334                      break;
 335                  case 'R':
 336                  case 'Y':
 337                      $row = substr($rowDatum, 1);
 338  
 339                      break;
 340                  case 'P':
 341                      $formatStyle = $rowDatum;
 342  
 343                      break;
 344                  case 'W':
 345                      [$startCol, $endCol, $columnWidth] = explode(' ', substr($rowDatum, 1));
 346  
 347                      break;
 348                  case 'S':
 349                      $this->styleSettings($rowDatum, $styleData, $fontStyle);
 350  
 351                      break;
 352              }
 353          }
 354          $this->addFormats($spreadsheet, $formatStyle, $row, $column);
 355          $this->addFonts($spreadsheet, $fontStyle, $row, $column);
 356          $this->addStyle($spreadsheet, $styleData, $row, $column);
 357          $this->addWidth($spreadsheet, $columnWidth, $startCol, $endCol);
 358      }
 359  
 360      private $styleSettingsFont = ['D' => 'bold', 'I' => 'italic'];
 361  
 362      private $styleSettingsBorder = [
 363          'B' => 'bottom',
 364          'L' => 'left',
 365          'R' => 'right',
 366          'T' => 'top',
 367      ];
 368  
 369      private function styleSettings(string $rowDatum, array &$styleData, string &$fontStyle): void
 370      {
 371          $styleSettings = substr($rowDatum, 1);
 372          $iMax = strlen($styleSettings);
 373          for ($i = 0; $i < $iMax; ++$i) {
 374              $char = $styleSettings[$i];
 375              if (array_key_exists($char, $this->styleSettingsFont)) {
 376                  $styleData['font'][$this->styleSettingsFont[$char]] = true;
 377              } elseif (array_key_exists($char, $this->styleSettingsBorder)) {
 378                  $styleData['borders'][$this->styleSettingsBorder[$char]]['borderStyle'] = Border::BORDER_THIN;
 379              } elseif ($char == 'S') {
 380                  $styleData['fill']['fillType'] = \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_PATTERN_GRAY125;
 381              } elseif ($char == 'M') {
 382                  if (preg_match('/M([1-9]\\d*)/', $styleSettings, $matches)) {
 383                      $fontStyle = $matches[1];
 384                  }
 385              }
 386          }
 387      }
 388  
 389      private function addFormats(Spreadsheet &$spreadsheet, string $formatStyle, string $row, string $column): void
 390      {
 391          if ($formatStyle && $column > '' && $row > '') {
 392              $columnLetter = Coordinate::stringFromColumnIndex((int) $column);
 393              if (isset($this->formats[$formatStyle])) {
 394                  $spreadsheet->getActiveSheet()->getStyle($columnLetter . $row)->applyFromArray($this->formats[$formatStyle]);
 395              }
 396          }
 397      }
 398  
 399      private function addFonts(Spreadsheet &$spreadsheet, string $fontStyle, string $row, string $column): void
 400      {
 401          if ($fontStyle && $column > '' && $row > '') {
 402              $columnLetter = Coordinate::stringFromColumnIndex((int) $column);
 403              if (isset($this->fonts[$fontStyle])) {
 404                  $spreadsheet->getActiveSheet()->getStyle($columnLetter . $row)->applyFromArray($this->fonts[$fontStyle]);
 405              }
 406          }
 407      }
 408  
 409      private function addStyle(Spreadsheet &$spreadsheet, array $styleData, string $row, string $column): void
 410      {
 411          if ((!empty($styleData)) && $column > '' && $row > '') {
 412              $columnLetter = Coordinate::stringFromColumnIndex($column);
 413              $spreadsheet->getActiveSheet()->getStyle($columnLetter . $row)->applyFromArray($styleData);
 414          }
 415      }
 416  
 417      private function addWidth(Spreadsheet $spreadsheet, string $columnWidth, string $startCol, string $endCol): void
 418      {
 419          if ($columnWidth > '') {
 420              if ($startCol == $endCol) {
 421                  $startCol = Coordinate::stringFromColumnIndex((int) $startCol);
 422                  $spreadsheet->getActiveSheet()->getColumnDimension($startCol)->setWidth($columnWidth);
 423              } else {
 424                  $startCol = Coordinate::stringFromColumnIndex($startCol);
 425                  $endCol = Coordinate::stringFromColumnIndex($endCol);
 426                  $spreadsheet->getActiveSheet()->getColumnDimension($startCol)->setWidth((float) $columnWidth);
 427                  do {
 428                      $spreadsheet->getActiveSheet()->getColumnDimension(++$startCol)->setWidth($columnWidth);
 429                  } while ($startCol != $endCol);
 430              }
 431          }
 432      }
 433  
 434      private function processPRecord(array $rowData, Spreadsheet &$spreadsheet): void
 435      {
 436          //    Read shared styles
 437          $formatArray = [];
 438          $fromFormats = ['\-', '\ '];
 439          $toFormats = ['-', ' '];
 440          foreach ($rowData as $rowDatum) {
 441              switch ($rowDatum[0]) {
 442                  case 'P':
 443                      $formatArray['numberFormat']['formatCode'] = str_replace($fromFormats, $toFormats, substr($rowDatum, 1));
 444  
 445                      break;
 446                  case 'E':
 447                  case 'F':
 448                      $formatArray['font']['name'] = substr($rowDatum, 1);
 449  
 450                      break;
 451                  case 'M':
 452                      $formatArray['font']['size'] = substr($rowDatum, 1) / 20;
 453  
 454                      break;
 455                  case 'L':
 456                      $this->processPColors($rowDatum, $formatArray);
 457  
 458                      break;
 459                  case 'S':
 460                      $this->processPFontStyles($rowDatum, $formatArray);
 461  
 462                      break;
 463              }
 464          }
 465          $this->processPFinal($spreadsheet, $formatArray);
 466      }
 467  
 468      private function processPColors(string $rowDatum, array &$formatArray): void
 469      {
 470          if (preg_match('/L([1-9]\\d*)/', $rowDatum, $matches)) {
 471              $fontColor = $matches[1] % 8;
 472              $formatArray['font']['color']['argb'] = $this->colorArray[$fontColor];
 473          }
 474      }
 475  
 476      private function processPFontStyles(string $rowDatum, array &$formatArray): void
 477      {
 478          $styleSettings = substr($rowDatum, 1);
 479          $iMax = strlen($styleSettings);
 480          for ($i = 0; $i < $iMax; ++$i) {
 481              if (array_key_exists($styleSettings[$i], $this->fontStyleMappings)) {
 482                  $formatArray['font'][$this->fontStyleMappings[$styleSettings[$i]]] = true;
 483              }
 484          }
 485      }
 486  
 487      private function processPFinal(Spreadsheet &$spreadsheet, array $formatArray): void
 488      {
 489          if (array_key_exists('numberFormat', $formatArray)) {
 490              $this->formats['P' . $this->format] = $formatArray;
 491              ++$this->format;
 492          } elseif (array_key_exists('font', $formatArray)) {
 493              ++$this->fontcount;
 494              $this->fonts[$this->fontcount] = $formatArray;
 495              if ($this->fontcount === 1) {
 496                  $spreadsheet->getDefaultStyle()->applyFromArray($formatArray);
 497              }
 498          }
 499      }
 500  
 501      /**
 502       * Loads PhpSpreadsheet from file into PhpSpreadsheet instance.
 503       *
 504       * @param string $pFilename
 505       *
 506       * @return Spreadsheet
 507       */
 508      public function loadIntoExisting($pFilename, Spreadsheet $spreadsheet)
 509      {
 510          // Open file
 511          $this->canReadOrBust($pFilename);
 512          $fileHandle = $this->fileHandle;
 513          rewind($fileHandle);
 514  
 515          // Create new Worksheets
 516          while ($spreadsheet->getSheetCount() <= $this->sheetIndex) {
 517              $spreadsheet->createSheet();
 518          }
 519          $spreadsheet->setActiveSheetIndex($this->sheetIndex);
 520          $spreadsheet->getActiveSheet()->setTitle(substr(basename($pFilename, '.slk'), 0, Worksheet::SHEET_TITLE_MAXIMUM_LENGTH));
 521  
 522          // Loop through file
 523          $column = $row = '';
 524  
 525          // loop through one row (line) at a time in the file
 526          while (($rowDataTxt = fgets($fileHandle)) !== false) {
 527              // convert SYLK encoded $rowData to UTF-8
 528              $rowDataTxt = StringHelper::SYLKtoUTF8($rowDataTxt);
 529  
 530              // explode each row at semicolons while taking into account that literal semicolon (;)
 531              // is escaped like this (;;)
 532              $rowData = explode("\t", str_replace('¤', ';', str_replace(';', "\t", str_replace(';;', '¤', rtrim($rowDataTxt)))));
 533  
 534              $dataType = array_shift($rowData);
 535              if ($dataType == 'P') {
 536                  //    Read shared styles
 537                  $this->processPRecord($rowData, $spreadsheet);
 538              } elseif ($dataType == 'C') {
 539                  //    Read cell value data
 540                  $this->processCRecord($rowData, $spreadsheet, $row, $column);
 541              } elseif ($dataType == 'F') {
 542                  //    Read cell formatting
 543                  $this->processFRecord($rowData, $spreadsheet, $row, $column);
 544              } else {
 545                  $this->columnRowFromRowData($rowData, $column, $row);
 546              }
 547          }
 548  
 549          // Close file
 550          fclose($fileHandle);
 551  
 552          // Return
 553          return $spreadsheet;
 554      }
 555  
 556      private function columnRowFromRowData(array $rowData, string &$column, string &$row): void
 557      {
 558          foreach ($rowData as $rowDatum) {
 559              $char0 = $rowDatum[0];
 560              if ($char0 === 'X' || $char0 == 'C') {
 561                  $column = substr($rowDatum, 1);
 562              } elseif ($char0 === 'Y' || $char0 == 'R') {
 563                  $row = substr($rowDatum, 1);
 564              }
 565          }
 566      }
 567  
 568      /**
 569       * Get sheet index.
 570       *
 571       * @return int
 572       */
 573      public function getSheetIndex()
 574      {
 575          return $this->sheetIndex;
 576      }
 577  
 578      /**
 579       * Set sheet index.
 580       *
 581       * @param int $pValue Sheet index
 582       *
 583       * @return $this
 584       */
 585      public function setSheetIndex($pValue)
 586      {
 587          $this->sheetIndex = $pValue;
 588  
 589          return $this;
 590      }
 591  }