Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 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   * Library code for manipulating PDFs
  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  use setasign\Fpdi\TcpdfFpdi;
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  
  30  global $CFG;
  31  require_once($CFG->libdir.'/pdflib.php');
  32  require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/autoload.php');
  33  
  34  /**
  35   * Library code for manipulating PDFs
  36   *
  37   * @package assignfeedback_editpdf
  38   * @copyright 2012 Davo Smith
  39   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  40   */
  41  class pdf extends TcpdfFpdi {
  42  
  43      /** @var int the number of the current page in the PDF being processed */
  44      protected $currentpage = 0;
  45      /** @var int the total number of pages in the PDF being processed */
  46      protected $pagecount = 0;
  47      /** @var float used to scale the pixel position of annotations (in the database) to the position in the final PDF */
  48      protected $scale = 0.0;
  49      /** @var string the path in which to store generated page images */
  50      protected $imagefolder = null;
  51      /** @var string the path to the PDF currently being processed */
  52      protected $filename = null;
  53  
  54      /** No errors */
  55      const GSPATH_OK = 'ok';
  56      /** Not set */
  57      const GSPATH_EMPTY = 'empty';
  58      /** Does not exist */
  59      const GSPATH_DOESNOTEXIST = 'doesnotexist';
  60      /** Is a dir */
  61      const GSPATH_ISDIR = 'isdir';
  62      /** Not executable */
  63      const GSPATH_NOTEXECUTABLE = 'notexecutable';
  64      /** Test file missing */
  65      const GSPATH_NOTESTFILE = 'notestfile';
  66      /** Any other error */
  67      const GSPATH_ERROR = 'error';
  68      /** Min. width an annotation should have */
  69      const MIN_ANNOTATION_WIDTH = 5;
  70      /** Min. height an annotation should have */
  71      const MIN_ANNOTATION_HEIGHT = 5;
  72      /** Blank PDF file used during error. */
  73      const BLANK_PDF = '/mod/assign/feedback/editpdf/fixtures/blank.pdf';
  74      /** Page image file name prefix*/
  75      const IMAGE_PAGE = 'image_page';
  76      /**
  77       * Get the name of the font to use in generated PDF files.
  78       * If $CFG->pdfexportfont is set - use it, otherwise use "freesans" as this
  79       * open licensed font has wide support for different language charsets.
  80       *
  81       * @return string
  82       */
  83      private function get_export_font_name() {
  84          global $CFG;
  85  
  86          $fontname = 'freesans';
  87          if (!empty($CFG->pdfexportfont)) {
  88              $fontname = $CFG->pdfexportfont;
  89          }
  90          return $fontname;
  91      }
  92  
  93      /**
  94       * Combine the given PDF files into a single PDF. Optionally add a coversheet and coversheet fields.
  95       * @param string[] $pdflist  the filenames of the files to combine
  96       * @param string $outfilename the filename to write to
  97       * @return int the number of pages in the combined PDF
  98       */
  99      public function combine_pdfs($pdflist, $outfilename) {
 100  
 101          raise_memory_limit(MEMORY_EXTRA);
 102          $olddebug = error_reporting(0);
 103  
 104          $this->setPageUnit('pt');
 105          $this->setPrintHeader(false);
 106          $this->setPrintFooter(false);
 107          $this->scale = 72.0 / 100.0;
 108          // Use font supporting the widest range of characters.
 109          $this->SetFont($this->get_export_font_name(), '', 16.0 * $this->scale, '', true);
 110          $this->SetTextColor(0, 0, 0);
 111  
 112          $totalpagecount = 0;
 113  
 114          foreach ($pdflist as $file) {
 115              $pagecount = $this->setSourceFile($file);
 116              $totalpagecount += $pagecount;
 117              for ($i = 1; $i<=$pagecount; $i++) {
 118                  $this->create_page_from_source($i);
 119              }
 120          }
 121  
 122          $this->save_pdf($outfilename);
 123          error_reporting($olddebug);
 124  
 125          return $totalpagecount;
 126      }
 127  
 128      /**
 129       * The number of the current page in the PDF being processed
 130       * @return int
 131       */
 132      public function current_page() {
 133          return $this->currentpage;
 134      }
 135  
 136      /**
 137       * The total number of pages in the PDF being processed
 138       * @return int
 139       */
 140      public function page_count() {
 141          return $this->pagecount;
 142      }
 143  
 144      /**
 145       * Load the specified PDF and set the initial output configuration
 146       * Used when processing comments and outputting a new PDF
 147       * @param string $filename the path to the PDF to load
 148       * @return int the number of pages in the PDF
 149       */
 150      public function load_pdf($filename) {
 151          raise_memory_limit(MEMORY_EXTRA);
 152          $olddebug = error_reporting(0);
 153  
 154          $this->setPageUnit('pt');
 155          $this->scale = 72.0 / 100.0;
 156          $this->SetFont($this->get_export_font_name(), '', 16.0 * $this->scale, '', true);
 157          $this->SetFillColor(255, 255, 176);
 158          $this->SetDrawColor(0, 0, 0);
 159          $this->SetLineWidth(1.0 * $this->scale);
 160          $this->SetTextColor(0, 0, 0);
 161          $this->setPrintHeader(false);
 162          $this->setPrintFooter(false);
 163          $this->pagecount = $this->setSourceFile($filename);
 164          $this->filename = $filename;
 165  
 166          error_reporting($olddebug);
 167          return $this->pagecount;
 168      }
 169  
 170      /**
 171       * Sets the name of the PDF to process, but only loads the file if the
 172       * pagecount is zero (in order to count the number of pages)
 173       * Used when generating page images (but not a new PDF)
 174       * @param string $filename the path to the PDF to process
 175       * @param int $pagecount optional the number of pages in the PDF, if known
 176       * @return int the number of pages in the PDF
 177       */
 178      public function set_pdf($filename, $pagecount = 0) {
 179          if ($pagecount == 0) {
 180              return $this->load_pdf($filename);
 181          } else {
 182              $this->filename = $filename;
 183              $this->pagecount = $pagecount;
 184              return $pagecount;
 185          }
 186      }
 187  
 188      /**
 189       * Copy the next page from the source file and set it as the current page
 190       * @return bool true if successful
 191       */
 192      public function copy_page() {
 193          if (!$this->filename) {
 194              return false;
 195          }
 196          if ($this->currentpage>=$this->pagecount) {
 197              return false;
 198          }
 199          $this->currentpage++;
 200          $this->create_page_from_source($this->currentpage);
 201          return true;
 202      }
 203  
 204      /**
 205       * Create a page from a source PDF.
 206       *
 207       * @param int $pageno
 208       */
 209      protected function create_page_from_source($pageno) {
 210          // Get the size (and deduce the orientation) of the next page.
 211          $template = $this->importPage($pageno);
 212          $size = $this->getTemplateSize($template);
 213  
 214          // Create a page of the required size / orientation.
 215          $this->AddPage($size['orientation'], array($size['width'], $size['height']));
 216          // Prevent new page creation when comments are at the bottom of a page.
 217          $this->setPageOrientation($size['orientation'], false, 0);
 218          // Fill in the page with the original contents from the student.
 219          $this->useTemplate($template);
 220      }
 221  
 222      /**
 223       * Copy all the remaining pages in the file
 224       */
 225      public function copy_remaining_pages() {
 226          $morepages = true;
 227          while ($morepages) {
 228              $morepages = $this->copy_page();
 229          }
 230      }
 231  
 232      /**
 233       * Append all comments to the end of the document.
 234       *
 235       * @param array $allcomments All comments, indexed by page number (starting at 0).
 236       * @return array|bool An array of links to comments, or false.
 237       */
 238      public function append_comments($allcomments) {
 239          if (!$this->filename) {
 240              return false;
 241          }
 242  
 243          $this->SetFontSize(12 * $this->scale);
 244          $this->SetMargins(100 * $this->scale, 120 * $this->scale, -1, true);
 245          $this->SetAutoPageBreak(true, 100 * $this->scale);
 246          $this->setHeaderFont(array($this->get_export_font_name(), '', 24 * $this->scale, '', true));
 247          $this->setHeaderMargin(24 * $this->scale);
 248          $this->setHeaderData('', 0, '', get_string('commentindex', 'assignfeedback_editpdf'));
 249  
 250          // Add a new page to the document with an appropriate header.
 251          $this->setPrintHeader(true);
 252          $this->AddPage();
 253  
 254          // Add the comments.
 255          $commentlinks = array();
 256          foreach ($allcomments as $pageno => $comments) {
 257              foreach ($comments as $index => $comment) {
 258                  // Create a link to the current location, which will be added to the marker.
 259                  $commentlink = $this->AddLink();
 260                  $this->SetLink($commentlink, -1);
 261                  $commentlinks[$pageno][$index] = $commentlink;
 262                  // Also create a link back to the marker, which will be added here.
 263                  $markerlink = $this->AddLink();
 264                  $this->SetLink($markerlink, $comment->y * $this->scale, $pageno + 1);
 265                  $label = get_string('commentlabel', 'assignfeedback_editpdf', array('pnum' => $pageno + 1, 'cnum' => $index + 1));
 266                  $this->Cell(50 * $this->scale, 0, $label, 0, 0, '', false, $markerlink);
 267                  $this->MultiCell(0, 0, $comment->rawtext, 0, 'L');
 268                  $this->Ln(12 * $this->scale);
 269              }
 270              // Add an extra line break between pages.
 271              $this->Ln(12 * $this->scale);
 272          }
 273  
 274          return $commentlinks;
 275      }
 276  
 277      /**
 278       * Add a comment marker to the specified page.
 279       *
 280       * @param int $pageno The page number to add markers to (starting at 0).
 281       * @param int $index The comment index.
 282       * @param int $x The x-coordinate of the marker (in pixels).
 283       * @param int $y The y-coordinate of the marker (in pixels).
 284       * @param int $link The link identifier pointing to the full comment text.
 285       * @param string $colour The fill colour of the marker (red, yellow, green, blue, white, clear).
 286       * @return bool Success status.
 287       */
 288      public function add_comment_marker($pageno, $index, $x, $y, $link, $colour = 'yellow') {
 289          if (!$this->filename) {
 290              return false;
 291          }
 292  
 293          $fill = '';
 294          $fillopacity = 0.9;
 295          switch ($colour) {
 296              case 'red':
 297                  $fill = 'rgb(249, 181, 179)';
 298                  break;
 299              case 'green':
 300                  $fill = 'rgb(214, 234, 178)';
 301                  break;
 302              case 'blue':
 303                  $fill = 'rgb(203, 217, 237)';
 304                  break;
 305              case 'white':
 306                  $fill = 'rgb(255, 255, 255)';
 307                  break;
 308              case 'clear':
 309                  $fillopacity = 0;
 310                  break;
 311              default: /* Yellow */
 312                  $fill = 'rgb(255, 236, 174)';
 313          }
 314          $marker = '@<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 12 12" preserveAspectRatio="xMinYMin meet">' .
 315                  '<path d="M11 0H1C.4 0 0 .4 0 1v6c0 .6.4 1 1 1h1v4l4-4h5c.6 0 1-.4 1-1V1c0-.6-.4-1-1-1z" fill="' . $fill . '" ' .
 316                  'fill-opacity="' . $fillopacity . '" stroke="rgb(153, 153, 153)" stroke-width="0.5"/></svg>';
 317          $label = get_string('commentlabel', 'assignfeedback_editpdf', array('pnum' => $pageno + 1, 'cnum' => $index + 1));
 318  
 319          $x *= $this->scale;
 320          $y *= $this->scale;
 321          $size = 24 * $this->scale;
 322          $this->SetDrawColor(51, 51, 51);
 323          $this->SetFontSize(10 * $this->scale);
 324          $this->setPage($pageno + 1);
 325  
 326          // Add the marker image.
 327          $this->ImageSVG($marker, $x - 0.5, $y - 0.5, $size, $size, $link);
 328  
 329          // Add the label.
 330          $this->MultiCell($size * 0.95, 0, $label, 0, 'C', false, 1, $x, $y, true, 0, false, true, $size * 0.60, 'M', true);
 331  
 332          return true;
 333      }
 334  
 335      /**
 336       * Add a comment to the current page
 337       * @param string $text the text of the comment
 338       * @param int $x the x-coordinate of the comment (in pixels)
 339       * @param int $y the y-coordinate of the comment (in pixels)
 340       * @param int $width the width of the comment (in pixels)
 341       * @param string $colour optional the background colour of the comment (red, yellow, green, blue, white, clear)
 342       * @return bool true if successful (always)
 343       */
 344      public function add_comment($text, $x, $y, $width, $colour = 'yellow') {
 345          if (!$this->filename) {
 346              return false;
 347          }
 348          $this->SetDrawColor(51, 51, 51);
 349          switch ($colour) {
 350              case 'red':
 351                  $this->SetFillColor(249, 181, 179);
 352                  break;
 353              case 'green':
 354                  $this->SetFillColor(214, 234, 178);
 355                  break;
 356              case 'blue':
 357                  $this->SetFillColor(203, 217, 237);
 358                  break;
 359              case 'white':
 360                  $this->SetFillColor(255, 255, 255);
 361                  break;
 362              default: /* Yellow */
 363                  $this->SetFillColor(255, 236, 174);
 364                  break;
 365          }
 366  
 367          $x *= $this->scale;
 368          $y *= $this->scale;
 369          $width *= $this->scale;
 370          $text = str_replace('&lt;', '<', $text);
 371          $text = str_replace('&gt;', '>', $text);
 372          // Draw the text with a border, but no background colour (using a background colour would cause the fill to
 373          // appear behind any existing content on the page, hence the extra filled rectangle drawn below).
 374          $this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
 375          if ($colour != 'clear') {
 376              $newy = $this->GetY();
 377              // Now we know the final size of the comment, draw a rectangle with the background colour.
 378              $this->Rect($x, $y, $width, $newy - $y, 'DF');
 379              // Re-draw the text over the top of the background rectangle.
 380              $this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
 381          }
 382          return true;
 383      }
 384  
 385      /**
 386       * Add an annotation to the current page
 387       * @param int $sx starting x-coordinate (in pixels)
 388       * @param int $sy starting y-coordinate (in pixels)
 389       * @param int $ex ending x-coordinate (in pixels)
 390       * @param int $ey ending y-coordinate (in pixels)
 391       * @param string $colour optional the colour of the annotation (red, yellow, green, blue, white, black)
 392       * @param string $type optional the type of annotation (line, oval, rectangle, highlight, pen, stamp)
 393       * @param int[]|string $path optional for 'pen' annotations this is an array of x and y coordinates for
 394       *              the line, for 'stamp' annotations it is the name of the stamp file (without the path)
 395       * @param string $imagefolder - Folder containing stamp images.
 396       * @return bool true if successful (always)
 397       */
 398      public function add_annotation($sx, $sy, $ex, $ey, $colour, $type, $path, $imagefolder) {
 399          global $CFG;
 400          if (!$this->filename) {
 401              return false;
 402          }
 403          switch ($colour) {
 404              case 'yellow':
 405                  $colourarray = array(255, 207, 53);
 406                  break;
 407              case 'green':
 408                  $colourarray = array(153, 202, 62);
 409                  break;
 410              case 'blue':
 411                  $colourarray = array(125, 159, 211);
 412                  break;
 413              case 'white':
 414                  $colourarray = array(255, 255, 255);
 415                  break;
 416              case 'black':
 417                  $colourarray = array(51, 51, 51);
 418                  break;
 419              default: /* Red */
 420                  $colour = 'red';
 421                  $colourarray = array(239, 69, 64);
 422                  break;
 423          }
 424          $this->SetDrawColorArray($colourarray);
 425  
 426          $sx *= $this->scale;
 427          $sy *= $this->scale;
 428          $ex *= $this->scale;
 429          $ey *= $this->scale;
 430  
 431          $this->SetLineWidth(3.0 * $this->scale);
 432          switch ($type) {
 433              case 'oval':
 434                  $rx = abs($sx - $ex) / 2;
 435                  $ry = abs($sy - $ey) / 2;
 436                  $sx = min($sx, $ex) + $rx;
 437                  $sy = min($sy, $ey) + $ry;
 438  
 439                  // $rx and $ry should be >= min width and height
 440                  if ($rx < self::MIN_ANNOTATION_WIDTH) {
 441                      $rx = self::MIN_ANNOTATION_WIDTH;
 442                  }
 443                  if ($ry < self::MIN_ANNOTATION_HEIGHT) {
 444                      $ry = self::MIN_ANNOTATION_HEIGHT;
 445                  }
 446  
 447                  $this->Ellipse($sx, $sy, $rx, $ry);
 448                  break;
 449              case 'rectangle':
 450                  $w = abs($sx - $ex);
 451                  $h = abs($sy - $ey);
 452                  $sx = min($sx, $ex);
 453                  $sy = min($sy, $ey);
 454  
 455                  // Width or height should be >= min width and height
 456                  if ($w < self::MIN_ANNOTATION_WIDTH) {
 457                      $w = self::MIN_ANNOTATION_WIDTH;
 458                  }
 459                  if ($h < self::MIN_ANNOTATION_HEIGHT) {
 460                      $h = self::MIN_ANNOTATION_HEIGHT;
 461                  }
 462                  $this->Rect($sx, $sy, $w, $h);
 463                  break;
 464              case 'highlight':
 465                  $w = abs($sx - $ex);
 466                  $h = 8.0 * $this->scale;
 467                  $sx = min($sx, $ex);
 468                  $sy = min($sy, $ey) + ($h * 0.5);
 469                  $this->SetAlpha(0.5, 'Normal', 0.5, 'Normal');
 470                  $this->SetLineWidth(8.0 * $this->scale);
 471  
 472                  // width should be >= min width
 473                  if ($w < self::MIN_ANNOTATION_WIDTH) {
 474                      $w = self::MIN_ANNOTATION_WIDTH;
 475                  }
 476  
 477                  $this->Rect($sx, $sy, $w, $h);
 478                  $this->SetAlpha(1.0, 'Normal', 1.0, 'Normal');
 479                  break;
 480              case 'pen':
 481                  if ($path) {
 482                      $scalepath = array();
 483                      $points = preg_split('/[,:]/', $path);
 484                      foreach ($points as $point) {
 485                          $scalepath[] = intval($point) * $this->scale;
 486                      }
 487  
 488                      if (!empty($scalepath)) {
 489                          $this->PolyLine($scalepath, 'S');
 490                      }
 491                  }
 492                  break;
 493              case 'stamp':
 494                  $imgfile = $imagefolder . '/' . clean_filename($path);
 495                  $w = abs($sx - $ex);
 496                  $h = abs($sy - $ey);
 497                  $sx = min($sx, $ex);
 498                  $sy = min($sy, $ey);
 499  
 500                  // Stamp is always more than 40px, so no need to check width/height.
 501                  $this->Image($imgfile, $sx, $sy, $w, $h);
 502                  break;
 503              default: // Line.
 504                  $this->Line($sx, $sy, $ex, $ey);
 505                  break;
 506          }
 507          $this->SetDrawColor(0, 0, 0);
 508          $this->SetLineWidth(1.0 * $this->scale);
 509  
 510          return true;
 511      }
 512  
 513      /**
 514       * Save the completed PDF to the given file
 515       * @param string $filename the filename for the PDF (including the full path)
 516       */
 517      public function save_pdf($filename) {
 518          $olddebug = error_reporting(0);
 519          $this->Output($filename, 'F');
 520          error_reporting($olddebug);
 521      }
 522  
 523      /**
 524       * Set the path to the folder in which to generate page image files
 525       * @param string $folder
 526       */
 527      public function set_image_folder($folder) {
 528          $this->imagefolder = $folder;
 529      }
 530  
 531      /**
 532       * Generate images from the PDF
 533       * @return array Array of filename of the generated images
 534       */
 535      public function get_images(): array {
 536          $this->precheck_generate_image();
 537  
 538          $imagefile = $this->imagefolder . '/' . self::IMAGE_PAGE;
 539          $command = $this->get_command_for_image(-1, $imagefile);
 540          exec($command);
 541          $images = array();
 542          for ($i = 0; $i < $this->pagecount; $i++) {
 543              // Image file is created from 1, so need to change to 0.
 544              $file = $imagefile . ($i + 1) . '.png';
 545              $newfile = $imagefile . $i . '.png';
 546              if (file_exists($file)) {
 547                  rename($file, $newfile);
 548              } else {
 549                  // Converter added '-' and zerofill for the pagenumber.
 550                  $length = strlen($this->pagecount);
 551                  $file = $imagefile . '-' . str_pad(($i + 1), $length, '0', STR_PAD_LEFT) . '.png';
 552                  if (file_exists($file)) {
 553                      rename($file, $newfile);
 554                  } else {
 555                      $newfile = self::get_error_image($this->imagefolder, $i);
 556                  }
 557              }
 558              $images[$i] = basename($newfile);
 559          }
 560          return $images;
 561      }
 562  
 563      /**
 564       * Generate an image of the specified page in the PDF
 565       * @param int $pageno the page to generate the image of
 566       * @throws \moodle_exception
 567       * @throws \coding_exception
 568       * @return string the filename of the generated image
 569       */
 570      public function get_image($pageno) {
 571          $this->precheck_generate_image();
 572  
 573          $imagefile = $this->imagefolder . '/' . self::IMAGE_PAGE . $pageno . '.png';
 574          $generate = true;
 575          if (file_exists($imagefile)) {
 576              if (filemtime($imagefile) > filemtime($this->filename)) {
 577                  // Make sure the image is newer than the PDF file.
 578                  $generate = false;
 579              }
 580          }
 581  
 582          if ($generate) {
 583              $command = $this->get_command_for_image($pageno, $imagefile);
 584              $output = null;
 585              $result = exec($command, $output);
 586              if (!file_exists($imagefile)) {
 587                  $fullerror = '<pre>'.get_string('command', 'assignfeedback_editpdf')."\n";
 588                  $fullerror .= $command . "\n\n";
 589                  $fullerror .= get_string('result', 'assignfeedback_editpdf')."\n";
 590                  $fullerror .= htmlspecialchars($result, ENT_COMPAT) . "\n\n";
 591                  $fullerror .= get_string('output', 'assignfeedback_editpdf')."\n";
 592                  $fullerror .= htmlspecialchars(implode("\n", $output), ENT_COMPAT) . '</pre>';
 593                  throw new \moodle_exception('errorgenerateimage', 'assignfeedback_editpdf', '', $fullerror);
 594              }
 595          }
 596  
 597          return self::IMAGE_PAGE . $pageno . '.png';
 598      }
 599  
 600      /**
 601       * Make sure the file name and image folder are ready before generate image.
 602       * @return bool
 603       */
 604      protected function precheck_generate_image() {
 605          if (!$this->filename) {
 606              throw new \coding_exception('Attempting to generate a page image without first setting the PDF filename');
 607          }
 608  
 609          if (!$this->imagefolder) {
 610              throw new \coding_exception('Attempting to generate a page image without first specifying the image output folder');
 611          }
 612  
 613          if (!is_dir($this->imagefolder)) {
 614              throw new \coding_exception('The specified image output folder is not a valid folder');
 615          }
 616          return true;
 617      }
 618  
 619      /**
 620       * Gets the command to use to extract as image the given $pageno page number
 621       * from a PDF document into the $imagefile file.
 622       * @param int $pageno Page number to extract from document. -1 means for all pages.
 623       * @param string $imagefile Target filename for the PNG image as absolute path.
 624       * @return string The command to use to extract a page as PNG image.
 625       */
 626      private function get_command_for_image(int $pageno, string $imagefile): string {
 627          global $CFG;
 628  
 629          // First, quickest convertion option.
 630          if (!empty($CFG->pathtopdftoppm) && is_executable($CFG->pathtopdftoppm)) {
 631              return $this->get_pdftoppm_command_for_image($pageno, $imagefile);
 632          }
 633  
 634          // Otherwise, rely on default behaviour.
 635          return $this->get_gs_command_for_image($pageno, $imagefile);
 636      }
 637  
 638      /**
 639       * Gets the pdftoppm command to use to extract as image the given $pageno page number
 640       * from a PDF document into the $imagefile file.
 641       * @param int $pageno Page number to extract from document. -1 means for all pages.
 642       * @param string $imagefile Target filename for the PNG image as absolute path.
 643       * @return string The pdftoppm command to use to extract a page as PNG image.
 644       */
 645      private function get_pdftoppm_command_for_image(int $pageno, string $imagefile): string {
 646          global $CFG;
 647          $pdftoppmexec = \escapeshellarg($CFG->pathtopdftoppm);
 648          $imageres = \escapeshellarg(100);
 649          $filename = \escapeshellarg($this->filename);
 650          $pagenoinc = \escapeshellarg($pageno + 1);
 651          if ($pageno >= 0) {
 652              // Convert 1 page.
 653              $imagefile = substr($imagefile, 0, -4); // Pdftoppm tool automatically adds extension file.
 654              $frompageno = $pagenoinc;
 655              $topageno = $pagenoinc;
 656              $singlefile = '-singlefile';
 657          } else {
 658              // Convert all pages at once.
 659              $frompageno = 1;
 660              $topageno = $this->pagecount;
 661              $singlefile = '';
 662          }
 663          $imagefilearg = \escapeshellarg($imagefile);
 664          return "$pdftoppmexec -q -r $imageres -f $frompageno -l $topageno -png $singlefile $filename $imagefilearg";
 665      }
 666  
 667      /**
 668       * Gets the ghostscript (gs) command to use to extract as image the given $pageno page number
 669       * from a PDF document into the $imagefile file.
 670       * @param int $pageno Page number to extract from document. -1 means for all pages.
 671       * @param string $imagefile Target filename for the PNG image as absolute path.
 672       * @return string The ghostscript (gs) command to use to extract a page as PNG image.
 673       */
 674      private function get_gs_command_for_image(int $pageno, string $imagefile): string {
 675          global $CFG;
 676          $gsexec = \escapeshellarg($CFG->pathtogs);
 677          $imageres = \escapeshellarg(100);
 678          $imagefilearg = \escapeshellarg($imagefile);
 679          $filename = \escapeshellarg($this->filename);
 680          $pagenoinc = \escapeshellarg($pageno + 1);
 681          if ($pageno >= 0) {
 682              // Convert 1 page.
 683              $firstpage = $pagenoinc;
 684              $lastpage = $pagenoinc;
 685          } else {
 686              // Convert all pages at once.
 687              $imagefilearg = \escapeshellarg($imagefile . '%d.png');
 688              $firstpage = 1;
 689              $lastpage = $this->pagecount;
 690          }
 691          return "$gsexec -q -sDEVICE=png16m -dSAFER -dBATCH -dNOPAUSE -r$imageres -dFirstPage=$firstpage -dLastPage=$lastpage ".
 692              "-dDOINTERPOLATE -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -sOutputFile=$imagefilearg $filename";
 693      }
 694  
 695      /**
 696       * Check to see if PDF is version 1.4 (or below); if not: use ghostscript to convert it
 697       *
 698       * @param stored_file $file
 699       * @return string path to copy or converted pdf (false == fail)
 700       */
 701      public static function ensure_pdf_compatible(\stored_file $file) {
 702          global $CFG;
 703  
 704          // Copy the stored_file to local disk for checking.
 705          $temparea = make_request_directory();
 706          $tempsrc = $temparea . "/source.pdf";
 707          $file->copy_content_to($tempsrc);
 708  
 709          return self::ensure_pdf_file_compatible($tempsrc);
 710      }
 711  
 712      /**
 713       * Flatten and convert file using ghostscript then load pdf.
 714       *
 715       * @param   string $tempsrc The path to the file on disk.
 716       * @return  string path to copy or converted pdf (false == fail)
 717       */
 718      public static function ensure_pdf_file_compatible($tempsrc) {
 719          global $CFG;
 720  
 721          $temparea = make_request_directory();
 722          $tempdst = $temparea . "/target.pdf";
 723  
 724          $gsexec = \escapeshellarg($CFG->pathtogs);
 725          $tempdstarg = \escapeshellarg($tempdst);
 726          $tempsrcarg = \escapeshellarg($tempsrc);
 727          $command = "$gsexec -q -sDEVICE=pdfwrite -dPreserveAnnots=false -dSAFER -dBATCH -dNOPAUSE "
 728              . "-sOutputFile=$tempdstarg $tempsrcarg";
 729  
 730          exec($command);
 731          if (!file_exists($tempdst)) {
 732              // Something has gone wrong in the conversion.
 733              return false;
 734          }
 735  
 736          $pdf = new pdf();
 737          $pagecount = 0;
 738          try {
 739              $pagecount = $pdf->load_pdf($tempdst);
 740          } catch (\Exception $e) {
 741              // PDF was not valid - try running it through ghostscript to clean it up.
 742              $pagecount = 0;
 743          }
 744          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 745  
 746          if ($pagecount <= 0) {
 747              // Could not parse the converted pdf.
 748              return false;
 749          }
 750  
 751          return $tempdst;
 752      }
 753  
 754      /**
 755       * Generate an localised error image for the given pagenumber.
 756       *
 757       * @param string $errorimagefolder path of the folder where error image needs to be created.
 758       * @param int $pageno page number for which error image needs to be created.
 759       *
 760       * @return string File name
 761       * @throws \coding_exception
 762       */
 763      public static function get_error_image($errorimagefolder, $pageno) {
 764          global $CFG;
 765  
 766          $errorfile = $CFG->dirroot . self::BLANK_PDF;
 767          if (!file_exists($errorfile)) {
 768              throw new \coding_exception("Blank PDF not found", "File path" . $errorfile);
 769          }
 770  
 771          $tmperrorimagefolder = make_request_directory();
 772  
 773          $pdf = new pdf();
 774          $pdf->set_pdf($errorfile);
 775          $pdf->copy_page();
 776          $pdf->add_comment(get_string('errorpdfpage', 'assignfeedback_editpdf'), 250, 300, 200, "red");
 777          $generatedpdf = $tmperrorimagefolder . '/' . 'error.pdf';
 778          $pdf->save_pdf($generatedpdf);
 779  
 780          $pdf = new pdf();
 781          $pdf->set_pdf($generatedpdf);
 782          $pdf->set_image_folder($tmperrorimagefolder);
 783          $image = $pdf->get_image(0);
 784          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 785          $newimg = self::IMAGE_PAGE . $pageno . '.png';
 786  
 787          copy($tmperrorimagefolder . '/' . $image, $errorimagefolder . '/' . $newimg);
 788          return $newimg;
 789      }
 790  
 791      /**
 792       * Test that the configured path to ghostscript is correct and working.
 793       * @param bool $generateimage - If true - a test image will be generated to verify the install.
 794       * @return \stdClass
 795       */
 796      public static function test_gs_path($generateimage = true) {
 797          global $CFG;
 798  
 799          $ret = (object)array(
 800              'status' => self::GSPATH_OK,
 801              'message' => null,
 802          );
 803          $gspath = $CFG->pathtogs;
 804          if (empty($gspath)) {
 805              $ret->status = self::GSPATH_EMPTY;
 806              return $ret;
 807          }
 808          if (!file_exists($gspath)) {
 809              $ret->status = self::GSPATH_DOESNOTEXIST;
 810              return $ret;
 811          }
 812          if (is_dir($gspath)) {
 813              $ret->status = self::GSPATH_ISDIR;
 814              return $ret;
 815          }
 816          if (!is_executable($gspath)) {
 817              $ret->status = self::GSPATH_NOTEXECUTABLE;
 818              return $ret;
 819          }
 820  
 821          if (!$generateimage) {
 822              return $ret;
 823          }
 824  
 825          $testfile = $CFG->dirroot.'/mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf';
 826          if (!file_exists($testfile)) {
 827              $ret->status = self::GSPATH_NOTESTFILE;
 828              return $ret;
 829          }
 830  
 831          $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
 832          $filepath = $testimagefolder . '/' . self::IMAGE_PAGE . '0.png';
 833          // Delete any previous test images, if they exist.
 834          if (file_exists($filepath)) {
 835              unlink($filepath);
 836          }
 837  
 838          $pdf = new pdf();
 839          $pdf->set_pdf($testfile);
 840          $pdf->set_image_folder($testimagefolder);
 841          try {
 842              $pdf->get_image(0);
 843          } catch (\moodle_exception $e) {
 844              $ret->status = self::GSPATH_ERROR;
 845              $ret->message = $e->getMessage();
 846          }
 847          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 848  
 849          return $ret;
 850      }
 851  
 852      /**
 853       * If the test image has been generated correctly - send it direct to the browser.
 854       */
 855      public static function send_test_image() {
 856          global $CFG;
 857          header('Content-type: image/png');
 858          require_once($CFG->libdir.'/filelib.php');
 859  
 860          $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
 861          $testimage = $testimagefolder . '/' . self::IMAGE_PAGE . '0.png';
 862          send_file($testimage, basename($testimage), 0);
 863          die();
 864      }
 865  
 866      /**
 867       * This function add an image file to PDF page.
 868       * @param \stored_file $imagestoredfile Image file to be added
 869       */
 870      public function add_image_page($imagestoredfile) {
 871          $imageinfo = $imagestoredfile->get_imageinfo();
 872          $imagecontent = $imagestoredfile->get_content();
 873          $this->currentpage++;
 874          $template = $this->importPage($this->currentpage);
 875          $size = $this->getTemplateSize($template);
 876          $orientation = 'P';
 877          if ($imageinfo["width"] > $imageinfo["height"]) {
 878              if ($size['width'] < $size['height']) {
 879                  $temp = $size['width'];
 880                  $size['width'] = $size['height'];
 881                  $size['height'] = $temp;
 882              }
 883              $orientation = 'L';
 884          } else if ($imageinfo["width"] < $imageinfo["height"]) {
 885              if ($size['width'] > $size['height']) {
 886                  $temp = $size['width'];
 887                  $size['width'] = $size['height'];
 888                  $size['height'] = $temp;
 889              }
 890          }
 891  
 892          $this->SetHeaderMargin(0);
 893          $this->SetFooterMargin(0);
 894          $this->SetMargins(0, 0, 0, true);
 895          $this->setPrintFooter(false);
 896          $this->setPrintHeader(false);
 897  
 898          $this->AddPage($orientation, $size);
 899          $this->SetAutoPageBreak(false, 0);
 900          $this->Image('@' . $imagecontent, 0, 0, $size['width'], $size['height'],
 901              '', '', '', false, null, '', false, false, 0);
 902      }
 903  }
 904