Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Class for converting files between different file formats using unoconv.
  19   *
  20   * @package    fileconverter_unoconv
  21   * @copyright  2017 Damyon Wiese
  22   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  namespace fileconverter_unoconv;
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  require_once($CFG->libdir . '/filelib.php');
  29  
  30  use stored_file;
  31  use \core_files\conversion;
  32  
  33  /**
  34   * Class for converting files between different formats using unoconv.
  35   *
  36   * @package    fileconverter_unoconv
  37   * @copyright  2017 Damyon Wiese
  38   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class converter implements \core_files\converter_interface {
  41  
  42      /** No errors */
  43      const UNOCONVPATH_OK = 'ok';
  44  
  45      /** Not set */
  46      const UNOCONVPATH_EMPTY = 'empty';
  47  
  48      /** Does not exist */
  49      const UNOCONVPATH_DOESNOTEXIST = 'doesnotexist';
  50  
  51      /** Is a dir */
  52      const UNOCONVPATH_ISDIR = 'isdir';
  53  
  54      /** Not executable */
  55      const UNOCONVPATH_NOTEXECUTABLE = 'notexecutable';
  56  
  57      /** Test file missing */
  58      const UNOCONVPATH_NOTESTFILE = 'notestfile';
  59  
  60      /** Version not supported */
  61      const UNOCONVPATH_VERSIONNOTSUPPORTED = 'versionnotsupported';
  62  
  63      /** Any other error */
  64      const UNOCONVPATH_ERROR = 'error';
  65  
  66      /**
  67       * @var bool $requirementsmet Whether requirements have been met.
  68       */
  69      protected static $requirementsmet = null;
  70  
  71      /**
  72       * @var array $formats The list of formats supported by unoconv.
  73       */
  74      protected static $formats;
  75  
  76      /**
  77       * Convert a document to a new format and return a conversion object relating to the conversion in progress.
  78       *
  79       * @param   conversion $conversion The file to be converted
  80       * @return  $this
  81       */
  82      public function start_document_conversion(\core_files\conversion $conversion) {
  83          global $CFG;
  84  
  85          if (!self::are_requirements_met()) {
  86              $conversion->set('status', conversion::STATUS_FAILED);
  87              error_log(
  88                  "Unoconv conversion failed to verify the configuraton meets the minimum requirements. " .
  89                  "Please check the unoconv installation configuration."
  90              );
  91              return $this;
  92          }
  93  
  94          $file = $conversion->get_sourcefile();
  95          $filepath = $file->get_filepath();
  96  
  97          // Sanity check that the conversion is supported.
  98          $fromformat = pathinfo($file->get_filename(), PATHINFO_EXTENSION);
  99          if (!self::is_format_supported($fromformat)) {
 100              $conversion->set('status', conversion::STATUS_FAILED);
 101              error_log(
 102                  "Unoconv conversion for '" . $filepath . "' found input '" . $fromformat . "' " .
 103                  "file extension to convert from is not supported."
 104              );
 105              return $this;
 106          }
 107  
 108          $format = $conversion->get('targetformat');
 109          if (!self::is_format_supported($format)) {
 110              $conversion->set('status', conversion::STATUS_FAILED);
 111              error_log(
 112                  "Unoconv conversion for '" . $filepath . "' found output '" . $format . "' " .
 113                  "file extension to convert to is not supported."
 114              );
 115              return $this;
 116          }
 117  
 118          // Copy the file to the tmp dir.
 119          $uniqdir = make_unique_writable_directory(make_temp_directory('core_file/conversions'));
 120          \core_shutdown_manager::register_function('remove_dir', array($uniqdir));
 121          $localfilename = $file->get_id() . '.' . $fromformat;
 122  
 123          $filename = $uniqdir . '/' . $localfilename;
 124          try {
 125              // This function can either return false, or throw an exception so we need to handle both.
 126              if ($file->copy_content_to($filename) === false) {
 127                  throw new \file_exception('storedfileproblem', 'Could not copy file contents to temp file.');
 128              }
 129          } catch (\file_exception $fe) {
 130              error_log(
 131                  "Unoconv conversion for '" . $filepath . "' encountered disk permission error when copying " .
 132                  "submitted file contents to unique temp file: '" . $filename . "'."
 133              );
 134              throw $fe;
 135          }
 136  
 137          // The temporary file to copy into.
 138          $newtmpfile = pathinfo($filename, PATHINFO_FILENAME) . '.' . $format;
 139          $newtmpfile = $uniqdir . '/' . clean_param($newtmpfile, PARAM_FILE);
 140  
 141          $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' ' .
 142                 escapeshellarg('-f') . ' ' .
 143                 escapeshellarg($format) . ' ' .
 144                 escapeshellarg('-o') . ' ' .
 145                 escapeshellarg($newtmpfile) . ' ' .
 146                 escapeshellarg($filename);
 147  
 148          $output = null;
 149          $currentdir = getcwd();
 150          chdir($uniqdir);
 151          $result = exec($cmd, $output, $returncode);
 152          chdir($currentdir);
 153          touch($newtmpfile);
 154  
 155          if ($returncode != 0) {
 156              $conversion->set('status', conversion::STATUS_FAILED);
 157              error_log(
 158                  "Unoconv conversion for '" . $filepath . "' from '" . $fromformat . "' to '" . $format . "' " .
 159                  "was unsuccessful; returned with exit status code (" . $returncode . "). Please check the unoconv " .
 160                  "configuration and conversion file content / format."
 161              );
 162              return $this;
 163          }
 164  
 165          if (!file_exists($newtmpfile)) {
 166              $conversion->set('status', conversion::STATUS_FAILED);
 167              error_log(
 168                  "Unoconv conversion for '" . $filepath . "' from '" . $fromformat . "' to '" . $format . "' " .
 169                  "was unsuccessful; the output file was not found in '" . $newtmpfile . "'. Please check the disk " .
 170                  "permissions."
 171              );
 172              return $this;
 173          }
 174  
 175          if (filesize($newtmpfile) === 0) {
 176              $conversion->set('status', conversion::STATUS_FAILED);
 177              error_log(
 178                  "Unoconv conversion for '" . $filepath . "' from '" . $fromformat . "' to '" . $format . "' " .
 179                  "was unsuccessful; the output file size has 0 bytes in '" . $newtmpfile . "'. Please check the " .
 180                  "conversion file content / format with the command: [ " . $cmd . " ]"
 181              );
 182              return $this;
 183          }
 184  
 185          $conversion
 186              ->store_destfile_from_path($newtmpfile)
 187              ->set('status', conversion::STATUS_COMPLETE)
 188              ->update();
 189  
 190          return $this;
 191      }
 192  
 193      /**
 194       * Poll an existing conversion for status update.
 195       *
 196       * @param   conversion $conversion The file to be converted
 197       * @return  $this
 198       */
 199      public function poll_conversion_status(conversion $conversion) {
 200          // Unoconv does not support asynchronous conversion.
 201          return $this;
 202      }
 203  
 204      /**
 205       * Generate and serve the test document.
 206       *
 207       * @return  void
 208       */
 209      public function serve_test_document() {
 210          global $CFG;
 211          require_once($CFG->libdir . '/filelib.php');
 212  
 213          $format = 'pdf';
 214  
 215          $filerecord = [
 216              'contextid' => \context_system::instance()->id,
 217              'component' => 'test',
 218              'filearea' => 'fileconverter_unoconv',
 219              'itemid' => 0,
 220              'filepath' => '/',
 221              'filename' => 'unoconv_test.docx'
 222          ];
 223  
 224          // Get the fixture doc file content and generate and stored_file object.
 225          $fs = get_file_storage();
 226          $testdocx = $fs->get_file($filerecord['contextid'], $filerecord['component'], $filerecord['filearea'],
 227                  $filerecord['itemid'], $filerecord['filepath'], $filerecord['filename']);
 228  
 229          if (!$testdocx) {
 230              $fixturefile = dirname(__DIR__) . '/tests/fixtures/unoconv-source.docx';
 231              $testdocx = $fs->create_file_from_pathname($filerecord, $fixturefile);
 232          }
 233  
 234          $conversions = conversion::get_conversions_for_file($testdocx, $format);
 235          foreach ($conversions as $conversion) {
 236              if ($conversion->get('id')) {
 237                  $conversion->delete();
 238              }
 239          }
 240  
 241          $conversion = new conversion(0, (object) [
 242                  'sourcefileid' => $testdocx->get_id(),
 243                  'targetformat' => $format,
 244              ]);
 245          $conversion->create();
 246  
 247          // Convert the doc file to the target format and send it direct to the browser.
 248          $this->start_document_conversion($conversion);
 249          do {
 250              sleep(1);
 251              $this->poll_conversion_status($conversion);
 252              $status = $conversion->get('status');
 253          } while ($status !== conversion::STATUS_COMPLETE && $status !== conversion::STATUS_FAILED);
 254  
 255          readfile_accel($conversion->get_destfile(), 'application/pdf', true);
 256      }
 257  
 258      /**
 259       * Whether the plugin is configured and requirements are met.
 260       *
 261       * @return  bool
 262       */
 263      public static function are_requirements_met() {
 264          if (self::$requirementsmet === null) {
 265              $requirementsmet = self::test_unoconv_path()->status === self::UNOCONVPATH_OK;
 266              $requirementsmet = $requirementsmet && self::is_minimum_version_met();
 267              self::$requirementsmet = $requirementsmet;
 268          }
 269  
 270          return self::$requirementsmet;
 271      }
 272  
 273      /**
 274       * Whether the minimum version of unoconv has been met.
 275       *
 276       * @return  bool
 277       */
 278      protected static function is_minimum_version_met() {
 279          global $CFG;
 280  
 281          $currentversion = 0;
 282          $supportedversion = 0.7;
 283          $unoconvbin = \escapeshellarg($CFG->pathtounoconv);
 284          $command = "$unoconvbin --version";
 285          exec($command, $output);
 286  
 287          // If the command execution returned some output, then get the unoconv version.
 288          if ($output) {
 289              foreach ($output as $response) {
 290                  if (preg_match('/unoconv (\\d+\\.\\d+)/', $response, $matches)) {
 291                      $currentversion = (float) $matches[1];
 292                  }
 293              }
 294              if ($currentversion < $supportedversion) {
 295                  return false;
 296              } else {
 297                  return true;
 298              }
 299          }
 300  
 301          return false;
 302      }
 303  
 304      /**
 305       * Whether the plugin is fully configured.
 306       *
 307       * @return  \stdClass
 308       */
 309      public static function test_unoconv_path() {
 310          global $CFG;
 311  
 312          $unoconvpath = $CFG->pathtounoconv;
 313  
 314          $ret = new \stdClass();
 315          $ret->status = self::UNOCONVPATH_OK;
 316          $ret->message = null;
 317  
 318          if (empty($unoconvpath)) {
 319              $ret->status = self::UNOCONVPATH_EMPTY;
 320              return $ret;
 321          }
 322          if (!file_exists($unoconvpath)) {
 323              $ret->status = self::UNOCONVPATH_DOESNOTEXIST;
 324              return $ret;
 325          }
 326          if (is_dir($unoconvpath)) {
 327              $ret->status = self::UNOCONVPATH_ISDIR;
 328              return $ret;
 329          }
 330          if (!\file_is_executable($unoconvpath)) {
 331              $ret->status = self::UNOCONVPATH_NOTEXECUTABLE;
 332              return $ret;
 333          }
 334          if (!self::is_minimum_version_met()) {
 335              $ret->status = self::UNOCONVPATH_VERSIONNOTSUPPORTED;
 336              return $ret;
 337          }
 338  
 339          return $ret;
 340  
 341      }
 342  
 343      /**
 344       * Whether a file conversion can be completed using this converter.
 345       *
 346       * @param   string $from The source type
 347       * @param   string $to The destination type
 348       * @return  bool
 349       */
 350      public static function supports($from, $to) {
 351          return self::is_format_supported($from) && self::is_format_supported($to);
 352      }
 353  
 354      /**
 355       * Whether the specified file format is supported.
 356       *
 357       * @param   string $format Whether conversions between this format and another are supported
 358       * @return  bool
 359       */
 360      protected static function is_format_supported($format) {
 361          $formats = self::fetch_supported_formats();
 362  
 363          $format = trim(\core_text::strtolower($format));
 364          return in_array($format, $formats);
 365      }
 366  
 367      /**
 368       * Fetch the list of supported file formats.
 369       *
 370       * @return  array
 371       */
 372      protected static function fetch_supported_formats() {
 373          global $CFG;
 374  
 375          if (!isset(self::$formats)) {
 376              // Ask unoconv for it's list of supported document formats.
 377              $cmd = escapeshellcmd(trim($CFG->pathtounoconv)) . ' --show';
 378              $pipes = array();
 379              $pipesspec = array(2 => array('pipe', 'w'));
 380              $proc = proc_open($cmd, $pipesspec, $pipes);
 381              $programoutput = stream_get_contents($pipes[2]);
 382              fclose($pipes[2]);
 383              proc_close($proc);
 384              $matches = array();
 385              preg_match_all('/\[\.(.*)\]/', $programoutput, $matches);
 386  
 387              $formats = $matches[1];
 388              self::$formats = array_unique($formats);
 389          }
 390  
 391          return self::$formats;
 392      }
 393  
 394      /**
 395       * A list of the supported conversions.
 396       *
 397       * @return  string
 398       */
 399      public function get_supported_conversions() {
 400          return implode(', ', self::fetch_supported_formats());
 401      }
 402  }