Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
 
25
namespace assignfeedback_editpdf;
26
 
27
use 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
 */
39
class 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
68
JVBERi0xLjQKJcOkw7zDtsOfCjIgMCBvYmoKPDwvTGVuZ3RoIDMgMCBSL0ZpbHRlci9GbGF0ZURl
69
Y29kZT4+CnN0cmVhbQp4nDPQM1Qo5ypUMFAwALJMLU31jBQsTAz1LBSKUrnCtRTyuAIVAIcdB3IK
70
ZW5kc3RyZWFtCmVuZG9iagoKMyAwIG9iago0MgplbmRvYmoKCjUgMCBvYmoKPDwKPj4KZW5kb2Jq
71
Cgo2IDAgb2JqCjw8L0ZvbnQgNSAwIFIKL1Byb2NTZXRbL1BERi9UZXh0XQo+PgplbmRvYmoKCjEg
72
MCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCA0IDAgUi9SZXNvdXJjZXMgNiAwIFIvTWVkaWFCb3hb
73
MCAwIDU5NSA4NDJdL0dyb3VwPDwvUy9UcmFuc3BhcmVuY3kvQ1MvRGV2aWNlUkdCL0kgdHJ1ZT4+
74
L0NvbnRlbnRzIDIgMCBSPj4KZW5kb2JqCgo0IDAgb2JqCjw8L1R5cGUvUGFnZXMKL1Jlc291cmNl
75
cyA2IDAgUgovTWVkaWFCb3hbIDAgMCA1OTUgODQyIF0KL0tpZHNbIDEgMCBSIF0KL0NvdW50IDE+
76
PgplbmRvYmoKCjcgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDQgMCBSCi9PcGVuQWN0aW9u
77
WzEgMCBSIC9YWVogbnVsbCBudWxsIDBdCi9MYW5nKGVuLUFVKQo+PgplbmRvYmoKCjggMCBvYmoK
78
PDwvQ3JlYXRvcjxGRUZGMDA1NzAwNzIwMDY5MDA3NDAwNjUwMDcyPgovUHJvZHVjZXI8RkVGRjAw
79
NEMwMDY5MDA2MjAwNzIwMDY1MDA0RjAwNjYwMDY2MDA2OTAwNjMwMDY1MDAyMDAwMzQwMDJFMDAz
80
ND4KL0NyZWF0aW9uRGF0ZShEOjIwMTYwMjI2MTMyMzE0KzA4JzAwJyk+PgplbmRvYmoKCnhyZWYK
81
MCA5CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDIyNiAwMDAwMCBuIAowMDAwMDAwMDE5IDAw
82
MDAwIG4gCjAwMDAwMDAxMzIgMDAwMDAgbiAKMDAwMDAwMDM2OCAwMDAwMCBuIAowMDAwMDAwMTUx
83
IDAwMDAwIG4gCjAwMDAwMDAxNzMgMDAwMDAgbiAKMDAwMDAwMDQ2NiAwMDAwMCBuIAowMDAwMDAw
84
NTYyIDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA5L1Jvb3QgNyAwIFIKL0luZm8gOCAwIFIKL0lE
85
IFsgPEJDN0REQUQwRDQyOTQ1OTQ2OUU4NzJCMjI1ODUyNkU4Pgo8QkM3RERBRDBENDI5NDU5NDY5
86
RTg3MkIyMjU4NTI2RTg+IF0KL0RvY0NoZWNrc3VtIC9BNTYwMEZCMDAzRURCRTg0MTNBNTk3RTZF
87
MURDQzJBRgo+PgpzdGFydHhyZWYKNzM2CiUlRU9GCg==
88
EOD;
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
            throw new \moodle_exception('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
            throw new \moodle_exception('nopermissiontoaccesspage', 'error');
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
            throw new \moodle_exception('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
            throw new \moodle_exception('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
            throw new \moodle_exception('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_request_directory();
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
        $images = $pdf->get_images();
442
        for ($i = 0; $i < $pagecount; $i++) {
443
            try {
444
                if (empty($images[$i])) {
445
                    throw new \moodle_exception('error image');
446
                }
447
                $image = $images[$i];
448
                if (!$resetrotation) {
449
                    $pagerotation = page_editor::get_page_rotation($grade->id, $i);
450
                    $degree = !empty($pagerotation) ? $pagerotation->degree : 0;
451
                    if ($degree != 0) {
452
                        $filepath = $tmpdir . '/' . $image;
453
                        $imageresource = imagecreatefrompng($filepath);
454
                        $content = imagerotate($imageresource, $degree, 0);
455
                        imagepng($content, $filepath);
456
                    }
457
                }
458
            } catch (\moodle_exception $e) {
459
                // We catch only moodle_exception here as other exceptions indicate issue with setup not the pdf.
460
                $image = pdf::get_error_image($tmpdir, $i);
461
            }
462
            $record->filename = basename($image);
463
            $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
464
            @unlink($tmpdir . '/' . $image);
465
            // Set page rotation default value.
466
            if (!empty($files[$i])) {
467
                if ($resetrotation) {
468
                    page_editor::set_page_rotation($grade->id, $i, false, $files[$i]->get_pathnamehash());
469
                }
470
            }
471
        }
472
        $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
473
 
474
        @unlink($combined);
475
        @rmdir($tmpdir);
476
 
477
        return $files;
478
    }
479
 
480
    /**
481
     * This function returns a list of the page images from a pdf.
482
     *
483
     * The readonly version is different than the normal one. The readonly version contains a copy
484
     * of the pages in the state they were when the PDF was annotated, by doing so we prevent the
485
     * the pages that are displayed to change as soon as the submission changes.
486
     *
487
     * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible
488
     * that we do not find any readonly version of the pages. In that case, we will get the normal
489
     * pages and copy them to the readonly area. This ensures that the pages will remain in that
490
     * state until the submission is updated. When the normal files do not exist, we throw an exception
491
     * because the readonly pages should only ever be displayed after a teacher has annotated the PDF,
492
     * they would not exist until they do.
493
     *
494
     * @param int|\assign $assignment
495
     * @param int $userid
496
     * @param int $attemptnumber (-1 means latest attempt)
497
     * @param bool $readonly If true, then we are requesting the readonly version.
498
     * @return array(stored_file)
499
     */
500
    public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
501
        global $DB;
502
 
503
        $assignment = self::get_assignment_from_param($assignment);
504
 
505
        if (!$assignment->can_view_submission($userid)) {
506
            throw new \moodle_exception('nopermission');
507
        }
508
 
509
        if ($assignment->get_instance()->teamsubmission) {
510
            $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
511
        } else {
512
            $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
513
        }
514
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
515
 
516
        $contextid = $assignment->get_context()->id;
517
        $component = 'assignfeedback_editpdf';
518
        $itemid = $grade->id;
519
        $filepath = '/';
520
        $filearea = self::PAGE_IMAGE_FILEAREA;
521
 
522
        $fs = get_file_storage();
523
 
524
        // If we are after the readonly pages...
525
        if ($readonly) {
526
            $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
527
            if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
528
                // We have a problem here, we were supposed to find the files.
529
                // Attempt to re-generate the pages from the combined images.
530
                self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber);
531
                self::copy_pages_to_readonly_area($assignment, $grade);
532
            }
533
        }
534
 
535
        $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
536
 
537
        $pages = array();
538
        $resetrotation = false;
539
        if (!empty($files)) {
540
            $first = reset($files);
541
            $pagemodified = $first->get_timemodified();
542
            // Check that we don't just have a single blank page. The hash of a blank page image can vary with
543
            // the version of ghostscript used, so we need to examine the combined pdf it was generated from.
544
            $blankpage = false;
545
            if (!$readonly && count($files) == 1) {
546
                $pdfarea = self::COMBINED_PDF_FILEAREA;
547
                $pdfname = self::COMBINED_PDF_FILENAME;
548
                if ($pdf = $fs->get_file($contextid, $component, $pdfarea, $itemid, $filepath, $pdfname)) {
549
                    // The combined pdf may have a different hash if it has been regenerated since the page
550
                    // image was created. However if this is the case the page image will be stale anyway.
551
                    if ($pdf->get_contenthash() == self::BLANK_PDF_HASH || $pagemodified < $pdf->get_timemodified()) {
552
                        $blankpage = true;
553
                    }
554
                }
555
            }
556
            if (!$readonly && ($pagemodified < $submission->timemodified || $blankpage)) {
557
                // Image files are stale, we need to regenerate them, except in readonly mode.
558
                // We also need to remove the draft annotations and comments associated with this attempt.
559
                $fs->delete_area_files($contextid, $component, $filearea, $itemid);
560
                page_editor::delete_draft_content($itemid);
561
                $files = array();
562
                $resetrotation = true;
563
            } else {
564
 
565
                // Need to reorder the files following their name.
566
                // because get_directory_files() return a different order than generate_page_images_for_attempt().
567
                foreach ($files as $file) {
568
                    // Extract the page number from the file name image_pageXXXX.png.
569
                    preg_match('/page([\d]+)\./', $file->get_filename(), $matches);
570
                    if (empty($matches) or !is_numeric($matches[1])) {
571
                        throw new \coding_exception("'" . $file->get_filename()
572
                            . "' file hasn't the expected format filename: image_pageXXXX.png.");
573
                    }
574
                    $pagenumber = (int)$matches[1];
575
 
576
                    // Save the page in the ordered array.
577
                    $pages[$pagenumber] = $file;
578
                }
579
                ksort($pages);
580
            }
581
        }
582
 
583
        // This should never happen, there should be a version of the pages available
584
        // whenever we are requesting the readonly version.
585
        if (empty($pages) && $readonly) {
586
            throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
587
        }
588
 
589
        // There are two situations where the number of page images generated does not
590
        // match the number of pages in the PDF:
591
        //
592
        // 1. The document conversion adhoc task was interrupted somehow (node died, solar flare, etc)
593
        // 2. The submission has been updated by the student
594
        //
595
        // In the case of 1. we need to regenerate the pages, see MDL-66626.
596
        // In the case of 2. we should do nothing, see MDL-45580.
597
        //
598
        // To differentiate between 1. and 2. we can check if the submission has been modified since the
599
        // pages were generated. If it has, then we're in situation 2.
600
        $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
601
        $submissionmodified = isset($pagemodified) && $submission->timemodified > $pagemodified;
602
        if (empty($pages) || (count($pages) != $totalpagesforattempt && !$submissionmodified)) {
603
            $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
604
        }
605
 
606
        return $pages;
607
    }
