MDL-63349 assignfeedback_editpdf: Rotate submitted image automatically
[moodle.git] / mod / assign / feedback / editpdf / classes / document_services.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  * This file contains the ingest manager for the assignfeedback_editpdf plugin
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 use DOMDocument;
29 /**
30  * Functions for generating the annotated pdf.
31  *
32  * This class controls the ingest of student submission files to a normalised
33  * PDF 1.4 document with all submission files concatinated together. It also
34  * provides the functions to generate a downloadable pdf with all comments and
35  * annotations embedded.
36  * @copyright 2012 Davo Smith
37  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class document_services {
41     /** Compoment name */
42     const COMPONENT = "assignfeedback_editpdf";
43     /** File area for generated pdf */
44     const FINAL_PDF_FILEAREA = 'download';
45     /** File area for combined pdf */
46     const COMBINED_PDF_FILEAREA = 'combined';
47     /** File area for partial combined pdf */
48     const PARTIAL_PDF_FILEAREA = 'partial';
49     /** File area for importing html */
50     const IMPORT_HTML_FILEAREA = 'importhtml';
51     /** File area for page images */
52     const PAGE_IMAGE_FILEAREA = 'pages';
53     /** File area for readonly page images */
54     const PAGE_IMAGE_READONLY_FILEAREA = 'readonlypages';
55     /** File area for the stamps */
56     const STAMPS_FILEAREA = 'stamps';
57     /** Filename for combined pdf */
58     const COMBINED_PDF_FILENAME = 'combined.pdf';
59     /**  Temporary place to save JPG Image to PDF file */
60     const TMP_JPG_TO_PDF_FILEAREA = 'tmp_jpg_to_pdf';
61     /**  Temporary place to save (Automatically) Rotated JPG FILE */
62     const TMP_ROTATED_JPG_FILEAREA = 'tmp_rotated_jpg';
63     /** Hash of blank pdf */
64     const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
66     /** Base64 encoded blank pdf. This is the most reliable/fastest way to generate a blank pdf. */
67     const BLANK_PDF_BASE64 = <<<EOD
68 JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl
69 Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMFAwALJMLU31jBQsTAz1LBSKUrnCtRTyuAIVAIcdB3IK
70 ZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iago0MgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq
71 Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEg
72 MCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCA0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hb
73 MCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+
74 L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
75 cyA2IDAgUgovTWVkaWFCb3hbIDAgMCA1OTUgODQyIF0KL0tpZHNbIDEgMCBSIF0KL0NvdW50IDE+
76 PgplbmRvYmoKCjcgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0aW9u
77 WzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLUFVKQo+PgplbmRvYmoKCjggMCBvYmoK
78 PDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAw
79 NEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzQwMDJFMDAz
80 ND4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwMjI2MTMyMzE0KzA4JzAwJyk+PgplbmRvYmoKCnhyZWYK
81 MCA5CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDIyNiAwMDAwMCBuIAowMDAwMDAwMDE5IDAw
82 MDAwIG4gCjAwMDAwMDAxMzIgMDAwMDAgbiAKMDAwMDAwMDM2OCAwMDAwMCBuIAowMDAwMDAwMTUx
83 IDAwMDAwIG4gCjAwMDAwMDAxNzMgMDAwMDAgbiAKMDAwMDAwMDQ2NiAwMDAwMCBuIAowMDAwMDAw
84 NTYyIDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA5L1Jvb3QgNyAwIFIKL0luZm8gOCAwIFIKL0lE
85 IFsgPEJDN0REQUQwRDQyOTQ1OTQ2OUU4NzJCMjI1ODUyNkU4Pgo8QkM3RERBRDBENDI5NDU5NDY5
86 RTg3MkIyMjU4NTI2RTg+IF0KL0RvY0NoZWNrc3VtIC9BNTYwMEZCMDAzRURCRTg0MTNBNTk3RTZF
87 MURDQzJBRgo+PgpzdGFydHhyZWYKNzM2CiUlRU9GCg==
88 EOD;
90     /**
91      * This function will take an int or an assignment instance and
92      * return an assignment instance. It is just for convenience.
93      * @param int|\assign $assignment
94      * @return assign
95      */
96     private static function get_assignment_from_param($assignment) {
97         global $CFG;
99         require_once($CFG->dirroot . '/mod/assign/locallib.php');
101         if (!is_object($assignment)) {
102             $cm = get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST);
103             $context = \context_module::instance($cm->id);
105             $assignment = new \assign($context, null, null);
106         }
107         return $assignment;
108     }
110     /**
111      * Get a hash that will be unique and can be used in a path name.
112      * @param int|\assign $assignment
113      * @param int $userid
114      * @param int $attemptnumber (-1 means latest attempt)
115      */
116     private static function hash($assignment, $userid, $attemptnumber) {
117         if (is_object($assignment)) {
118             $assignmentid = $assignment->get_instance()->id;
119         } else {
120             $assignmentid = $assignment;
121         }
122         return sha1($assignmentid . '_' . $userid . '_' . $attemptnumber);
123     }
125     /**
126      * Use a DOM parser to accurately replace images with their alt text.
127      * @param string $html
128      * @return string New html with no image tags.
129      */
130     protected static function strip_images($html) {
131         // Load HTML and suppress any parsing errors (DOMDocument->loadHTML() does not current support HTML5 tags).
132         $dom = new DOMDocument();
133         libxml_use_internal_errors(true);
134         $dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $html);
135         libxml_clear_errors();
137         // Find all img tags.
138         if ($imgnodes = $dom->getElementsByTagName('img')) {
139             // Replace img nodes with the img alt text without overriding DOM elements.
140             for ($i = ($imgnodes->length - 1); $i >= 0; $i--) {
141                 $imgnode = $imgnodes->item($i);
142                 $alt = ($imgnode->hasAttribute('alt')) ? ' [ ' . $imgnode->getAttribute('alt') . ' ] ' : ' ';
143                 $textnode = $dom->createTextNode($alt);
145                 $imgnode->parentNode->replaceChild($textnode, $imgnode);
146             }
147         }
148         $count = 1;
149         return str_replace("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>", "", $dom->saveHTML(), $count);
150     }
152     /**
153      * This function will search for all files that can be converted
154      * and concatinated into a PDF (1.4) - for any submission plugin
155      * for this students attempt.
156      *
157      * @param int|\assign $assignment
158      * @param int $userid
159      * @param int $attemptnumber (-1 means latest attempt)
160      * @return combined_document
161      */
162     protected static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) {
163         global $USER, $DB;
165         $assignment = self::get_assignment_from_param($assignment);
167         // Capability checks.
168         if (!$assignment->can_view_submission($userid)) {
169             print_error('nopermission');
170         }
172         $files = array();
174         if ($assignment->get_instance()->teamsubmission) {
175             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
176         } else {
177             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
178         }
179         $user = $DB->get_record('user', array('id' => $userid));
181         // User has not submitted anything yet.
182         if (!$submission) {
183             return new combined_document();
184         }
186         $fs = get_file_storage();
187         $converter = new \core_files\converter();
188         // Ask each plugin for it's list of files.
189         foreach ($assignment->get_submission_plugins() as $plugin) {
190             if ($plugin->is_enabled() && $plugin->is_visible()) {
191                 $pluginfiles = $plugin->get_files($submission, $user);
192                 foreach ($pluginfiles as $filename => $file) {
193                     if ($file instanceof \stored_file) {
194                         $mimetype = $file->get_mimetype();
195                         // PDF File, no conversion required.
196                         if ($mimetype === 'application/pdf') {
197                             $files[$filename] = $file;
198                         } else if ($plugin->allow_image_conversion() && $mimetype === "image/jpeg") {
199                             // Rotates image based on the EXIF value.
200                             list ($rotateddata, $size) = $file->rotate_image();
201                             if ($rotateddata) {
202                                 $file = self::save_rotated_image_file($assignment, $userid, $attemptnumber,
203                                     $rotateddata, $filename);
204                             }
205                             // Save as PDF file if there is no available converter.
206                             if (!$converter->can_convert_format_to('jpg', 'pdf')) {
207                                 $pdffile = self::save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size);
208                                 if ($pdffile) {
209                                     $files[$filename] = $pdffile;
210                                 }
211                             }
212                         }
213                         // The file has not been converted to PDF, try to convert it to PDF.
214                         if (!isset($files[$filename])
215                             && $convertedfile = $converter->start_conversion($file, 'pdf')) {
216                             $files[$filename] = $convertedfile;
217                         }
218                     } else if ($converter->can_convert_format_to('html', 'pdf')) {
219                         // Create a tmp stored_file from this html string.
220                         $file = reset($file);
221                         // Strip image tags, because they will not be resolvable.
222                         $file = self::strip_images($file);
223                         $record = new \stdClass();
224                         $record->contextid = $assignment->get_context()->id;
225                         $record->component = 'assignfeedback_editpdf';
226                         $record->filearea = self::IMPORT_HTML_FILEAREA;
227                         $record->itemid = $submission->id;
228                         $record->filepath = '/';
229                         $record->filename = $plugin->get_type() . '-' . $filename;
231                         $htmlfile = $fs->get_file($record->contextid,
232                                 $record->component,
233                                 $record->filearea,
234                                 $record->itemid,
235                                 $record->filepath,
236                                 $record->filename);
238                         $newhash = sha1($file);
240                         // If the file exists, and the content hash doesn't match, remove it.
241                         if ($htmlfile && $newhash !== $htmlfile->get_contenthash()) {
242                             $htmlfile->delete();
243                             $htmlfile = false;
244                         }
246                         // If the file doesn't exist, or if it was removed above, create a new one.
247                         if (!$htmlfile) {
248                             $htmlfile = $fs->create_file_from_string($record, $file);
249                         }
251                         $convertedfile = $converter->start_conversion($htmlfile, 'pdf');
253                         if ($convertedfile) {
254                             $files[$filename] = $convertedfile;
255                         }
256                     }
257                 }
258             }
259         }
260         $combineddocument = new combined_document();
261         $combineddocument->set_source_files($files);
263         return $combineddocument;
264     }
266     /**
267      * Fetch the current combined document ready for state checking.
268      *
269      * @param int|\assign $assignment
270      * @param int $userid
271      * @param int $attemptnumber (-1 means latest attempt)
272      * @return combined_document
273      */
274     public static function get_combined_document_for_attempt($assignment, $userid, $attemptnumber) {
275         global $USER, $DB;
277         $assignment = self::get_assignment_from_param($assignment);
279         // Capability checks.
280         if (!$assignment->can_view_submission($userid)) {
281             print_error('nopermission');
282         }
284         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
285         if ($assignment->get_instance()->teamsubmission) {
286             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
287         } else {
288             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
289         }
291         $contextid = $assignment->get_context()->id;
292         $component = 'assignfeedback_editpdf';
293         $filearea = self::COMBINED_PDF_FILEAREA;
294         $partialfilearea = self::PARTIAL_PDF_FILEAREA;
295         $itemid = $grade->id;
296         $filepath = '/';
297         $filename = self::COMBINED_PDF_FILENAME;
298         $fs = get_file_storage();
300         $partialpdf = $fs->get_file($contextid, $component, $partialfilearea, $itemid, $filepath, $filename);
301         if (!empty($partialpdf)) {
302             $combinedpdf = $partialpdf;
303         } else {
304             $combinedpdf = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename);
305         }
307         if ($combinedpdf && $submission) {
308             if ($combinedpdf->get_timemodified() < $submission->timemodified) {
309                 // The submission has been updated since the PDF was generated.
310                 $combinedpdf = false;
311             } else if ($combinedpdf->get_contenthash() == self::BLANK_PDF_HASH) {
312                 // The PDF is for a blank page.
313                 $combinedpdf = false;
314             }
315         }
317         if (empty($combinedpdf)) {
318             // The combined PDF does not exist yet. Return the list of files to be combined.
319             return self::list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber);
320         } else {
321             // The combined PDF aleady exists. Return it in a new combined_document object.
322             $combineddocument = new combined_document();
323             return $combineddocument->set_combined_file($combinedpdf);
324         }
325     }
327     /**
328      * This function return the combined pdf for all valid submission files.
329      *
330      * @param int|\assign $assignment
331      * @param int $userid
332      * @param int $attemptnumber (-1 means latest attempt)
333      * @return combined_document
334      */
335     public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) {
336         $document = self::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
338         if ($document->get_status() === combined_document::STATUS_COMPLETE) {
339             // The combined document is already ready.
340             return $document;
341         } else {
342             // Attempt to combined the files in the document.
343             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
344             $document->combine_files($assignment->get_context()->id, $grade->id);
345             return $document;
346         }
347     }
349     /**
350      * This function will return the number of pages of a pdf.
351      *
352      * @param int|\assign $assignment
353      * @param int $userid
354      * @param int $attemptnumber (-1 means latest attempt)
355      * @param bool $readonly When true we get the number of pages for the readonly version.
356      * @return int number of pages
357      */
358     public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
359         global $CFG;
361         require_once($CFG->libdir . '/pdflib.php');
363         $assignment = self::get_assignment_from_param($assignment);
365         if (!$assignment->can_view_submission($userid)) {
366             print_error('nopermission');
367         }
369         // When in readonly we can return the number of images in the DB because they should already exist,
370         // if for some reason they do not, then we proceed as for the normal version.
371         if ($readonly) {
372             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
373             $fs = get_file_storage();
374             $files = $fs->get_directory_files($assignment->get_context()->id, 'assignfeedback_editpdf',
375                 self::PAGE_IMAGE_READONLY_FILEAREA, $grade->id, '/');
376             $pagecount = count($files);
377             if ($pagecount > 0) {
378                 return $pagecount;
379             }
380         }
382         // Get a combined pdf file from all submitted pdf files.
383         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
384         return $document->get_page_count();
385     }
387     /**
388      * This function will generate and return a list of the page images from a pdf.
389      * @param int|\assign $assignment
390      * @param int $userid
391      * @param int $attemptnumber (-1 means latest attempt)
392      * @param bool $resetrotation check if need to reset page rotation information
393      * @return array(stored_file)
394      */
395     protected static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation = true) {
396         global $CFG;
398         require_once($CFG->libdir . '/pdflib.php');
400         $assignment = self::get_assignment_from_param($assignment);
402         if (!$assignment->can_view_submission($userid)) {
403             print_error('nopermission');
404         }
406         // Need to generate the page images - first get a combined pdf.
407         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
409         $status = $document->get_status();
410         if ($status === combined_document::STATUS_FAILED) {
411             print_error('Could not generate combined pdf.');
412         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
413             // The conversion is still in progress.
414             return [];
415         }
417         $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber));
418         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
420         $document->get_combined_file()->copy_content_to($combined); // Copy the file.
422         $pdf = new pdf();
424         $pdf->set_image_folder($tmpdir);
425         $pagecount = $pdf->set_pdf($combined);
427         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
429         $record = new \stdClass();
430         $record->contextid = $assignment->get_context()->id;
431         $record->component = 'assignfeedback_editpdf';
432         $record->filearea = self::PAGE_IMAGE_FILEAREA;
433         $record->itemid = $grade->id;
434         $record->filepath = '/';
435         $fs = get_file_storage();
437         // Remove the existing content of the filearea.
438         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
440         $files = array();
441         for ($i = 0; $i < $pagecount; $i++) {
442             try {
443                 $image = $pdf->get_image($i);
444                 if (!$resetrotation) {
445                     $pagerotation = page_editor::get_page_rotation($grade->id, $i);
446                     $degree = !empty($pagerotation) ? $pagerotation->degree : 0;
447                     if ($degree != 0) {
448                         $filepath = $tmpdir . '/' . $image;
449                         $imageresource = imagecreatefrompng($filepath);
450                         $content = imagerotate($imageresource, $degree, 0);
451                         imagepng($content, $filepath);
452                     }
453                 }
454             } catch (\moodle_exception $e) {
455                 // We catch only moodle_exception here as other exceptions indicate issue with setup not the pdf.
456                 $image = pdf::get_error_image($tmpdir, $i);
457             }
458             $record->filename = basename($image);
459             $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
460             @unlink($tmpdir . '/' . $image);
461             // Set page rotation default value.
462             if (!empty($files[$i])) {
463                 if ($resetrotation) {
464                     page_editor::set_page_rotation($grade->id, $i, false, $files[$i]->get_pathnamehash());
465                 }
466             }
467         }
468         $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
470         @unlink($combined);
471         @rmdir($tmpdir);
473         return $files;
474     }
476     /**
477      * This function returns a list of the page images from a pdf.
478      *
479      * The readonly version is different than the normal one. The readonly version contains a copy
480      * of the pages in the state they were when the PDF was annotated, by doing so we prevent the
481      * the pages that are displayed to change as soon as the submission changes.
482      *
483      * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible
484      * that we do not find any readonly version of the pages. In that case, we will get the normal
485      * pages and copy them to the readonly area. This ensures that the pages will remain in that
486      * state until the submission is updated. When the normal files do not exist, we throw an exception
487      * because the readonly pages should only ever be displayed after a teacher has annotated the PDF,
488      * they would not exist until they do.
489      *
490      * @param int|\assign $assignment
491      * @param int $userid
492      * @param int $attemptnumber (-1 means latest attempt)
493      * @param bool $readonly If true, then we are requesting the readonly version.
494      * @return array(stored_file)
495      */
496     public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
497         global $DB;
499         $assignment = self::get_assignment_from_param($assignment);
501         if (!$assignment->can_view_submission($userid)) {
502             print_error('nopermission');
503         }
505         if ($assignment->get_instance()->teamsubmission) {
506             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
507         } else {
508             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
509         }
510         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
512         $contextid = $assignment->get_context()->id;
513         $component = 'assignfeedback_editpdf';
514         $itemid = $grade->id;
515         $filepath = '/';
516         $filearea = self::PAGE_IMAGE_FILEAREA;
518         $fs = get_file_storage();
520         // If we are after the readonly pages...
521         if ($readonly) {
522             $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
523             if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
524                 // We have a problem here, we were supposed to find the files.
525                 // Attempt to re-generate the pages from the combined images.
526                 self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
527                 self::copy_pages_to_readonly_area($assignment, $grade);
528             }
529         }
531         $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
533         $pages = array();
534         $resetrotation = false;
535         if (!empty($files)) {
536             $first = reset($files);
537             $pagemodified = $first->get_timemodified();
538             // Check that we don't just have a single blank page. The hash of a blank page image can vary with
539             // the version of ghostscript used, so we need to examine the combined pdf it was generated from.
540             $blankpage = false;
541             if (!$readonly && count($files) == 1) {
542                 $pdfarea = self::COMBINED_PDF_FILEAREA;
543                 $pdfname = self::COMBINED_PDF_FILENAME;
544                 if ($pdf = $fs->get_file($contextid, $component, $pdfarea, $itemid, $filepath, $pdfname)) {
545                     // The combined pdf may have a different hash if it has been regenerated since the page
546                     // image was created. However if this is the case the page image will be stale anyway.
547                     if ($pdf->get_contenthash() == self::BLANK_PDF_HASH || $pagemodified < $pdf->get_timemodified()) {
548                         $blankpage = true;
549                     }
550                 }
551             }
552             if (!$readonly && ($pagemodified < $submission->timemodified || $blankpage)) {
553                 // Image files are stale, we need to regenerate them, except in readonly mode.
554                 // We also need to remove the draft annotations and comments associated with this attempt.
555                 $fs->delete_area_files($contextid, $component, $filearea, $itemid);
556                 page_editor::delete_draft_content($itemid);
557                 $files = array();
558                 $resetrotation = true;
559             } else {
561                 // Need to reorder the files following their name.
562                 // because get_directory_files() return a different order than generate_page_images_for_attempt().
563                 foreach ($files as $file) {
564                     // Extract the page number from the file name image_pageXXXX.png.
565                     preg_match('/page([\d]+)\./', $file->get_filename(), $matches);
566                     if (empty($matches) or !is_numeric($matches[1])) {
567                         throw new \coding_exception("'" . $file->get_filename()
568                             . "' file hasn't the expected format filename: image_pageXXXX.png.");
569                     }
570                     $pagenumber = (int)$matches[1];
572                     // Save the page in the ordered array.
573                     $pages[$pagenumber] = $file;
574                 }
575                 ksort($pages);
576             }
577         }
579         if (empty($pages)) {
580             if ($readonly) {
581                 // This should never happen, there should be a version of the pages available
582                 // whenever we are requesting the readonly version.
583                 throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
584             }
585             $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
586         }
588         return $pages;
589     }
591     /**
592      * This function returns sensible filename for a feedback file.
593      * @param int|\assign $assignment
594      * @param int $userid
595      * @param int $attemptnumber (-1 means latest attempt)
596      * @return string
597      */
598     protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
599         global $DB;
601         $assignment = self::get_assignment_from_param($assignment);
603         $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
604         $groupname = '';
605         if ($groupmode) {
606             $groupid = groups_get_activity_group($assignment->get_course_module(), true);
607             $groupname = groups_get_group_name($groupid).'-';
608         }
609         if ($groupname == '-') {
610             $groupname = '';
611         }
612         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
613         $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
615         if ($assignment->is_blind_marking()) {
616             $prefix = $groupname . get_string('participant', 'assign');
617             $prefix = str_replace('_', ' ', $prefix);
618             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
619         } else {
620             $prefix = $groupname . fullname($user);
621             $prefix = str_replace('_', ' ', $prefix);
622             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
623         }
624         $prefix .= $grade->attemptnumber;
626         return $prefix . '.pdf';
627     }
629     /**
630      * This function takes the combined pdf and embeds all the comments and annotations.
631      *
632      * This also moves the annotations and comments from drafts to not drafts. And it will
633      * copy all the images stored to the readonly area, so that they can be viewed online, and
634      * not be overwritten when a new submission is sent.
635      *
636      * @param int|\assign $assignment
637      * @param int $userid
638      * @param int $attemptnumber (-1 means latest attempt)
639      * @return stored_file
640      */
641     public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
643         $assignment = self::get_assignment_from_param($assignment);
645         if (!$assignment->can_view_submission($userid)) {
646             print_error('nopermission');
647         }
648         if (!$assignment->can_grade()) {
649             print_error('nopermission');
650         }
652         // Need to generate the page images - first get a combined pdf.
653         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
655         $status = $document->get_status();
656         if ($status === combined_document::STATUS_FAILED) {
657             print_error('Could not generate combined pdf.');
658         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
659             // The conversion is still in progress.
660             return false;
661         }
663         $file = $document->get_combined_file();
665         $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
666         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
667         $file->copy_content_to($combined); // Copy the file.
669         $pdf = new pdf();
671         $fs = get_file_storage();
672         $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
673         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
674         // Copy any new stamps to this instance.
675         if ($files = $fs->get_area_files($assignment->get_context()->id,
676                                          'assignfeedback_editpdf',
677                                          'stamps',
678                                          $grade->id,
679                                          "filename",
680                                          false)) {
681             foreach ($files as $file) {
682                 $filename = $stamptmpdir . '/' . $file->get_filename();
683                 $file->copy_content_to($filename); // Copy the file.
684             }
685         }
687         $pagecount = $pdf->set_pdf($combined);
688         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
689         page_editor::release_drafts($grade->id);
691         $allcomments = array();
693         for ($i = 0; $i < $pagecount; $i++) {
694             $pagerotation = page_editor::get_page_rotation($grade->id, $i);
695             $pagemargin = $pdf->getBreakMargin();
696             $autopagebreak = $pdf->getAutoPageBreak();
697             if (empty($pagerotation) || !$pagerotation->isrotated) {
698                 $pdf->copy_page();
699             } else {
700                 $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
701                 if (empty($rotatedimagefile)) {
702                     $pdf->copy_page();
703                 } else {
704                     $pdf->add_image_page($rotatedimagefile);
705                 }
706             }
708             $comments = page_editor::get_comments($grade->id, $i, false);
709             $annotations = page_editor::get_annotations($grade->id, $i, false);
711             if (!empty($comments)) {
712                 $allcomments[$i] = $comments;
713             }
715             foreach ($annotations as $annotation) {
716                 $pdf->add_annotation($annotation->x,
717                                      $annotation->y,
718                                      $annotation->endx,
719                                      $annotation->endy,
720                                      $annotation->colour,
721                                      $annotation->type,
722                                      $annotation->path,
723                                      $stamptmpdir);
724             }
725             $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
726             $pdf->setPageMark();
727         }
729         if (!empty($allcomments)) {
730             // Append all comments to the end of the document.
731             $links = $pdf->append_comments($allcomments);
732             // Add the comment markers with links.
733             foreach ($allcomments as $pageno => $comments) {
734                 foreach ($comments as $index => $comment) {
735                     $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
736                             $comment->colour);
737                 }
738             }
739         }
741         fulldelete($stamptmpdir);
743         $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
744         $filename = clean_param($filename, PARAM_FILE);
746         $generatedpdf = $tmpdir . '/' . $filename;
747         $pdf->save_pdf($generatedpdf);
749         $record = new \stdClass();
751         $record->contextid = $assignment->get_context()->id;
752         $record->component = 'assignfeedback_editpdf';
753         $record->filearea = self::FINAL_PDF_FILEAREA;
754         $record->itemid = $grade->id;
755         $record->filepath = '/';
756         $record->filename = $filename;
758         // Only keep one current version of the generated pdf.
759         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
761         $file = $fs->create_file_from_pathname($record, $generatedpdf);
763         // Cleanup.
764         @unlink($generatedpdf);
765         @unlink($combined);
766         @rmdir($tmpdir);
768         self::copy_pages_to_readonly_area($assignment, $grade);
770         return $file;
771     }
773     /**
774      * Copy the pages image to the readonly area.
775      *
776      * @param int|\assign $assignment The assignment.
777      * @param \stdClass $grade The grade record.
778      * @return void
779      */
780     public static function copy_pages_to_readonly_area($assignment, $grade) {
781         $fs = get_file_storage();
782         $assignment = self::get_assignment_from_param($assignment);
783         $contextid = $assignment->get_context()->id;
784         $component = 'assignfeedback_editpdf';
785         $itemid = $grade->id;
787         // Get all the pages.
788         $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
789         if (empty($originalfiles)) {
790             // Nothing to do here...
791             return;
792         }
794         // Delete the old readonly files.
795         $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
797         // Do the copying.
798         foreach ($originalfiles as $originalfile) {
799             $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
800         }
801     }
803     /**
804      * This function returns the generated pdf (if it exists).
805      * @param int|\assign $assignment
806      * @param int $userid
807      * @param int $attemptnumber (-1 means latest attempt)
808      * @return stored_file
809      */
810     public static function get_feedback_document($assignment, $userid, $attemptnumber) {
812         $assignment = self::get_assignment_from_param($assignment);
814         if (!$assignment->can_view_submission($userid)) {
815             print_error('nopermission');
816         }
818         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
820         $contextid = $assignment->get_context()->id;
821         $component = 'assignfeedback_editpdf';
822         $filearea = self::FINAL_PDF_FILEAREA;
823         $itemid = $grade->id;
824         $filepath = '/';
826         $fs = get_file_storage();
827         $files = $fs->get_area_files($contextid,
828                                      $component,
829                                      $filearea,
830                                      $itemid,
831                                      "itemid, filepath, filename",
832                                      false);
833         if ($files) {
834             return reset($files);
835         }
836         return false;
837     }
839     /**
840      * This function deletes the generated pdf for a student.
841      * @param int|\assign $assignment
842      * @param int $userid
843      * @param int $attemptnumber (-1 means latest attempt)
844      * @return bool
845      */
846     public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
848         $assignment = self::get_assignment_from_param($assignment);
850         if (!$assignment->can_view_submission($userid)) {
851             print_error('nopermission');
852         }
853         if (!$assignment->can_grade()) {
854             print_error('nopermission');
855         }
857         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
859         $contextid = $assignment->get_context()->id;
860         $component = 'assignfeedback_editpdf';
861         $filearea = self::FINAL_PDF_FILEAREA;
862         $itemid = $grade->id;
864         $fs = get_file_storage();
865         return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
866     }
868     /**
869      * Get All files in a File area
870      * @param int|\assign $assignment Assignment
871      * @param int $userid User ID
872      * @param int $attemptnumber Attempt Number
873      * @param string $filearea File Area
874      * @param string $filepath File Path
875      * @return array
876      */
877     private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
878         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
879         $itemid = $grade->id;
880         $contextid = $assignment->get_context()->id;
881         $component = self::COMPONENT;
882         $fs = get_file_storage();
883         $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
884         return $files;
885     }
887     /**
888      * Save file.
889      * @param int|\assign $assignment Assignment
890      * @param int $userid User ID
891      * @param int $attemptnumber Attempt Number
892      * @param string $filearea File Area
893      * @param string $newfilepath File Path
894      * @param string $storedfilepath stored file path
895      * @return \stored_file
896      * @throws \file_exception
897      * @throws \stored_file_creation_exception
898      */
899     private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
900         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
901         $itemid = $grade->id;
902         $contextid = $assignment->get_context()->id;
904         $record = new \stdClass();
905         $record->contextid = $contextid;
906         $record->component = self::COMPONENT;
907         $record->filearea = $filearea;
908         $record->itemid = $itemid;
909         $record->filepath = $storedfilepath;
910         $record->filename = basename($newfilepath);
912         $fs = get_file_storage();
914         $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
915             $record->itemid, $record->filepath, $record->filename);
917         $newhash = sha1($newfilepath);
919         // Delete old file if exists.
920         if ($oldfile && $newhash !== $oldfile->get_contenthash()) {
921             $oldfile->delete();
922         }
924         return $fs->create_file_from_pathname($record, $newfilepath);
925     }
927     /**
928      * This function rotate a page, and mark the page as rotated.
929      * @param int|\assign $assignment Assignment
930      * @param int $userid User ID
931      * @param int $attemptnumber Attempt Number
932      * @param int $index Index of Current Page
933      * @param bool $rotateleft To determine whether the page is rotated left or right.
934      * @return null|\stored_file return rotated File
935      * @throws \coding_exception
936      * @throws \file_exception
937      * @throws \moodle_exception
938      * @throws \stored_file_creation_exception
939      */
940     public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
941         $assignment = self::get_assignment_from_param($assignment);
942         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
943         // Check permission.
944         if (!$assignment->can_view_submission($userid)) {
945             print_error('nopermission');
946         }
948         $filearea = self::PAGE_IMAGE_FILEAREA;
949         $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
950         if (!empty($files)) {
951             foreach ($files as $file) {
952                 preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
953                 if (empty($matches) or !is_numeric($matches[1])) {
954                     throw new \coding_exception("'" . $file->get_filename()
955                         . "' file hasn't the expected format filename: image_pageXXXX.png.");
956                 }
957                 $pagenumber = (int)$matches[1];
959                 if ($pagenumber == $index) {
960                     $source = imagecreatefromstring($file->get_content());
961                     $pagerotation = page_editor::get_page_rotation($grade->id, $index);
962                     $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
963                     if ($rotateleft) {
964                         $content = imagerotate($source, 90, 0);
965                         $degree = ($degree + 90) % 360;
966                     } else {
967                         $content = imagerotate($source, -90, 0);
968                         $degree = ($degree - 90) % 360;
969                     }
970                     $filename = $matches[0].'png';
971                     $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/'
972                         . self::hash($assignment, $userid, $attemptnumber));
973                     $tempfile = $tmpdir . '/' . time() . '_' . $filename;
974                     imagepng($content, $tempfile);
976                     $filearea = self::PAGE_IMAGE_FILEAREA;
977                     $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
979                     unlink($tempfile);
980                     rmdir($tmpdir);
981                     imagedestroy($source);
982                     imagedestroy($content);
983                     $file->delete();
984                     if (!empty($newfile)) {
985                         page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
986                     }
987                     return $newfile;
988                 }
989             }
990         }
991         return null;
992     }
994     /**
995      * Convert jpg file to pdf file
996      * @param int|\assign $assignment Assignment
997      * @param int $userid User ID
998      * @param int $attemptnumber Attempt Number
999      * @param \stored_file $file file to save
1000      * @param null|array $size size of image
1001      * @return \stored_file
1002      * @throws \file_exception
1003      * @throws \stored_file_creation_exception
1004      */
1005     private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
1006         // Temporary file.
1007         $filename = $file->get_filename();
1008         $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1009             . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
1010             . self::hash($assignment, $userid, $attemptnumber));
1011         $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
1012         // Determine orientation.
1013         $orientation = 'P';
1014         if (!empty($size['width']) && !empty($size['height'])) {
1015             if ($size['width'] > $size['height']) {
1016                 $orientation = 'L';
1017             }
1018         }
1019         // Save JPG image to PDF file.
1020         $pdf = new pdf();
1021         $pdf->SetHeaderMargin(0);
1022         $pdf->SetFooterMargin(0);
1023         $pdf->SetMargins(0, 0, 0, true);
1024         $pdf->setPrintFooter(false);
1025         $pdf->setPrintHeader(false);
1026         $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
1027         $pdf->AddPage($orientation);
1028         $pdf->SetAutoPageBreak(false);
1029         // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
1030         if ($orientation == 'P') {
1031             $pdf->Image('@' . $file->get_content(), 0, 0, 210);
1032         } else {
1033             $pdf->Image('@' . $file->get_content(), 0, 0, 297);
1034         }
1035         $pdf->setPageMark();
1036         $pdf->save_pdf($tempfile);
1037         $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
1038         $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1039         if (file_exists($tempfile)) {
1040             unlink($tempfile);
1041             rmdir($tmpdir);
1042         }
1043         return $pdffile;
1044     }
1046     /**
1047      * Save rotated image data to file.
1048      * @param int|\assign $assignment Assignment
1049      * @param int $userid User ID
1050      * @param int $attemptnumber Attempt Number
1051      * @param resource $rotateddata image data to save
1052      * @param string $filename name of the image file
1053      * @return \stored_file
1054      * @throws \file_exception
1055      * @throws \stored_file_creation_exception
1056      */
1057     private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
1058         $filearea = self::TMP_ROTATED_JPG_FILEAREA;
1059         $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1060             . $filearea . DIRECTORY_SEPARATOR
1061             . self::hash($assignment, $userid, $attemptnumber));
1062         $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
1063         imagejpeg($rotateddata, $tempfile);
1064         $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1065         if (file_exists($tempfile)) {
1066             unlink($tempfile);
1067             rmdir($tmpdir);
1068         }
1069         return $newfile;
1070     }