Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.

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

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