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