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\Helper;
   6  
   7  use DateTimeImmutable;
   8  use OpenSpout\Common\Helper\Escaper\XLSX;
   9  use OpenSpout\Common\Helper\FileSystemHelper as CommonFileSystemHelper;
  10  use OpenSpout\Writer\Common\Entity\Sheet;
  11  use OpenSpout\Writer\Common\Entity\Worksheet;
  12  use OpenSpout\Writer\Common\Helper\CellHelper;
  13  use OpenSpout\Writer\Common\Helper\FileSystemWithRootFolderHelperInterface;
  14  use OpenSpout\Writer\Common\Helper\ZipHelper;
  15  use OpenSpout\Writer\XLSX\Manager\Style\StyleManager;
  16  use OpenSpout\Writer\XLSX\MergeCell;
  17  use OpenSpout\Writer\XLSX\Options;
  18  
  19  /**
  20   * @internal
  21   */
  22  final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
  23  {
  24      public const RELS_FOLDER_NAME = '_rels';
  25      public const DRAWINGS_FOLDER_NAME = 'drawings';
  26      public const DOC_PROPS_FOLDER_NAME = 'docProps';
  27      public const XL_FOLDER_NAME = 'xl';
  28      public const WORKSHEETS_FOLDER_NAME = 'worksheets';
  29  
  30      public const RELS_FILE_NAME = '.rels';
  31      public const APP_XML_FILE_NAME = 'app.xml';
  32      public const CORE_XML_FILE_NAME = 'core.xml';
  33      public const CONTENT_TYPES_XML_FILE_NAME = '[Content_Types].xml';
  34      public const WORKBOOK_XML_FILE_NAME = 'workbook.xml';
  35      public const WORKBOOK_RELS_XML_FILE_NAME = 'workbook.xml.rels';
  36      public const STYLES_XML_FILE_NAME = 'styles.xml';
  37  
  38      private const SHEET_XML_FILE_HEADER = <<<'EOD'
  39          <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
  40          <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  41          EOD;
  42  
  43      private string $baseFolderRealPath;
  44      private CommonFileSystemHelper $baseFileSystemHelper;
  45  
  46      /** @var ZipHelper Helper to perform tasks with Zip archive */
  47      private ZipHelper $zipHelper;
  48  
  49      /** @var string document creator */
  50      private string $creator;
  51  
  52      /** @var XLSX Used to escape XML data */
  53      private XLSX $escaper;
  54  
  55      /** @var string Path to the root folder inside the temp folder where the files to create the XLSX will be stored */
  56      private string $rootFolder;
  57  
  58      /** @var string Path to the "_rels" folder inside the root folder */
  59      private string $relsFolder;
  60  
  61      /** @var string Path to the "docProps" folder inside the root folder */
  62      private string $docPropsFolder;
  63  
  64      /** @var string Path to the "xl" folder inside the root folder */
  65      private string $xlFolder;
  66  
  67      /** @var string Path to the "_rels" folder inside the "xl" folder */
  68      private string $xlRelsFolder;
  69  
  70      /** @var string Path to the "worksheets" folder inside the "xl" folder */
  71      private string $xlWorksheetsFolder;
  72  
  73      /** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */
  74      private string $sheetsContentTempFolder;
  75  
  76      /**
  77       * @param string    $baseFolderPath The path of the base folder where all the I/O can occur
  78       * @param ZipHelper $zipHelper      Helper to perform tasks with Zip archive
  79       * @param XLSX      $escaper        Used to escape XML data
  80       * @param string    $creator        document creator
  81       */
  82      public function __construct(string $baseFolderPath, ZipHelper $zipHelper, XLSX $escaper, string $creator)
  83      {
  84          $this->baseFileSystemHelper = new CommonFileSystemHelper($baseFolderPath);
  85          $this->baseFolderRealPath = $this->baseFileSystemHelper->getBaseFolderRealPath();
  86          $this->zipHelper = $zipHelper;
  87          $this->escaper = $escaper;
  88          $this->creator = $creator;
  89      }
  90  
  91      public function createFolder(string $parentFolderPath, string $folderName): string
  92      {
  93          return $this->baseFileSystemHelper->createFolder($parentFolderPath, $folderName);
  94      }
  95  
  96      public function createFileWithContents(string $parentFolderPath, string $fileName, string $fileContents): string
  97      {
  98          return $this->baseFileSystemHelper->createFileWithContents($parentFolderPath, $fileName, $fileContents);
  99      }
 100  
 101      public function deleteFile(string $filePath): void
 102      {
 103          $this->baseFileSystemHelper->deleteFile($filePath);
 104      }
 105  
 106      public function deleteFolderRecursively(string $folderPath): void
 107      {
 108          $this->baseFileSystemHelper->deleteFolderRecursively($folderPath);
 109      }
 110  
 111      public function getRootFolder(): string
 112      {
 113          return $this->rootFolder;
 114      }
 115  
 116      public function getXlFolder(): string
 117      {
 118          return $this->xlFolder;
 119      }
 120  
 121      public function getXlWorksheetsFolder(): string
 122      {
 123          return $this->xlWorksheetsFolder;
 124      }
 125  
 126      public function getSheetsContentTempFolder(): string
 127      {
 128          return $this->sheetsContentTempFolder;
 129      }
 130  
 131      /**
 132       * Creates all the folders needed to create a XLSX file, as well as the files that won't change.
 133       *
 134       * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the base folders
 135       */
 136      public function createBaseFilesAndFolders(): void
 137      {
 138          $this
 139              ->createRootFolder()
 140              ->createRelsFolderAndFile()
 141              ->createDocPropsFolderAndFiles()
 142              ->createXlFolderAndSubFolders()
 143              ->createSheetsContentTempFolder()
 144          ;
 145      }
 146  
 147      /**
 148       * Creates the "[Content_Types].xml" file under the root folder.
 149       *
 150       * @param Worksheet[] $worksheets
 151       */
 152      public function createContentTypesFile(array $worksheets): self
 153      {
 154          $contentTypesXmlFileContents = <<<'EOD'
 155              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 156              <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
 157                  <Default ContentType="application/xml" Extension="xml"/>
 158                  <Default ContentType="application/vnd.openxmlformats-package.relationships+xml" Extension="rels"/>
 159                  <Default ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing" Extension="vml"/>
 160                  <Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" PartName="/xl/workbook.xml"/>
 161              EOD;
 162  
 163          /** @var Worksheet $worksheet */
 164          foreach ($worksheets as $worksheet) {
 165              $contentTypesXmlFileContents .= '<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" PartName="/xl/worksheets/sheet'.$worksheet->getId().'.xml"/>';
 166              $contentTypesXmlFileContents .= '<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml" PartName="/xl/comments'.$worksheet->getId().'.xml" />';
 167          }
 168  
 169          $contentTypesXmlFileContents .= <<<'EOD'
 170                  <Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" PartName="/xl/styles.xml"/>
 171                  <Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" PartName="/xl/sharedStrings.xml"/>
 172                  <Override ContentType="application/vnd.openxmlformats-package.core-properties+xml" PartName="/docProps/core.xml"/>
 173                  <Override ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml" PartName="/docProps/app.xml"/>
 174              </Types>
 175              EOD;
 176  
 177          $this->createFileWithContents($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME, $contentTypesXmlFileContents);
 178  
 179          return $this;
 180      }
 181  
 182      /**
 183       * Creates the "workbook.xml" file under the "xl" folder.
 184       *
 185       * @param Worksheet[] $worksheets
 186       */
 187      public function createWorkbookFile(array $worksheets): self
 188      {
 189          $workbookXmlFileContents = <<<'EOD'
 190              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 191              <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
 192                  <sheets>
 193              EOD;
 194  
 195          /** @var Worksheet $worksheet */
 196          foreach ($worksheets as $worksheet) {
 197              $worksheetName = $worksheet->getExternalSheet()->getName();
 198              $worksheetVisibility = $worksheet->getExternalSheet()->isVisible() ? 'visible' : 'hidden';
 199              $worksheetId = $worksheet->getId();
 200              $workbookXmlFileContents .= '<sheet name="'.$this->escaper->escape($worksheetName).'" sheetId="'.$worksheetId.'" r:id="rIdSheet'.$worksheetId.'" state="'.$worksheetVisibility.'"/>';
 201          }
 202  
 203          $workbookXmlFileContents .= <<<'EOD'
 204                  </sheets>
 205              EOD;
 206  
 207          $definedNames = '';
 208  
 209          /** @var Worksheet $worksheet */
 210          foreach ($worksheets as $worksheet) {
 211              $sheet = $worksheet->getExternalSheet();
 212              if (null !== $autofilter = $sheet->getAutoFilter()) {
 213                  $worksheetName = $sheet->getName();
 214                  $name = sprintf(
 215                      '\'%s\'!$%s$%s:$%s$%s',
 216                      $this->escaper->escape($worksheetName),
 217                      CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
 218                      $autofilter->fromRow,
 219                      CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex),
 220                      $autofilter->toRow
 221                  );
 222                  $definedNames .= '<definedName function="false" hidden="true" localSheetId="'.$sheet->getIndex().'" name="_xlnm._FilterDatabase" vbProcedure="false">'.$name.'</definedName>';
 223              }
 224          }
 225          if ('' !== $definedNames) {
 226              $workbookXmlFileContents .= '<definedNames>'.$definedNames.'</definedNames>';
 227          }
 228  
 229          $workbookXmlFileContents .= <<<'EOD'
 230              </workbook>
 231              EOD;
 232  
 233          $this->createFileWithContents($this->xlFolder, self::WORKBOOK_XML_FILE_NAME, $workbookXmlFileContents);
 234  
 235          return $this;
 236      }
 237  
 238      /**
 239       * Creates the "workbook.xml.res" file under the "xl/_res" folder.
 240       *
 241       * @param Worksheet[] $worksheets
 242       */
 243      public function createWorkbookRelsFile(array $worksheets): self
 244      {
 245          $workbookRelsXmlFileContents = <<<'EOD'
 246              <?xml version="1.0" encoding="UTF-8"?>
 247              <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 248                  <Relationship Id="rIdStyles" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
 249                  <Relationship Id="rIdSharedStrings" Target="sharedStrings.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"/>
 250              EOD;
 251  
 252          /** @var Worksheet $worksheet */
 253          foreach ($worksheets as $worksheet) {
 254              $worksheetId = $worksheet->getId();
 255              $workbookRelsXmlFileContents .= '<Relationship Id="rIdSheet'.$worksheetId.'" Target="worksheets/sheet'.$worksheetId.'.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"/>';
 256          }
 257  
 258          $workbookRelsXmlFileContents .= '</Relationships>';
 259  
 260          $this->createFileWithContents($this->xlRelsFolder, self::WORKBOOK_RELS_XML_FILE_NAME, $workbookRelsXmlFileContents);
 261  
 262          return $this;
 263      }
 264  
 265      /**
 266       * Create the "rels" file for a given worksheet. This contains relations to the comments.xml and drawing.vml files for this worksheet.
 267       *
 268       * @param Worksheet[] $worksheets
 269       */
 270      public function createWorksheetRelsFiles(array $worksheets): self
 271      {
 272          $this->createFolder($this->getXlWorksheetsFolder(), self::RELS_FOLDER_NAME);
 273  
 274          foreach ($worksheets as $worksheet) {
 275              $worksheetId = $worksheet->getId();
 276              $worksheetRelsContent = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 277                <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 278                  <Relationship Id="rId_comments_vml1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing'.$worksheetId.'.vml"/>
 279                  <Relationship Id="rId_comments1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments'.$worksheetId.'.xml"/>
 280                </Relationships>';
 281  
 282              $folder = $this->getXlWorksheetsFolder().\DIRECTORY_SEPARATOR.'_rels';
 283              $filename = 'sheet'.$worksheetId.'.xml.rels';
 284  
 285              $this->createFileWithContents($folder, $filename, $worksheetRelsContent);
 286          }
 287  
 288          return $this;
 289      }
 290  
 291      /**
 292       * Creates the "styles.xml" file under the "xl" folder.
 293       */
 294      public function createStylesFile(StyleManager $styleManager): self
 295      {
 296          $stylesXmlFileContents = $styleManager->getStylesXMLFileContent();
 297          $this->createFileWithContents($this->xlFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
 298  
 299          return $this;
 300      }
 301  
 302      /**
 303       * Creates the "content.xml" file under the root folder.
 304       *
 305       * @param Worksheet[] $worksheets
 306       */
 307      public function createContentFiles(Options $options, array $worksheets): self
 308      {
 309          $allMergeCells = $options->getMergeCells();
 310  
 311          foreach ($worksheets as $worksheet) {
 312              $contentXmlFilePath = $this->getXlWorksheetsFolder().\DIRECTORY_SEPARATOR.basename($worksheet->getFilePath());
 313              $worksheetFilePointer = fopen($contentXmlFilePath, 'w');
 314              \assert(false !== $worksheetFilePointer);
 315  
 316              $sheet = $worksheet->getExternalSheet();
 317              fwrite($worksheetFilePointer, self::SHEET_XML_FILE_HEADER);
 318  
 319              // AutoFilter tags
 320              $range = '';
 321              if (null !== $autofilter = $sheet->getAutoFilter()) {
 322                  $range = sprintf(
 323                      '%s%s:%s%s',
 324                      CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex),
 325                      $autofilter->fromRow,
 326                      CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex),
 327                      $autofilter->toRow
 328                  );
 329                  fwrite($worksheetFilePointer, '<sheetPr filterMode="false"><pageSetUpPr fitToPage="false"/></sheetPr>');
 330                  fwrite($worksheetFilePointer, sprintf('<dimension ref="%s"/>', $range));
 331              }
 332  
 333              if (null !== ($sheetView = $sheet->getSheetView())) {
 334                  fwrite($worksheetFilePointer, '<sheetViews>'.$sheetView->getXml().'</sheetViews>');
 335              }
 336              fwrite($worksheetFilePointer, $this->getXMLFragmentForDefaultCellSizing($options));
 337              fwrite($worksheetFilePointer, $this->getXMLFragmentForColumnWidths($options, $sheet));
 338              fwrite($worksheetFilePointer, '<sheetData>');
 339  
 340              $worksheetFilePath = $worksheet->getFilePath();
 341              $this->copyFileContentsToTarget($worksheetFilePath, $worksheetFilePointer);
 342              fwrite($worksheetFilePointer, '</sheetData>');
 343  
 344              // AutoFilter tag
 345              if ('' !== $range) {
 346                  fwrite($worksheetFilePointer, sprintf('<autoFilter ref="%s"/>', $range));
 347              }
 348  
 349              // create nodes for merge cells
 350              $mergeCells = array_filter(
 351                  $allMergeCells,
 352                  static fn (MergeCell $c) => $c->sheetIndex === $worksheet->getExternalSheet()->getIndex(),
 353              );
 354              if ([] !== $mergeCells) {
 355                  $mergeCellString = '<mergeCells count="'.\count($mergeCells).'">';
 356                  foreach ($mergeCells as $mergeCell) {
 357                      $topLeft = CellHelper::getColumnLettersFromColumnIndex($mergeCell->topLeftColumn).$mergeCell->topLeftRow;
 358                      $bottomRight = CellHelper::getColumnLettersFromColumnIndex($mergeCell->bottomRightColumn).$mergeCell->bottomRightRow;
 359                      $mergeCellString .= sprintf(
 360                          '<mergeCell ref="%s:%s"/>',
 361                          $topLeft,
 362                          $bottomRight
 363                      );
 364                  }
 365                  $mergeCellString .= '</mergeCells>';
 366                  fwrite($worksheetFilePointer, $mergeCellString);
 367              }
 368  
 369              // Add the legacy drawing for comments
 370              fwrite($worksheetFilePointer, '<legacyDrawing r:id="rId_comments_vml1"/>');
 371  
 372              fwrite($worksheetFilePointer, '</worksheet>');
 373              fclose($worksheetFilePointer);
 374          }
 375  
 376          return $this;
 377      }
 378  
 379      /**
 380       * Deletes the temporary folder where sheets content was stored.
 381       */
 382      public function deleteWorksheetTempFolder(): self
 383      {
 384          $this->deleteFolderRecursively($this->sheetsContentTempFolder);
 385  
 386          return $this;
 387      }
 388  
 389      /**
 390       * Zips the root folder and streams the contents of the zip into the given stream.
 391       *
 392       * @param resource $streamPointer Pointer to the stream to copy the zip
 393       */
 394      public function zipRootFolderAndCopyToStream($streamPointer): void
 395      {
 396          $zip = $this->zipHelper->createZip($this->rootFolder);
 397  
 398          $zipFilePath = $this->zipHelper->getZipFilePath($zip);
 399  
 400          // In order to have the file's mime type detected properly, files need to be added
 401          // to the zip file in a particular order.
 402          // "[Content_Types].xml" then at least 2 files located in "xl" folder should be zipped first.
 403          $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME);
 404          $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::XL_FOLDER_NAME.\DIRECTORY_SEPARATOR.self::WORKBOOK_XML_FILE_NAME);
 405          $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::XL_FOLDER_NAME.\DIRECTORY_SEPARATOR.self::STYLES_XML_FILE_NAME);
 406  
 407          $this->zipHelper->addFolderToArchive($zip, $this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
 408          $this->zipHelper->closeArchiveAndCopyToStream($zip, $streamPointer);
 409  
 410          // once the zip is copied, remove it
 411          $this->deleteFile($zipFilePath);
 412      }
 413  
 414      /**
 415       * Construct column width references xml to inject into worksheet xml file.
 416       */
 417      private function getXMLFragmentForColumnWidths(Options $options, Sheet $sheet): string
 418      {
 419          if ([] !== $sheet->getColumnWidths()) {
 420              $widths = $sheet->getColumnWidths();
 421          } elseif ([] !== $options->getColumnWidths()) {
 422              $widths = $options->getColumnWidths();
 423          } else {
 424              return '';
 425          }
 426  
 427          $xml = '<cols>';
 428  
 429          foreach ($widths as $columnWidth) {
 430              $xml .= '<col min="'.$columnWidth->start.'" max="'.$columnWidth->end.'" width="'.$columnWidth->width.'" customWidth="true"/>';
 431          }
 432          $xml .= '</cols>';
 433  
 434          return $xml;
 435      }
 436  
 437      /**
 438       * Constructs default row height and width xml to inject into worksheet xml file.
 439       */
 440      private function getXMLFragmentForDefaultCellSizing(Options $options): string
 441      {
 442          $rowHeightXml = null === $options->DEFAULT_ROW_HEIGHT ? '' : " defaultRowHeight=\"{$options->DEFAULT_ROW_HEIGHT}\"";
 443          $colWidthXml = null === $options->DEFAULT_COLUMN_WIDTH ? '' : " defaultColWidth=\"{$options->DEFAULT_COLUMN_WIDTH}\"";
 444          if ('' === $colWidthXml && '' === $rowHeightXml) {
 445              return '';
 446          }
 447          // Ensure that the required defaultRowHeight is set
 448          $rowHeightXml = '' === $rowHeightXml ? ' defaultRowHeight="0"' : $rowHeightXml;
 449  
 450          return "<sheetFormatPr{$colWidthXml}{$rowHeightXml}/>";
 451      }
 452  
 453      /**
 454       * Creates the folder that will be used as root.
 455       *
 456       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 457       */
 458      private function createRootFolder(): self
 459      {
 460          $this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
 461  
 462          return $this;
 463      }
 464  
 465      /**
 466       * Creates the "_rels" folder under the root folder as well as the ".rels" file in it.
 467       *
 468       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or the ".rels" file
 469       */
 470      private function createRelsFolderAndFile(): self
 471      {
 472          $this->relsFolder = $this->createFolder($this->rootFolder, self::RELS_FOLDER_NAME);
 473  
 474          $this->createRelsFile();
 475  
 476          return $this;
 477      }
 478  
 479      /**
 480       * Creates the ".rels" file under the "_rels" folder (under root).
 481       *
 482       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 483       */
 484      private function createRelsFile(): self
 485      {
 486          $relsFileContents = <<<'EOD'
 487              <?xml version="1.0" encoding="UTF-8"?>
 488              <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
 489                  <Relationship Id="rIdWorkbook" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
 490                  <Relationship Id="rIdCore" Type="http://schemas.openxmlformats.org/officedocument/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
 491                  <Relationship Id="rIdApp" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
 492              </Relationships>
 493              EOD;
 494  
 495          $this->createFileWithContents($this->relsFolder, self::RELS_FILE_NAME, $relsFileContents);
 496  
 497          return $this;
 498      }
 499  
 500      /**
 501       * Creates the "docProps" folder under the root folder as well as the "app.xml" and "core.xml" files in it.
 502       *
 503       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or one of the files
 504       */
 505      private function createDocPropsFolderAndFiles(): self
 506      {
 507          $this->docPropsFolder = $this->createFolder($this->rootFolder, self::DOC_PROPS_FOLDER_NAME);
 508  
 509          $this->createAppXmlFile();
 510          $this->createCoreXmlFile();
 511  
 512          return $this;
 513      }
 514  
 515      /**
 516       * Creates the "app.xml" file under the "docProps" folder.
 517       *
 518       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 519       */
 520      private function createAppXmlFile(): self
 521      {
 522          $appXmlFileContents = <<<EOD
 523              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 524              <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
 525                  <Application>{$this->creator}</Application>
 526                  <TotalTime>0</TotalTime>
 527              </Properties>
 528              EOD;
 529  
 530          $this->createFileWithContents($this->docPropsFolder, self::APP_XML_FILE_NAME, $appXmlFileContents);
 531  
 532          return $this;
 533      }
 534  
 535      /**
 536       * Creates the "core.xml" file under the "docProps" folder.
 537       *
 538       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 539       */
 540      private function createCoreXmlFile(): self
 541      {
 542          $createdDate = (new DateTimeImmutable())->format(DateTimeImmutable::W3C);
 543          $coreXmlFileContents = <<<EOD
 544              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 545              <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
 546                  <dcterms:created xsi:type="dcterms:W3CDTF">{$createdDate}</dcterms:created>
 547                  <dcterms:modified xsi:type="dcterms:W3CDTF">{$createdDate}</dcterms:modified>
 548                  <cp:revision>0</cp:revision>
 549              </cp:coreProperties>
 550              EOD;
 551  
 552          $this->createFileWithContents($this->docPropsFolder, self::CORE_XML_FILE_NAME, $coreXmlFileContents);
 553  
 554          return $this;
 555      }
 556  
 557      /**
 558       * Creates the "xl" folder under the root folder as well as its subfolders.
 559       *
 560       * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the folders
 561       */
 562      private function createXlFolderAndSubFolders(): self
 563      {
 564          $this->xlFolder = $this->createFolder($this->rootFolder, self::XL_FOLDER_NAME);
 565          $this->createXlRelsFolder();
 566          $this->createXlWorksheetsFolder();
 567          $this->createDrawingsFolder();
 568  
 569          return $this;
 570      }
 571  
 572      /**
 573       * Creates the temp folder where specific sheets content will be written to.
 574       * This folder is not part of the final ODS file and is only used to be able to jump between sheets.
 575       *
 576       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 577       */
 578      private function createSheetsContentTempFolder(): self
 579      {
 580          $this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, 'worksheets-temp');
 581  
 582          return $this;
 583      }
 584  
 585      /**
 586       * Creates the "_rels" folder under the "xl" folder.
 587       *
 588       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 589       */
 590      private function createXlRelsFolder(): self
 591      {
 592          $this->xlRelsFolder = $this->createFolder($this->xlFolder, self::RELS_FOLDER_NAME);
 593  
 594          return $this;
 595      }
 596  
 597      /**
 598       * Creates the "drawings" folder under the "xl" folder.
 599       *
 600       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 601       */
 602      private function createDrawingsFolder(): self
 603      {
 604          $this->createFolder($this->getXlFolder(), self::DRAWINGS_FOLDER_NAME);
 605  
 606          return $this;
 607      }
 608  
 609      /**
 610       * Creates the "worksheets" folder under the "xl" folder.
 611       *
 612       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 613       */
 614      private function createXlWorksheetsFolder(): self
 615      {
 616          $this->xlWorksheetsFolder = $this->createFolder($this->xlFolder, self::WORKSHEETS_FOLDER_NAME);
 617  
 618          return $this;
 619      }
 620  
 621      /**
 622       * Streams the content of the file at the given path into the target resource.
 623       * Depending on which mode the target resource was created with, it will truncate then copy
 624       * or append the content to the target file.
 625       *
 626       * @param string   $sourceFilePath Path of the file whose content will be copied
 627       * @param resource $targetResource Target resource that will receive the content
 628       */
 629      private function copyFileContentsToTarget(string $sourceFilePath, $targetResource): void
 630      {
 631          $sourceHandle = fopen($sourceFilePath, 'r');
 632          \assert(false !== $sourceHandle);
 633          stream_copy_to_stream($sourceHandle, $targetResource);
 634          fclose($sourceHandle);
 635      }
 636  }