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.

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