MDL-53923 mod_assign: Movement of functions to file_storage.
[moodle.git] / mod / assign / feedback / editpdf / classes / pdf.php
CommitLineData
5c386472
DW
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
25namespace assignfeedback_editpdf;
26
27defined('MOODLE_INTERNAL') || die();
28
29global $CFG;
30require_once($CFG->libdir.'/pdflib.php');
31require_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 */
40class 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';
baf881b8
RT
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;
5c386472
DW
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
4fe9950b 80 raise_memory_limit(MEMORY_EXTRA);
ac2b4ffc 81 $olddebug = error_reporting(0);
4fe9950b 82
5c386472
DW
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);
ac2b4ffc 101 error_reporting($olddebug);
5c386472
DW
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) {
4fe9950b 129 raise_memory_limit(MEMORY_EXTRA);
ac2b4ffc 130 $olddebug = error_reporting(0);
4fe9950b 131
5c386472
DW
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;
4fe9950b 143
ac2b4ffc 144 error_reporting($olddebug);
5c386472
DW
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 }
1d38083c 226 $this->SetDrawColor(51, 51, 51);
5c386472
DW
227 switch ($colour) {
228 case 'red':
1d38083c 229 $this->SetFillColor(249, 181, 179);
5c386472
DW
230 break;
231 case 'green':
1d38083c 232 $this->SetFillColor(214, 234, 178);
5c386472
DW
233 break;
234 case 'blue':
1d38083c 235 $this->SetFillColor(203, 217, 237);
5c386472
DW
236 break;
237 case 'white':
238 $this->SetFillColor(255, 255, 255);
239 break;
240 default: /* Yellow */
1d38083c 241 $this->SetFillColor(255, 236, 174);
5c386472
DW
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':
1d38083c 283 $colourarray = array(255, 207, 53);
5c386472
DW
284 break;
285 case 'green':
1d38083c 286 $colourarray = array(153, 202, 62);
5c386472
DW
287 break;
288 case 'blue':
1d38083c 289 $colourarray = array(125, 159, 211);
5c386472
DW
290 break;
291 case 'white':
292 $colourarray = array(255, 255, 255);
293 break;
294 case 'black':
1d38083c 295 $colourarray = array(51, 51, 51);
5c386472
DW
296 break;
297 default: /* Red */
298 $colour = 'red';
1d38083c 299 $colourarray = array(239, 69, 64);
5c386472
DW
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;
baf881b8
RT
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
5c386472
DW
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);
baf881b8
RT
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 }
5c386472
DW
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);
baf881b8
RT
349
350 // width should be >= min width
351 if ($w < self::MIN_ANNOTATION_WIDTH) {
352 $w = self::MIN_ANNOTATION_WIDTH;
353 }
354
5c386472
DW
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 }
baf881b8
RT
365
366 if (!empty($scalepath)) {
367 $this->PolyLine($scalepath, 'S');
368 }
5c386472
DW
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);
baf881b8
RT
377
378 // Stamp is always more than 40px, so no need to check width/height.
5c386472
DW
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) {
ac2b4ffc 396 $olddebug = error_reporting(0);
5c386472 397 $this->Output($filename, 'F');
ac2b4ffc 398 error_reporting($olddebug);
5c386472
DW
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) {
1bce3a70
RT
417 global $CFG;
418
5c386472
DW
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.
1bce3a70 442 $gsexec = \escapeshellarg($CFG->pathtogs);
1f738c8c
DW
443 $imageres = \escapeshellarg(100);
444 $imagefilearg = \escapeshellarg($imagefile);
445 $filename = \escapeshellarg($this->filename);
446 $pagenoinc = \escapeshellarg($pageno + 1);
5c386472 447 $command = "$gsexec -q -sDEVICE=png16m -dSAFER -dBATCH -dNOPAUSE -r$imageres -dFirstPage=$pagenoinc -dLastPage=$pagenoinc ".
626d8335 448 "-dDOINTERPOLATE -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -sOutputFile=$imagefilearg $filename";
5c386472 449
9092378d
DP
450 $output = null;
451 $result = exec($command, $output);
5c386472 452 if (!file_exists($imagefile)) {
9092378d
DP
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);
5c386472
DW
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) {
1bce3a70
RT
472 global $CFG;
473
5c386472
DW
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";
ac2b4ffc 478 $file->copy_content_to($tempsrc); // Copy the file.
5c386472 479
ac2b4ffc
DW
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 }
a916d557 488 $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
ac2b4ffc
DW
489
490 if ($pagecount > 0) {
491 // Page is valid and can be read by tcpdf.
492 return $tempsrc;
5c386472
DW
493 }
494
1bce3a70 495 $gsexec = \escapeshellarg($CFG->pathtogs);
1f738c8c
DW
496 $tempdstarg = \escapeshellarg($tempdst);
497 $tempsrcarg = \escapeshellarg($tempsrc);
498 $command = "$gsexec -q -sDEVICE=pdfwrite -dBATCH -dNOPAUSE -sOutputFile=$tempdstarg $tempsrcarg";
5c386472
DW
499 exec($command);
500 @unlink($tempsrc);
501 if (!file_exists($tempdst)) {
502 // Something has gone wrong in the conversion.
503 return false;
504 }
505
ac2b4ffc
DW
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 }
a916d557
EL
514 $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
515
ac2b4ffc
DW
516 if ($pagecount <= 0) {
517 @unlink($tempdst);
518 // Could not parse the converted pdf.
519 return false;
520 }
521
5c386472
DW
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 );
1bce3a70 537 $gspath = $CFG->pathtogs;
5c386472
DW
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
9f7674bd 555 if (!$generateimage) {
5c386472
DW
556 return $ret;
557 }
558
9f7674bd
AO
559 $testfile = $CFG->dirroot.'/mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf';
560 if (!file_exists($testfile)) {
561 $ret->status = self::GSPATH_NOTESTFILE;
5c386472
DW
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 }
a916d557 577 $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
5c386472
DW
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';
0c431257 592 send_file($testimage, basename($testimage), 0);
5c386472
DW
593 die();
594 }
595
596}
597