45b4083f18fc4c3dc0c75d270aab42f3b13e5914
[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     /** File area for generated pdf */
42     const FINAL_PDF_FILEAREA = 'download';
43     /** File area for combined pdf */
44     const COMBINED_PDF_FILEAREA = 'combined';
45     /** File area for partial combined pdf */
46     const PARTIAL_PDF_FILEAREA = 'partial';
47     /** File area for importing html */
48     const IMPORT_HTML_FILEAREA = 'importhtml';
49     /** File area for page images */
50     const PAGE_IMAGE_FILEAREA = 'pages';
51     /** File area for readonly page images */
52     const PAGE_IMAGE_READONLY_FILEAREA = 'readonlypages';
53     /** File area for the stamps */
54     const STAMPS_FILEAREA = 'stamps';
55     /** Filename for combined pdf */
56     const COMBINED_PDF_FILENAME = 'combined.pdf';
57     /** Hash of blank pdf */
58     const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
60     /** Base64 encoded blank pdf. This is the most reliable/fastest way to generate a blank pdf. */
61     const BLANK_PDF_BASE64 = <<<EOD
62 JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl
63 Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMFAwALJMLU31jBQsTAz1LBSKUrnCtRTyuAIVAIcdB3IK
64 ZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iago0MgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq
65 Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEg
66 MCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCA0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hb
67 MCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+
68 L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
69 cyA2IDAgUgovTWVkaWFCb3hbIDAgMCA1OTUgODQyIF0KL0tpZHNbIDEgMCBSIF0KL0NvdW50IDE+
70 PgplbmRvYmoKCjcgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0aW9u
71 WzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLUFVKQo+PgplbmRvYmoKCjggMCBvYmoK
72 PDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAw
73 NEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzQwMDJFMDAz
74 ND4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwMjI2MTMyMzE0KzA4JzAwJyk+PgplbmRvYmoKCnhyZWYK
75 MCA5CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDIyNiAwMDAwMCBuIAowMDAwMDAwMDE5IDAw
76 MDAwIG4gCjAwMDAwMDAxMzIgMDAwMDAgbiAKMDAwMDAwMDM2OCAwMDAwMCBuIAowMDAwMDAwMTUx
77 IDAwMDAwIG4gCjAwMDAwMDAxNzMgMDAwMDAgbiAKMDAwMDAwMDQ2NiAwMDAwMCBuIAowMDAwMDAw
78 NTYyIDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA5L1Jvb3QgNyAwIFIKL0luZm8gOCAwIFIKL0lE
79 IFsgPEJDN0REQUQwRDQyOTQ1OTQ2OUU4NzJCMjI1ODUyNkU4Pgo8QkM3RERBRDBENDI5NDU5NDY5
80 RTg3MkIyMjU4NTI2RTg+IF0KL0RvY0NoZWNrc3VtIC9BNTYwMEZCMDAzRURCRTg0MTNBNTk3RTZF
81 MURDQzJBRgo+PgpzdGFydHhyZWYKNzM2CiUlRU9GCg==
82 EOD;
84     /**
85      * This function will take an int or an assignment instance and
86      * return an assignment instance. It is just for convenience.
87      * @param int|\assign $assignment
88      * @return assign
89      */
90     private static function get_assignment_from_param($assignment) {
91         global $CFG;
93         require_once($CFG->dirroot . '/mod/assign/locallib.php');
95         if (!is_object($assignment)) {
96             $cm = get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST);
97             $context = \context_module::instance($cm->id);
99             $assignment = new \assign($context, null, null);
100         }
101         return $assignment;
102     }
104     /**
105      * Get a hash that will be unique and can be used in a path name.
106      * @param int|\assign $assignment
107      * @param int $userid
108      * @param int $attemptnumber (-1 means latest attempt)
109      */
110     private static function hash($assignment, $userid, $attemptnumber) {
111         if (is_object($assignment)) {
112             $assignmentid = $assignment->get_instance()->id;
113         } else {
114             $assignmentid = $assignment;
115         }
116         return sha1($assignmentid . '_' . $userid . '_' . $attemptnumber);
117     }
119     /**
120      * Use a DOM parser to accurately replace images with their alt text.
121      * @param string $html
122      * @return string New html with no image tags.
123      */
124     protected static function strip_images($html) {
125         // Load HTML and suppress any parsing errors (DOMDocument->loadHTML() does not current support HTML5 tags).
126         $dom = new DOMDocument();
127         libxml_use_internal_errors(true);
128         $dom->loadHTML('<?xml version="1.0" encoding="UTF-8" ?>' . $html);
129         libxml_clear_errors();
131         // Find all img tags.
132         if ($imgnodes = $dom->getElementsByTagName('img')) {
133             // Replace img nodes with the img alt text without overriding DOM elements.
134             for ($i = ($imgnodes->length - 1); $i >= 0; $i--) {
135                 $imgnode = $imgnodes->item($i);
136                 $alt = ($imgnode->hasAttribute('alt')) ? ' [ ' . $imgnode->getAttribute('alt') . ' ] ' : ' ';
137                 $textnode = $dom->createTextNode($alt);
139                 $imgnode->parentNode->replaceChild($textnode, $imgnode);
140             }
141         }
142         $count = 1;
143         return str_replace("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>", "", $dom->saveHTML(), $count);
144     }
146     /**
147      * This function will search for all files that can be converted
148      * and concatinated into a PDF (1.4) - for any submission plugin
149      * for this students attempt.
150      *
151      * @param int|\assign $assignment
152      * @param int $userid
153      * @param int $attemptnumber (-1 means latest attempt)
154      * @return combined_document
155      */
156     protected static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) {
157         global $USER, $DB;
159         $assignment = self::get_assignment_from_param($assignment);
161         // Capability checks.
162         if (!$assignment->can_view_submission($userid)) {
163             print_error('nopermission');
164         }
166         $files = array();
168         if ($assignment->get_instance()->teamsubmission) {
169             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
170         } else {
171             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
172         }
173         $user = $DB->get_record('user', array('id' => $userid));
175         // User has not submitted anything yet.
176         if (!$submission) {
177             return new combined_document();
178         }
180         $fs = get_file_storage();
181         $converter = new \core_files\converter();
182         // Ask each plugin for it's list of files.
183         foreach ($assignment->get_submission_plugins() as $plugin) {
184             if ($plugin->is_enabled() && $plugin->is_visible()) {
185                 $pluginfiles = $plugin->get_files($submission, $user);
186                 foreach ($pluginfiles as $filename => $file) {
187                     if ($file instanceof \stored_file) {
188                         if ($file->get_mimetype() === 'application/pdf') {
189                             $files[$filename] = $file;
190                         } else if ($convertedfile = $converter->start_conversion($file, 'pdf')) {
191                             $files[$filename] = $convertedfile;
192                         }
193                     } else if ($converter->can_convert_format_to('html', 'pdf')) {
194                         // Create a tmp stored_file from this html string.
195                         $file = reset($file);
196                         // Strip image tags, because they will not be resolvable.
197                         $file = self::strip_images($file);
198                         $record = new \stdClass();
199                         $record->contextid = $assignment->get_context()->id;
200                         $record->component = 'assignfeedback_editpdf';
201                         $record->filearea = self::IMPORT_HTML_FILEAREA;
202                         $record->itemid = $submission->id;
203                         $record->filepath = '/';
204                         $record->filename = $plugin->get_type() . '-' . $filename;
206                         $htmlfile = $fs->get_file($record->contextid,
207                                 $record->component,
208                                 $record->filearea,
209                                 $record->itemid,
210                                 $record->filepath,
211                                 $record->filename);
213                         $newhash = sha1($file);
215                         // If the file exists, and the content hash doesn't match, remove it.
216                         if ($htmlfile && $newhash !== $htmlfile->get_contenthash()) {
217                             $htmlfile->delete();
218                             $htmlfile = false;
219                         }
221                         // If the file doesn't exist, or if it was removed above, create a new one.
222                         if (!$htmlfile) {
223                             $htmlfile = $fs->create_file_from_string($record, $file);
224                         }
226                         $convertedfile = $converter->start_conversion($htmlfile, 'pdf');
228                         if ($convertedfile) {
229                             $files[$filename] = $convertedfile;
230                         }
231                     }
232                 }
233             }
234         }
235         $combineddocument = new combined_document();
236         $combineddocument->set_source_files($files);
238         return $combineddocument;
239     }
241     /**
242      * Fetch the current combined document ready for state checking.
243      *
244      * @param int|\assign $assignment
245      * @param int $userid
246      * @param int $attemptnumber (-1 means latest attempt)
247      * @return combined_document
248      */
249     public static function get_combined_document_for_attempt($assignment, $userid, $attemptnumber) {
250         global $USER, $DB;
252         $assignment = self::get_assignment_from_param($assignment);
254         // Capability checks.
255         if (!$assignment->can_view_submission($userid)) {
256             print_error('nopermission');
257         }
259         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
260         if ($assignment->get_instance()->teamsubmission) {
261             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
262         } else {
263             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
264         }
267         $contextid = $assignment->get_context()->id;
268         $component = 'assignfeedback_editpdf';
269         $filearea = self::COMBINED_PDF_FILEAREA;
270         $partialfilearea = self::PARTIAL_PDF_FILEAREA;
271         $itemid = $grade->id;
272         $filepath = '/';
273         $filename = self::COMBINED_PDF_FILENAME;
274         $fs = get_file_storage();
276         $partialpdf = $fs->get_file($contextid, $component, $partialfilearea, $itemid, $filepath, $filename);
277         if (!empty($partialpdf)) {
278             $combinedpdf = $partialpdf;
279         } else {
280             $combinedpdf = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename);
281         }
283         if ($combinedpdf && $submission) {
284             if ($combinedpdf->get_timemodified() < $submission->timemodified) {
285                 // The submission has been updated since the PDF was generated.
286                 $combinedpdf = false;
287             } else if ($combinedpdf->get_contenthash() == self::BLANK_PDF_HASH) {
288                 // The PDF is for a blank page.
289                 $combinedpdf = false;
290             }
291         }
293         if (empty($combinedpdf)) {
294             // The combined PDF does not exist yet. Return the list of files to be combined.
295             return self::list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber);
296         } else {
297             // The combined PDF aleady exists. Return it in a new combined_document object.
298             $combineddocument = new combined_document();
299             return $combineddocument->set_combined_file($combinedpdf);
300         }
301     }
303     /**
304      * This function return the combined pdf for all valid submission files.
305      *
306      * @param int|\assign $assignment
307      * @param int $userid
308      * @param int $attemptnumber (-1 means latest attempt)
309      * @return combined_document
310      */
311     public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) {
312         $document = self::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
314         if ($document->get_status() === combined_document::STATUS_COMPLETE) {
315             // The combined document is already ready.
316             return $document;
317         } else {
318             // Attempt to combined the files in the document.
319             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
320             $document->combine_files($assignment->get_context()->id, $grade->id);
321             return $document;
322         }
323     }
325     /**
326      * This function will return the number of pages of a pdf.
327      *
328      * @param int|\assign $assignment
329      * @param int $userid
330      * @param int $attemptnumber (-1 means latest attempt)
331      * @param bool $readonly When true we get the number of pages for the readonly version.
332      * @return int number of pages
333      */
334     public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
335         global $CFG;
337         require_once($CFG->libdir . '/pdflib.php');
339         $assignment = self::get_assignment_from_param($assignment);
341         if (!$assignment->can_view_submission($userid)) {
342             print_error('nopermission');
343         }
345         // When in readonly we can return the number of images in the DB because they should already exist,
346         // if for some reason they do not, then we proceed as for the normal version.
347         if ($readonly) {
348             $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
349             $fs = get_file_storage();
350             $files = $fs->get_directory_files($assignment->get_context()->id, 'assignfeedback_editpdf',
351                 self::PAGE_IMAGE_READONLY_FILEAREA, $grade->id, '/');
352             $pagecount = count($files);
353             if ($pagecount > 0) {
354                 return $pagecount;
355             }
356         }
358         // Get a combined pdf file from all submitted pdf files.
359         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
360         return $document->get_page_count();
361     }
363     /**
364      * This function will generate and return a list of the page images from a pdf.
365      * @param int|\assign $assignment
366      * @param int $userid
367      * @param int $attemptnumber (-1 means latest attempt)
368      * @return array(stored_file)
369      */
370     protected static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber) {
371         global $CFG;
373         require_once($CFG->libdir . '/pdflib.php');
375         $assignment = self::get_assignment_from_param($assignment);
377         if (!$assignment->can_view_submission($userid)) {
378             print_error('nopermission');
379         }
381         // Need to generate the page images - first get a combined pdf.
382         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
384         $status = $document->get_status();
385         if ($status === combined_document::STATUS_FAILED) {
386             print_error('Could not generate combined pdf.');
387         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
388             // The conversion is still in progress.
389             return [];
390         }
392         $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber));
393         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
395         $document->get_combined_file()->copy_content_to($combined); // Copy the file.
397         $pdf = new pdf();
399         $pdf->set_image_folder($tmpdir);
400         $pagecount = $pdf->set_pdf($combined);
402         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
404         $record = new \stdClass();
405         $record->contextid = $assignment->get_context()->id;
406         $record->component = 'assignfeedback_editpdf';
407         $record->filearea = self::PAGE_IMAGE_FILEAREA;
408         $record->itemid = $grade->id;
409         $record->filepath = '/';
410         $fs = get_file_storage();
412         // Remove the existing content of the filearea.
413         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
415         $files = array();
416         for ($i = 0; $i < $pagecount; $i++) {
417             try {
418                 $image = $pdf->get_image($i);
419             } catch (\moodle_exception $e) {
420                 // We catch only moodle_exception here as other exceptions indicate issue with setup not the pdf.
421                 $image = pdf::get_error_image($tmpdir, $i);
422             }
423             $record->filename = basename($image);
424             $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
425             @unlink($tmpdir . '/' . $image);
426         }
427         $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
429         @unlink($combined);
430         @rmdir($tmpdir);
432         return $files;
433     }
435     /**
436      * This function returns a list of the page images from a pdf.
437      *
438      * The readonly version is different than the normal one. The readonly version contains a copy
439      * of the pages in the state they were when the PDF was annotated, by doing so we prevent the
440      * the pages that are displayed to change as soon as the submission changes.
441      *
442      * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible
443      * that we do not find any readonly version of the pages. In that case, we will get the normal
444      * pages and copy them to the readonly area. This ensures that the pages will remain in that
445      * state until the submission is updated. When the normal files do not exist, we throw an exception
446      * because the readonly pages should only ever be displayed after a teacher has annotated the PDF,
447      * they would not exist until they do.
448      *
449      * @param int|\assign $assignment
450      * @param int $userid
451      * @param int $attemptnumber (-1 means latest attempt)
452      * @param bool $readonly If true, then we are requesting the readonly version.
453      * @return array(stored_file)
454      */
455     public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
456         global $DB;
458         $assignment = self::get_assignment_from_param($assignment);
460         if (!$assignment->can_view_submission($userid)) {
461             print_error('nopermission');
462         }
464         if ($assignment->get_instance()->teamsubmission) {
465             $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
466         } else {
467             $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
468         }
469         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
471         $contextid = $assignment->get_context()->id;
472         $component = 'assignfeedback_editpdf';
473         $itemid = $grade->id;
474         $filepath = '/';
475         $filearea = self::PAGE_IMAGE_FILEAREA;
477         $fs = get_file_storage();
479         // If we are after the readonly pages...
480         if ($readonly) {
481             $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
482             if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
483                 // We have a problem here, we were supposed to find the files.
484                 // Attempt to re-generate the pages from the combined images.
485                 self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
486                 self::copy_pages_to_readonly_area($assignment, $grade);
487             }
488         }
490         $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
492         $pages = array();
493         if (!empty($files)) {
494             $first = reset($files);
495             $pagemodified = $first->get_timemodified();
496             // Check that we don't just have a single blank page. The hash of a blank page image can vary with
497             // the version of ghostscript used, so we need to examine the combined pdf it was generated from.
498             $blankpage = false;
499             if (!$readonly && count($files) == 1) {
500                 $pdfarea = self::COMBINED_PDF_FILEAREA;
501                 $pdfname = self::COMBINED_PDF_FILENAME;
502                 if ($pdf = $fs->get_file($contextid, $component, $pdfarea, $itemid, $filepath, $pdfname)) {
503                     // The combined pdf may have a different hash if it has been regenerated since the page
504                     // image was created. However if this is the case the page image will be stale anyway.
505                     if ($pdf->get_contenthash() == self::BLANK_PDF_HASH || $pagemodified < $pdf->get_timemodified()) {
506                         $blankpage = true;
507                     }
508                 }
509             }
510             if (!$readonly && ($pagemodified < $submission->timemodified || $blankpage)) {
511                 // Image files are stale, we need to regenerate them, except in readonly mode.
512                 // We also need to remove the draft annotations and comments associated with this attempt.
513                 $fs->delete_area_files($contextid, $component, $filearea, $itemid);
514                 page_editor::delete_draft_content($itemid);
515                 $files = array();
516             } else {
518                 // Need to reorder the files following their name.
519                 // because get_directory_files() return a different order than generate_page_images_for_attempt().
520                 foreach($files as $file) {
521                     // Extract the page number from the file name image_pageXXXX.png.
522                     preg_match('/page([\d]+)\./', $file->get_filename(), $matches);
523                     if (empty($matches) or !is_numeric($matches[1])) {
524                         throw new \coding_exception("'" . $file->get_filename()
525                             . "' file hasn't the expected format filename: image_pageXXXX.png.");
526                     }
527                     $pagenumber = (int)$matches[1];
529                     // Save the page in the ordered array.
530                     $pages[$pagenumber] = $file;
531                 }
532                 ksort($pages);
533             }
534         }
536         if (empty($pages)) {
537             if ($readonly) {
538                 // This should never happen, there should be a version of the pages available
539                 // whenever we are requesting the readonly version.
540                 throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
541             }
542             $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
543         }
545         return $pages;
546     }
548     /**
549      * This function returns sensible filename for a feedback file.
550      * @param int|\assign $assignment
551      * @param int $userid
552      * @param int $attemptnumber (-1 means latest attempt)
553      * @return string
554      */
555     protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
556         global $DB;
558         $assignment = self::get_assignment_from_param($assignment);
560         $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
561         $groupname = '';
562         if ($groupmode) {
563             $groupid = groups_get_activity_group($assignment->get_course_module(), true);
564             $groupname = groups_get_group_name($groupid).'-';
565         }
566         if ($groupname == '-') {
567             $groupname = '';
568         }
569         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
570         $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
572         if ($assignment->is_blind_marking()) {
573             $prefix = $groupname . get_string('participant', 'assign');
574             $prefix = str_replace('_', ' ', $prefix);
575             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
576         } else {
577             $prefix = $groupname . fullname($user);
578             $prefix = str_replace('_', ' ', $prefix);
579             $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
580         }
581         $prefix .= $grade->attemptnumber;
583         return $prefix . '.pdf';
584     }
586     /**
587      * This function takes the combined pdf and embeds all the comments and annotations.
588      *
589      * This also moves the annotations and comments from drafts to not drafts. And it will
590      * copy all the images stored to the readonly area, so that they can be viewed online, and
591      * not be overwritten when a new submission is sent.
592      *
593      * @param int|\assign $assignment
594      * @param int $userid
595      * @param int $attemptnumber (-1 means latest attempt)
596      * @return stored_file
597      */
598     public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
600         $assignment = self::get_assignment_from_param($assignment);
602         if (!$assignment->can_view_submission($userid)) {
603             print_error('nopermission');
604         }
605         if (!$assignment->can_grade()) {
606             print_error('nopermission');
607         }
609         // Need to generate the page images - first get a combined pdf.
610         $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
612         $status = $document->get_status();
613         if ($status === combined_document::STATUS_FAILED) {
614             print_error('Could not generate combined pdf.');
615         } else if ($status === combined_document::STATUS_PENDING_INPUT) {
616             // The conversion is still in progress.
617             return false;
618         }
620         $file = $document->get_combined_file();
622         $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
623         $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
624         $file->copy_content_to($combined); // Copy the file.
626         $pdf = new pdf();
628         $fs = get_file_storage();
629         $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
630         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
631         // Copy any new stamps to this instance.
632         if ($files = $fs->get_area_files($assignment->get_context()->id,
633                                          'assignfeedback_editpdf',
634                                          'stamps',
635                                          $grade->id,
636                                          "filename",
637                                          false)) {
638             foreach ($files as $file) {
639                 $filename = $stamptmpdir . '/' . $file->get_filename();
640                 $file->copy_content_to($filename); // Copy the file.
641             }
642         }
644         $pagecount = $pdf->set_pdf($combined);
645         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
646         page_editor::release_drafts($grade->id);
648         $allcomments = array();
650         for ($i = 0; $i < $pagecount; $i++) {
651             $pdf->copy_page();
652             $comments = page_editor::get_comments($grade->id, $i, false);
653             $annotations = page_editor::get_annotations($grade->id, $i, false);
655             if (!empty($comments)) {
656                 $allcomments[$i] = $comments;
657             }
659             foreach ($annotations as $annotation) {
660                 $pdf->add_annotation($annotation->x,
661                                      $annotation->y,
662                                      $annotation->endx,
663                                      $annotation->endy,
664                                      $annotation->colour,
665                                      $annotation->type,
666                                      $annotation->path,
667                                      $stamptmpdir);
668             }
669         }
671         if (!empty($allcomments)) {
672             // Append all comments to the end of the document.
673             $links = $pdf->append_comments($allcomments);
674             // Add the comment markers with links.
675             foreach ($allcomments as $pageno => $comments) {
676                 foreach ($comments as $index => $comment) {
677                     $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
678                             $comment->colour);
679                 }
680             }
681         }
683         fulldelete($stamptmpdir);
685         $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
686         $filename = clean_param($filename, PARAM_FILE);
688         $generatedpdf = $tmpdir . '/' . $filename;
689         $pdf->save_pdf($generatedpdf);
692         $record = new \stdClass();
694         $record->contextid = $assignment->get_context()->id;
695         $record->component = 'assignfeedback_editpdf';
696         $record->filearea = self::FINAL_PDF_FILEAREA;
697         $record->itemid = $grade->id;
698         $record->filepath = '/';
699         $record->filename = $filename;
702         // Only keep one current version of the generated pdf.
703         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
705         $file = $fs->create_file_from_pathname($record, $generatedpdf);
707         // Cleanup.
708         @unlink($generatedpdf);
709         @unlink($combined);
710         @rmdir($tmpdir);
712         self::copy_pages_to_readonly_area($assignment, $grade);
714         return $file;
715     }
717     /**
718      * Copy the pages image to the readonly area.
719      *
720      * @param int|\assign $assignment The assignment.
721      * @param \stdClass $grade The grade record.
722      * @return void
723      */
724     public static function copy_pages_to_readonly_area($assignment, $grade) {
725         $fs = get_file_storage();
726         $assignment = self::get_assignment_from_param($assignment);
727         $contextid = $assignment->get_context()->id;
728         $component = 'assignfeedback_editpdf';
729         $itemid = $grade->id;
731         // Get all the pages.
732         $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
733         if (empty($originalfiles)) {
734             // Nothing to do here...
735             return;
736         }
738         // Delete the old readonly files.
739         $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
741         // Do the copying.
742         foreach ($originalfiles as $originalfile) {
743             $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
744         }
745     }
747     /**
748      * This function returns the generated pdf (if it exists).
749      * @param int|\assign $assignment
750      * @param int $userid
751      * @param int $attemptnumber (-1 means latest attempt)
752      * @return stored_file
753      */
754     public static function get_feedback_document($assignment, $userid, $attemptnumber) {
756         $assignment = self::get_assignment_from_param($assignment);
758         if (!$assignment->can_view_submission($userid)) {
759             print_error('nopermission');
760         }
762         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
764         $contextid = $assignment->get_context()->id;
765         $component = 'assignfeedback_editpdf';
766         $filearea = self::FINAL_PDF_FILEAREA;
767         $itemid = $grade->id;
768         $filepath = '/';
770         $fs = get_file_storage();
771         $files = $fs->get_area_files($contextid,
772                                      $component,
773                                      $filearea,
774                                      $itemid,
775                                      "itemid, filepath, filename",
776                                      false);
777         if ($files) {
778             return reset($files);
779         }
780         return false;
781     }
783     /**
784      * This function deletes the generated pdf for a student.
785      * @param int|\assign $assignment
786      * @param int $userid
787      * @param int $attemptnumber (-1 means latest attempt)
788      * @return bool
789      */
790     public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
792         $assignment = self::get_assignment_from_param($assignment);
794         if (!$assignment->can_view_submission($userid)) {
795             print_error('nopermission');
796         }
797         if (!$assignment->can_grade()) {
798             print_error('nopermission');
799         }
801         $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
803         $contextid = $assignment->get_context()->id;
804         $component = 'assignfeedback_editpdf';
805         $filearea = self::FINAL_PDF_FILEAREA;
806         $itemid = $grade->id;
808         $fs = get_file_storage();
809         return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
810     }