Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

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

   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          $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
 580          // Here we are comparing the total number of images against the total number of pages from the combined PDF.
 581          if (empty($pages) || count($pages) != $totalpagesforattempt) {
 582              if ($readonly) {
 583                  // This should never happen, there should be a version of the pages available
 584                  // whenever we are requesting the readonly version.
 585                  throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
 586              }
 587              $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
 588          }
 589  
 590          return $pages;
 591      }
 592  
 593      /**
 594       * This function returns sensible filename for a feedback file.
 595       * @param int|\assign $assignment
 596       * @param int $userid
 597       * @param int $attemptnumber (-1 means latest attempt)
 598       * @return string
 599       */
 600      protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
 601          global $DB;
 602  
 603          $assignment = self::get_assignment_from_param($assignment);
 604  
 605          $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
 606          $groupname = '';
 607          if ($groupmode) {
 608              $groupid = groups_get_activity_group($assignment->get_course_module(), true);
 609              $groupname = groups_get_group_name($groupid).'-';
 610          }
 611          if ($groupname == '-') {
 612              $groupname = '';
 613          }
 614          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 615          $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
 616  
 617          if ($assignment->is_blind_marking()) {
 618              $prefix = $groupname . get_string('participant', 'assign');
 619              $prefix = str_replace('_', ' ', $prefix);
 620              $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
 621          } else {
 622              $prefix = $groupname . fullname($user);
 623              $prefix = str_replace('_', ' ', $prefix);
 624              $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
 625          }
 626          $prefix .= $grade->attemptnumber;
 627  
 628          return $prefix . '.pdf';
 629      }
 630  
 631      /**
 632       * This function takes the combined pdf and embeds all the comments and annotations.
 633       *
 634       * This also moves the annotations and comments from drafts to not drafts. And it will
 635       * copy all the images stored to the readonly area, so that they can be viewed online, and
 636       * not be overwritten when a new submission is sent.
 637       *
 638       * @param int|\assign $assignment
 639       * @param int $userid
 640       * @param int $attemptnumber (-1 means latest attempt)
 641       * @return stored_file
 642       */
 643      public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
 644  
 645          $assignment = self::get_assignment_from_param($assignment);
 646  
 647          if (!$assignment->can_view_submission($userid)) {
 648              print_error('nopermission');
 649          }
 650          if (!$assignment->can_grade()) {
 651              print_error('nopermission');
 652          }
 653  
 654          // Need to generate the page images - first get a combined pdf.
 655          $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
 656  
 657          $status = $document->get_status();
 658          if ($status === combined_document::STATUS_FAILED) {
 659              print_error('Could not generate combined pdf.');
 660          } else if ($status === combined_document::STATUS_PENDING_INPUT) {
 661              // The conversion is still in progress.
 662              return false;
 663          }
 664  
 665          $file = $document->get_combined_file();
 666  
 667          $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
 668          $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
 669          $file->copy_content_to($combined); // Copy the file.
 670  
 671          $pdf = new pdf();
 672  
 673          $fs = get_file_storage();
 674          $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
 675          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 676          // Copy any new stamps to this instance.
 677          if ($files = $fs->get_area_files($assignment->get_context()->id,
 678                                           'assignfeedback_editpdf',
 679                                           'stamps',
 680                                           $grade->id,
 681                                           "filename",
 682                                           false)) {
 683              foreach ($files as $file) {
 684                  $filename = $stamptmpdir . '/' . $file->get_filename();
 685                  $file->copy_content_to($filename); // Copy the file.
 686              }
 687          }
 688  
 689          $pagecount = $pdf->set_pdf($combined);
 690          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 691          page_editor::release_drafts($grade->id);
 692  
 693          $allcomments = array();
 694  
 695          for ($i = 0; $i < $pagecount; $i++) {
 696              $pagerotation = page_editor::get_page_rotation($grade->id, $i);
 697              $pagemargin = $pdf->getBreakMargin();
 698              $autopagebreak = $pdf->getAutoPageBreak();
 699              if (empty($pagerotation) || !$pagerotation->isrotated) {
 700                  $pdf->copy_page();
 701              } else {
 702                  $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
 703                  if (empty($rotatedimagefile)) {
 704                      $pdf->copy_page();
 705                  } else {
 706                      $pdf->add_image_page($rotatedimagefile);
 707                  }
 708              }
 709  
 710              $comments = page_editor::get_comments($grade->id, $i, false);
 711              $annotations = page_editor::get_annotations($grade->id, $i, false);
 712  
 713              if (!empty($comments)) {
 714                  $allcomments[$i] = $comments;
 715              }
 716  
 717              foreach ($annotations as $annotation) {
 718                  $pdf->add_annotation($annotation->x,
 719                                       $annotation->y,
 720                                       $annotation->endx,
 721                                       $annotation->endy,
 722                                       $annotation->colour,
 723                                       $annotation->type,
 724                                       $annotation->path,
 725                                       $stamptmpdir);
 726              }
 727              $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
 728              $pdf->setPageMark();
 729          }
 730  
 731          if (!empty($allcomments)) {
 732              // Append all comments to the end of the document.
 733              $links = $pdf->append_comments($allcomments);
 734              // Add the comment markers with links.
 735              foreach ($allcomments as $pageno => $comments) {
 736                  foreach ($comments as $index => $comment) {
 737                      $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
 738                              $comment->colour);
 739                  }
 740              }
 741          }
 742  
 743          fulldelete($stamptmpdir);
 744  
 745          $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
 746          $filename = clean_param($filename, PARAM_FILE);
 747  
 748          $generatedpdf = $tmpdir . '/' . $filename;
 749          $pdf->save_pdf($generatedpdf);
 750  
 751          $record = new \stdClass();
 752  
 753          $record->contextid = $assignment->get_context()->id;
 754          $record->component = 'assignfeedback_editpdf';
 755          $record->filearea = self::FINAL_PDF_FILEAREA;
 756          $record->itemid = $grade->id;
 757          $record->filepath = '/';
 758          $record->filename = $filename;
 759  
 760          // Only keep one current version of the generated pdf.
 761          $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
 762  
 763          $file = $fs->create_file_from_pathname($record, $generatedpdf);
 764  
 765          // Cleanup.
 766          @unlink($generatedpdf);
 767          @unlink($combined);
 768          @rmdir($tmpdir);
 769  
 770          self::copy_pages_to_readonly_area($assignment, $grade);
 771  
 772          return $file;
 773      }
 774  
 775      /**
 776       * Copy the pages image to the readonly area.
 777       *
 778       * @param int|\assign $assignment The assignment.
 779       * @param \stdClass $grade The grade record.
 780       * @return void
 781       */
 782      public static function copy_pages_to_readonly_area($assignment, $grade) {
 783          $fs = get_file_storage();
 784          $assignment = self::get_assignment_from_param($assignment);
 785          $contextid = $assignment->get_context()->id;
 786          $component = 'assignfeedback_editpdf';
 787          $itemid = $grade->id;
 788  
 789          // Get all the pages.
 790          $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
 791          if (empty($originalfiles)) {
 792              // Nothing to do here...
 793              return;
 794          }
 795  
 796          // Delete the old readonly files.
 797          $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
 798  
 799          // Do the copying.
 800          foreach ($originalfiles as $originalfile) {
 801              $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
 802          }
 803      }
 804  
 805      /**
 806       * This function returns the generated pdf (if it exists).
 807       * @param int|\assign $assignment
 808       * @param int $userid
 809       * @param int $attemptnumber (-1 means latest attempt)
 810       * @return stored_file
 811       */
 812      public static function get_feedback_document($assignment, $userid, $attemptnumber) {
 813  
 814          $assignment = self::get_assignment_from_param($assignment);
 815  
 816          if (!$assignment->can_view_submission($userid)) {
 817              print_error('nopermission');
 818          }
 819  
 820          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 821  
 822          $contextid = $assignment->get_context()->id;
 823          $component = 'assignfeedback_editpdf';
 824          $filearea = self::FINAL_PDF_FILEAREA;
 825          $itemid = $grade->id;
 826          $filepath = '/';
 827  
 828          $fs = get_file_storage();
 829          $files = $fs->get_area_files($contextid,
 830                                       $component,
 831                                       $filearea,
 832                                       $itemid,
 833                                       "itemid, filepath, filename",
 834                                       false);
 835          if ($files) {
 836              return reset($files);
 837          }
 838          return false;
 839      }
 840  
 841      /**
 842       * This function deletes the generated pdf for a student.
 843       * @param int|\assign $assignment
 844       * @param int $userid
 845       * @param int $attemptnumber (-1 means latest attempt)
 846       * @return bool
 847       */
 848      public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
 849  
 850          $assignment = self::get_assignment_from_param($assignment);
 851  
 852          if (!$assignment->can_view_submission($userid)) {
 853              print_error('nopermission');
 854          }
 855          if (!$assignment->can_grade()) {
 856              print_error('nopermission');
 857          }
 858  
 859          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 860  
 861          $contextid = $assignment->get_context()->id;
 862          $component = 'assignfeedback_editpdf';
 863          $filearea = self::FINAL_PDF_FILEAREA;
 864          $itemid = $grade->id;
 865  
 866          $fs = get_file_storage();
 867          return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
 868      }
 869  
 870      /**
 871       * Get All files in a File area
 872       * @param int|\assign $assignment Assignment
 873       * @param int $userid User ID
 874       * @param int $attemptnumber Attempt Number
 875       * @param string $filearea File Area
 876       * @param string $filepath File Path
 877       * @return array
 878       */
 879      private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
 880          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 881          $itemid = $grade->id;
 882          $contextid = $assignment->get_context()->id;
 883          $component = self::COMPONENT;
 884          $fs = get_file_storage();
 885          $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
 886          return $files;
 887      }
 888  
 889      /**
 890       * Save file.
 891       * @param int|\assign $assignment Assignment
 892       * @param int $userid User ID
 893       * @param int $attemptnumber Attempt Number
 894       * @param string $filearea File Area
 895       * @param string $newfilepath File Path
 896       * @param string $storedfilepath stored file path
 897       * @return \stored_file
 898       * @throws \file_exception
 899       * @throws \stored_file_creation_exception
 900       */
 901      private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
 902          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 903          $itemid = $grade->id;
 904          $contextid = $assignment->get_context()->id;
 905  
 906          $record = new \stdClass();
 907          $record->contextid = $contextid;
 908          $record->component = self::COMPONENT;
 909          $record->filearea = $filearea;
 910          $record->itemid = $itemid;
 911          $record->filepath = $storedfilepath;
 912          $record->filename = basename($newfilepath);
 913  
 914          $fs = get_file_storage();
 915  
 916          $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
 917              $record->itemid, $record->filepath, $record->filename);
 918  
 919          if ($oldfile) {
 920              $newhash = \file_storage::hash_from_path($newfilepath);
 921              if ($newhash === $oldfile->get_contenthash()) {
 922                  // Use existing file if contenthash match.
 923                  return $oldfile;
 924              }
 925              // Delete existing file.
 926              $oldfile->delete();
 927          }
 928  
 929          return $fs->create_file_from_pathname($record, $newfilepath);
 930      }
 931  
 932      /**
 933       * This function rotate a page, and mark the page as rotated.
 934       * @param int|\assign $assignment Assignment
 935       * @param int $userid User ID
 936       * @param int $attemptnumber Attempt Number
 937       * @param int $index Index of Current Page
 938       * @param bool $rotateleft To determine whether the page is rotated left or right.
 939       * @return null|\stored_file return rotated File
 940       * @throws \coding_exception
 941       * @throws \file_exception
 942       * @throws \moodle_exception
 943       * @throws \stored_file_creation_exception
 944       */
 945      public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
 946          $assignment = self::get_assignment_from_param($assignment);
 947          $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
 948          // Check permission.
 949          if (!$assignment->can_view_submission($userid)) {
 950              print_error('nopermission');
 951          }
 952  
 953          $filearea = self::PAGE_IMAGE_FILEAREA;
 954          $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
 955          if (!empty($files)) {
 956              foreach ($files as $file) {
 957                  preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
 958                  if (empty($matches) or !is_numeric($matches[1])) {
 959                      throw new \coding_exception("'" . $file->get_filename()
 960                          . "' file hasn't the expected format filename: image_pageXXXX.png.");
 961                  }
 962                  $pagenumber = (int)$matches[1];
 963  
 964                  if ($pagenumber == $index) {
 965                      $source = imagecreatefromstring($file->get_content());
 966                      $pagerotation = page_editor::get_page_rotation($grade->id, $index);
 967                      $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
 968                      if ($rotateleft) {
 969                          $content = imagerotate($source, 90, 0);
 970                          $degree = ($degree + 90) % 360;
 971                      } else {
 972                          $content = imagerotate($source, -90, 0);
 973                          $degree = ($degree - 90) % 360;
 974                      }
 975                      $filename = $matches[0].'png';
 976                      $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/'
 977                          . self::hash($assignment, $userid, $attemptnumber));
 978                      $tempfile = $tmpdir . '/' . time() . '_' . $filename;
 979                      imagepng($content, $tempfile);
 980  
 981                      $filearea = self::PAGE_IMAGE_FILEAREA;
 982                      $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
 983  
 984                      unlink($tempfile);
 985                      rmdir($tmpdir);
 986                      imagedestroy($source);
 987                      imagedestroy($content);
 988                      $file->delete();
 989                      if (!empty($newfile)) {
 990                          page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
 991                      }
 992                      return $newfile;
 993                  }
 994              }
 995          }
 996          return null;
 997      }
 998  
 999      /**
1000       * Convert jpg file to pdf file
1001       * @param int|\assign $assignment Assignment
1002       * @param int $userid User ID
1003       * @param int $attemptnumber Attempt Number
1004       * @param \stored_file $file file to save
1005       * @param null|array $size size of image
1006       * @return \stored_file
1007       * @throws \file_exception
1008       * @throws \stored_file_creation_exception
1009       */
1010      private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
1011          // Temporary file.
1012          $filename = $file->get_filename();
1013          $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1014              . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
1015              . self::hash($assignment, $userid, $attemptnumber));
1016          $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
1017          // Determine orientation.
1018          $orientation = 'P';
1019          if (!empty($size['width']) && !empty($size['height'])) {
1020              if ($size['width'] > $size['height']) {
1021                  $orientation = 'L';
1022              }
1023          }
1024          // Save JPG image to PDF file.
1025          $pdf = new pdf();
1026          $pdf->SetHeaderMargin(0);
1027          $pdf->SetFooterMargin(0);
1028          $pdf->SetMargins(0, 0, 0, true);
1029          $pdf->setPrintFooter(false);
1030          $pdf->setPrintHeader(false);
1031          $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
1032          $pdf->AddPage($orientation);
1033          $pdf->SetAutoPageBreak(false);
1034          // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
1035          if ($orientation == 'P') {
1036              $pdf->Image('@' . $file->get_content(), 0, 0, 210);
1037          } else {
1038              $pdf->Image('@' . $file->get_content(), 0, 0, 297);
1039          }
1040          $pdf->setPageMark();
1041          $pdf->save_pdf($tempfile);
1042          $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
1043          $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1044          if (file_exists($tempfile)) {
1045              unlink($tempfile);
1046              rmdir($tmpdir);
1047          }
1048          return $pdffile;
1049      }
1050  
1051      /**
1052       * Save rotated image data to file.
1053       * @param int|\assign $assignment Assignment
1054       * @param int $userid User ID
1055       * @param int $attemptnumber Attempt Number
1056       * @param resource $rotateddata image data to save
1057       * @param string $filename name of the image file
1058       * @return \stored_file
1059       * @throws \file_exception
1060       * @throws \stored_file_creation_exception
1061       */
1062      private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
1063          $filearea = self::TMP_ROTATED_JPG_FILEAREA;
1064          $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1065              . $filearea . DIRECTORY_SEPARATOR
1066              . self::hash($assignment, $userid, $attemptnumber));
1067          $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
1068          imagejpeg($rotateddata, $tempfile);
1069          $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1070          if (file_exists($tempfile)) {
1071              unlink($tempfile);
1072              rmdir($tmpdir);
1073          }
1074          return $newfile;
1075      }
1076  
1077  }