Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   1  <?php
   2  
   3  declare(strict_types=1);
   4  
   5  namespace OpenSpout\Writer\ODS\Manager;
   6  
   7  use DateTimeImmutable;
   8  use DateTimeInterface;
   9  use OpenSpout\Common\Entity\Cell;
  10  use OpenSpout\Common\Entity\Row;
  11  use OpenSpout\Common\Entity\Style\Style;
  12  use OpenSpout\Common\Exception\InvalidArgumentException;
  13  use OpenSpout\Common\Exception\IOException;
  14  use OpenSpout\Common\Helper\Escaper\ODS as ODSEscaper;
  15  use OpenSpout\Writer\Common\Entity\Worksheet;
  16  use OpenSpout\Writer\Common\Helper\CellHelper;
  17  use OpenSpout\Writer\Common\Manager\RegisteredStyle;
  18  use OpenSpout\Writer\Common\Manager\Style\StyleMerger;
  19  use OpenSpout\Writer\Common\Manager\WorksheetManagerInterface;
  20  use OpenSpout\Writer\ODS\Manager\Style\StyleManager;
  21  
  22  /**
  23   * @internal
  24   */
  25  final class WorksheetManager implements WorksheetManagerInterface
  26  {
  27      /** @var ODSEscaper Strings escaper */
  28      private ODSEscaper $stringsEscaper;
  29  
  30      /** @var StyleManager Manages styles */
  31      private StyleManager $styleManager;
  32  
  33      /** @var StyleMerger Helper to merge styles together */
  34      private StyleMerger $styleMerger;
  35  
  36      /**
  37       * WorksheetManager constructor.
  38       */
  39      public function __construct(
  40          StyleManager $styleManager,
  41          StyleMerger $styleMerger,
  42          ODSEscaper $stringsEscaper
  43      ) {
  44          $this->styleManager = $styleManager;
  45          $this->styleMerger = $styleMerger;
  46          $this->stringsEscaper = $stringsEscaper;
  47      }
  48  
  49      /**
  50       * Prepares the worksheet to accept data.
  51       *
  52       * @param Worksheet $worksheet The worksheet to start
  53       *
  54       * @throws \OpenSpout\Common\Exception\IOException If the sheet data file cannot be opened for writing
  55       */
  56      public function startSheet(Worksheet $worksheet): void
  57      {
  58          $sheetFilePointer = fopen($worksheet->getFilePath(), 'w');
  59          \assert(false !== $sheetFilePointer);
  60  
  61          $worksheet->setFilePointer($sheetFilePointer);
  62      }
  63  
  64      /**
  65       * Returns the table XML root node as string.
  66       *
  67       * @return string "<table>" node as string
  68       */
  69      public function getTableElementStartAsString(Worksheet $worksheet): string
  70      {
  71          $externalSheet = $worksheet->getExternalSheet();
  72          $escapedSheetName = $this->stringsEscaper->escape($externalSheet->getName());
  73          $tableStyleName = 'ta'.($externalSheet->getIndex() + 1);
  74  
  75          $tableElement = '<table:table table:style-name="'.$tableStyleName.'" table:name="'.$escapedSheetName.'">';
  76          $tableElement .= $this->styleManager->getStyledTableColumnXMLContent($worksheet->getMaxNumColumns());
  77  
  78          return $tableElement;
  79      }
  80  
  81      /**
  82       * Returns the table:database-range XML node for AutoFilter as string.
  83       */
  84      public function getTableDatabaseRangeElementAsString(Worksheet $worksheet): string
  85      {
  86          $externalSheet = $worksheet->getExternalSheet();
  87          $escapedSheetName = $this->stringsEscaper->escape($externalSheet->getName());
  88          $databaseRange = '';
  89  
  90          if (null !== $autofilter = $externalSheet->getAutoFilter()) {
  91              $rangeAddress = sprintf(
  92                  '\'%s\'.%s%s:\'%s\'.%s%s',
  93                  $escapedSheetName,
  94                  CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
  95                  $autofilter->fromRow,
  96                  $escapedSheetName,
  97                  CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex),
  98                  $autofilter->toRow
  99              );
 100              $databaseRange = '<table:database-range table:name="__Anonymous_Sheet_DB__'.$externalSheet->getIndex().'" table:target-range-address="'.$rangeAddress.'" table:display-filter-buttons="true"/>';
 101          }
 102  
 103          return $databaseRange;
 104      }
 105  
 106      /**
 107       * Adds a row to the given worksheet.
 108       *
 109       * @param Worksheet $worksheet The worksheet to add the row to
 110       * @param Row       $row       The row to be added
 111       *
 112       * @throws InvalidArgumentException If a cell value's type is not supported
 113       * @throws IOException              If the data cannot be written
 114       */
 115      public function addRow(Worksheet $worksheet, Row $row): void
 116      {
 117          $cells = $row->getCells();
 118          $rowStyle = $row->getStyle();
 119  
 120          $data = '<table:table-row table:style-name="ro1">';
 121  
 122          $currentCellIndex = 0;
 123          $nextCellIndex = 1;
 124  
 125          for ($i = 0; $i < $row->getNumCells(); ++$i) {
 126              /** @var Cell $cell */
 127              $cell = $cells[$currentCellIndex];
 128  
 129              /** @var null|Cell $nextCell */
 130              $nextCell = $cells[$nextCellIndex] ?? null;
 131  
 132              if (null === $nextCell || $cell->getValue() !== $nextCell->getValue()) {
 133                  $registeredStyle = $this->applyStyleAndRegister($cell, $rowStyle);
 134                  $cellStyle = $registeredStyle->getStyle();
 135                  if ($registeredStyle->isMatchingRowStyle()) {
 136                      $rowStyle = $cellStyle; // Replace actual rowStyle (possibly with null id) by registered style (with id)
 137                  }
 138  
 139                  $data .= $this->getCellXMLWithStyle($cell, $cellStyle, $currentCellIndex, $nextCellIndex);
 140                  $currentCellIndex = $nextCellIndex;
 141              }
 142  
 143              ++$nextCellIndex;
 144          }
 145  
 146          $data .= '</table:table-row>';
 147  
 148          $wasWriteSuccessful = fwrite($worksheet->getFilePointer(), $data);
 149          if (false === $wasWriteSuccessful) {
 150              throw new IOException("Unable to write data in {$worksheet->getFilePath()}");
 151          }
 152  
 153          // only update the count if the write worked
 154          $lastWrittenRowIndex = $worksheet->getLastWrittenRowIndex();
 155          $worksheet->setLastWrittenRowIndex($lastWrittenRowIndex + 1);
 156      }
 157  
 158      /**
 159       * Closes the worksheet.
 160       */
 161      public function close(Worksheet $worksheet): void
 162      {
 163          fclose($worksheet->getFilePointer());
 164      }
 165  
 166      /**
 167       * Applies styles to the given style, merging the cell's style with its row's style.
 168       *
 169       * @throws InvalidArgumentException If a cell value's type is not supported
 170       */
 171      private function applyStyleAndRegister(Cell $cell, Style $rowStyle): RegisteredStyle
 172      {
 173          $isMatchingRowStyle = false;
 174          if ($cell->getStyle()->isEmpty()) {
 175              $cell->setStyle($rowStyle);
 176  
 177              $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
 178  
 179              if ($possiblyUpdatedStyle->isUpdated()) {
 180                  $registeredStyle = $this->styleManager->registerStyle($possiblyUpdatedStyle->getStyle());
 181              } else {
 182                  $registeredStyle = $this->styleManager->registerStyle($rowStyle);
 183                  $isMatchingRowStyle = true;
 184              }
 185          } else {
 186              $mergedCellAndRowStyle = $this->styleMerger->merge($cell->getStyle(), $rowStyle);
 187              $cell->setStyle($mergedCellAndRowStyle);
 188  
 189              $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
 190              if ($possiblyUpdatedStyle->isUpdated()) {
 191                  $newCellStyle = $possiblyUpdatedStyle->getStyle();
 192              } else {
 193                  $newCellStyle = $mergedCellAndRowStyle;
 194              }
 195  
 196              $registeredStyle = $this->styleManager->registerStyle($newCellStyle);
 197          }
 198  
 199          return new RegisteredStyle($registeredStyle, $isMatchingRowStyle);
 200      }
 201  
 202      private function getCellXMLWithStyle(Cell $cell, Style $style, int $currentCellIndex, int $nextCellIndex): string
 203      {
 204          $styleIndex = $style->getId() + 1; // 1-based
 205  
 206          $numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
 207  
 208          return $this->getCellXML($cell, $styleIndex, $numTimesValueRepeated);
 209      }
 210  
 211      /**
 212       * Returns the cell XML content, given its value.
 213       *
 214       * @param Cell $cell                  The cell to be written
 215       * @param int  $styleIndex            Index of the used style
 216       * @param int  $numTimesValueRepeated Number of times the value is consecutively repeated
 217       *
 218       * @return string The cell XML content
 219       *
 220       * @throws InvalidArgumentException If a cell value's type is not supported
 221       */
 222      private function getCellXML(Cell $cell, int $styleIndex, int $numTimesValueRepeated): string
 223      {
 224          $data = '<table:table-cell table:style-name="ce'.$styleIndex.'"';
 225  
 226          if (1 !== $numTimesValueRepeated) {
 227              $data .= ' table:number-columns-repeated="'.$numTimesValueRepeated.'"';
 228          }
 229  
 230          if ($cell instanceof Cell\StringCell) {
 231              $data .= ' office:value-type="string" calcext:value-type="string">';
 232  
 233              $cellValueLines = explode("\n", $cell->getValue());
 234              foreach ($cellValueLines as $cellValueLine) {
 235                  $data .= '<text:p>'.$this->stringsEscaper->escape($cellValueLine).'</text:p>';
 236              }
 237  
 238              $data .= '</table:table-cell>';
 239          } elseif ($cell instanceof Cell\BooleanCell) {
 240              $value = $cell->getValue() ? 'true' : 'false'; // boolean-value spec: http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#datatype-boolean
 241              $data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="'.$value.'">';
 242              $data .= '<text:p>'.$cell->getValue().'</text:p>';
 243              $data .= '</table:table-cell>';
 244          } elseif ($cell instanceof Cell\NumericCell) {
 245              $cellValue = $cell->getValue();
 246              $data .= ' office:value-type="float" calcext:value-type="float" office:value="'.$cellValue.'">';
 247              $data .= '<text:p>'.$cellValue.'</text:p>';
 248              $data .= '</table:table-cell>';
 249          } elseif ($cell instanceof Cell\DateTimeCell) {
 250              $datevalue = substr((new DateTimeImmutable('@'.$cell->getValue()->getTimestamp()))->format(DateTimeInterface::W3C), 0, -6);
 251              $data .= ' office:value-type="date" calcext:value-type="date" office:date-value="'.$datevalue.'Z">';
 252              $data .= '<text:p>'.$datevalue.'Z</text:p>';
 253              $data .= '</table:table-cell>';
 254          } elseif ($cell instanceof Cell\DateIntervalCell) {
 255              // workaround for missing DateInterval::format('c'), see https://stackoverflow.com/a/61088115/53538
 256              static $f = ['M0S', 'H0M', 'DT0H', 'M0D', 'Y0M', 'P0Y', 'Y0M', 'P0M'];
 257              static $r = ['M', 'H', 'DT', 'M', 'Y0M', 'P', 'Y', 'P'];
 258              $value = rtrim(str_replace($f, $r, $cell->getValue()->format('P%yY%mM%dDT%hH%iM%sS')), 'PT') ?: 'PT0S';
 259              $data .= ' office:value-type="time" office:time-value="'.$value.'">';
 260              $data .= '<text:p>'.$value.'</text:p>';
 261              $data .= '</table:table-cell>';
 262          } elseif ($cell instanceof Cell\ErrorCell) {
 263              // only writes the error value if it's a string
 264              $data .= ' office:value-type="string" calcext:value-type="error" office:value="">';
 265              $data .= '<text:p>'.$cell->getRawValue().'</text:p>';
 266              $data .= '</table:table-cell>';
 267          } elseif ($cell instanceof Cell\EmptyCell) {
 268              $data .= '/>';
 269          }
 270  
 271          return $data;
 272      }
 273  }