608
 
609
    /**
610
     * This function returns sensible filename for a feedback file.
611
     * @param int|\assign $assignment
612
     * @param int $userid
613
     * @param int $attemptnumber (-1 means latest attempt)
614
     * @return string
615
     */
616
    protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
617
        global $DB;
618
 
619
        $assignment = self::get_assignment_from_param($assignment);
620
 
621
        $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
622
        $groupname = '';
623
        if ($groupmode) {
624
            $groupid = groups_get_activity_group($assignment->get_course_module(), true);
625
            $groupname = groups_get_group_name($groupid).'-';
626
        }
627
        if ($groupname == '-') {
628
            $groupname = '';
629
        }
630
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
631
        $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
632
 
633
        if ($assignment->is_blind_marking()) {
634
            $prefix = $groupname . get_string('participant', 'assign');
635
            $prefix = str_replace('_', ' ', $prefix);
636
            $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
637
        } else {
638
            $prefix = $groupname . fullname($user);
639
            $prefix = str_replace('_', ' ', $prefix);
640
            $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
641
        }
642
        $prefix .= $grade->attemptnumber;
643
 
644
        return $prefix . '.pdf';
645
    }
646
 
647
    /**
648
     * This function takes the combined pdf and embeds all the comments and annotations.
649
     *
650
     * This also moves the annotations and comments from drafts to not drafts. And it will
651
     * copy all the images stored to the readonly area, so that they can be viewed online, and
652
     * not be overwritten when a new submission is sent.
653
     *
654
     * @param int|\assign $assignment
655
     * @param int $userid
656
     * @param int $attemptnumber (-1 means latest attempt)
657
     * @return \stored_file
658
     */
