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\ODS\Helper;
   6  
   7  use DateTimeImmutable;
   8  use OpenSpout\Common\Helper\FileSystemHelper as CommonFileSystemHelper;
   9  use OpenSpout\Writer\Common\Entity\Worksheet;
  10  use OpenSpout\Writer\Common\Helper\FileSystemWithRootFolderHelperInterface;
  11  use OpenSpout\Writer\Common\Helper\ZipHelper;
  12  use OpenSpout\Writer\ODS\Manager\Style\StyleManager;
  13  use OpenSpout\Writer\ODS\Manager\WorksheetManager;
  14  
  15  /**
  16   * @internal
  17   */
  18  final class FileSystemHelper implements FileSystemWithRootFolderHelperInterface
  19  {
  20      public const APP_NAME = 'OpenSpout';
  21      public const MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet';
  22  
  23      public const META_INF_FOLDER_NAME = 'META-INF';
  24  
  25      public const MANIFEST_XML_FILE_NAME = 'manifest.xml';
  26      public const CONTENT_XML_FILE_NAME = 'content.xml';
  27      public const META_XML_FILE_NAME = 'meta.xml';
  28      public const MIMETYPE_FILE_NAME = 'mimetype';
  29      public const STYLES_XML_FILE_NAME = 'styles.xml';
  30  
  31      private string $baseFolderRealPath;
  32      private CommonFileSystemHelper $baseFileSystemHelper;
  33  
  34      /** @var string Path to the root folder inside the temp folder where the files to create the ODS will be stored */
  35      private string $rootFolder;
  36  
  37      /** @var string Path to the "META-INF" folder inside the root folder */
  38      private string $metaInfFolder;
  39  
  40      /** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */
  41      private string $sheetsContentTempFolder;
  42  
  43      /** @var ZipHelper Helper to perform tasks with Zip archive */
  44      private ZipHelper $zipHelper;
  45  
  46      /**
  47       * @param string    $baseFolderPath The path of the base folder where all the I/O can occur
  48       * @param ZipHelper $zipHelper      Helper to perform tasks with Zip archive
  49       */
  50      public function __construct(string $baseFolderPath, ZipHelper $zipHelper)
  51      {
  52          $this->baseFileSystemHelper = new CommonFileSystemHelper($baseFolderPath);
  53          $this->baseFolderRealPath = $this->baseFileSystemHelper->getBaseFolderRealPath();
  54          $this->zipHelper = $zipHelper;
  55      }
  56  
  57      public function createFolder(string $parentFolderPath, string $folderName): string
  58      {
  59          return $this->baseFileSystemHelper->createFolder($parentFolderPath, $folderName);
  60      }
  61  
  62      public function createFileWithContents(string $parentFolderPath, string $fileName, string $fileContents): string
  63      {
  64          return $this->baseFileSystemHelper->createFileWithContents($parentFolderPath, $fileName, $fileContents);
  65      }
  66  
  67      public function deleteFile(string $filePath): void
  68      {
  69          $this->baseFileSystemHelper->deleteFile($filePath);
  70      }
  71  
  72      public function deleteFolderRecursively(string $folderPath): void
  73      {
  74          $this->baseFileSystemHelper->deleteFolderRecursively($folderPath);
  75      }
  76  
  77      public function getRootFolder(): string
  78      {
  79          return $this->rootFolder;
  80      }
  81  
  82      public function getSheetsContentTempFolder(): string
  83      {
  84          return $this->sheetsContentTempFolder;
  85      }
  86  
  87      /**
  88       * Creates all the folders needed to create a ODS file, as well as the files that won't change.
  89       *
  90       * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the base folders
  91       */
  92      public function createBaseFilesAndFolders(): void
  93      {
  94          $this
  95              ->createRootFolder()
  96              ->createMetaInfoFolderAndFile()
  97              ->createSheetsContentTempFolder()
  98              ->createMetaFile()
  99              ->createMimetypeFile()
 100          ;
 101      }
 102  
 103      /**
 104       * Creates the "content.xml" file under the root folder.
 105       *
 106       * @param Worksheet[] $worksheets
 107       */
 108      public function createContentFile(WorksheetManager $worksheetManager, StyleManager $styleManager, array $worksheets): self
 109      {
 110          $contentXmlFileContents = <<<'EOD'
 111              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 112              <office:document-content office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
 113              EOD;
 114  
 115          $contentXmlFileContents .= $styleManager->getContentXmlFontFaceSectionContent();
 116          $contentXmlFileContents .= $styleManager->getContentXmlAutomaticStylesSectionContent($worksheets);
 117  
 118          $contentXmlFileContents .= '<office:body><office:spreadsheet>';
 119  
 120          $topContentTempFile = uniqid(self::CONTENT_XML_FILE_NAME);
 121          $this->createFileWithContents($this->rootFolder, $topContentTempFile, $contentXmlFileContents);
 122  
 123          // Append sheets content to "content.xml"
 124          $contentXmlFilePath = $this->rootFolder.\DIRECTORY_SEPARATOR.self::CONTENT_XML_FILE_NAME;
 125          $contentXmlHandle = fopen($contentXmlFilePath, 'w');
 126          \assert(false !== $contentXmlHandle);
 127  
 128          $topContentTempPathname = $this->rootFolder.\DIRECTORY_SEPARATOR.$topContentTempFile;
 129          $topContentTempHandle = fopen($topContentTempPathname, 'r');
 130          \assert(false !== $topContentTempHandle);
 131          stream_copy_to_stream($topContentTempHandle, $contentXmlHandle);
 132          fclose($topContentTempHandle);
 133          unlink($topContentTempPathname);
 134  
 135          foreach ($worksheets as $worksheet) {
 136              // write the "<table:table>" node, with the final sheet's name
 137              fwrite($contentXmlHandle, $worksheetManager->getTableElementStartAsString($worksheet));
 138  
 139              $worksheetFilePath = $worksheet->getFilePath();
 140              $this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle);
 141  
 142              fwrite($contentXmlHandle, '</table:table>');
 143          }
 144  
 145          // add AutoFilter
 146          $databaseRanges = '';
 147          foreach ($worksheets as $worksheet) {
 148              $databaseRanges .= $worksheetManager->getTableDatabaseRangeElementAsString($worksheet);
 149          }
 150          if ('' !== $databaseRanges) {
 151              fwrite($contentXmlHandle, '<table:database-ranges>');
 152              fwrite($contentXmlHandle, $databaseRanges);
 153              fwrite($contentXmlHandle, '</table:database-ranges>');
 154          }
 155  
 156          $contentXmlFileContents = '</office:spreadsheet></office:body></office:document-content>';
 157  
 158          fwrite($contentXmlHandle, $contentXmlFileContents);
 159          fclose($contentXmlHandle);
 160  
 161          return $this;
 162      }
 163  
 164      /**
 165       * Deletes the temporary folder where sheets content was stored.
 166       */
 167      public function deleteWorksheetTempFolder(): self
 168      {
 169          $this->deleteFolderRecursively($this->sheetsContentTempFolder);
 170  
 171          return $this;
 172      }
 173  
 174      /**
 175       * Creates the "styles.xml" file under the root folder.
 176       *
 177       * @param int $numWorksheets Number of created worksheets
 178       */
 179      public function createStylesFile(StyleManager $styleManager, int $numWorksheets): self
 180      {
 181          $stylesXmlFileContents = $styleManager->getStylesXMLFileContent($numWorksheets);
 182          $this->createFileWithContents($this->rootFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
 183  
 184          return $this;
 185      }
 186  
 187      /**
 188       * Zips the root folder and streams the contents of the zip into the given stream.
 189       *
 190       * @param resource $streamPointer Pointer to the stream to copy the zip
 191       */
 192      public function zipRootFolderAndCopyToStream($streamPointer): void
 193      {
 194          $zip = $this->zipHelper->createZip($this->rootFolder);
 195  
 196          $zipFilePath = $this->zipHelper->getZipFilePath($zip);
 197  
 198          // In order to have the file's mime type detected properly, files need to be added
 199          // to the zip file in a particular order.
 200          // @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
 201          $this->zipHelper->addUncompressedFileToArchive($zip, $this->rootFolder, self::MIMETYPE_FILE_NAME);
 202  
 203          $this->zipHelper->addFolderToArchive($zip, $this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
 204          $this->zipHelper->closeArchiveAndCopyToStream($zip, $streamPointer);
 205  
 206          // once the zip is copied, remove it
 207          $this->deleteFile($zipFilePath);
 208      }
 209  
 210      /**
 211       * Creates the folder that will be used as root.
 212       *
 213       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 214       */
 215      private function createRootFolder(): self
 216      {
 217          $this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
 218  
 219          return $this;
 220      }
 221  
 222      /**
 223       * Creates the "META-INF" folder under the root folder as well as the "manifest.xml" file in it.
 224       *
 225       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or the "manifest.xml" file
 226       */
 227      private function createMetaInfoFolderAndFile(): self
 228      {
 229          $this->metaInfFolder = $this->createFolder($this->rootFolder, self::META_INF_FOLDER_NAME);
 230  
 231          $this->createManifestFile();
 232  
 233          return $this;
 234      }
 235  
 236      /**
 237       * Creates the "manifest.xml" file under the "META-INF" folder (under root).
 238       *
 239       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 240       */
 241      private function createManifestFile(): self
 242      {
 243          $manifestXmlFileContents = <<<'EOD'
 244              <?xml version="1.0" encoding="UTF-8"?>
 245              <manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
 246                  <manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
 247                  <manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
 248                  <manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
 249                  <manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
 250              </manifest:manifest>
 251              EOD;
 252  
 253          $this->createFileWithContents($this->metaInfFolder, self::MANIFEST_XML_FILE_NAME, $manifestXmlFileContents);
 254  
 255          return $this;
 256      }
 257  
 258      /**
 259       * Creates the temp folder where specific sheets content will be written to.
 260       * This folder is not part of the final ODS file and is only used to be able to jump between sheets.
 261       *
 262       * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
 263       */
 264      private function createSheetsContentTempFolder(): self
 265      {
 266          $this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, 'worksheets-temp');
 267  
 268          return $this;
 269      }
 270  
 271      /**
 272       * Creates the "meta.xml" file under the root folder.
 273       *
 274       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 275       */
 276      private function createMetaFile(): self
 277      {
 278          $appName = self::APP_NAME;
 279          $createdDate = (new DateTimeImmutable())->format(DateTimeImmutable::W3C);
 280  
 281          $metaXmlFileContents = <<<EOD
 282              <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
 283              <office:document-meta office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
 284                  <office:meta>
 285                      <dc:creator>{$appName}</dc:creator>
 286                      <meta:creation-date>{$createdDate}</meta:creation-date>
 287                      <dc:date>{$createdDate}</dc:date>
 288                  </office:meta>
 289              </office:document-meta>
 290              EOD;
 291  
 292          $this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents);
 293  
 294          return $this;
 295      }
 296  
 297      /**
 298       * Creates the "mimetype" file under the root folder.
 299       *
 300       * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
 301       */
 302      private function createMimetypeFile(): self
 303      {
 304          $this->createFileWithContents($this->rootFolder, self::MIMETYPE_FILE_NAME, self::MIMETYPE);
 305  
 306          return $this;
 307      }
 308  
 309      /**
 310       * Streams the content of the file at the given path into the target resource.
 311       * Depending on which mode the target resource was created with, it will truncate then copy
 312       * or append the content to the target file.
 313       *
 314       * @param string   $sourceFilePath Path of the file whose content will be copied
 315       * @param resource $targetResource Target resource that will receive the content
 316       */
 317      private function copyFileContentsToTarget(string $sourceFilePath, $targetResource): void
 318      {
 319          $sourceHandle = fopen($sourceFilePath, 'r');
 320          \assert(false !== $sourceHandle);
 321          stream_copy_to_stream($sourceHandle, $targetResource);
 322          fclose($sourceHandle);
 323      }
 324  }