[ Index ]

PHP Cross Reference of Unnamed Project

title

Body

[close]

/mod/assign/feedback/editpdf/classes/ -> pdf.php (source)

   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  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  global $CFG;
  30  require_once($CFG->libdir.'/pdflib.php');
  31  require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
  32  
  33  /**
  34   * Library code for manipulating PDFs
  35   *
  36   * @package assignfeedback_editpdf
  37   * @copyright 2012 Davo Smith
  38   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class pdf extends \FPDI {
  41  
  42      /** @var int the number of the current page in the PDF being processed */
  43      protected $currentpage = 0;
  44      /** @var int the total number of pages in the PDF being processed */
  45      protected $pagecount = 0;
  46      /** @var float used to scale the pixel position of annotations (in the database) to the position in the final PDF */
  47      protected $scale = 0.0;
  48      /** @var string the path in which to store generated page images */
  49      protected $imagefolder = null;
  50      /** @var string the path to the PDF currently being processed */
  51      protected $filename = null;
  52  
  53      /** No errors */
  54      const GSPATH_OK = 'ok';
  55      /** Not set */
  56      const GSPATH_EMPTY = 'empty';
  57      /** Does not exist */
  58      const GSPATH_DOESNOTEXIST = 'doesnotexist';
  59      /** Is a dir */
  60      const GSPATH_ISDIR = 'isdir';
  61      /** Not executable */
  62      const GSPATH_NOTEXECUTABLE = 'notexecutable';
  63      /** Test file missing */
  64      const GSPATH_NOTESTFILE = 'notestfile';
  65      /** Any other error */
  66      const GSPATH_ERROR = 'error';
  67      /** Min. width an annotation should have */
  68      const MIN_ANNOTATION_WIDTH = 5;
  69      /** Min. height an annotation should have */
  70      const MIN_ANNOTATION_HEIGHT = 5;
  71  
  72      /**
  73       * Combine the given PDF files into a single PDF. Optionally add a coversheet and coversheet fields.
  74       * @param string[] $pdflist  the filenames of the files to combine
  75       * @param string $outfilename the filename to write to
  76       * @return int the number of pages in the combined PDF
  77       */
  78      public function combine_pdfs($pdflist, $outfilename) {
  79  
  80          raise_memory_limit(MEMORY_EXTRA);
  81          $olddebug = error_reporting(0);
  82  
  83          $this->setPageUnit('pt');
  84          $this->setPrintHeader(false);
  85          $this->setPrintFooter(false);
  86          $this->scale = 72.0 / 100.0;
  87          $this->SetFont('helvetica', '', 16.0 * $this->scale);
  88          $this->SetTextColor(0, 0, 0);
  89  
  90          $totalpagecount = 0;
  91  
  92          foreach ($pdflist as $file) {
  93              $pagecount = $this->setSourceFile($file);
  94              $totalpagecount += $pagecount;
  95              for ($i = 1; $i<=$pagecount; $i++) {
  96                  $this->create_page_from_source($i);
  97              }
  98          }
  99  
 100          $this->save_pdf($outfilename);
 101          error_reporting($olddebug);
 102  
 103          return $totalpagecount;
 104      }
 105  
 106      /**
 107       * The number of the current page in the PDF being processed
 108       * @return int
 109       */
 110      public function current_page() {
 111          return $this->currentpage;
 112      }
 113  
 114      /**
 115       * The total number of pages in the PDF being processed
 116       * @return int
 117       */
 118      public function page_count() {
 119          return $this->pagecount;
 120      }
 121  
 122      /**
 123       * Load the specified PDF and set the initial output configuration
 124       * Used when processing comments and outputting a new PDF
 125       * @param string $filename the path to the PDF to load
 126       * @return int the number of pages in the PDF
 127       */
 128      public function load_pdf($filename) {
 129          raise_memory_limit(MEMORY_EXTRA);
 130          $olddebug = error_reporting(0);
 131  
 132          $this->setPageUnit('pt');
 133          $this->scale = 72.0 / 100.0;
 134          $this->SetFont('helvetica', '', 16.0 * $this->scale);
 135          $this->SetFillColor(255, 255, 176);
 136          $this->SetDrawColor(0, 0, 0);
 137          $this->SetLineWidth(1.0 * $this->scale);
 138          $this->SetTextColor(0, 0, 0);
 139          $this->setPrintHeader(false);
 140          $this->setPrintFooter(false);
 141          $this->pagecount = $this->setSourceFile($filename);
 142          $this->filename = $filename;
 143  
 144          error_reporting($olddebug);
 145          return $this->pagecount;
 146      }
 147  
 148      /**
 149       * Sets the name of the PDF to process, but only loads the file if the
 150       * pagecount is zero (in order to count the number of pages)
 151       * Used when generating page images (but not a new PDF)
 152       * @param string $filename the path to the PDF to process
 153       * @param int $pagecount optional the number of pages in the PDF, if known
 154       * @return int the number of pages in the PDF
 155       */
 156      public function set_pdf($filename, $pagecount = 0) {
 157          if ($pagecount == 0) {
 158              return $this->load_pdf($filename);
 159          } else {
 160              $this->filename = $filename;
 161              $this->pagecount = $pagecount;
 162              return $pagecount;
 163          }
 164      }
 165  
 166      /**
 167       * Copy the next page from the source file and set it as the current page
 168       * @return bool true if successful
 169       */
 170      public function copy_page() {
 171          if (!$this->filename) {
 172              return false;
 173          }
 174          if ($this->currentpage>=$this->pagecount) {
 175              return false;
 176          }
 177          $this->currentpage++;
 178          $this->create_page_from_source($this->currentpage);
 179          return true;
 180      }
 181  
 182      /**
 183       * Create a page from a source PDF.
 184       *
 185       * @param int $pageno
 186       */
 187      protected function create_page_from_source($pageno) {
 188          // Get the size (and deduce the orientation) of the next page.
 189          $template = $this->importPage($pageno);
 190          $size = $this->getTemplateSize($template);
 191          $orientation = 'P';
 192          if ($size['w'] > $size['h']) {
 193              $orientation = 'L';
 194          }
 195          // Create a page of the required size / orientation.
 196          $this->AddPage($orientation, array($size['w'], $size['h']));
 197          // Prevent new page creation when comments are at the bottom of a page.
 198          $this->setPageOrientation($orientation, false, 0);
 199          // Fill in the page with the original contents from the student.
 200          $this->useTemplate($template);
 201      }
 202  
 203      /**
 204       * Copy all the remaining pages in the file
 205       */
 206      public function copy_remaining_pages() {
 207          $morepages = true;
 208          while ($morepages) {
 209              $morepages = $this->copy_page();
 210          }
 211      }
 212  
 213      /**
 214       * Add a comment to the current page
 215       * @param string $text the text of the comment
 216       * @param int $x the x-coordinate of the comment (in pixels)
 217       * @param int $y the y-coordinate of the comment (in pixels)
 218       * @param int $width the width of the comment (in pixels)
 219       * @param string $colour optional the background colour of the comment (red, yellow, green, blue, white, clear)
 220       * @return bool true if successful (always)
 221       */
 222      public function add_comment($text, $x, $y, $width, $colour = 'yellow') {
 223          if (!$this->filename) {
 224              return false;
 225          }
 226          $this->SetDrawColor(51, 51, 51);
 227          switch ($colour) {
 228              case 'red':
 229                  $this->SetFillColor(249, 181, 179);
 230                  break;
 231              case 'green':
 232                  $this->SetFillColor(214, 234, 178);
 233                  break;
 234              case 'blue':
 235                  $this->SetFillColor(203, 217, 237);
 236                  break;
 237              case 'white':
 238                  $this->SetFillColor(255, 255, 255);
 239                  break;
 240              default: /* Yellow */
 241                  $this->SetFillColor(255, 236, 174);
 242                  break;
 243          }
 244  
 245          $x *= $this->scale;
 246          $y *= $this->scale;
 247          $width *= $this->scale;
 248          $text = str_replace('&lt;', '<', $text);
 249          $text = str_replace('&gt;', '>', $text);
 250          // Draw the text with a border, but no background colour (using a background colour would cause the fill to
 251          // appear behind any existing content on the page, hence the extra filled rectangle drawn below).
 252          $this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
 253          if ($colour != 'clear') {
 254              $newy = $this->GetY();
 255              // Now we know the final size of the comment, draw a rectangle with the background colour.
 256              $this->Rect($x, $y, $width, $newy - $y, 'DF');
 257              // Re-draw the text over the top of the background rectangle.
 258              $this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
 259          }
 260          return true;
 261      }
 262  
 263      /**
 264       * Add an annotation to the current page
 265       * @param int $sx starting x-coordinate (in pixels)
 266       * @param int $sy starting y-coordinate (in pixels)
 267       * @param int $ex ending x-coordinate (in pixels)
 268       * @param int $ey ending y-coordinate (in pixels)
 269       * @param string $colour optional the colour of the annotation (red, yellow, green, blue, white, black)
 270       * @param string $type optional the type of annotation (line, oval, rectangle, highlight, pen, stamp)
 271       * @param int[]|string $path optional for 'pen' annotations this is an array of x and y coordinates for
 272       *              the line, for 'stamp' annotations it is the name of the stamp file (without the path)
 273       * @param string $imagefolder - Folder containing stamp images.
 274       * @return bool true if successful (always)
 275       */
 276      public function add_annotation($sx, $sy, $ex, $ey, $colour = 'yellow', $type = 'line', $path, $imagefolder) {
 277          global $CFG;
 278          if (!$this->filename) {
 279              return false;
 280          }
 281          switch ($colour) {
 282              case 'yellow':
 283                  $colourarray = array(255, 207, 53);
 284                  break;
 285              case 'green':
 286                  $colourarray = array(153, 202, 62);
 287                  break;
 288              case 'blue':
 289                  $colourarray = array(125, 159, 211);
 290                  break;
 291              case 'white':
 292                  $colourarray = array(255, 255, 255);
 293                  break;
 294              case 'black':
 295                  $colourarray = array(51, 51, 51);
 296                  break;
 297              default: /* Red */
 298                  $colour = 'red';
 299                  $colourarray = array(239, 69, 64);
 300                  break;
 301          }
 302          $this->SetDrawColorArray($colourarray);
 303  
 304          $sx *= $this->scale;
 305          $sy *= $this->scale;
 306          $ex *= $this->scale;
 307          $ey *= $this->scale;
 308  
 309          $this->SetLineWidth(3.0 * $this->scale);
 310          switch ($type) {
 311              case 'oval':
 312                  $rx = abs($sx - $ex) / 2;
 313                  $ry = abs($sy - $ey) / 2;
 314                  $sx = min($sx, $ex) + $rx;
 315                  $sy = min($sy, $ey) + $ry;
 316  
 317                  // $rx and $ry should be >= min width and height
 318                  if ($rx < self::MIN_ANNOTATION_WIDTH) {
 319                      $rx = self::MIN_ANNOTATION_WIDTH;
 320                  }
 321                  if ($ry < self::MIN_ANNOTATION_HEIGHT) {
 322                      $ry = self::MIN_ANNOTATION_HEIGHT;
 323                  }
 324  
 325                  $this->Ellipse($sx, $sy, $rx, $ry);
 326                  break;
 327              case 'rectangle':
 328                  $w = abs($sx - $ex);
 329                  $h = abs($sy - $ey);
 330                  $sx = min($sx, $ex);
 331                  $sy = min($sy, $ey);
 332  
 333                  // Width or height should be >= min width and height
 334                  if ($w < self::MIN_ANNOTATION_WIDTH) {
 335                      $w = self::MIN_ANNOTATION_WIDTH;
 336                  }
 337                  if ($h < self::MIN_ANNOTATION_HEIGHT) {
 338                      $h = self::MIN_ANNOTATION_HEIGHT;
 339                  }
 340                  $this->Rect($sx, $sy, $w, $h);
 341                  break;
 342              case 'highlight':
 343                  $w = abs($sx - $ex);
 344                  $h = 8.0 * $this->scale;
 345                  $sx = min($sx, $ex);
 346                  $sy = min($sy, $ey) + ($h * 0.5);
 347                  $this->SetAlpha(0.5, 'Normal', 0.5, 'Normal');
 348                  $this->SetLineWidth(8.0 * $this->scale);
 349  
 350                  // width should be >= min width
 351                  if ($w < self::MIN_ANNOTATION_WIDTH) {
 352                      $w = self::MIN_ANNOTATION_WIDTH;
 353                  }
 354  
 355                  $this->Rect($sx, $sy, $w, $h);
 356                  $this->SetAlpha(1.0, 'Normal', 1.0, 'Normal');
 357                  break;
 358              case 'pen':
 359                  if ($path) {
 360                      $scalepath = array();
 361                      $points = preg_split('/[,:]/', $path);
 362                      foreach ($points as $point) {
 363                          $scalepath[] = intval($point) * $this->scale;
 364                      }
 365  
 366                      if (!empty($scalepath)) {
 367                          $this->PolyLine($scalepath, 'S');
 368                      }
 369                  }
 370                  break;
 371              case 'stamp':
 372                  $imgfile = $imagefolder . '/' . clean_filename($path);
 373                  $w = abs($sx - $ex);
 374                  $h = abs($sy - $ey);
 375                  $sx = min($sx, $ex);
 376                  $sy = min($sy, $ey);
 377  
 378                  // Stamp is always more than 40px, so no need to check width/height.
 379                  $this->Image($imgfile, $sx, $sy, $w, $h);
 380                  break;
 381              default: // Line.
 382                  $this->Line($sx, $sy, $ex, $ey);
 383                  break;
 384          }
 385          $this->SetDrawColor(0, 0, 0);
 386          $this->SetLineWidth(1.0 * $this->scale);
 387  
 388          return true;
 389      }
 390  
 391      /**
 392       * Save the completed PDF to the given file
 393       * @param string $filename the filename for the PDF (including the full path)
 394       */
 395      public function save_pdf($filename) {
 396          $olddebug = error_reporting(0);
 397          $this->Output($filename, 'F');
 398          error_reporting($olddebug);
 399      }
 400  
 401      /**
 402       * Set the path to the folder in which to generate page image files
 403       * @param string $folder
 404       */
 405      public function set_image_folder($folder) {
 406          $this->imagefolder = $folder;
 407      }
 408  
 409      /**
 410       * Generate an image of the specified page in the PDF
 411       * @param int $pageno the page to generate the image of
 412       * @throws moodle_exception
 413       * @throws coding_exception
 414       * @return string the filename of the generated image
 415       */
 416      public function get_image($pageno) {
 417          global $CFG;
 418  
 419          if (!$this->filename) {
 420              throw new \coding_exception('Attempting to generate a page image without first setting the PDF filename');
 421          }
 422  
 423          if (!$this->imagefolder) {
 424              throw new \coding_exception('Attempting to generate a page image without first specifying the image output folder');
 425          }
 426  
 427          if (!is_dir($this->imagefolder)) {
 428              throw new \coding_exception('The specified image output folder is not a valid folder');
 429          }
 430  
 431          $imagefile = $this->imagefolder.'/image_page' . $pageno . '.png';
 432          $generate = true;
 433          if (file_exists($imagefile)) {
 434              if (filemtime($imagefile)>filemtime($this->filename)) {
 435                  // Make sure the image is newer than the PDF file.
 436                  $generate = false;
 437              }
 438          }
 439  
 440          if ($generate) {
 441              // Use ghostscript to generate an image of the specified page.
 442              $gsexec = \escapeshellarg($CFG->pathtogs);
 443              $imageres = \escapeshellarg(100);
 444              $imagefilearg = \escapeshellarg($imagefile);
 445              $filename = \escapeshellarg($this->filename);
 446              $pagenoinc = \escapeshellarg($pageno + 1);
 447              $command = "$gsexec -q -sDEVICE=png16m -dSAFER -dBATCH -dNOPAUSE -r$imageres -dFirstPage=$pagenoinc -dLastPage=$pagenoinc ".
 448                  "-dDOINTERPOLATE -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -sOutputFile=$imagefilearg $filename";
 449  
 450              $output = null;
 451              $result = exec($command, $output);
 452              if (!file_exists($imagefile)) {
 453                  $fullerror = '<pre>'.get_string('command', 'assignfeedback_editpdf')."\n";
 454                  $fullerror .= $command . "\n\n";
 455                  $fullerror .= get_string('result', 'assignfeedback_editpdf')."\n";
 456                  $fullerror .= htmlspecialchars($result) . "\n\n";
 457                  $fullerror .= get_string('output', 'assignfeedback_editpdf')."\n";
 458                  $fullerror .= htmlspecialchars(implode("\n",$output)) . '</pre>';
 459                  throw new \moodle_exception('errorgenerateimage', 'assignfeedback_editpdf', '', $fullerror);
 460              }
 461          }
 462  
 463          return 'image_page'.$pageno.'.png';
 464      }
 465  
 466      /**
 467       * Check to see if PDF is version 1.4 (or below); if not: use ghostscript to convert it
 468       * @param stored_file $file
 469       * @return string path to copy or converted pdf (false == fail)
 470       */
 471      public static function ensure_pdf_compatible(\stored_file $file) {
 472          global $CFG;
 473  
 474          $temparea = \make_temp_directory('assignfeedback_editpdf');
 475          $hash = $file->get_contenthash(); // Use the contenthash to make sure the temp files have unique names.
 476          $tempsrc = $temparea . "/src-$hash.pdf";
 477          $tempdst = $temparea . "/dst-$hash.pdf";
 478          $file->copy_content_to($tempsrc); // Copy the file.
 479  
 480          $pdf = new pdf();
 481          $pagecount = 0;
 482          try {
 483              $pagecount = $pdf->load_pdf($tempsrc);
 484          } catch (\Exception $e) {
 485              // PDF was not valid - try running it through ghostscript to clean it up.
 486              $pagecount = 0;
 487          }
 488          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 489  
 490          if ($pagecount > 0) {
 491              // Page is valid and can be read by tcpdf.
 492              return $tempsrc;
 493          }
 494  
 495          $gsexec = \escapeshellarg($CFG->pathtogs);
 496          $tempdstarg = \escapeshellarg($tempdst);
 497          $tempsrcarg = \escapeshellarg($tempsrc);
 498          $command = "$gsexec -q -sDEVICE=pdfwrite -dBATCH -dNOPAUSE -sOutputFile=$tempdstarg $tempsrcarg";
 499          exec($command);
 500          @unlink($tempsrc);
 501          if (!file_exists($tempdst)) {
 502              // Something has gone wrong in the conversion.
 503              return false;
 504          }
 505  
 506          $pdf = new pdf();
 507          $pagecount = 0;
 508          try {
 509              $pagecount = $pdf->load_pdf($tempdst);
 510          } catch (\Exception $e) {
 511              // PDF was not valid - try running it through ghostscript to clean it up.
 512              $pagecount = 0;
 513          }
 514          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 515  
 516          if ($pagecount <= 0) {
 517              @unlink($tempdst);
 518              // Could not parse the converted pdf.
 519              return false;
 520          }
 521  
 522          return $tempdst;
 523      }
 524  
 525      /**
 526       * Test that the configured path to ghostscript is correct and working.
 527       * @param bool $generateimage - If true - a test image will be generated to verify the install.
 528       * @return bool
 529       */
 530      public static function test_gs_path($generateimage = true) {
 531          global $CFG;
 532  
 533          $ret = (object)array(
 534              'status' => self::GSPATH_OK,
 535              'message' => null,
 536          );
 537          $gspath = $CFG->pathtogs;
 538          if (empty($gspath)) {
 539              $ret->status = self::GSPATH_EMPTY;
 540              return $ret;
 541          }
 542          if (!file_exists($gspath)) {
 543              $ret->status = self::GSPATH_DOESNOTEXIST;
 544              return $ret;
 545          }
 546          if (is_dir($gspath)) {
 547              $ret->status = self::GSPATH_ISDIR;
 548              return $ret;
 549          }
 550          if (!is_executable($gspath)) {
 551              $ret->status = self::GSPATH_NOTEXECUTABLE;
 552              return $ret;
 553          }
 554  
 555          if (!$generateimage) {
 556              return $ret;
 557          }
 558  
 559          $testfile = $CFG->dirroot.'/mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf';
 560          if (!file_exists($testfile)) {
 561              $ret->status = self::GSPATH_NOTESTFILE;
 562              return $ret;
 563          }
 564  
 565          $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
 566          @unlink($testimagefolder.'/image_page0.png'); // Delete any previous test images.
 567  
 568          $pdf = new pdf();
 569          $pdf->set_pdf($testfile);
 570          $pdf->set_image_folder($testimagefolder);
 571          try {
 572              $pdf->get_image(0);
 573          } catch (\moodle_exception $e) {
 574              $ret->status = self::GSPATH_ERROR;
 575              $ret->message = $e->getMessage();
 576          }
 577          $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
 578  
 579          return $ret;
 580      }
 581  
 582      /**
 583       * If the test image has been generated correctly - send it direct to the browser.
 584       */
 585      public static function send_test_image() {
 586          global $CFG;
 587          header('Content-type: image/png');
 588          require_once($CFG->libdir.'/filelib.php');
 589  
 590          $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
 591          $testimage = $testimagefolder.'/image_page0.png';
 592          send_file($testimage, basename($testimage), 0);
 593          die();
 594      }
 595  
 596  }
 597  


Generated: Thu Aug 11 10:00:09 2016 Cross-referenced by PHPXref 0.7.1