659
    public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
660
        global $CFG;
661
 
662
        $assignment = self::get_assignment_from_param($assignment);
663
 
664
        if (!$assignment->can_view_submission($userid)) {
665
            throw new \moodle_exception('nopermission');
666
        }
667
        if (!$assignment->can_grade()) {
668
            throw new \moodle_exception('nopermission');
669
        }
670
 
671
        // Need to generate the page images - first get a combined pdf.
672
        $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
673
 
674
        $status = $document->get_status();
675
        if ($status === combined_document::STATUS_FAILED) {
676
            throw new \moodle_exception('Could not generate combined pdf.');
677
        } else if ($status === combined_document::STATUS_PENDING_INPUT) {
678
            // The conversion is still in progress.
679
            return false;
680
        }
681
 
682
        $file = $document->get_combined_file();
683
 
684
        $tmpdir = make_request_directory();
685
        $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
686
        $file->copy_content_to($combined); // Copy the file.
687
 
688
        $pdf = new pdf();
689
 
690
        // Set fontname from course setting if it's enabled.
691
        if (!empty($CFG->enablepdfexportfont)) {
692
            $fontlist = $pdf->get_export_fontlist();
693
            // Load font from course if it's more than 1.
694
            if (count($fontlist) > 1) {
695
                $course = $assignment->get_course();
696
                if (!empty($course->pdfexportfont)) {
697
                    $pdf->set_export_font_name($course->pdfexportfont);
698
                }
699
            } else {
700
                $pdf->set_export_font_name(current($fontlist));
701
            }
702
        }
