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