MDL-53923 mod_assign: Movement of functions to file_storage.
[moodle.git] / mod / assign / feedback / editpdf / classes / pdf.php
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/>.
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  */
25 namespace assignfeedback_editpdf;
27 defined('MOODLE_INTERNAL') || die();
29 global $CFG;
30 require_once($CFG->libdir.'/pdflib.php');
31 require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
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 {
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;
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;
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) {
80         raise_memory_limit(MEMORY_EXTRA);
81         $olddebug = error_reporting(0);
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);
90         $totalpagecount = 0;
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         }
100         $this->save_pdf($outfilename);
101         error_reporting($olddebug);
103         return $totalpagecount;
104     }
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     }
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     }
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);
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;
144         error_reporting($olddebug);
145         return $this->pagecount;
146     }
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     }
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     }
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     }
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     }
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         }
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     }
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);
304         $sx *= $this->scale;
305         $sy *= $this->scale;
306         $ex *= $this->scale;
307         $ey *= $this->scale;
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;
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                 }
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);
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);
350                 // width should be >= min width
351                 if ($w < self::MIN_ANNOTATION_WIDTH) {
352                     $w = self::MIN_ANNOTATION_WIDTH;
353                 }
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                     }
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);
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);
388         return true;
389     }
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     }
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     }
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;
419         if (!$this->filename) {
420             throw new \coding_exception('Attempting to generate a page image without first setting the PDF filename');
421         }
423         if (!$this->imagefolder) {
424             throw new \coding_exception('Attempting to generate a page image without first specifying the image output folder');
425         }
427         if (!is_dir($this->imagefolder)) {
428             throw new \coding_exception('The specified image output folder is not a valid folder');
429         }
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         }
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";
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         }
463         return 'image_page'.$pageno.'.png';
464     }
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;
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.
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.
490         if ($pagecount > 0) {
491             // Page is valid and can be read by tcpdf.
492             return $tempsrc;
493         }
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         }
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.
516         if ($pagecount <= 0) {
517             @unlink($tempdst);
518             // Could not parse the converted pdf.
519             return false;
520         }
522         return $tempdst;
523     }
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;
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         }
555         if (!$generateimage) {
556             return $ret;
557         }
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         }
565         $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
566         @unlink($testimagefolder.'/image_page0.png'); // Delete any previous test images.
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.
579         return $ret;
580     }
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');
590         $testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
591         $testimage = $testimagefolder.'/image_page0.png';
592         send_file($testimage, basename($testimage), 0);
593         die();
594     }