MDL-61537 assignfeedback_editpdf: Rotate PDF page
[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     /** Hash of blank pdf */
60     const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
62     /** Base64 encoded blank pdf. This is the most reliable/fastest way to generate a blank pdf. */
63     const BLANK_PDF_BASE64 = <<<EOD
64 JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl
65 Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMFAwALJMLU31jBQsTAz1LBSKUrnCtRTyuAIVAIcdB3IK
66 ZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iago0MgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq
67 Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEg
68 MCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCA0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hb
69 MCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+
70 L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
71 cyA2IDAgUgovTWVkaWFCb3hbIDAgMCA1OTUgODQyIF0KL0tpZHNbIDEgMCBSIF0KL0NvdW50IDE+
72 PgplbmRvYmoKCjcgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0aW9u
73 WzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLUFVKQo+PgplbmRvYmoKCjggMCBvYmoK
74 PDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAw
75 NEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzQwMDJFMDAz
76 ND4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwMjI2MTMyMzE0KzA4JzAwJyk+PgplbmRvYmoKCnhyZWYK
77 MCA5CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDIyNiAwMDAwMCBuIAowMDAwMDAwMDE5IDAw
78 MDAwIG4gCjAwMDAwMDAxMzIgMDAwMDAgbiAKMDAwMDAwMDM2OCAwMDAwMCBuIAowMDAwMDAwMTUx
79 IDAwMDAwIG4gCjAwMDAwMDAxNzMgMDAwMDAgbiAKMDAwMDAwMDQ2NiAwMDAwMCBuIAowMDAwMDAw
80 NTYyIDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA5L1Jvb3QgNyAwIFIKL0luZm8gOCAwIFIKL0lE
81 IFsgPEJDN0REQUQwRDQyOTQ1OTQ2OUU4NzJCMjI1ODUyNkU4Pgo8QkM3RERBRDBENDI5NDU5NDY5
82 RTg3MkIyMjU4NTI2RTg+IF0KL0RvY0NoZWNrc3VtIC9BNTYwMEZCMDAzRURCRTg0MTNBNTk3RTZF
83 MURDQzJBRgo+PgpzdGFydHhyZWYKNzM2CiUlRU9GCg==
84 EOD;
86     /**
87      * This function will take an int or an assignment instance and
88      * return an assignment instance. It is just for convenience.
89      * @param int|\assign $assignment
90      * @return assign
91      */
92     private static function get_assignment_from_param($assignment) {
93         global $CFG;
95         require_once($CFG->dirroot . '/mod/assign/locallib.php');
97         if (!is_object($assignment)) {
98             $cm = get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST);
99             $context = \context_module::instance($cm->id);
101             $assignment = new \assign($context, null, null);
102         }
103         return $assignment;
104     }
106     /**
107      * Get a hash that will be unique and can be used in a path name.
108      * @param int|\assign $assignment
109      * @param int $userid
110      * @param int $attemptnumber (-1 means latest attempt)
111      */
112     private static function hash($assignment, $userid, $attemptnumber) {
113         if (is_object($assignment)) {
114             $assignmentid = $assignment->get_instance()->id;
115         } else {
116             $assignmentid = $assignment;
117         }
118         return sha1($assignmentid . '_' . $userid . '_' . $attemptnumber);
119     }
121     /**
122      * Use a DOM parser to accurately replace images with their alt text.
123      * @param string $html
124      * @return string New html with no image tags.
125      */
126     protected static function strip_images($html) {
127         // Load HTML and suppress any parsing errors (DOMDocument->loadHTML() does not current support HTML5 tags).
128         $dom = new DOMDocument();
129         libxml_use_internal_errors(true);
130         $dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $html);
131         libxml_clear_errors();
133         // Find all img tags.
134         if ($imgnodes = $dom->getElementsByTagName('img')) {
135             // Replace img nodes with the img alt text without overriding DOM elements.
136             for ($i = ($imgnodes->length - 1); $i >= 0; $i--) {
137                 $imgnode = $imgnodes->item($i);
138                 $alt = ($imgnode->hasAttribute('alt')) ? ' [ ' . $imgnode->getAttribute('alt') . ' ] ' : ' ';
139                 $textnode = $dom->createTextNode($alt);
141                 $imgnode->parentNode->replaceChild($textnode, $imgnode);
142             }
143         }
144         $count = 1;
145         return str_replace("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>", "", $dom->saveHTML(), $count);
146     }
148     /**
149      * This function will search for all files that can be converted
150      * and concatinated into a PDF (1.4) - for any submission plugin
151      * for this students attempt.
152      *
153      * @param int|\assign $assignment
154      * @param int $userid
155      * @param int $attemptnumber (-1 means latest attempt)
156      * @return combined_document
157      */
158     protected static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) {
159         global $USER, $DB;
161         $assignment = self::get_assignment_from_param($assignment);
163         // Capability checks.
164         if (!$assignment->can_view_submission($userid)) {
165             print_error('nopermission');
166         }
168         $files = array();
170         if ($assignment->get_instance()->teamsubmission) {
171             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
172         } else {
173             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
174         }
175         $user = $DB->get_record('user', array('id' => $userid));
177         // User has not submitted anything yet.
178         if (!$submission) {
179             return new combined_document();
180         }
182         $fs = get_file_storage();
183         $converter = new \core_files\converter();
184         // Ask each plugin for it's list of files.
185         foreach ($assignment->get_submission_plugins() as $plugin) {
186             if ($plugin->is_enabled() && $plugin->is_visible()) {
187                 $pluginfiles = $plugin->get_files($submission, $user);
188                 foreach ($pluginfiles as $filename => $file) {
189                     if ($file instanceof \stored_file) {
190                         if ($file->get_mimetype() === 'application/pdf') {
191                             $files[$filename] = $file;
192                         } else if ($convertedfile = $converter->start_conversion($file, 'pdf')) {
193                             $files[$filename] = $convertedfile;
194                         }
195                     } else if ($converter->can_convert_format_to('html', 'pdf')) {
196                         // Create a tmp stored_file from this html string.
197                         $file = reset($file);
198                         // Strip image tags, because they will not be resolvable.
199                         $file = self::strip_images($file);
200                         $record = new \stdClass();
201                         $record->contextid = $assignment->get_context()->id;
202                         $record->component = 'assignfeedback_editpdf';
203                         $record->filearea = self::IMPORT_HTML_FILEAREA;
204                         $record->itemid = $submission->id;
205                         $record->filepath = '/';
206                         $record->filename = $plugin->get_type() . '-' . $filename;
208                         $htmlfile = $fs->get_file($record->contextid,
209                                 $record->component,
210                                 $record->filearea,
211                                 $record->itemid,
212                                 $record->filepath,
213                                 $record->filename);
215                         $newhash = sha1($file);
217                         // If the file exists, and the content hash doesn't match, remove it.
218                         if ($htmlfile && $newhash !== $htmlfile->get_contenthash()) {
219                             $htmlfile->delete();
220                             $htmlfile = false;
221                         }
223                         // If the file doesn't exist, or if it was removed above, create a new one.
224                         if (!$htmlfile) {
225                             $htmlfile = $fs->create_file_from_string($record, $file);
226                         }
228                         $convertedfile = $converter->start_conversion($htmlfile, 'pdf');
230                         if ($convertedfile) {
231                             $files[$filename] = $convertedfile;
232                         }
233                     }
234                 }
235             }
236         }
237         $combineddocument = new combined_document();
238         $combineddocument->set_source_files($files);
240         return $combineddocument;
241     }
243     /**
244      * Fetch the current combined document ready for state checking.
245      *
246      * @param int|\assign $assignment
247      * @param int $userid
248      * @param int $attemptnumber (-1 means latest attempt)
249      * @return combined_document
250      */
251     public static function get_combined_document_for_attempt($assignment, $userid, $attemptnumber) {
252         global $USER, $DB;
254         $assignment = self::get_assignment_from_param($assignment);
256         // Capability checks.
257         if (!$assignment->can_view_submission($userid)) {
258             print_error('nopermission');
259         }
261         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
262         if ($assignment->get_instance()->teamsubmission) {
263             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
264         } else {
265             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
266         }
268         $contextid = $assignment->get_context()->id;
269         $component = 'assignfeedback_editpdf';
270         $filearea = self::COMBINED_PDF_FILEAREA;
271         $partialfilearea = self::PARTIAL_PDF_FILEAREA;
272         $itemid = $grade->id;
273         $filepath = '/';
274         $filename = self::COMBINED_PDF_FILENAME;
275         $fs = get_file_storage();
277         $partialpdf = $fs->get_file($contextid, $component, $partialfilearea, $itemid, $filepath, $filename);
278         if (!empty($partialpdf)) {
279             $combinedpdf = $partialpdf;
280         } else {
281             $combinedpdf = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename);
282         }
284         if ($combinedpdf && $submission) {
285             if ($combinedpdf->get_timemodified() < $submission->timemodified) {
286                 // The submission has been updated since the PDF was generated.
287                 $combinedpdf = false;
288             } else if ($combinedpdf->get_contenthash() == self::BLANK_PDF_HASH) {
289                 // The PDF is for a blank page.
290                 $combinedpdf = false;
291             }
292         }
294         if (empty($combinedpdf)) {
295             // The combined PDF does not exist yet. Return the list of files to be combined.
296             return self::list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber);
297         } else {
298             // The combined PDF aleady exists. Return it in a new combined_document object.
299             $combineddocument = new combined_document();
300             return $combineddocument->set_combined_file($combinedpdf);
301         }
302     }
304     /**
305      * This function return the combined pdf for all valid submission files.
306      *
307      * @param int|\assign $assignment
308      * @param int $userid
309      * @param int $attemptnumber (-1 means latest attempt)
310      * @return combined_document
311      */
312     public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) {
313         $document = self::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
315         if ($document->get_status() === combined_document::STATUS_COMPLETE) {
316             // The combined document is already ready.
317             return $document;
318         } else {
319             // Attempt to combined the files in the document.
320             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
321             $document->combine_files($assignment->get_context()->id, $grade->id);
322             return $document;
323         }
324     }
326     /**
327      * This function will return the number of pages of a pdf.
328      *
329      * @param int|\assign $assignment
330      * @param int $userid
331      * @param int $attemptnumber (-1 means latest attempt)
332      * @param bool $readonly When true we get the number of pages for the readonly version.
333      * @return int number of pages
334      */
335     public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
336         global $CFG;
338         require_once($CFG->libdir . '/pdflib.php');
340         $assignment = self::get_assignment_from_param($assignment);
342         if (!$assignment->can_view_submission($userid)) {
343             print_error('nopermission');
344         }
346         // When in readonly we can return the number of images in the DB because they should already exist,
347         // if for some reason they do not, then we proceed as for the normal version.
348         if ($readonly) {
349             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
350             $fs = get_file_storage();
351             $files = $fs->get_directory_files($assignment->get_context()->id, 'assignfeedback_editpdf',
352                 self::PAGE_IMAGE_READONLY_FILEAREA, $grade->id, '/');
353             $pagecount = count($files);
354             if ($pagecount > 0) {
355                 return $pagecount;
356             }
357         }
359         // Get a combined pdf file from all submitted pdf files.
360         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
361         return $document->get_page_count();
362     }
364     /**
365      * This function will generate and return a list of the page images from a pdf.
366      * @param int|\assign $assignment
367      * @param int $userid
368      * @param int $attemptnumber (-1 means latest attempt)
369      * @param bool $resetrotation check if need to reset page rotation information
370      * @return array(stored_file)
371      */
372     protected static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation = true) {
373         global $CFG;
375         require_once($CFG->libdir . '/pdflib.php');
377         $assignment = self::get_assignment_from_param($assignment);
379         if (!$assignment->can_view_submission($userid)) {
380             print_error('nopermission');
381         }
383         // Need to generate the page images - first get a combined pdf.
384         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
386         $status = $document->get_status();
387         if ($status === combined_document::STATUS_FAILED) {
388             print_error('Could not generate combined pdf.');
389         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
390             // The conversion is still in progress.
391             return [];
392         }
394         $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber));
395         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
397         $document->get_combined_file()->copy_content_to($combined); // Copy the file.
399         $pdf = new pdf();
401         $pdf->set_image_folder($tmpdir);
402         $pagecount = $pdf->set_pdf($combined);
404         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
406         $record = new \stdClass();
407         $record->contextid = $assignment->get_context()->id;
408         $record->component = 'assignfeedback_editpdf';
409         $record->filearea = self::PAGE_IMAGE_FILEAREA;
410         $record->itemid = $grade->id;
411         $record->filepath = '/';
412         $fs = get_file_storage();
414         // Remove the existing content of the filearea.
415         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
417         $files = array();
418         for ($i = 0; $i < $pagecount; $i++) {
419             try {
420                 $image = $pdf->get_image($i);
421                 if (!$resetrotation) {
422                     $pagerotation = page_editor::get_page_rotation($grade->id, $i);
423                     $degree = !empty($pagerotation) ? $pagerotation->degree : 0;
424                     if ($degree != 0) {
425                         $filepath = $tmpdir . '/' . $image;
426                         $imageresource = imagecreatefrompng($filepath);
427                         $content = imagerotate($imageresource, $degree, 0);
428                         imagepng($content, $filepath);
429                     }
430                 }
431             } catch (\moodle_exception $e) {
432                 // We catch only moodle_exception here as other exceptions indicate issue with setup not the pdf.
433                 $image = pdf::get_error_image($tmpdir, $i);
434             }
435             $record->filename = basename($image);
436             $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
437             @unlink($tmpdir . '/' . $image);
438             // Set page rotation default value.
439             if (!empty($files[$i])) {
440                 if ($resetrotation) {
441                     page_editor::set_page_rotation($grade->id, $i, false, $files[$i]->get_pathnamehash());
442                 }
443             }
444         }
445         $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
447         @unlink($combined);
448         @rmdir($tmpdir);
450         return $files;
451     }
453     /**
454      * This function returns a list of the page images from a pdf.
455      *
456      * The readonly version is different than the normal one. The readonly version contains a copy
457      * of the pages in the state they were when the PDF was annotated, by doing so we prevent the
458      * the pages that are displayed to change as soon as the submission changes.
459      *
460      * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible
461      * that we do not find any readonly version of the pages. In that case, we will get the normal
462      * pages and copy them to the readonly area. This ensures that the pages will remain in that
463      * state until the submission is updated. When the normal files do not exist, we throw an exception
464      * because the readonly pages should only ever be displayed after a teacher has annotated the PDF,
465      * they would not exist until they do.
466      *
467      * @param int|\assign $assignment
468      * @param int $userid
469      * @param int $attemptnumber (-1 means latest attempt)
470      * @param bool $readonly If true, then we are requesting the readonly version.
471      * @return array(stored_file)
472      */
473     public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
474         global $DB;
476         $assignment = self::get_assignment_from_param($assignment);
478         if (!$assignment->can_view_submission($userid)) {
479             print_error('nopermission');
480         }
482         if ($assignment->get_instance()->teamsubmission) {
483             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
484         } else {
485             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
486         }
487         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
489         $contextid = $assignment->get_context()->id;
490         $component = 'assignfeedback_editpdf';
491         $itemid = $grade->id;
492         $filepath = '/';
493         $filearea = self::PAGE_IMAGE_FILEAREA;
495         $fs = get_file_storage();
497         // If we are after the readonly pages...
498         if ($readonly) {
499             $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
500             if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
501                 // We have a problem here, we were supposed to find the files.
502                 // Attempt to re-generate the pages from the combined images.
503                 self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
504                 self::copy_pages_to_readonly_area($assignment, $grade);
505             }
506         }
508         $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
510         $pages = array();
511         $resetrotation = false;
512         if (!empty($files)) {
513             $first = reset($files);
514             $pagemodified = $first->get_timemodified();
515             // Check that we don't just have a single blank page. The hash of a blank page image can vary with
516             // the version of ghostscript used, so we need to examine the combined pdf it was generated from.
517             $blankpage = false;
518             if (!$readonly && count($files) == 1) {
519                 $pdfarea = self::COMBINED_PDF_FILEAREA;
520                 $pdfname = self::COMBINED_PDF_FILENAME;
521                 if ($pdf = $fs->get_file($contextid, $component, $pdfarea, $itemid, $filepath, $pdfname)) {
522                     // The combined pdf may have a different hash if it has been regenerated since the page
523                     // image was created. However if this is the case the page image will be stale anyway.
524                     if ($pdf->get_contenthash() == self::BLANK_PDF_HASH || $pagemodified < $pdf->get_timemodified()) {
525                         $blankpage = true;
526                     }
527                 }
528             }
529             if (!$readonly && ($pagemodified < $submission->timemodified || $blankpage)) {
530                 // Image files are stale, we need to regenerate them, except in readonly mode.
531                 // We also need to remove the draft annotations and comments associated with this attempt.
532                 $fs->delete_area_files($contextid, $component, $filearea, $itemid);
533                 page_editor::delete_draft_content($itemid);
534                 $files = array();
535                 $resetrotation = true;
536             } else {
538                 // Need to reorder the files following their name.
539                 // because get_directory_files() return a different order than generate_page_images_for_attempt().
540                 foreach ($files as $file) {
541                     // Extract the page number from the file name image_pageXXXX.png.
542                     preg_match('/page([\d]+)\./', $file->get_filename(), $matches);
543                     if (empty($matches) or !is_numeric($matches[1])) {
544                         throw new \coding_exception("'" . $file->get_filename()
545                             . "' file hasn't the expected format filename: image_pageXXXX.png.");
546                     }
547                     $pagenumber = (int)$matches[1];
549                     // Save the page in the ordered array.
550                     $pages[$pagenumber] = $file;
551                 }
552                 ksort($pages);
553             }
554         }
556         if (empty($pages)) {
557             if ($readonly) {
558                 // This should never happen, there should be a version of the pages available
559                 // whenever we are requesting the readonly version.
560                 throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
561             }
562             $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
563         }
565         return $pages;
566     }
568     /**
569      * This function returns sensible filename for a feedback file.
570      * @param int|\assign $assignment
571      * @param int $userid
572      * @param int $attemptnumber (-1 means latest attempt)
573      * @return string
574      */
575     protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
576         global $DB;
578         $assignment = self::get_assignment_from_param($assignment);
580         $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
581         $groupname = '';
582         if ($groupmode) {
583             $groupid = groups_get_activity_group($assignment->get_course_module(), true);
584             $groupname = groups_get_group_name($groupid).'-';
585         }
586         if ($groupname == '-') {
587             $groupname = '';
588         }
589         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
590         $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
592         if ($assignment->is_blind_marking()) {
593             $prefix = $groupname . get_string('participant', 'assign');
594             $prefix = str_replace('_', ' ', $prefix);
595             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
596         } else {
597             $prefix = $groupname . fullname($user);
598             $prefix = str_replace('_', ' ', $prefix);
599             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
600         }
601         $prefix .= $grade->attemptnumber;
603         return $prefix . '.pdf';
604     }
606     /**
607      * This function takes the combined pdf and embeds all the comments and annotations.
608      *
609      * This also moves the annotations and comments from drafts to not drafts. And it will
610      * copy all the images stored to the readonly area, so that they can be viewed online, and
611      * not be overwritten when a new submission is sent.
612      *
613      * @param int|\assign $assignment
614      * @param int $userid
615      * @param int $attemptnumber (-1 means latest attempt)
616      * @return stored_file
617      */
618     public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
620         $assignment = self::get_assignment_from_param($assignment);
622         if (!$assignment->can_view_submission($userid)) {
623             print_error('nopermission');
624         }
625         if (!$assignment->can_grade()) {
626             print_error('nopermission');
627         }
629         // Need to generate the page images - first get a combined pdf.
630         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
632         $status = $document->get_status();
633         if ($status === combined_document::STATUS_FAILED) {
634             print_error('Could not generate combined pdf.');
635         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
636             // The conversion is still in progress.
637             return false;
638         }
640         $file = $document->get_combined_file();
642         $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
643         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
644         $file->copy_content_to($combined); // Copy the file.
646         $pdf = new pdf();
648         $fs = get_file_storage();
649         $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
650         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
651         // Copy any new stamps to this instance.
652         if ($files = $fs->get_area_files($assignment->get_context()->id,
653                                          'assignfeedback_editpdf',
654                                          'stamps',
655                                          $grade->id,
656                                          "filename",
657                                          false)) {
658             foreach ($files as $file) {
659                 $filename = $stamptmpdir . '/' . $file->get_filename();
660                 $file->copy_content_to($filename); // Copy the file.
661             }
662         }
664         $pagecount = $pdf->set_pdf($combined);
665         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
666         page_editor::release_drafts($grade->id);
668         $allcomments = array();
670         for ($i = 0; $i < $pagecount; $i++) {
671             $pagerotation = page_editor::get_page_rotation($grade->id, $i);
672             $pagemargin = $pdf->getBreakMargin();
673             $autopagebreak = $pdf->getAutoPageBreak();
674             if (empty($pagerotation) || !$pagerotation->isrotated) {
675                 $pdf->copy_page();
676             } else {
677                 $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
678                 if (empty($rotatedimagefile)) {
679                     $pdf->copy_page();
680                 } else {
681                     $pdf->add_image_page($rotatedimagefile);
682                 }
683             }
685             $comments = page_editor::get_comments($grade->id, $i, false);
686             $annotations = page_editor::get_annotations($grade->id, $i, false);
688             if (!empty($comments)) {
689                 $allcomments[$i] = $comments;
690             }
692             foreach ($annotations as $annotation) {
693                 $pdf->add_annotation($annotation->x,
694                                      $annotation->y,
695                                      $annotation->endx,
696                                      $annotation->endy,
697                                      $annotation->colour,
698                                      $annotation->type,
699                                      $annotation->path,
700                                      $stamptmpdir);
701             }
702             $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
703             $pdf->setPageMark();
704         }
706         if (!empty($allcomments)) {
707             // Append all comments to the end of the document.
708             $links = $pdf->append_comments($allcomments);
709             // Add the comment markers with links.
710             foreach ($allcomments as $pageno => $comments) {
711                 foreach ($comments as $index => $comment) {
712                     $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
713                             $comment->colour);
714                 }
715             }
716         }
718         fulldelete($stamptmpdir);
720         $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
721         $filename = clean_param($filename, PARAM_FILE);
723         $generatedpdf = $tmpdir . '/' . $filename;
724         $pdf->save_pdf($generatedpdf);
726         $record = new \stdClass();
728         $record->contextid = $assignment->get_context()->id;
729         $record->component = 'assignfeedback_editpdf';
730         $record->filearea = self::FINAL_PDF_FILEAREA;
731         $record->itemid = $grade->id;
732         $record->filepath = '/';
733         $record->filename = $filename;
735         // Only keep one current version of the generated pdf.
736         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
738         $file = $fs->create_file_from_pathname($record, $generatedpdf);
740         // Cleanup.
741         @unlink($generatedpdf);
742         @unlink($combined);
743         @rmdir($tmpdir);
745         self::copy_pages_to_readonly_area($assignment, $grade);
747         return $file;
748     }
750     /**
751      * Copy the pages image to the readonly area.
752      *
753      * @param int|\assign $assignment The assignment.
754      * @param \stdClass $grade The grade record.
755      * @return void
756      */
757     public static function copy_pages_to_readonly_area($assignment, $grade) {
758         $fs = get_file_storage();
759         $assignment = self::get_assignment_from_param($assignment);
760         $contextid = $assignment->get_context()->id;
761         $component = 'assignfeedback_editpdf';
762         $itemid = $grade->id;
764         // Get all the pages.
765         $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
766         if (empty($originalfiles)) {
767             // Nothing to do here...
768             return;
769         }
771         // Delete the old readonly files.
772         $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
774         // Do the copying.
775         foreach ($originalfiles as $originalfile) {
776             $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
777         }
778     }
780     /**
781      * This function returns the generated pdf (if it exists).
782      * @param int|\assign $assignment
783      * @param int $userid
784      * @param int $attemptnumber (-1 means latest attempt)
785      * @return stored_file
786      */
787     public static function get_feedback_document($assignment, $userid, $attemptnumber) {
789         $assignment = self::get_assignment_from_param($assignment);
791         if (!$assignment->can_view_submission($userid)) {
792             print_error('nopermission');
793         }
795         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
797         $contextid = $assignment->get_context()->id;
798         $component = 'assignfeedback_editpdf';
799         $filearea = self::FINAL_PDF_FILEAREA;
800         $itemid = $grade->id;
801         $filepath = '/';
803         $fs = get_file_storage();
804         $files = $fs->get_area_files($contextid,
805                                      $component,
806                                      $filearea,
807                                      $itemid,
808                                      "itemid, filepath, filename",
809                                      false);
810         if ($files) {
811             return reset($files);
812         }
813         return false;
814     }
816     /**
817      * This function deletes the generated pdf for a student.
818      * @param int|\assign $assignment
819      * @param int $userid
820      * @param int $attemptnumber (-1 means latest attempt)
821      * @return bool
822      */
823     public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
825         $assignment = self::get_assignment_from_param($assignment);
827         if (!$assignment->can_view_submission($userid)) {
828             print_error('nopermission');
829         }
830         if (!$assignment->can_grade()) {
831             print_error('nopermission');
832         }
834         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
836         $contextid = $assignment->get_context()->id;
837         $component = 'assignfeedback_editpdf';
838         $filearea = self::FINAL_PDF_FILEAREA;
839         $itemid = $grade->id;
841         $fs = get_file_storage();
842         return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
843     }
845     /**
846      * Get All files in a File area
847      * @param int|\assign $assignment Assignment
848      * @param int $userid User ID
849      * @param int $attemptnumber Attempt Number
850      * @param string $filearea File Area
851      * @param string $filepath File Path
852      * @return array
853      */
854     private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
855         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
856         $itemid = $grade->id;
857         $contextid = $assignment->get_context()->id;
858         $component = self::COMPONENT;
859         $fs = get_file_storage();
860         $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
861         return $files;
862     }
864     /**
865      * Save file.
866      * @param int|\assign $assignment Assignment
867      * @param int $userid User ID
868      * @param int $attemptnumber Attempt Number
869      * @param string $filearea File Area
870      * @param string $newfilepath File Path
871      * @param string $storedfilepath stored file path
872      * @return \stored_file
873      * @throws \file_exception
874      * @throws \stored_file_creation_exception
875      */
876     private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
877         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
878         $itemid = $grade->id;
879         $contextid = $assignment->get_context()->id;
881         $record = new \stdClass();
882         $record->contextid = $contextid;
883         $record->component = self::COMPONENT;
884         $record->filearea = $filearea;
885         $record->itemid = $itemid;
886         $record->filepath = $storedfilepath;
887         $record->filename = basename($newfilepath);
889         $fs = get_file_storage();
891         $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
892             $record->itemid, $record->filepath, $record->filename);
894         $newhash = sha1($newfilepath);
896         // Delete old file if exists.
897         if ($oldfile && $newhash !== $oldfile->get_contenthash()) {
898             $oldfile->delete();
899         }
901         return $fs->create_file_from_pathname($record, $newfilepath);
902     }
904     /**
905      * This function rotate a page, and mark the page as rotated.
906      * @param int|\assign $assignment Assignment
907      * @param int $userid User ID
908      * @param int $attemptnumber Attempt Number
909      * @param int $index Index of Current Page
910      * @param bool $rotateleft To determine whether the page is rotated left or right.
911      * @return null|\stored_file return rotated File
912      * @throws \coding_exception
913      * @throws \file_exception
914      * @throws \moodle_exception
915      * @throws \stored_file_creation_exception
916      */
917     public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
918         $assignment = self::get_assignment_from_param($assignment);
919         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
920         // Check permission.
921         if (!$assignment->can_view_submission($userid)) {
922             print_error('nopermission');
923         }
925         $filearea = self::PAGE_IMAGE_FILEAREA;
926         $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
927         if (!empty($files)) {
928             foreach ($files as $file) {
929                 preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
930                 if (empty($matches) or !is_numeric($matches[1])) {
931                     throw new \coding_exception("'" . $file->get_filename()
932                         . "' file hasn't the expected format filename: image_pageXXXX.png.");
933                 }
934                 $pagenumber = (int)$matches[1];
936                 if ($pagenumber == $index) {
937                     $source = imagecreatefromstring($file->get_content());
938                     $pagerotation = page_editor::get_page_rotation($grade->id, $index);
939                     $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
940                     if ($rotateleft) {
941                         $content = imagerotate($source, 90, 0);
942                         $degree = ($degree + 90) % 360;
943                     } else {
944                         $content = imagerotate($source, -90, 0);
945                         $degree = ($degree - 90) % 360;
946                     }
947                     $filename = $matches[0].'png';
948                     $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/'
949                         . self::hash($assignment, $userid, $attemptnumber));
950                     $tempfile = $tmpdir . '/' . time() . '_' . $filename;
951                     imagepng($content, $tempfile);
953                     $filearea = self::PAGE_IMAGE_FILEAREA;
954                     $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
956                     unlink($tempfile);
957                     rmdir($tmpdir);
958                     imagedestroy($source);
959                     imagedestroy($content);
960                     $file->delete();
961                     if (!empty($newfile)) {
962                         page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
963                     }
964                     return $newfile;
965                 }
966             }
967         }
968         return null;
969     }