703
 
704
        $fs = get_file_storage();
705
        $stamptmpdir = make_request_directory();
706
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
707
        // Copy any new stamps to this instance.
708
        if ($files = $fs->get_area_files($assignment->get_context()->id,
709
                                         'assignfeedback_editpdf',
710
                                         'stamps',
711
                                         $grade->id,
712
                                         "filename",
713
                                         false)) {
714
            foreach ($files as $file) {
715
                $filename = $stamptmpdir . '/' . $file->get_filename();
716
                $file->copy_content_to($filename); // Copy the file.
717
            }
718
        }
719
 
720
        $pagecount = $pdf->set_pdf($combined);
721
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
722
        page_editor::release_drafts($grade->id);
723
 
724
        $allcomments = array();
725
 
726
        for ($i = 0; $i < $pagecount; $i++) {
727
            $pagerotation = page_editor::get_page_rotation($grade->id, $i);
728
            $pagemargin = $pdf->getBreakMargin();
729
            $autopagebreak = $pdf->getAutoPageBreak();
730
            if (empty($pagerotation) || !$pagerotation->isrotated) {
731
                $pdf->copy_page();
732
            } else {
733
                $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
734
                if (empty($rotatedimagefile)) {
735
                    $pdf->copy_page();
736
                } else {
737
                    $pdf->add_image_page($rotatedimagefile);
738
                }
739
            }
740
 
741
            $comments = page_editor::get_comments($grade->id, $i, false);
742
            $annotations = page_editor::get_annotations($grade->id, $i, false);
743
 
744
            if (!empty($comments)) {
745
                $allcomments[$i] = $comments;
746
            }
747
 
748
            foreach ($annotations as $annotation) {
749
                $pdf->add_annotation($annotation->x,
750
                                     $annotation->y,
751
                                     $annotation->endx,
752
                                     $annotation->endy,
753
                                     $annotation->colour,
754
                                     $annotation->type,
755
                                     $annotation->path,
756
                                     $stamptmpdir);
757
            }
758
            $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
759
            $pdf->setPageMark();
760
        }
