Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 and 403]

   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   * This file contains the ingest manager for the assignfeedback_editpdf plugin
  19   *
  20   * @package   assignfeedback_editpdf
  21   * @copyright 2012 Davo Smith
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace assignfeedback_editpdf;
  26  
  27  use DOMDocument;
  28  
  29  /**
  30   * Functions for generating the annotated pdf.
  31   *
  32   * This class controls the ingest of student submission files to a normalised
  33   * PDF 1.4 document with all submission files concatinated together. It also
  34   * provides the functions to generate a downloadable pdf with all comments and
  35   * annotations embedded.
  36   * @copyright 2012 Davo Smith
  37   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class document_services {
  40  
  41      /** Compoment name */
  42      const COMPONENT = "assignfeedback_editpdf";
  43      /** File area for generated pdf */
  44      const FINAL_PDF_FILEAREA = 'download';
  45      /** File area for combined pdf */
  46      const COMBINED_PDF_FILEAREA = 'combined';
  47      /** File area for partial combined pdf */
  48      const PARTIAL_PDF_FILEAREA = 'partial';
  49      /** File area for importing html */
  50      const IMPORT_HTML_FILEAREA = 'importhtml';
  51      /** File area for page images */
  52      const PAGE_IMAGE_FILEAREA = 'pages';
  53      /** File area for readonly page images */
  54      const PAGE_IMAGE_READONLY_FILEAREA = 'readonlypages';
  55      /** File area for the stamps */
  56      const STAMPS_FILEAREA = 'stamps';
  57      /** Filename for combined pdf */
  58      const COMBINED_PDF_FILENAME = 'combined.pdf';
  59      /**  Temporary place to save JPG Image to PDF file */
  60      const TMP_JPG_TO_PDF_FILEAREA = 'tmp_jpg_to_pdf';
  61      /**  Temporary place to save (Automatically) Rotated JPG FILE */
  62      const TMP_ROTATED_JPG_FILEAREA = 'tmp_rotated_jpg';
  63      /** Hash of blank pdf */
  64      const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
  65  
  66      /** Base64 encoded blank pdf. This is the most reliable/fastest way to generate a blank pdf. */
  67      const BLANK_PDF_BASE64 = <<<EOD
  68  JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl
  69  Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMFAwALJMLU31jBQsTAz1LBSKUrnCtRTyuAIVAIcdB3IK
  70  ZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iago0MgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq
  71  Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEg
  72  MCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCA0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hb
  73  MCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+
  74  L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
  75  cyA2IDAgUgovTWVkaWFCb3hbIDAgMCA1OTUgODQyIF0KL0tpZHNbIDEgMCBSIF0KL0NvdW50IDE+
  76  PgplbmRvYmoKCjcgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0aW9u
  77  WzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLUFVKQo+PgplbmRvYmoKCjggMCBvYmoK
  78  PDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAw
  79  NEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzQwMDJFMDAz
  80  ND4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwMjI2MTMyMzE0KzA4JzAwJyk+PgplbmRvYmoKCnhyZWYK
  81  MCA5CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDIyNiAwMDAwMCBuIAowMDAwMDAwMDE5IDAw
  82  MDAwIG4gCjAwMDAwMDAxMzIgMDAwMDAgbiAKMDAwMDAwMDM2OCAwMDAwMCBuIAowMDAwMDAwMTUx
  83  IDAwMDAwIG4gCjAwMDAwMDAxNzMgMDAwMDAgbiAKMDAwMDAwMDQ2NiAwMDAwMCBuIAowMDAwMDAw
  84  NTYyIDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA5L1Jvb3QgNyAwIFIKL0luZm8gOCAwIFIKL0lE
  85  IFsgPEJDN0REQUQwRDQyOTQ1OTQ2OUU4NzJCMjI1ODUyNkU4Pgo8QkM3RERBRDBENDI5NDU5NDY5
  86  RTg3MkIyMjU4NTI2RTg+IF0KL0RvY0NoZWNrc3VtIC9BNTYwMEZCMDAzRURCRTg0MTNBNTk3RTZF
  87  MURDQzJBRgo+PgpzdGFydHhyZWYKNzM2CiUlRU9GCg==
  88  EOD;
  89  
  90      /**
  91       * This function will take an int or an assignment instance and
  92       * return an assignment instance. It is just for convenience.
  93       * @param int|\assign $assignment
  94       * @return assign
  95       */
  96      private static function get_assignment_from_param($assignment) {
  97          global $CFG;
  98  
  99          require_once($CFG->dirroot . '/mod/assign/locallib.php');
 100  
 101          if (!is_object($assignment)) {
 102              $cm = get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST);
 103              $context = \context_module::instance($cm->id);
 104  
 105              $assignment = new \assign($context, null, null);
 106          }
 107          return $assignment;
 108      }
 109  
 110      /**
 111       * Get a hash that will be unique and can be used in a path name.
 112       * @param int|\assign $assignment
 113       * @param int $userid
 114       * @param int $attemptnumber (-1 means latest attempt)
 115       */
 116      private static function hash($assignment, $userid, $attemptnumber) {
 117          if (is_object($assignment)) {
 118              $assignmentid = $assignment->get_instance()->id;
 119          } else {
 120              $assignmentid = $assignment;
 121          }
 122          return sha1($assignmentid . '_' . $userid . '_' . $attemptnumber);
 123      }
 124  
 125      /**
 126       * Use a DOM parser to accurately replace images with their alt text.
 127       * @param string $html
 128       * @return string New html with no image tags.
 129       */
 130      protected static function strip_images($html) {
 131          // Load HTML and suppress any parsing errors (DOMDocument->loadHTML() does not current support HTML5 tags).
 132          $dom = new DOMDocument();
 133          libxml_use_internal_errors(true);
 134          $dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $html);
 135          libxml_clear_errors();
 136  
 137          // Find all img tags.
 138          if ($imgnodes = $dom->getElementsByTagName('img')) {
 139              // Replace img nodes with the img alt text without overriding DOM elements.
 140              for ($i = ($imgnodes->length - 1); $i >= 0; $i--) {
 141                  $imgnode = $imgnodes->item($i);
 142                  $alt = ($imgnode->hasAttribute('alt')) ? ' [ ' . $imgnode->getAttribute('alt') . ' ] ' : ' ';
 143                  $textnode = $dom->createTextNode($alt);
 144  
 145                  $imgnode->parentNode->replaceChild($textnode, $imgnode);
 146              }
 147          }
 148          $count = 1;
 149          return str_replace("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>", "", $dom->saveHTML(), $count);
 150      }
 151  
 152      /**
 153       * This function will search for all files that can be converted
 154       * and concatinated into a PDF (1.4) - for any submission plugin
 155       * for this students attempt.
 156       *
 157       * @param int|\assign $assignment
 158       * @param int $userid
 159       * @param int $attemptnumber (-1 means latest attempt)
 160       * @return combined_document
 161       */
 162      protected static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) {
 163          global $USER, $DB;
 164  
 165          $assignment = self::get_assignment_from_param($assignment);
 166  
 167          // Capability checks.
 168          if (!$assignment->can_view_submission($userid)) {
 169              print_error('nopermission');
 170          }
 171  
 172          $files = array();
 173  
 174          if ($assignment->get_instance()->teamsubmission) {
 175              $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
 176          } else {
 177              $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
 178          }
 179          $user = $DB->get_record('user', array('id' => $userid));
 180  
 181          // User has not submitted anything yet.
 182          if (!$submission) {
 183              return new combined_document();
 184          }
 185  
 186          $fs = get_file_storage();
 187          $converter = new \core_files\converter();
 188          // Ask each plugin for it's list of files.
 189          foreach ($assignment->get_submission_plugins() as $plugin) {
 190              if ($plugin->is_enabled() && $plugin->is_visible()) {
 191                  $pluginfiles = $plugin->get_files($submission, $user);
 192                  foreach ($pluginfiles as $filename => $file) {
 193                      if ($file instanceof \stored_file) {
 194                          $mimetype = $file->get_mimetype();
 195                          // PDF File, no conversion required.
 196                          if ($mimetype === 'application/pdf') {
 197                              $files[$filename] = $file;
 198                          } else if ($plugin->allow_image_conversion() && $mimetype === "image/jpeg") {
 199                              // Rotates image based on the EXIF value.
 200                              list ($rotateddata, $size) = $file->rotate_image();
 201                              if ($rotateddata) {
 202                                  $file = self::save_rotated_image_file($assignment, $userid, $attemptnumber,
 203                                      $rotateddata, $filename);
 204                              }
 205                              // Save as PDF file if there is no available converter.
 206                              if (!$converter->can_convert_format_to('jpg', 'pdf')) {
 207                                  $pdffile = self::save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size);
 208                                  if ($pdffile) {
 209                                      $files[$filename] = $pdffile;
 210                                  }
 211                              }
 212                          }
 213                          // The file has not been converted to PDF, try to convert it to PDF.
 214                          if (!isset($files[$filename])
 215                              && $convertedfile = $converter->start_conversion($file, 'pdf')) {
 216                              $files[$filename] = $convertedfile;
 217                          }
 218                      } else if ($converter->can_convert_format_to('html', 'pdf')) {
 219                          // Create a tmp stored_file from this html string.
 220                          $file = reset($file);
 221                          // Strip image tags, because they will not be resolvable.
 222                          $file = self::strip_images($file);
 223                          $record = new \stdClass();
 224                          $record->contextid = $assignment->get_context()->id;
 225                          $record->component = 'assignfeedback_editpdf';
 226                          $record->filearea = self::IMPORT_HTML_FILEAREA;
 227                          $record->itemid = $submission->id;
 228                          $record->filepath = '/';
 229                          $record->filename = $plugin->get_type() . '-' . $filename;
 230  
 231                          $htmlfile = $fs->get_file($record->contextid,
 232                                  $record->component,
 233                                  $record->filearea,
 234                                  $record->itemid,
 235                                  $record->filepath,
 236                                  $record->filename);
 237  
 238                          $newhash = sha1($file);
 239  
 240                          // If the file exists, and the content hash doesn't match, remove it.
 241                          if ($htmlfile && $newhash !== $htmlfile->get_contenthash()) {
 242                              $htmlfile->delete();
 243                              $htmlfile = false;
 244                          }
 245  
 246                          // If the file doesn't exist, or if it was removed above, create a new one.
 247                          if (!$htmlfile) {
 248                              $htmlfile = $fs->create_file_from_string($record, $file);
 249                          }
 250  
 251                          $convertedfile = $converter->start_conversion($htmlfile, 'pdf');
 252  
 253                          if ($convertedfile) {
 254                              $files[$filename] = $convertedfile;
 255                          }
 256                      }
 257                  }
 258              }
 259          }
 260          $combineddocument = new combined_document();
 261          $combineddocument->set_source_files($files);
 262  
 263          return $combineddocument;
 264      }
 265  
 266      /**
 267       * Fetch the current combined document ready for state checking.
 268       *
 269       * @param int|\assign $assignment
 270       * @param int $userid
 271       * @param int $attemptnumber (-1 means latest attempt)
 272       * @return combined_document
 273       */
 274      public static function get_combined_document_for_attempt($assignment, $userid, $attemptnumber) {
 275          global $USER, $DB;
 276  
 277          $assignment = self::get_assignment_from_param($assignment);
 278  
 279          // Capability checks.
 280          if (!$assignment->can_view_submission($userid)) {
 281              print_error('nopermission');
 282          }
 283  
 284          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 285          if ($assignment->get_instance()->teamsubmission) {
 286              $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
 287          } else {
 288              $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
 289          }
 290  
 291          $contextid = $assignment->get_context()->id;
 292          $component = 'assignfeedback_editpdf';
 293          $filearea = self::COMBINED_PDF_FILEAREA;
 294          $partialfilearea = self::PARTIAL_PDF_FILEAREA;
 295          $itemid = $grade->id;
 296          $filepath = '/';
 297          $filename = self::COMBINED_PDF_FILENAME;
 298          $fs = get_file_storage();
 299  
 300          $partialpdf = $fs->get_file($contextid, $component, $partialfilearea, $itemid, $filepath, $filename);
 301          if (!empty($partialpdf)) {
 302              $combinedpdf = $partialpdf;
 303          } else {
 304              $combinedpdf = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename);
 305          }
 306  
 307          if ($combinedpdf && $submission) {
 308              if ($combinedpdf->get_timemodified() < $submission->timemodified) {
 309                  // The submission has been updated since the PDF was generated.
 310                  $combinedpdf = false;
 311              } else if ($combinedpdf->get_contenthash() == self::BLANK_PDF_HASH) {
 312                  // The PDF is for a blank page.
 313                  $combinedpdf = false;
 314              }
 315          }
 316  
 317          if (empty($combinedpdf)) {
 318              // The combined PDF does not exist yet. Return the list of files to be combined.
 319              return self::list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber);
 320          } else {
 321              // The combined PDF aleady exists. Return it in a new combined_document object.
 322              $combineddocument = new combined_document();
 323              return $combineddocument->set_combined_file($combinedpdf);
 324          }
 325      }
 326  
 327      /**
 328       * This function return the combined pdf for all valid submission files.
 329       *
 330       * @param int|\assign $assignment
 331       * @param int $userid
 332       * @param int $attemptnumber (-1 means latest attempt)
 333       * @return combined_document
 334       */
 335      public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) {
 336          $document = self::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
 337  
 338          if ($document->get_status() === combined_document::STATUS_COMPLETE) {
 339              // The combined document is already ready.
 340              return $document;
 341          } else {
 342              // Attempt to combined the files in the document.
 343              $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 344              $document->combine_files($assignment->get_context()->id, $grade->id);
 345              return $document;
 346          }
 347      }
 348  
 349      /**
 350       * This function will return the number of pages of a pdf.
 351       *
 352       * @param int|\assign $assignment
 353       * @param int $userid
 354       * @param int $attemptnumber (-1 means latest attempt)
 355       * @param bool $readonly When true we get the number of pages for the readonly version.
 356       * @return int number of pages
 357       */
 358      public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
 359          global $CFG;
 360  
 361          require_once($CFG->libdir . '/pdflib.php');
 362  
 363          $assignment = self::get_assignment_from_param($assignment);
 364  
 365          if (!$assignment->can_view_submission($userid)) {
 366              print_error('nopermission');
 367          }
 368  
 369          // When in readonly we can return the number of images in the DB because they should already exist,
 370          // if for some reason they do not, then we proceed as for the normal version.
 371          if ($readonly) {
 372              $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 373              $fs = get_file_storage();
 374              $files = $fs->get_directory_files($assignment->get_context()->id, 'assignfeedback_editpdf',
 375                  self::PAGE_IMAGE_READONLY_FILEAREA, $grade->id, '/');
 376              $pagecount = count($files);
 377              if ($pagecount > 0) {
 378                  return $pagecount;
 379              }
 380          }
 381  
 382          // Get a combined pdf file from all submitted pdf files.
 383          $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
 384          return $document->get_page_count();
 385      }
 386  
 387      /**
 388       * This function will generate and return a list of the page images from a pdf.
 389       * @param int|\assign $assignment
 390       * @param int $userid
 391       * @param int $attemptnumber (-1 means latest attempt)
 392       * @param bool $resetrotation check if need to reset page rotation information
 393       * @return array(stored_file)
 394       */
 395      protected static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation = true) {
 396          global $CFG;
 397  
 398          require_once($CFG->libdir . '/pdflib.php');
 399  
 400          $assignment = self::get_assignment_from_param($assignment);
 401  
 402          if (!$assignment->can_view_submission($userid)) {
 403              print_error('nopermission');
 404          }
 405  
 406          // Need to generate the page images - first get a combined pdf.
 407          $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
 408  
 409          $status = $document->get_status();
 410          if ($status === combined_document::STATUS_FAILED) {
 411              print_error('Could not generate combined pdf.');
 412          } else if ($status === combined_document::STATUS_PENDING_INPUT) {
 413              // The conversion is still in progress.
 414              return [];
 415          }
 416  
 417          $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber));
 418          $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
 419  
 420          $document->get_combined_file()->copy_content_to($combined); // Copy the file.
 421  
 422          $pdf = new pdf();
 423  
 424          $pdf->set_image_folder($tmpdir);
 425          $pagecount = $pdf->set_pdf($combined);
 426  
 427          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 428  
 429          $record = new \stdClass();
 430          $record->contextid = $assignment->get_context()->id;
 431          $record->component = 'assignfeedback_editpdf';
 432          $record->filearea = self::PAGE_IMAGE_FILEAREA;
 433          $record->itemid = $grade->id;
 434          $record->filepath = '/';
 435          $fs = get_file_storage();
 436  
 437          // Remove the existing content of the filearea.
 438          $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
 439  
 440          $files = array();
 441          for ($i = 0; $i < $pagecount; $i++) {
 442              try {
 443                  $image = $pdf->get_image($i);
 444                  if (!$resetrotation) {
 445                      $pagerotation = page_editor::get_page_rotation($grade->id, $i);
 446                      $degree = !empty($pagerotation) ? $pagerotation->degree : 0;
 447                      if ($degree != 0) {
 448                          $filepath = $tmpdir . '/' . $image;
 449                          $imageresource = imagecreatefrompng($filepath);
 450                          $content = imagerotate($imageresource, $degree, 0);
 451                          imagepng($content, $filepath);
 452                      }
 453                  }
 454              } catch (\moodle_exception $e) {
 455                  // We catch only moodle_exception here as other exceptions indicate issue with setup not the pdf.
 456                  $image = pdf::get_error_image($tmpdir, $i);
 457              }
 458              $record->filename = basename($image);
 459              $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
 460              @unlink($tmpdir . '/' . $image);
 461              // Set page rotation default value.
 462              if (!empty($files[$i])) {
 463                  if ($resetrotation) {
 464                      page_editor::set_page_rotation($grade->id, $i, false, $files[$i]->get_pathnamehash());
 465                  }
 466              }
 467          }
 468          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 469  
 470          @unlink($combined);
 471          @rmdir($tmpdir);
 472  
 473          return $files;
 474      }
 475  
 476      /**
 477       * This function returns a list of the page images from a pdf.
 478       *
 479       * The readonly version is different than the normal one. The readonly version contains a copy
 480       * of the pages in the state they were when the PDF was annotated, by doing so we prevent the
 481       * the pages that are displayed to change as soon as the submission changes.
 482       *
 483       * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible
 484       * that we do not find any readonly version of the pages. In that case, we will get the normal
 485       * pages and copy them to the readonly area. This ensures that the pages will remain in that
 486       * state until the submission is updated. When the normal files do not exist, we throw an exception
 487       * because the readonly pages should only ever be displayed after a teacher has annotated the PDF,
 488       * they would not exist until they do.
 489       *
 490       * @param int|\assign $assignment
 491       * @param int $userid
 492       * @param int $attemptnumber (-1 means latest attempt)
 493       * @param bool $readonly If true, then we are requesting the readonly version.
 494       * @return array(stored_file)
 495       */
 496      public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
 497          global $DB;
 498  
 499          $assignment = self::get_assignment_from_param($assignment);
 500  
 501          if (!$assignment->can_view_submission($userid)) {
 502              print_error('nopermission');
 503          }
 504  
 505          if ($assignment->get_instance()->teamsubmission) {
 506              $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
 507          } else {
 508              $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
 509          }
 510          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 511  
 512          $contextid = $assignment->get_context()->id;
 513          $component = 'assignfeedback_editpdf';
 514          $itemid = $grade->id;
 515          $filepath = '/';
 516          $filearea = self::PAGE_IMAGE_FILEAREA;
 517  
 518          $fs = get_file_storage();
 519  
 520          // If we are after the readonly pages...
 521          if ($readonly) {
 522              $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
 523              if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
 524                  // We have a problem here, we were supposed to find the files.
 525                  // Attempt to re-generate the pages from the combined images.
 526                  self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
 527                  self::copy_pages_to_readonly_area($assignment, $grade);
 528              }
 529          }
 530  
 531          $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
 532  
 533          $pages = array();
 534          $resetrotation = false;
 535          if (!empty($files)) {
 536              $first = reset($files);
 537              $pagemodified = $first->get_timemodified();
 538              // Check that we don't just have a single blank page. The hash of a blank page image can vary with
 539              // the version of ghostscript used, so we need to examine the combined pdf it was generated from.
 540              $blankpage = false;
 541              if (!$readonly && count($files) == 1) {
 542                  $pdfarea = self::COMBINED_PDF_FILEAREA;
 543                  $pdfname = self::COMBINED_PDF_FILENAME;
 544                  if ($pdf = $fs->get_file($contextid, $component, $pdfarea, $itemid, $filepath, $pdfname)) {
 545                      // The combined pdf may have a different hash if it has been regenerated since the page
 546                      // image was created. However if this is the case the page image will be stale anyway.
 547                      if ($pdf->get_contenthash() == self::BLANK_PDF_HASH || $pagemodified < $pdf->get_timemodified()) {
 548                          $blankpage = true;
 549                      }
 550                  }
 551              }
 552              if (!$readonly && ($pagemodified < $submission->timemodified || $blankpage)) {
 553                  // Image files are stale, we need to regenerate them, except in readonly mode.
 554                  // We also need to remove the draft annotations and comments associated with this attempt.
 555                  $fs->delete_area_files($contextid, $component, $filearea, $itemid);
 556                  page_editor::delete_draft_content($itemid);
 557                  $files = array();
 558                  $resetrotation = true;
 559              } else {
 560  
 561                  // Need to reorder the files following their name.
 562                  // because get_directory_files() return a different order than generate_page_images_for_attempt().
 563                  foreach ($files as $file) {
 564                      // Extract the page number from the file name image_pageXXXX.png.
 565                      preg_match('/page([\d]+)\./', $file->get_filename(), $matches);
 566                      if (empty($matches) or !is_numeric($matches[1])) {
 567                          throw new \coding_exception("'" . $file->get_filename()
 568                              . "' file hasn't the expected format filename: image_pageXXXX.png.");
 569                      }
 570                      $pagenumber = (int)$matches[1];
 571  
 572                      // Save the page in the ordered array.
 573                      $pages[$pagenumber] = $file;
 574                  }
 575                  ksort($pages);
 576              }
 577          }
 578  
 579          // This should never happen, there should be a version of the pages available
 580          // whenever we are requesting the readonly version.
 581          if (empty($pages) && $readonly) {
 582              throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
 583          }
 584  
 585          // There are two situations where the number of page images generated does not
 586          // match the number of pages in the PDF:
 587          //
 588          // 1. The document conversion adhoc task was interrupted somehow (node died, solar flare, etc)
 589          // 2. The submission has been updated by the student
 590          //
 591          // In the case of 1. we need to regenerate the pages, see MDL-66626.
 592          // In the case of 2. we should do nothing, see MDL-45580.
 593          //
 594          // To differentiate between 1. and 2. we can check if the submission has been modified since the
 595          // pages were generated. If it has, then we're in situation 2.
 596          $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
 597          $submissionmodified = isset($pagemodified) && $submission->timemodified > $pagemodified;
 598          if (empty($pages) || (count($pages) != $totalpagesforattempt && !$submissionmodified)) {
 599              $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
 600          }
 601  
 602          return $pages;
 603      }
 604  
 605      /**
 606       * This function returns sensible filename for a feedback file.
 607       * @param int|\assign $assignment
 608       * @param int $userid
 609       * @param int $attemptnumber (-1 means latest attempt)
 610       * @return string
 611       */
 612      protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
 613          global $DB;
 614  
 615          $assignment = self::get_assignment_from_param($assignment);
 616  
 617          $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
 618          $groupname = '';
 619          if ($groupmode) {
 620              $groupid = groups_get_activity_group($assignment->get_course_module(), true);
 621              $groupname = groups_get_group_name($groupid).'-';
 622          }
 623          if ($groupname == '-') {
 624              $groupname = '';
 625          }
 626          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 627          $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
 628  
 629          if ($assignment->is_blind_marking()) {
 630              $prefix = $groupname . get_string('participant', 'assign');
 631              $prefix = str_replace('_', ' ', $prefix);
 632              $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
 633          } else {
 634              $prefix = $groupname . fullname($user);
 635              $prefix = str_replace('_', ' ', $prefix);
 636              $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
 637          }
 638          $prefix .= $grade->attemptnumber;
 639  
 640          return $prefix . '.pdf';
 641      }
 642  
 643      /**
 644       * This function takes the combined pdf and embeds all the comments and annotations.
 645       *
 646       * This also moves the annotations and comments from drafts to not drafts. And it will
 647       * copy all the images stored to the readonly area, so that they can be viewed online, and
 648       * not be overwritten when a new submission is sent.
 649       *
 650       * @param int|\assign $assignment
 651       * @param int $userid
 652       * @param int $attemptnumber (-1 means latest attempt)
 653       * @return stored_file
 654       */
 655      public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
 656  
 657          $assignment = self::get_assignment_from_param($assignment);
 658  
 659          if (!$assignment->can_view_submission($userid)) {
 660              print_error('nopermission');
 661          }
 662          if (!$assignment->can_grade()) {
 663              print_error('nopermission');
 664          }
 665  
 666          // Need to generate the page images - first get a combined pdf.
 667          $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
 668  
 669          $status = $document->get_status();
 670          if ($status === combined_document::STATUS_FAILED) {
 671              print_error('Could not generate combined pdf.');
 672          } else if ($status === combined_document::STATUS_PENDING_INPUT) {
 673              // The conversion is still in progress.
 674              return false;
 675          }
 676  
 677          $file = $document->get_combined_file();
 678  
 679          $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
 680          $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
 681          $file->copy_content_to($combined); // Copy the file.
 682  
 683          $pdf = new pdf();
 684  
 685          $fs = get_file_storage();
 686          $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
 687          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 688          // Copy any new stamps to this instance.
 689          if ($files = $fs->get_area_files($assignment->get_context()->id,
 690                                           'assignfeedback_editpdf',
 691                                           'stamps',
 692                                           $grade->id,
 693                                           "filename",
 694                                           false)) {
 695              foreach ($files as $file) {
 696                  $filename = $stamptmpdir . '/' . $file->get_filename();
 697                  $file->copy_content_to($filename); // Copy the file.
 698              }
 699          }
 700  
 701          $pagecount = $pdf->set_pdf($combined);
 702          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 703          page_editor::release_drafts($grade->id);
 704  
 705          $allcomments = array();
 706  
 707          for ($i = 0; $i < $pagecount; $i++) {
 708              $pagerotation = page_editor::get_page_rotation($grade->id, $i);
 709              $pagemargin = $pdf->getBreakMargin();
 710              $autopagebreak = $pdf->getAutoPageBreak();
 711              if (empty($pagerotation) || !$pagerotation->isrotated) {
 712                  $pdf->copy_page();
 713              } else {
 714                  $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
 715                  if (empty($rotatedimagefile)) {
 716                      $pdf->copy_page();
 717                  } else {
 718                      $pdf->add_image_page($rotatedimagefile);
 719                  }
 720              }
 721  
 722              $comments = page_editor::get_comments($grade->id, $i, false);
 723              $annotations = page_editor::get_annotations($grade->id, $i, false);
 724  
 725              if (!empty($comments)) {
 726                  $allcomments[$i] = $comments;
 727              }
 728  
 729              foreach ($annotations as $annotation) {
 730                  $pdf->add_annotation($annotation->x,
 731                                       $annotation->y,
 732                                       $annotation->endx,
 733                                       $annotation->endy,
 734                                       $annotation->colour,
 735                                       $annotation->type,
 736                                       $annotation->path,
 737                                       $stamptmpdir);
 738              }
 739              $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
 740              $pdf->setPageMark();
 741          }
 742  
 743          if (!empty($allcomments)) {
 744              // Append all comments to the end of the document.
 745              $links = $pdf->append_comments($allcomments);
 746              // Add the comment markers with links.
 747              foreach ($allcomments as $pageno => $comments) {
 748                  foreach ($comments as $index => $comment) {
 749                      $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
 750                              $comment->colour);
 751                  }
 752              }
 753          }
 754  
 755          fulldelete($stamptmpdir);
 756  
 757          $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
 758          $filename = clean_param($filename, PARAM_FILE);
 759  
 760          $generatedpdf = $tmpdir . '/' . $filename;
 761          $pdf->save_pdf($generatedpdf);
 762  
 763          $record = new \stdClass();
 764  
 765          $record->contextid = $assignment->get_context()->id;
 766          $record->component = 'assignfeedback_editpdf';
 767          $record->filearea = self::FINAL_PDF_FILEAREA;
 768          $record->itemid = $grade->id;
 769          $record->filepath = '/';
 770          $record->filename = $filename;
 771  
 772          // Only keep one current version of the generated pdf.
 773          $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
 774  
 775          $file = $fs->create_file_from_pathname($record, $generatedpdf);
 776  
 777          // Cleanup.
 778          @unlink($generatedpdf);
 779          @unlink($combined);
 780          @rmdir($tmpdir);
 781  
 782          self::copy_pages_to_readonly_area($assignment, $grade);
 783  
 784          return $file;
 785      }
 786  
 787      /**
 788       * Copy the pages image to the readonly area.
 789       *
 790       * @param int|\assign $assignment The assignment.
 791       * @param \stdClass $grade The grade record.
 792       * @return void
 793       */
 794      public static function copy_pages_to_readonly_area($assignment, $grade) {
 795          $fs = get_file_storage();
 796          $assignment = self::get_assignment_from_param($assignment);
 797          $contextid = $assignment->get_context()->id;
 798          $component = 'assignfeedback_editpdf';
 799          $itemid = $grade->id;
 800  
 801          // Get all the pages.
 802          $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
 803          if (empty($originalfiles)) {
 804              // Nothing to do here...
 805              return;
 806          }
 807  
 808          // Delete the old readonly files.
 809          $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
 810  
 811          // Do the copying.
 812          foreach ($originalfiles as $originalfile) {
 813              $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
 814          }
 815      }
 816  
 817      /**
 818       * This function returns the generated pdf (if it exists).
 819       * @param int|\assign $assignment
 820       * @param int $userid
 821       * @param int $attemptnumber (-1 means latest attempt)
 822       * @return stored_file
 823       */
 824      public static function get_feedback_document($assignment, $userid, $attemptnumber) {
 825  
 826          $assignment = self::get_assignment_from_param($assignment);
 827  
 828          if (!$assignment->can_view_submission($userid)) {
 829              print_error('nopermission');
 830          }
 831  
 832          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 833  
 834          $contextid = $assignment->get_context()->id;
 835          $component = 'assignfeedback_editpdf';
 836          $filearea = self::FINAL_PDF_FILEAREA;
 837          $itemid = $grade->id;
 838          $filepath = '/';
 839  
 840          $fs = get_file_storage();
 841          $files = $fs->get_area_files($contextid,
 842                                       $component,
 843                                       $filearea,
 844                                       $itemid,
 845                                       "itemid, filepath, filename",
 846                                       false);
 847          if ($files) {
 848              return reset($files);
 849          }
 850          return false;
 851      }
 852  
 853      /**
 854       * This function deletes the generated pdf for a student.
 855       * @param int|\assign $assignment
 856       * @param int $userid
 857       * @param int $attemptnumber (-1 means latest attempt)
 858       * @return bool
 859       */
 860      public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
 861  
 862          $assignment = self::get_assignment_from_param($assignment);
 863  
 864          if (!$assignment->can_view_submission($userid)) {
 865              print_error('nopermission');
 866          }
 867          if (!$assignment->can_grade()) {
 868              print_error('nopermission');
 869          }
 870  
 871          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 872  
 873          $contextid = $assignment->get_context()->id;
 874          $component = 'assignfeedback_editpdf';
 875          $filearea = self::FINAL_PDF_FILEAREA;
 876          $itemid = $grade->id;
 877  
 878          $fs = get_file_storage();
 879          return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
 880      }
 881  
 882      /**
 883       * Get All files in a File area
 884       * @param int|\assign $assignment Assignment
 885       * @param int $userid User ID
 886       * @param int $attemptnumber Attempt Number
 887       * @param string $filearea File Area
 888       * @param string $filepath File Path
 889       * @return array
 890       */
 891      private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
 892          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 893          $itemid = $grade->id;
 894          $contextid = $assignment->get_context()->id;
 895          $component = self::COMPONENT;
 896          $fs = get_file_storage();
 897          $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
 898          return $files;
 899      }
 900  
 901      /**
 902       * Save file.
 903       * @param int|\assign $assignment Assignment
 904       * @param int $userid User ID
 905       * @param int $attemptnumber Attempt Number
 906       * @param string $filearea File Area
 907       * @param string $newfilepath File Path
 908       * @param string $storedfilepath stored file path
 909       * @return \stored_file
 910       * @throws \file_exception
 911       * @throws \stored_file_creation_exception
 912       */
 913      private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
 914          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 915          $itemid = $grade->id;
 916          $contextid = $assignment->get_context()->id;
 917  
 918          $record = new \stdClass();
 919          $record->contextid = $contextid;
 920          $record->component = self::COMPONENT;
 921          $record->filearea = $filearea;
 922          $record->itemid = $itemid;
 923          $record->filepath = $storedfilepath;
 924          $record->filename = basename($newfilepath);
 925  
 926          $fs = get_file_storage();
 927  
 928          $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
 929              $record->itemid, $record->filepath, $record->filename);
 930  
 931          if ($oldfile) {
 932              $newhash = \file_storage::hash_from_path($newfilepath);
 933              if ($newhash === $oldfile->get_contenthash()) {
 934                  // Use existing file if contenthash match.
 935                  return $oldfile;
 936              }
 937              // Delete existing file.
 938              $oldfile->delete();
 939          }
 940  
 941          return $fs->create_file_from_pathname($record, $newfilepath);
 942      }
 943  
 944      /**
 945       * This function rotate a page, and mark the page as rotated.
 946       * @param int|\assign $assignment Assignment
 947       * @param int $userid User ID
 948       * @param int $attemptnumber Attempt Number
 949       * @param int $index Index of Current Page
 950       * @param bool $rotateleft To determine whether the page is rotated left or right.
 951       * @return null|\stored_file return rotated File
 952       * @throws \coding_exception
 953       * @throws \file_exception
 954       * @throws \moodle_exception
 955       * @throws \stored_file_creation_exception
 956       */
 957      public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
 958          $assignment = self::get_assignment_from_param($assignment);
 959          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 960          // Check permission.
 961          if (!$assignment->can_view_submission($userid)) {
 962              print_error('nopermission');
 963          }
 964  
 965          $filearea = self::PAGE_IMAGE_FILEAREA;
 966          $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
 967          if (!empty($files)) {
 968              foreach ($files as $file) {
 969                  preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
 970                  if (empty($matches) or !is_numeric($matches[1])) {
 971                      throw new \coding_exception("'" . $file->get_filename()
 972                          . "' file hasn't the expected format filename: image_pageXXXX.png.");
 973                  }
 974                  $pagenumber = (int)$matches[1];
 975  
 976                  if ($pagenumber == $index) {
 977                      $source = imagecreatefromstring($file->get_content());
 978                      $pagerotation = page_editor::get_page_rotation($grade->id, $index);
 979                      $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
 980                      if ($rotateleft) {
 981                          $content = imagerotate($source, 90, 0);
 982                          $degree = ($degree + 90) % 360;
 983                      } else {
 984                          $content = imagerotate($source, -90, 0);
 985                          $degree = ($degree - 90) % 360;
 986                      }
 987                      $filename = $matches[0].'png';
 988                      $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/'
 989                          . self::hash($assignment, $userid, $attemptnumber));
 990                      $tempfile = $tmpdir . '/' . time() . '_' . $filename;
 991                      imagepng($content, $tempfile);
 992  
 993                      $filearea = self::PAGE_IMAGE_FILEAREA;
 994                      $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
 995  
 996                      unlink($tempfile);
 997                      rmdir($tmpdir);
 998                      imagedestroy($source);
 999                      imagedestroy($content);
1000                      $file->delete();
1001                      if (!empty($newfile)) {
1002                          page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
1003                      }
1004                      return $newfile;
1005                  }
1006              }
1007          }
1008          return null;
1009      }
1010  
1011      /**
1012       * Convert jpg file to pdf file
1013       * @param int|\assign $assignment Assignment
1014       * @param int $userid User ID
1015       * @param int $attemptnumber Attempt Number
1016       * @param \stored_file $file file to save
1017       * @param null|array $size size of image
1018       * @return \stored_file
1019       * @throws \file_exception
1020       * @throws \stored_file_creation_exception
1021       */
1022      private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
1023          // Temporary file.
1024          $filename = $file->get_filename();
1025          $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1026              . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
1027              . self::hash($assignment, $userid, $attemptnumber));
1028          $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
1029          // Determine orientation.
1030          $orientation = 'P';
1031          if (!empty($size['width']) && !empty($size['height'])) {
1032              if ($size['width'] > $size['height']) {
1033                  $orientation = 'L';
1034              }
1035          }
1036          // Save JPG image to PDF file.
1037          $pdf = new pdf();
1038          $pdf->SetHeaderMargin(0);
1039          $pdf->SetFooterMargin(0);
1040          $pdf->SetMargins(0, 0, 0, true);
1041          $pdf->setPrintFooter(false);
1042          $pdf->setPrintHeader(false);
1043          $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
1044          $pdf->AddPage($orientation);
1045          $pdf->SetAutoPageBreak(false);
1046          // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
1047          if ($orientation == 'P') {
1048              $pdf->Image('@' . $file->get_content(), 0, 0, 210);
1049          } else {
1050              $pdf->Image('@' . $file->get_content(), 0, 0, 297);
1051          }
1052          $pdf->setPageMark();
1053          $pdf->save_pdf($tempfile);
1054          $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
1055          $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1056          if (file_exists($tempfile)) {
1057              unlink($tempfile);
1058              rmdir($tmpdir);
1059          }
1060          return $pdffile;
1061      }
1062  
1063      /**
1064       * Save rotated image data to file.
1065       * @param int|\assign $assignment Assignment
1066       * @param int $userid User ID
1067       * @param int $attemptnumber Attempt Number
1068       * @param resource $rotateddata image data to save
1069       * @param string $filename name of the image file
1070       * @return \stored_file
1071       * @throws \file_exception
1072       * @throws \stored_file_creation_exception
1073       */
1074      private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
1075          $filearea = self::TMP_ROTATED_JPG_FILEAREA;
1076          $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1077              . $filearea . DIRECTORY_SEPARATOR
1078              . self::hash($assignment, $userid, $attemptnumber));
1079          $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
1080          imagejpeg($rotateddata, $tempfile);
1081          $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1082          if (file_exists($tempfile)) {
1083              unlink($tempfile);
1084              rmdir($tmpdir);
1085          }
1086          return $newfile;
1087      }
1088  
1089  }