MDL-63349 assignfeedback_editpdf: Rotate submitted image automatically
[moodle.git] / mod / assign / feedback / editpdf / classes / document_services.php
CommitLineData
5c386472
DW
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
2e76c14e
DW
27use DOMDocument;
28
5c386472
DW
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
9432553b
NN
41 /** Compoment name */
42 const COMPONENT = "assignfeedback_editpdf";
5c386472
DW
43 /** File area for generated pdf */
44 const FINAL_PDF_FILEAREA = 'download';
45 /** File area for combined pdf */
46 const COMBINED_PDF_FILEAREA = 'combined';
86c57709
DW
47 /** File area for partial combined pdf */
48 const PARTIAL_PDF_FILEAREA = 'partial';
2e76c14e
DW
49 /** File area for importing html */
50 const IMPORT_HTML_FILEAREA = 'importhtml';
5c386472
DW
51 /** File area for page images */
52 const PAGE_IMAGE_FILEAREA = 'pages';
098f7dd4
FM
53 /** File area for readonly page images */
54 const PAGE_IMAGE_READONLY_FILEAREA = 'readonlypages';
9b65a080
AN
55 /** File area for the stamps */
56 const STAMPS_FILEAREA = 'stamps';
5c386472
DW
57 /** Filename for combined pdf */
58 const COMBINED_PDF_FILENAME = 'combined.pdf';
4a6edc57
NN
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';
fecfb4c6
TB
63 /** Hash of blank pdf */
64 const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
5c386472 65
1356d851
DW
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
5c386472
DW
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)) {
357712ea 102 $cm = get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST);
5c386472
DW
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
2e76c14e
DW
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) {
888d7b58 131 // Load HTML and suppress any parsing errors (DOMDocument->loadHTML() does not current support HTML5 tags).
2e76c14e 132 $dom = new DOMDocument();
888d7b58
ZT
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);
2e76c14e 146 }
2e76c14e 147 }
efacf3a0
DW
148 $count = 1;
149 return str_replace("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>", "", $dom->saveHTML(), $count);
2e76c14e
DW
150 }
151
5c386472
DW
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.
f7a9f1dd 156 *
5c386472
DW
157 * @param int|\assign $assignment
158 * @param int $userid
159 * @param int $attemptnumber (-1 means latest attempt)
f7a9f1dd 160 * @return combined_document
5c386472 161 */
f7a9f1dd 162 protected static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) {
5c386472
DW
163 global $USER, $DB;
164
165 $assignment = self::get_assignment_from_param($assignment);
166
167 // Capability checks.
168 if (!$assignment->can_view_submission($userid)) {
357712ea 169 print_error('nopermission');
5c386472
DW
170 }
171
172 $files = array();
173
174 if ($assignment->get_instance()->teamsubmission) {
f5ba9ac9 175 $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
5c386472 176 } else {
f5ba9ac9 177 $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
5c386472
DW
178 }
179 $user = $DB->get_record('user', array('id' => $userid));
180
181 // User has not submitted anything yet.
182 if (!$submission) {
f7a9f1dd 183 return new combined_document();
5c386472 184 }
2e76c14e
DW
185
186 $fs = get_file_storage();
f7a9f1dd 187 $converter = new \core_files\converter();
5c386472
DW
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) {
2e76c14e 193 if ($file instanceof \stored_file) {
4a6edc57
NN
194 $mimetype = $file->get_mimetype();
195 // PDF File, no conversion required.
196 if ($mimetype === 'application/pdf') {
2e76c14e 197 $files[$filename] = $file;
4a6edc57
NN
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')) {
2e76c14e
DW
216 $files[$filename] = $convertedfile;
217 }
23fe1e2b 218 } else if ($converter->can_convert_format_to('html', 'pdf')) {
2e76c14e
DW
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
f7a9f1dd
AN
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
2e76c14e
DW
253 if ($convertedfile) {
254 $files[$filename] = $convertedfile;
255 }
5c386472
DW
256 }
257 }
258 }
259 }
f7a9f1dd
AN
260 $combineddocument = new combined_document();
261 $combineddocument->set_source_files($files);
262
263 return $combineddocument;
5c386472
DW
264 }
265
266 /**
f7a9f1dd
AN
267 * Fetch the current combined document ready for state checking.
268 *
5c386472
DW
269 * @param int|\assign $assignment
270 * @param int $userid
271 * @param int $attemptnumber (-1 means latest attempt)
f7a9f1dd 272 * @return combined_document
5c386472 273 */
f7a9f1dd 274 public static function get_combined_document_for_attempt($assignment, $userid, $attemptnumber) {
5c386472
DW
275 global $USER, $DB;
276
277 $assignment = self::get_assignment_from_param($assignment);
278
279 // Capability checks.
280 if (!$assignment->can_view_submission($userid)) {
357712ea 281 print_error('nopermission');
5c386472
DW
282 }
283
284 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
285 if ($assignment->get_instance()->teamsubmission) {
f5ba9ac9 286 $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
5c386472 287 } else {
f5ba9ac9 288 $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
5c386472
DW
289 }
290
291 $contextid = $assignment->get_context()->id;
292 $component = 'assignfeedback_editpdf';
293 $filearea = self::COMBINED_PDF_FILEAREA;
86c57709 294 $partialfilearea = self::PARTIAL_PDF_FILEAREA;
5c386472
DW
295 $itemid = $grade->id;
296 $filepath = '/';
297 $filename = self::COMBINED_PDF_FILENAME;
357712ea 298 $fs = get_file_storage();
5c386472 299
86c57709
DW
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
f7a9f1dd
AN
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 }
fecfb4c6 315 }
f7a9f1dd
AN
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);
5c386472 324 }
5c386472
DW
325 }
326
327 /**
f7a9f1dd
AN
328 * This function return the combined pdf for all valid submission files.
329 *
5c386472
DW
330 * @param int|\assign $assignment
331 * @param int $userid
332 * @param int $attemptnumber (-1 means latest attempt)
f7a9f1dd 333 * @return combined_document
5c386472 334 */
f7a9f1dd
AN
335 public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) {
336 $document = self::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
ac2b4ffc 337
f7a9f1dd
AN
338 if ($document->get_status() === combined_document::STATUS_COMPLETE) {
339 // The combined document is already ready.
340 return $document;
5c386472 341 } else {
f7a9f1dd
AN
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;
5c386472 346 }
5c386472
DW
347 }
348
aa3e4bde
JM
349 /**
350 * This function will return the number of pages of a pdf.
f7a9f1dd 351 *
aa3e4bde
JM
352 * @param int|\assign $assignment
353 * @param int $userid
354 * @param int $attemptnumber (-1 means latest attempt)
098f7dd4 355 * @param bool $readonly When true we get the number of pages for the readonly version.
aa3e4bde
JM
356 * @return int number of pages
357 */
098f7dd4 358 public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
aa3e4bde
JM
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)) {
357712ea 366 print_error('nopermission');
aa3e4bde
JM
367 }
368
098f7dd4
FM
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
aa3e4bde 382 // Get a combined pdf file from all submitted pdf files.
f7a9f1dd
AN
383 $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
384 return $document->get_page_count();
aa3e4bde
JM
385 }
386
5c386472
DW
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)
9432553b 392 * @param bool $resetrotation check if need to reset page rotation information
5c386472
DW
393 * @return array(stored_file)
394 */
9432553b 395 protected static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation = true) {
5c386472
DW
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)) {
357712ea 403 print_error('nopermission');
5c386472
DW
404 }
405
406 // Need to generate the page images - first get a combined pdf.
f7a9f1dd
AN
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 [];
5c386472
DW
415 }
416
417 $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber));
418 $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
86c57709 419
f7a9f1dd 420 $document->get_combined_file()->copy_content_to($combined); // Copy the file.
5c386472
DW
421
422 $pdf = new pdf();
423
424 $pdf->set_image_folder($tmpdir);
425 $pagecount = $pdf->set_pdf($combined);
426
5c386472
DW
427 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
428
5c386472
DW
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 = '/';
357712ea 435 $fs = get_file_storage();
5c386472 436
9b65a080
AN
437 // Remove the existing content of the filearea.
438 $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
439
aa3e4bde
JM
440 $files = array();
441 for ($i = 0; $i < $pagecount; $i++) {
1f3556b0
AA
442 try {
443 $image = $pdf->get_image($i);
9432553b
NN
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 }
1f3556b0
AA
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 }
5c386472 458 $record->filename = basename($image);
aa3e4bde 459 $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image);
5c386472 460 @unlink($tmpdir . '/' . $image);
9432553b
NN
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 }
5c386472 467 }
a916d557 468 $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
aa3e4bde 469
5c386472
DW
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.
098f7dd4
FM
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 *
5c386472
DW
490 * @param int|\assign $assignment
491 * @param int $userid
492 * @param int $attemptnumber (-1 means latest attempt)
098f7dd4 493 * @param bool $readonly If true, then we are requesting the readonly version.
5c386472
DW
494 * @return array(stored_file)
495 */
098f7dd4 496 public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) {
fecfb4c6 497 global $DB;
5c386472
DW
498
499 $assignment = self::get_assignment_from_param($assignment);
500
501 if (!$assignment->can_view_submission($userid)) {
357712ea 502 print_error('nopermission');
5c386472
DW
503 }
504
505 if ($assignment->get_instance()->teamsubmission) {
f5ba9ac9 506 $submission = $assignment->get_group_submission($userid, 0, false, $attemptnumber);
5c386472 507 } else {
f5ba9ac9 508 $submission = $assignment->get_user_submission($userid, false, $attemptnumber);
5c386472
DW
509 }
510 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
511
512 $contextid = $assignment->get_context()->id;
513 $component = 'assignfeedback_editpdf';
5c386472
DW
514 $itemid = $grade->id;
515 $filepath = '/';
098f7dd4 516 $filearea = self::PAGE_IMAGE_FILEAREA;
5c386472 517
357712ea 518 $fs = get_file_storage();
5c386472 519
098f7dd4 520 // If we are after the readonly pages...
098f7dd4
FM
521 if ($readonly) {
522 $filearea = self::PAGE_IMAGE_READONLY_FILEAREA;
523 if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) {
9b65a080
AN
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);
098f7dd4
FM
528 }
529 }
530
5c386472
DW
531 $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
532
098f7dd4 533 $pages = array();
9432553b 534 $resetrotation = false;
5c386472
DW
535 if (!empty($files)) {
536 $first = reset($files);
fecfb4c6
TB
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)) {
7dbbb848
FM
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.
5c386472 555 $fs->delete_area_files($contextid, $component, $filearea, $itemid);
7dbbb848 556 page_editor::delete_draft_content($itemid);
5c386472 557 $files = array();
9432553b 558 $resetrotation = true;
5c386472 559 } else {
d40ce26f
JM
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().
9432553b 563 foreach ($files as $file) {
d40ce26f
JM
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.
098f7dd4 573 $pages[$pagenumber] = $file;
d40ce26f 574 }
098f7dd4 575 ksort($pages);
5c386472
DW
576 }
577 }
098f7dd4
FM
578
579 if (empty($pages)) {
580 if ($readonly) {
581 // This should never happen, there should be a version of the pages available
582 // whenever we are requesting the readonly version.
583 throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id);
584 }
9432553b 585 $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation);
098f7dd4
FM
586 }
587
588 return $pages;
5c386472
DW
589 }
590
591 /**
592 * This function returns sensible filename for a feedback file.
593 * @param int|\assign $assignment
594 * @param int $userid
595 * @param int $attemptnumber (-1 means latest attempt)
596 * @return string
597 */
598 protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) {
599 global $DB;
600
601 $assignment = self::get_assignment_from_param($assignment);
602
603 $groupmode = groups_get_activity_groupmode($assignment->get_course_module());
604 $groupname = '';
605 if ($groupmode) {
606 $groupid = groups_get_activity_group($assignment->get_course_module(), true);
607 $groupname = groups_get_group_name($groupid).'-';
608 }
609 if ($groupname == '-') {
610 $groupname = '';
611 }
612 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
613 $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST);
614
615 if ($assignment->is_blind_marking()) {
616 $prefix = $groupname . get_string('participant', 'assign');
617 $prefix = str_replace('_', ' ', $prefix);
618 $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
619 } else {
620 $prefix = $groupname . fullname($user);
621 $prefix = str_replace('_', ' ', $prefix);
622 $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_');
623 }
624 $prefix .= $grade->attemptnumber;
625
626 return $prefix . '.pdf';
627 }
628
629 /**
630 * This function takes the combined pdf and embeds all the comments and annotations.
098f7dd4
FM
631 *
632 * This also moves the annotations and comments from drafts to not drafts. And it will
633 * copy all the images stored to the readonly area, so that they can be viewed online, and
634 * not be overwritten when a new submission is sent.
635 *
5c386472
DW
636 * @param int|\assign $assignment
637 * @param int $userid
638 * @param int $attemptnumber (-1 means latest attempt)
639 * @return stored_file
640 */
641 public static function generate_feedback_document($assignment, $userid, $attemptnumber) {
642
643 $assignment = self::get_assignment_from_param($assignment);
644
645 if (!$assignment->can_view_submission($userid)) {
357712ea 646 print_error('nopermission');
5c386472
DW
647 }
648 if (!$assignment->can_grade()) {
357712ea 649 print_error('nopermission');
5c386472
DW
650 }
651
652 // Need to generate the page images - first get a combined pdf.
f7a9f1dd
AN
653 $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
654
655 $status = $document->get_status();
656 if ($status === combined_document::STATUS_FAILED) {
657 print_error('Could not generate combined pdf.');
658 } else if ($status === combined_document::STATUS_PENDING_INPUT) {
659 // The conversion is still in progress.
660 return false;
5c386472
DW
661 }
662
f7a9f1dd
AN
663 $file = $document->get_combined_file();
664
357712ea 665 $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber));
5c386472
DW
666 $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME;
667 $file->copy_content_to($combined); // Copy the file.
668
669 $pdf = new pdf();
670
357712ea
AN
671 $fs = get_file_storage();
672 $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber));
5c386472
DW
673 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
674 // Copy any new stamps to this instance.
675 if ($files = $fs->get_area_files($assignment->get_context()->id,
676 'assignfeedback_editpdf',
677 'stamps',
678 $grade->id,
679 "filename",
680 false)) {
681 foreach ($files as $file) {
682 $filename = $stamptmpdir . '/' . $file->get_filename();
683 $file->copy_content_to($filename); // Copy the file.
684 }
685 }
686
687 $pagecount = $pdf->set_pdf($combined);
688 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
689 page_editor::release_drafts($grade->id);
690
2c153c56
TB
691 $allcomments = array();
692
5c386472 693 for ($i = 0; $i < $pagecount; $i++) {
9432553b
NN
694 $pagerotation = page_editor::get_page_rotation($grade->id, $i);
695 $pagemargin = $pdf->getBreakMargin();
696 $autopagebreak = $pdf->getAutoPageBreak();
697 if (empty($pagerotation) || !$pagerotation->isrotated) {
698 $pdf->copy_page();
699 } else {
700 $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash);
701 if (empty($rotatedimagefile)) {
702 $pdf->copy_page();
703 } else {
704 $pdf->add_image_page($rotatedimagefile);
705 }
706 }
707
5c386472
DW
708 $comments = page_editor::get_comments($grade->id, $i, false);
709 $annotations = page_editor::get_annotations($grade->id, $i, false);
710
2c153c56
TB
711 if (!empty($comments)) {
712 $allcomments[$i] = $comments;
5c386472
DW
713 }
714
715 foreach ($annotations as $annotation) {
716 $pdf->add_annotation($annotation->x,
717 $annotation->y,
718 $annotation->endx,
719 $annotation->endy,
720 $annotation->colour,
721 $annotation->type,
722 $annotation->path,
723 $stamptmpdir);
724 }
9432553b
NN
725 $pdf->SetAutoPageBreak($autopagebreak, $pagemargin);
726 $pdf->setPageMark();
5c386472
DW
727 }
728
2c153c56
TB
729 if (!empty($allcomments)) {
730 // Append all comments to the end of the document.
731 $links = $pdf->append_comments($allcomments);
732 // Add the comment markers with links.
733 foreach ($allcomments as $pageno => $comments) {
734 foreach ($comments as $index => $comment) {
735 $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index],
736 $comment->colour);
737 }
738 }
739 }
740
5c386472
DW
741 fulldelete($stamptmpdir);
742
743 $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber);
744 $filename = clean_param($filename, PARAM_FILE);
745
746 $generatedpdf = $tmpdir . '/' . $filename;
747 $pdf->save_pdf($generatedpdf);
748
5c386472
DW
749 $record = new \stdClass();
750
751 $record->contextid = $assignment->get_context()->id;
752 $record->component = 'assignfeedback_editpdf';
753 $record->filearea = self::FINAL_PDF_FILEAREA;
754 $record->itemid = $grade->id;
755 $record->filepath = '/';
756 $record->filename = $filename;
757
5c386472
DW
758 // Only keep one current version of the generated pdf.
759 $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
760
761 $file = $fs->create_file_from_pathname($record, $generatedpdf);
762
763 // Cleanup.
764 @unlink($generatedpdf);
765 @unlink($combined);
766 @rmdir($tmpdir);
767
098f7dd4
FM
768 self::copy_pages_to_readonly_area($assignment, $grade);
769
5c386472
DW
770 return $file;
771 }
772
098f7dd4
FM
773 /**
774 * Copy the pages image to the readonly area.
775 *
776 * @param int|\assign $assignment The assignment.
777 * @param \stdClass $grade The grade record.
778 * @return void
779 */
780 public static function copy_pages_to_readonly_area($assignment, $grade) {
781 $fs = get_file_storage();
782 $assignment = self::get_assignment_from_param($assignment);
783 $contextid = $assignment->get_context()->id;
784 $component = 'assignfeedback_editpdf';
785 $itemid = $grade->id;
786
787 // Get all the pages.
788 $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid);
789 if (empty($originalfiles)) {
790 // Nothing to do here...
791 return;
792 }
793
794 // Delete the old readonly files.
795 $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid);
796
797 // Do the copying.
798 foreach ($originalfiles as $originalfile) {
799 $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile);
800 }
801 }
802
5c386472
DW
803 /**
804 * This function returns the generated pdf (if it exists).
805 * @param int|\assign $assignment
806 * @param int $userid
807 * @param int $attemptnumber (-1 means latest attempt)
808 * @return stored_file
809 */
810 public static function get_feedback_document($assignment, $userid, $attemptnumber) {
811
812 $assignment = self::get_assignment_from_param($assignment);
813
814 if (!$assignment->can_view_submission($userid)) {
357712ea 815 print_error('nopermission');
5c386472
DW
816 }
817
818 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
819
820 $contextid = $assignment->get_context()->id;
821 $component = 'assignfeedback_editpdf';
822 $filearea = self::FINAL_PDF_FILEAREA;
823 $itemid = $grade->id;
824 $filepath = '/';
825
357712ea 826 $fs = get_file_storage();
5c386472
DW
827 $files = $fs->get_area_files($contextid,
828 $component,
829 $filearea,
830 $itemid,
831 "itemid, filepath, filename",
832 false);
833 if ($files) {
834 return reset($files);
835 }
836 return false;
837 }
838
839 /**
840 * This function deletes the generated pdf for a student.
841 * @param int|\assign $assignment
842 * @param int $userid
843 * @param int $attemptnumber (-1 means latest attempt)
844 * @return bool
845 */
846 public static function delete_feedback_document($assignment, $userid, $attemptnumber) {
847
848 $assignment = self::get_assignment_from_param($assignment);
849
850 if (!$assignment->can_view_submission($userid)) {
357712ea 851 print_error('nopermission');
5c386472
DW
852 }
853 if (!$assignment->can_grade()) {
357712ea 854 print_error('nopermission');
5c386472
DW
855 }
856
857 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
858
859 $contextid = $assignment->get_context()->id;
860 $component = 'assignfeedback_editpdf';
861 $filearea = self::FINAL_PDF_FILEAREA;
862 $itemid = $grade->id;
863
357712ea 864 $fs = get_file_storage();
5c386472
DW
865 return $fs->delete_area_files($contextid, $component, $filearea, $itemid);
866 }
867
9432553b
NN
868 /**
869 * Get All files in a File area
870 * @param int|\assign $assignment Assignment
871 * @param int $userid User ID
872 * @param int $attemptnumber Attempt Number
873 * @param string $filearea File Area
874 * @param string $filepath File Path
875 * @return array
876 */
877 private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') {
878 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
879 $itemid = $grade->id;
880 $contextid = $assignment->get_context()->id;
881 $component = self::COMPONENT;
882 $fs = get_file_storage();
883 $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath);
884 return $files;
885 }
886
887 /**
888 * Save file.
889 * @param int|\assign $assignment Assignment
890 * @param int $userid User ID
891 * @param int $attemptnumber Attempt Number
892 * @param string $filearea File Area
893 * @param string $newfilepath File Path
894 * @param string $storedfilepath stored file path
895 * @return \stored_file
896 * @throws \file_exception
897 * @throws \stored_file_creation_exception
898 */
899 private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') {
900 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
901 $itemid = $grade->id;
902 $contextid = $assignment->get_context()->id;
903
904 $record = new \stdClass();
905 $record->contextid = $contextid;
906 $record->component = self::COMPONENT;
907 $record->filearea = $filearea;
908 $record->itemid = $itemid;
909 $record->filepath = $storedfilepath;
910 $record->filename = basename($newfilepath);
911
912 $fs = get_file_storage();
913
914 $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea,
915 $record->itemid, $record->filepath, $record->filename);
916
917 $newhash = sha1($newfilepath);
918
919 // Delete old file if exists.
920 if ($oldfile && $newhash !== $oldfile->get_contenthash()) {
921 $oldfile->delete();
922 }
923
924 return $fs->create_file_from_pathname($record, $newfilepath);
925 }
926
927 /**
928 * This function rotate a page, and mark the page as rotated.
929 * @param int|\assign $assignment Assignment
930 * @param int $userid User ID
931 * @param int $attemptnumber Attempt Number
932 * @param int $index Index of Current Page
933 * @param bool $rotateleft To determine whether the page is rotated left or right.
934 * @return null|\stored_file return rotated File
935 * @throws \coding_exception
936 * @throws \file_exception
937 * @throws \moodle_exception
938 * @throws \stored_file_creation_exception
939 */
940 public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) {
941 $assignment = self::get_assignment_from_param($assignment);
942 $grade = $assignment->get_user_grade($userid, true, $attemptnumber);
943 // Check permission.
944 if (!$assignment->can_view_submission($userid)) {
945 print_error('nopermission');
946 }
947
948 $filearea = self::PAGE_IMAGE_FILEAREA;
949 $files = self::get_files($assignment, $userid, $attemptnumber, $filearea);
950 if (!empty($files)) {
951 foreach ($files as $file) {
952 preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches);
953 if (empty($matches) or !is_numeric($matches[1])) {
954 throw new \coding_exception("'" . $file->get_filename()
955 . "' file hasn't the expected format filename: image_pageXXXX.png.");
956 }
957 $pagenumber = (int)$matches[1];
958
959 if ($pagenumber == $index) {
960 $source = imagecreatefromstring($file->get_content());
961 $pagerotation = page_editor::get_page_rotation($grade->id, $index);
962 $degree = empty($pagerotation) ? 0 : $pagerotation->degree;
963 if ($rotateleft) {
964 $content = imagerotate($source, 90, 0);
965 $degree = ($degree + 90) % 360;
966 } else {
967 $content = imagerotate($source, -90, 0);
968 $degree = ($degree - 90) % 360;
969 }
970 $filename = $matches[0].'png';
971 $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/'
972 . self::hash($assignment, $userid, $attemptnumber));
973 $tempfile = $tmpdir . '/' . time() . '_' . $filename;
974 imagepng($content, $tempfile);
975
976 $filearea = self::PAGE_IMAGE_FILEAREA;
977 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
978
979 unlink($tempfile);
980 rmdir($tmpdir);
981 imagedestroy($source);
982 imagedestroy($content);
983 $file->delete();
984 if (!empty($newfile)) {
985 page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree);
986 }
987 return $newfile;
988 }
989 }
990 }
991 return null;
992 }
4a6edc57
NN
993
994 /**
995 * Convert jpg file to pdf file
996 * @param int|\assign $assignment Assignment
997 * @param int $userid User ID
998 * @param int $attemptnumber Attempt Number
999 * @param \stored_file $file file to save
1000 * @param null|array $size size of image
1001 * @return \stored_file
1002 * @throws \file_exception
1003 * @throws \stored_file_creation_exception
1004 */
1005 private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
1006 // Temporary file.
1007 $filename = $file->get_filename();
1008 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1009 . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
1010 . self::hash($assignment, $userid, $attemptnumber));
1011 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
1012 // Determine orientation.
1013 $orientation = 'P';
1014 if (!empty($size['width']) && !empty($size['height'])) {
1015 if ($size['width'] > $size['height']) {
1016 $orientation = 'L';
1017 }
1018 }
1019 // Save JPG image to PDF file.
1020 $pdf = new pdf();
1021 $pdf->SetHeaderMargin(0);
1022 $pdf->SetFooterMargin(0);
1023 $pdf->SetMargins(0, 0, 0, true);
1024 $pdf->setPrintFooter(false);
1025 $pdf->setPrintHeader(false);
1026 $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
1027 $pdf->AddPage($orientation);
1028 $pdf->SetAutoPageBreak(false);
1029 // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
1030 if ($orientation == 'P') {
1031 $pdf->Image('@' . $file->get_content(), 0, 0, 210);
1032 } else {
1033 $pdf->Image('@' . $file->get_content(), 0, 0, 297);
1034 }
1035 $pdf->setPageMark();
1036 $pdf->save_pdf($tempfile);
1037 $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
1038 $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1039 if (file_exists($tempfile)) {
1040 unlink($tempfile);
1041 rmdir($tmpdir);
1042 }
1043 return $pdffile;
1044 }
1045
1046 /**
1047 * Save rotated image data to file.
1048 * @param int|\assign $assignment Assignment
1049 * @param int $userid User ID
1050 * @param int $attemptnumber Attempt Number
1051 * @param resource $rotateddata image data to save
1052 * @param string $filename name of the image file
1053 * @return \stored_file
1054 * @throws \file_exception
1055 * @throws \stored_file_creation_exception
1056 */
1057 private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
1058 $filearea = self::TMP_ROTATED_JPG_FILEAREA;
1059 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
1060 . $filearea . DIRECTORY_SEPARATOR
1061 . self::hash($assignment, $userid, $attemptnumber));
1062 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
1063 imagejpeg($rotateddata, $tempfile);
1064 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
1065 if (file_exists($tempfile)) {
1066 unlink($tempfile);
1067 rmdir($tmpdir);
1068 }
1069 return $newfile;
1070 }
1071
5c386472 1072}