761
 
762
        if (!empty($allcomments)) {
763
            // Append all comments to the end of the document.
764
            $links = $pdf->append_comments($allcomments);
765
            // Add the comment markers with links.
766
            foreach ($allcomments as $pageno => $comments) {
767
                foreach ($comments as $index => $comment) {
768
                    $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
769
                            $comment->colour);
770
                }
771
            }
772
        }
773
 
774
        fulldelete($stamptmpdir);
775
 
776
        $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
777
        $filename = clean_param($filename, PARAM_FILE);
778
 
779
        $generatedpdf = $tmpdir . '/' . $filename;
780
        $pdf->save_pdf($generatedpdf);
781
 
782
        $record = new \stdClass();
783
 
784
        $record->contextid = $assignment->get_context()->id;
785
        $record->component = 'assignfeedback_editpdf';
786
        $record->filearea = self::FINAL_PDF_FILEAREA;
787
        $record->itemid = $grade->id;
788
        $record->filepath = '/';
789
        $record->filename = $filename;
790
 
791
        // Only keep one current version of the generated pdf.
792
        $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
793
 
794
        $file = $fs->create_file_from_pathname($record, $generatedpdf);
795
 
796
        // Cleanup.
797
        @unlink($generatedpdf);
798
        @unlink($combined);
799
        @rmdir($tmpdir);
800
 
801
        self::copy_pages_to_readonly_area($assignment, $grade);
802
 
803
        return $file;
804
    }
805
 
806
    /**
807
     * Copy the pages image to the readonly area.
808
     *
809
     * @param int|\assign $assignment The assignment.
810
     * @param \stdClass $grade The grade record.
811
     * @return void
812
     */
813
    public static function copy_pages_to_readonly_area($assignment, $grade) {
814
        $fs = get_file_storage();
815
        $assignment = self::get_assignment_from_param($assignment);
816
        $contextid = $assignment->get_context()->id;
817
        $component = 'assignfeedback_editpdf';
818
        $itemid = $grade->id;
819
 
820
        // Get all the pages.
821
        $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
822
        if (empty($originalfiles)) {
823
            // Nothing to do here...
824
            return;
825
        }
826
 
827
        // Delete the old readonly files.
828
        $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
829
 
830
        // Do the copying.
831
        foreach ($originalfiles as $originalfile) {
832
            $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
833
        }
834
    }
835
 
836
    /**
837
     * This function returns the generated pdf (if it exists).
838
     * @param int|\assign $assignment
839
     * @param int $userid
840
     * @param int $attemptnumber (-1 means latest attempt)
841
     * @return \stored_file
842
     */
843
    public static function get_feedback_document($assignment, $userid, $attemptnumber) {
844
 
845
        $assignment = self::get_assignment_from_param($assignment);
846
 
847
        if (!$assignment->can_view_submission($userid)) {
848
            throw new \moodle_exception('nopermission');
849
        }
850
 
851
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
852
 
853
        $contextid = $assignment->get_context()->id;
854
        $component = 'assignfeedback_editpdf';
855
        $filearea = self::FINAL_PDF_FILEAREA;
856
        $itemid = $grade->id;
857
        $filepath = '/';
858
 
859
        $fs = get_file_storage();
860
        $files = $fs->get_area_files($contextid,
861
                                     $component,
862
                                     $filearea,
863
                                     $itemid,
864
                                     "itemid, filepath, filename",
865
                                     false);
866
        if ($files) {
867
            return reset($files);
868
        }
869
        return false;
870
    }
871
 
872
    /**
873
     * This function deletes the generated pdf for a student.
874
     * @param int|\assign $assignment
875
     * @param int $userid
876
     * @param int $attemptnumber (-1 means latest attempt)
877
     * @return bool
878
     */
