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