Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

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

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