879
    public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
880
 
881
        $assignment = self::get_assignment_from_param($assignment);
882
 
883
        if (!$assignment->can_view_submission($userid)) {
884
            throw new \moodle_exception('nopermission');
885
        }
886
        if (!$assignment->can_grade()) {
887
            throw new \moodle_exception('nopermission');
888
        }
889
 
890
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
891
 
892
        $contextid = $assignment->get_context()->id;
893
        $component = 'assignfeedback_editpdf';
894
        $filearea = self::FINAL_PDF_FILEAREA;
895
        $itemid = $grade->id;
896
 
897
        $fs = get_file_storage();
898
        return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
899
    }
900
 
901
    /**
902
     * Get All files in a File area
903
     * @param int|\assign $assignment Assignment
904
     * @param int $userid User ID
905
     * @param int $attemptnumber Attempt Number
906
     * @param string $filearea File Area
907
     * @param string $filepath File Path
908
     * @return array
909
     */
910
    private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
911
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
912
        $itemid = $grade->id;
913
        $contextid = $assignment->get_context()->id;
914
        $component = self::COMPONENT;
915
        $fs = get_file_storage();
916
        $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
917
        return $files;
918
    }
919
 
920
    /**
921
     * Save file.
922
     * @param int|\assign $assignment Assignment
923
     * @param int $userid User ID
924
     * @param int $attemptnumber Attempt Number
925
     * @param string $filearea File Area
926
     * @param string $newfilepath File Path
927
     * @param string $storedfilepath stored file path
928
     * @return \stored_file
929
     * @throws \file_exception
930
     * @throws \stored_file_creation_exception
931
     */
932
    private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
933
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
934
        $itemid = $grade->id;
935
        $contextid = $assignment->get_context()->id;
936
 
937
        $record = new \stdClass();
938
        $record->contextid = $contextid;
939
        $record->component = self::COMPONENT;
940
        $record->filearea = $filearea;
941
        $record->itemid = $itemid;
942
        $record->filepath = $storedfilepath;
943
        $record->filename = basename($newfilepath);
944
 
945
        $fs = get_file_storage();
946
 
947
        $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
948
            $record->itemid, $record->filepath, $record->filename);
949
 
950
        if ($oldfile) {
951
            $newhash = \file_storage::hash_from_path($newfilepath);
952
            if ($newhash === $oldfile->get_contenthash()) {
953
                // Use existing file if contenthash match.
954
                return $oldfile;
955
            }
956
            // Delete existing file.
957
            $oldfile->delete();
958
        }
959
 
960
        return $fs->create_file_from_pathname($record, $newfilepath);
961
    }
962
 
963
    /**
964
     * This function rotate a page, and mark the page as rotated.
965
     * @param int|\assign $assignment Assignment
966
     * @param int $userid User ID
967
     * @param int $attemptnumber Attempt Number
968
     * @param int $index Index of Current Page
969
     * @param bool $rotateleft To determine whether the page is rotated left or right.
970
     * @return null|\stored_file return rotated File
971
     * @throws \coding_exception
972
     * @throws \file_exception
973
     * @throws \moodle_exception
974
     * @throws \stored_file_creation_exception
975
     */
976
    public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
977
        $assignment = self::get_assignment_from_param($assignment);
978
        $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
979
        // Check permission.
980
        if (!$assignment->can_view_submission($userid)) {
981
            throw new \moodle_exception('nopermission');
982
        }
983
 
984
        $filearea = self::PAGE_IMAGE_FILEAREA;
985
        $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
