Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 402 and 403]

   1  <?php
   2  
   3  declare(strict_types=1);
   4  
   5  namespace OpenSpout\Writer\XLSX\Manager;
   6  
   7  use OpenSpout\Common\Entity\Cell;
   8  use OpenSpout\Common\Entity\Row;
   9  use OpenSpout\Common\Entity\Style\Style;
  10  use OpenSpout\Common\Exception\InvalidArgumentException;
  11  use OpenSpout\Common\Exception\IOException;
  12  use OpenSpout\Common\Helper\Escaper\XLSX as XLSXEscaper;
  13  use OpenSpout\Common\Helper\StringHelper;
  14  use OpenSpout\Writer\Common\Entity\Worksheet;
  15  use OpenSpout\Writer\Common\Helper\CellHelper;
  16  use OpenSpout\Writer\Common\Manager\RegisteredStyle;
  17  use OpenSpout\Writer\Common\Manager\Style\StyleMerger;
  18  use OpenSpout\Writer\Common\Manager\WorksheetManagerInterface;
  19  use OpenSpout\Writer\XLSX\Helper\DateHelper;
  20  use OpenSpout\Writer\XLSX\Manager\Style\StyleManager;
  21  use OpenSpout\Writer\XLSX\Options;
  22  
  23  /**
  24   * @internal
  25   */
  26  final class WorksheetManager implements WorksheetManagerInterface
  27  {
  28      /**
  29       * Maximum number of characters a cell can contain.
  30       *
  31       * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-16c69c74-3d6a-4aaf-ba35-e6eb276e8eaa [Excel 2007]
  32       * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3 [Excel 2010]
  33       * @see https://support.office.com/en-us/article/Excel-specifications-and-limits-ca36e2dc-1f09-4620-b726-67c00b05040f [Excel 2013/2016]
  34       */
  35      public const MAX_CHARACTERS_PER_CELL = 32767;
  36  
  37      /** @var CommentsManager Manages comments */
  38      private CommentsManager $commentsManager;
  39  
  40      private Options $options;
  41  
  42      /** @var StyleManager Manages styles */
  43      private StyleManager $styleManager;
  44  
  45      /** @var StyleMerger Helper to merge styles together */
  46      private StyleMerger $styleMerger;
  47  
  48      /** @var SharedStringsManager Helper to write shared strings */
  49      private SharedStringsManager $sharedStringsManager;
  50  
  51      /** @var XLSXEscaper Strings escaper */
  52      private XLSXEscaper $stringsEscaper;
  53  
  54      /** @var StringHelper String helper */
  55      private StringHelper $stringHelper;
  56  
  57      /**
  58       * WorksheetManager constructor.
  59       */
  60      public function __construct(
  61          Options $options,
  62          StyleManager $styleManager,
  63          StyleMerger $styleMerger,
  64          CommentsManager $commentsManager,
  65          SharedStringsManager $sharedStringsManager,
  66          XLSXEscaper $stringsEscaper,
  67          StringHelper $stringHelper
  68      ) {
  69          $this->options = $options;
  70          $this->styleManager = $styleManager;
  71          $this->styleMerger = $styleMerger;
  72          $this->commentsManager = $commentsManager;
  73          $this->sharedStringsManager = $sharedStringsManager;
  74          $this->stringsEscaper = $stringsEscaper;
  75          $this->stringHelper = $stringHelper;
  76      }
  77  
  78      public function getSharedStringsManager(): SharedStringsManager
  79      {
  80          return $this->sharedStringsManager;
  81      }
  82  
  83      public function startSheet(Worksheet $worksheet): void
  84      {
  85          $sheetFilePointer = fopen($worksheet->getFilePath(), 'w');
  86          \assert(false !== $sheetFilePointer);
  87  
  88          $worksheet->setFilePointer($sheetFilePointer);
  89          $this->commentsManager->createWorksheetCommentFiles($worksheet);
  90      }
  91  
  92      public function addRow(Worksheet $worksheet, Row $row): void
  93      {
  94          if (!$row->isEmpty()) {
  95              $this->addNonEmptyRow($worksheet, $row);
  96              $this->commentsManager->addComments($worksheet, $row);
  97          }
  98  
  99          $worksheet->setLastWrittenRowIndex($worksheet->getLastWrittenRowIndex() + 1);
 100      }
 101  
 102      public function close(Worksheet $worksheet): void
 103      {
 104          $this->commentsManager->closeWorksheetCommentFiles($worksheet);
 105          fclose($worksheet->getFilePointer());
 106      }
 107  
 108      /**
 109       * Adds non empty row to the worksheet.
 110       *
 111       * @param Worksheet $worksheet The worksheet to add the row to
 112       * @param Row       $row       The row to be written
 113       *
 114       * @throws InvalidArgumentException If a cell value's type is not supported
 115       * @throws IOException              If the data cannot be written
 116       */
 117      private function addNonEmptyRow(Worksheet $worksheet, Row $row): void
 118      {
 119          $sheetFilePointer = $worksheet->getFilePointer();
 120          $rowStyle = $row->getStyle();
 121          $rowIndexOneBased = $worksheet->getLastWrittenRowIndex() + 1;
 122          $numCells = $row->getNumCells();
 123  
 124          $rowHeight = $row->getHeight();
 125          $hasCustomHeight = ($this->options->DEFAULT_ROW_HEIGHT > 0 || $rowHeight > 0) ? '1' : '0';
 126          $rowXML = "<row r=\"{$rowIndexOneBased}\" spans=\"1:{$numCells}\" ".($rowHeight > 0 ? "ht=\"{$rowHeight}\" " : '')."customHeight=\"{$hasCustomHeight}\">";
 127  
 128          foreach ($row->getCells() as $columnIndexZeroBased => $cell) {
 129              $registeredStyle = $this->applyStyleAndRegister($cell, $rowStyle);
 130              $cellStyle = $registeredStyle->getStyle();
 131              if ($registeredStyle->isMatchingRowStyle()) {
 132                  $rowStyle = $cellStyle; // Replace actual rowStyle (possibly with null id) by registered style (with id)
 133              }
 134              $rowXML .= $this->getCellXML($rowIndexOneBased, $columnIndexZeroBased, $cell, $cellStyle->getId());
 135          }
 136  
 137          $rowXML .= '</row>';
 138  
 139          $wasWriteSuccessful = fwrite($sheetFilePointer, $rowXML);
 140          if (false === $wasWriteSuccessful) {
 141              throw new IOException("Unable to write data in {$worksheet->getFilePath()}");
 142          }
 143      }
 144  
 145      /**
 146       * Applies styles to the given style, merging the cell's style with its row's style.
 147       *
 148       * @throws InvalidArgumentException If the given value cannot be processed
 149       */
 150      private function applyStyleAndRegister(Cell $cell, Style $rowStyle): RegisteredStyle
 151      {
 152          $isMatchingRowStyle = false;
 153          if ($cell->getStyle()->isEmpty()) {
 154              $cell->setStyle($rowStyle);
 155  
 156              $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
 157  
 158              if ($possiblyUpdatedStyle->isUpdated()) {
 159                  $registeredStyle = $this->styleManager->registerStyle($possiblyUpdatedStyle->getStyle());
 160              } else {
 161                  $registeredStyle = $this->styleManager->registerStyle($rowStyle);
 162                  $isMatchingRowStyle = true;
 163              }
 164          } else {
 165              $mergedCellAndRowStyle = $this->styleMerger->merge($cell->getStyle(), $rowStyle);
 166              $cell->setStyle($mergedCellAndRowStyle);
 167  
 168              $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
 169  
 170              if ($possiblyUpdatedStyle->isUpdated()) {
 171                  $newCellStyle = $possiblyUpdatedStyle->getStyle();
 172              } else {
 173                  $newCellStyle = $mergedCellAndRowStyle;
 174              }
 175  
 176              $registeredStyle = $this->styleManager->registerStyle($newCellStyle);
 177          }
 178  
 179          return new RegisteredStyle($registeredStyle, $isMatchingRowStyle);
 180      }
 181  
 182      /**
 183       * Builds and returns xml for a single cell.
 184       *
 185       * @throws InvalidArgumentException If the given value cannot be processed
 186       */
 187      private function getCellXML(int $rowIndexOneBased, int $columnIndexZeroBased, Cell $cell, ?int $styleId): string
 188      {
 189          $columnLetters = CellHelper::getColumnLettersFromColumnIndex($columnIndexZeroBased);
 190          $cellXML = '<c r="'.$columnLetters.$rowIndexOneBased.'"';
 191          $cellXML .= ' s="'.$styleId.'"';
 192  
 193          if ($cell instanceof Cell\StringCell) {
 194              $cellXML .= $this->getCellXMLFragmentForNonEmptyString($cell->getValue());
 195          } elseif ($cell instanceof Cell\BooleanCell) {
 196              $cellXML .= ' t="b"><v>'.(int) $cell->getValue().'</v></c>';
 197          } elseif ($cell instanceof Cell\NumericCell) {
 198              $cellXML .= '><v>'.$cell->getValue().'</v></c>';
 199          } elseif ($cell instanceof Cell\FormulaCell) {
 200              $cellXML .= '><f>'.substr($cell->getValue(), 1).'</f></c>';
 201          } elseif ($cell instanceof Cell\DateTimeCell) {
 202              $cellXML .= '><v>'.DateHelper::toExcel($cell->getValue()).'</v></c>';
 203          } elseif ($cell instanceof Cell\ErrorCell) {
 204              // only writes the error value if it's a string
 205              $cellXML .= ' t="e"><v>'.$cell->getRawValue().'</v></c>';
 206          } elseif ($cell instanceof Cell\EmptyCell) {
 207              if ($this->styleManager->shouldApplyStyleOnEmptyCell($styleId)) {
 208                  $cellXML .= '/>';
 209              } else {
 210                  // don't write empty cells that do no need styling
 211                  // NOTE: not appending to $cellXML is the right behavior!!
 212                  $cellXML = '';
 213              }
 214          }
 215  
 216          return $cellXML;
 217      }
 218  
 219      /**
 220       * Returns the XML fragment for a cell containing a non empty string.
 221       *
 222       * @param string $cellValue The cell value
 223       *
 224       * @return string The XML fragment representing the cell
 225       *
 226       * @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
 227       */
 228      private function getCellXMLFragmentForNonEmptyString(string $cellValue): string
 229      {
 230          if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
 231              throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
 232          }
 233  
 234          if ($this->options->SHOULD_USE_INLINE_STRINGS) {
 235              $cellXMLFragment = ' t="inlineStr"><is><t>'.$this->stringsEscaper->escape($cellValue).'</t></is></c>';
 236          } else {
 237              $sharedStringId = $this->sharedStringsManager->writeString($cellValue);
 238              $cellXMLFragment = ' t="s"><v>'.$sharedStringId.'</v></c>';
 239          }
 240  
 241          return $cellXMLFragment;
 242      }
 243  }