986
        if (!empty($files)) {
987
            foreach ($files as $file) {
988
                preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
989
                if (empty($matches) or !is_numeric($matches[1])) {
990
                    throw new \coding_exception("'" . $file->get_filename()
991
                        . "' file hasn't the expected format filename: image_pageXXXX.png.");
992
                }
993
                $pagenumber = (int)$matches[1];
994
 
995
                if ($pagenumber == $index) {
996
                    $source = imagecreatefromstring($file->get_content());
997
                    $pagerotation = page_editor::get_page_rotation($grade->id, $index);
998
                    $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
999
                    if ($rotateleft) {
1000
                        $content = imagerotate($source, 90, 0);
1001
                        $degree = ($degree + 90) % 360;
1002
                    } else {
1003
                        $content = imagerotate($source, -90, 0);
1004
                        $degree = ($degree - 90) % 360;
1005
                    }
1006
                    $filename = $matches[0].'png';
1007
                    $tmpdir = make_request_directory();
1008
                    $tempfile = $tmpdir . '/' . time() . '_' . $filename;
1009
                    imagepng($content, $tempfile);
1010
 
1011
                    $filearea = self::PAGE_IMAGE_FILEAREA;
1012
                    $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1013
 
1014
                    unlink($tempfile);
1015
                    rmdir($tmpdir);
1016
                    imagedestroy($source);
1017
                    imagedestroy($content);
1018
                    $file->delete();
1019
                    if (!empty($newfile)) {
1020
                        page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
1021
                    }
1022
                    return $newfile;
1023
                }
1024
            }
1025
        }
1026
        return null;
1027
    }
1028
 
1029
    /**
1030
     * Convert jpg file to pdf file
1031
     * @param int|\assign $assignment Assignment
1032
     * @param int $userid User ID
1033
     * @param int $attemptnumber Attempt Number
1034
     * @param \stored_file $file file to save
1035
     * @param null|array $size size of image
1036
     * @return \stored_file
1037
     * @throws \file_exception
1038
     * @throws \stored_file_creation_exception
1039
     */
1040
    private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
1041
        // Temporary file.
1042
        $filename = $file->get_filename();
1043
        $tmpdir = make_request_directory();
1044
        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
1045
        // Determine orientation.
1046
        $orientation = 'P';
1047
        if (!empty($size['width']) && !empty($size['height'])) {
1048
            if ($size['width'] > $size['height']) {
1049
                $orientation = 'L';
1050
            }
1051
        }
1052
        // Save JPG image to PDF file.
1053
        $pdf = new pdf();
1054
        $pdf->SetHeaderMargin(0);
1055
        $pdf->SetFooterMargin(0);
1056
        $pdf->SetMargins(0, 0, 0, true);
1057
        $pdf->setPrintFooter(false);
1058
        $pdf->setPrintHeader(false);
1059
        $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
1060
        $pdf->AddPage($orientation);
1061
        $pdf->SetAutoPageBreak(false);
1062
        // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
1063
        if ($orientation == 'P') {
1064
            $pdf->Image('@' . $file->get_content(), 0, 0, 210);
1065
        } else {
1066
            $pdf->Image('@' . $file->get_content(), 0, 0, 297);
1067
        }
1068
        $pdf->setPageMark();
1069
        $pdf->save_pdf($tempfile);
1070
        $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
1071
        $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1072
        if (file_exists($tempfile)) {
1073
            unlink($tempfile);
1074
            rmdir($tmpdir);
1075
        }
1076
        return $pdffile;
1077
    }
1078
 
1079
    /**
1080
     * Save rotated image data to file.
1081
     * @param int|\assign $assignment Assignment
1082
     * @param int $userid User ID
1083
     * @param int $attemptnumber Attempt Number
1084
     * @param resource $rotateddata image data to save
1085
     * @param string $filename name of the image file
1086
     * @return \stored_file
1087
     * @throws \file_exception
1088
     * @throws \stored_file_creation_exception
1089
     */
1090
    private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
1091
        $filearea = self::TMP_ROTATED_JPG_FILEAREA;
1092
        $tmpdir = make_request_directory();
1093
        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
1094
        imagejpeg($rotateddata, $tempfile);
1095
        $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1096
        if (file_exists($tempfile)) {
1097
            unlink($tempfile);
1098
            rmdir($tmpdir);
1099
        }
1100
        return $newfile;
1101
    }
1102
}