See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [Versions 401 and 403]
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_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber)); 418 $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; 419 420 $document->get_combined_file()->copy_content_to($combined); // Copy the file. 421 422 $pdf = new pdf(); 423 424 $pdf->set_image_folder($tmpdir); 425 $pagecount = $pdf->set_pdf($combined); 426 427 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 428 429 $record = new \stdClass(); 430 $record->contextid = $assignment->get_context()->id; 431 $record->component = 'assignfeedback_editpdf'; 432 $record->filearea = self::PAGE_IMAGE_FILEAREA; 433 $record->itemid = $grade->id; 434 $record->filepath = '/'; 435 $fs = get_file_storage(); 436 437 // Remove the existing content of the filearea. 438 $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); 439 440 $files = array(); 441 $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 661 $assignment = self::get_assignment_from_param($assignment); 662 663 if (!$assignment->can_view_submission($userid)) { 664 throw new \moodle_exception('nopermission'); 665 } 666 if (!$assignment->can_grade()) { 667 throw new \moodle_exception('nopermission'); 668 } 669 670 // Need to generate the page images - first get a combined pdf. 671 $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); 672 673 $status = $document->get_status(); 674 if ($status === combined_document::STATUS_FAILED) { 675 throw new \moodle_exception('Could not generate combined pdf.'); 676 } else if ($status === combined_document::STATUS_PENDING_INPUT) { 677 // The conversion is still in progress. 678 return false; 679 } 680 681 $file = $document->get_combined_file(); 682 683 $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber)); 684 $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; 685 $file->copy_content_to($combined); // Copy the file. 686 687 $pdf = new pdf(); 688 689 $fs = get_file_storage(); 690 $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber)); 691 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 692 // Copy any new stamps to this instance. 693 if ($files = $fs->get_area_files($assignment->get_context()->id, 694 'assignfeedback_editpdf', 695 'stamps', 696 $grade->id, 697 "filename", 698 false)) { 699 foreach ($files as $file) { 700 $filename = $stamptmpdir . '/' . $file->get_filename(); 701 $file->copy_content_to($filename); // Copy the file. 702 } 703 } 704 705 $pagecount = $pdf->set_pdf($combined); 706 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 707 page_editor::release_drafts($grade->id); 708 709 $allcomments = array(); 710 711 for ($i = 0; $i < $pagecount; $i++) { 712 $pagerotation = page_editor::get_page_rotation($grade->id, $i); 713 $pagemargin = $pdf->getBreakMargin(); 714 $autopagebreak = $pdf->getAutoPageBreak(); 715 if (empty($pagerotation) || !$pagerotation->isrotated) { 716 $pdf->copy_page(); 717 } else { 718 $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash); 719 if (empty($rotatedimagefile)) { 720 $pdf->copy_page(); 721 } else { 722 $pdf->add_image_page($rotatedimagefile); 723 } 724 } 725 726 $comments = page_editor::get_comments($grade->id, $i, false); 727 $annotations = page_editor::get_annotations($grade->id, $i, false); 728 729 if (!empty($comments)) { 730 $allcomments[$i] = $comments; 731 } 732 733 foreach ($annotations as $annotation) { 734 $pdf->add_annotation($annotation->x, 735 $annotation->y, 736 $annotation->endx, 737 $annotation->endy, 738 $annotation->colour, 739 $annotation->type, 740 $annotation->path, 741 $stamptmpdir); 742 } 743 $pdf->SetAutoPageBreak($autopagebreak, $pagemargin); 744 $pdf->setPageMark(); 745 } 746 747 if (!empty($allcomments)) { 748 // Append all comments to the end of the document. 749 $links = $pdf->append_comments($allcomments); 750 // Add the comment markers with links. 751 foreach ($allcomments as $pageno => $comments) { 752 foreach ($comments as $index => $comment) { 753 $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index], 754 $comment->colour); 755 } 756 } 757 } 758 759 fulldelete($stamptmpdir); 760 761 $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber); 762 $filename = clean_param($filename, PARAM_FILE); 763 764 $generatedpdf = $tmpdir . '/' . $filename; 765 $pdf->save_pdf($generatedpdf); 766 767 $record = new \stdClass(); 768 769 $record->contextid = $assignment->get_context()->id; 770 $record->component = 'assignfeedback_editpdf'; 771 $record->filearea = self::FINAL_PDF_FILEAREA; 772 $record->itemid = $grade->id; 773 $record->filepath = '/'; 774 $record->filename = $filename; 775 776 // Only keep one current version of the generated pdf. 777 $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); 778 779 $file = $fs->create_file_from_pathname($record, $generatedpdf); 780 781 // Cleanup. 782 @unlink($generatedpdf); 783 @unlink($combined); 784 @rmdir($tmpdir); 785 786 self::copy_pages_to_readonly_area($assignment, $grade); 787 788 return $file; 789 } 790 791 /** 792 * Copy the pages image to the readonly area. 793 * 794 * @param int|\assign $assignment The assignment. 795 * @param \stdClass $grade The grade record. 796 * @return void 797 */ 798 public static function copy_pages_to_readonly_area($assignment, $grade) { 799 $fs = get_file_storage(); 800 $assignment = self::get_assignment_from_param($assignment); 801 $contextid = $assignment->get_context()->id; 802 $component = 'assignfeedback_editpdf'; 803 $itemid = $grade->id; 804 805 // Get all the pages. 806 $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid); 807 if (empty($originalfiles)) { 808 // Nothing to do here... 809 return; 810 } 811 812 // Delete the old readonly files. 813 $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid); 814 815 // Do the copying. 816 foreach ($originalfiles as $originalfile) { 817 $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile); 818 } 819 } 820 821 /** 822 * This function returns the generated pdf (if it exists). 823 * @param int|\assign $assignment 824 * @param int $userid 825 * @param int $attemptnumber (-1 means latest attempt) 826 * @return stored_file 827 */ 828 public static function get_feedback_document($assignment, $userid, $attemptnumber) { 829 830 $assignment = self::get_assignment_from_param($assignment); 831 832 if (!$assignment->can_view_submission($userid)) { 833 throw new \moodle_exception('nopermission'); 834 } 835 836 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 837 838 $contextid = $assignment->get_context()->id; 839 $component = 'assignfeedback_editpdf'; 840 $filearea = self::FINAL_PDF_FILEAREA; 841 $itemid = $grade->id; 842 $filepath = '/'; 843 844 $fs = get_file_storage(); 845 $files = $fs->get_area_files($contextid, 846 $component, 847 $filearea, 848 $itemid, 849 "itemid, filepath, filename", 850 false); 851 if ($files) { 852 return reset($files); 853 } 854 return false; 855 } 856 857 /** 858 * This function deletes the generated pdf for a student. 859 * @param int|\assign $assignment 860 * @param int $userid 861 * @param int $attemptnumber (-1 means latest attempt) 862 * @return bool 863 */ 864 public static function delete_feedback_document($assignment, $userid, $attemptnumber) { 865 866 $assignment = self::get_assignment_from_param($assignment); 867 868 if (!$assignment->can_view_submission($userid)) { 869 throw new \moodle_exception('nopermission'); 870 } 871 if (!$assignment->can_grade()) { 872 throw new \moodle_exception('nopermission'); 873 } 874 875 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 876 877 $contextid = $assignment->get_context()->id; 878 $component = 'assignfeedback_editpdf'; 879 $filearea = self::FINAL_PDF_FILEAREA; 880 $itemid = $grade->id; 881 882 $fs = get_file_storage(); 883 return $fs->delete_area_files($contextid, $component, $filearea, $itemid); 884 } 885 886 /** 887 * Get All files in a File area 888 * @param int|\assign $assignment Assignment 889 * @param int $userid User ID 890 * @param int $attemptnumber Attempt Number 891 * @param string $filearea File Area 892 * @param string $filepath File Path 893 * @return array 894 */ 895 private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') { 896 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 897 $itemid = $grade->id; 898 $contextid = $assignment->get_context()->id; 899 $component = self::COMPONENT; 900 $fs = get_file_storage(); 901 $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath); 902 return $files; 903 } 904 905 /** 906 * Save file. 907 * @param int|\assign $assignment Assignment 908 * @param int $userid User ID 909 * @param int $attemptnumber Attempt Number 910 * @param string $filearea File Area 911 * @param string $newfilepath File Path 912 * @param string $storedfilepath stored file path 913 * @return \stored_file 914 * @throws \file_exception 915 * @throws \stored_file_creation_exception 916 */ 917 private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') { 918 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 919 $itemid = $grade->id; 920 $contextid = $assignment->get_context()->id; 921 922 $record = new \stdClass(); 923 $record->contextid = $contextid; 924 $record->component = self::COMPONENT; 925 $record->filearea = $filearea; 926 $record->itemid = $itemid; 927 $record->filepath = $storedfilepath; 928 $record->filename = basename($newfilepath); 929 930 $fs = get_file_storage(); 931 932 $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea, 933 $record->itemid, $record->filepath, $record->filename); 934 935 if ($oldfile) { 936 $newhash = \file_storage::hash_from_path($newfilepath); 937 if ($newhash === $oldfile->get_contenthash()) { 938 // Use existing file if contenthash match. 939 return $oldfile; 940 } 941 // Delete existing file. 942 $oldfile->delete(); 943 } 944 945 return $fs->create_file_from_pathname($record, $newfilepath); 946 } 947 948 /** 949 * This function rotate a page, and mark the page as rotated. 950 * @param int|\assign $assignment Assignment 951 * @param int $userid User ID 952 * @param int $attemptnumber Attempt Number 953 * @param int $index Index of Current Page 954 * @param bool $rotateleft To determine whether the page is rotated left or right. 955 * @return null|\stored_file return rotated File 956 * @throws \coding_exception 957 * @throws \file_exception 958 * @throws \moodle_exception 959 * @throws \stored_file_creation_exception 960 */ 961 public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) { 962 $assignment = self::get_assignment_from_param($assignment); 963 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 964 // Check permission. 965 if (!$assignment->can_view_submission($userid)) { 966 throw new \moodle_exception('nopermission'); 967 } 968 969 $filearea = self::PAGE_IMAGE_FILEAREA; 970 $files = self::get_files($assignment, $userid, $attemptnumber, $filearea); 971 if (!empty($files)) { 972 foreach ($files as $file) { 973 preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches); 974 if (empty($matches) or !is_numeric($matches[1])) { 975 throw new \coding_exception("'" . $file->get_filename() 976 . "' file hasn't the expected format filename: image_pageXXXX.png."); 977 } 978 $pagenumber = (int)$matches[1]; 979 980 if ($pagenumber == $index) { 981 $source = imagecreatefromstring($file->get_content()); 982 $pagerotation = page_editor::get_page_rotation($grade->id, $index); 983 $degree = empty($pagerotation) ? 0 : $pagerotation->degree; 984 if ($rotateleft) { 985 $content = imagerotate($source, 90, 0); 986 $degree = ($degree + 90) % 360; 987 } else { 988 $content = imagerotate($source, -90, 0); 989 $degree = ($degree - 90) % 360; 990 } 991 $filename = $matches[0].'png'; 992 $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/' 993 . self::hash($assignment, $userid, $attemptnumber)); 994 $tempfile = $tmpdir . '/' . time() . '_' . $filename; 995 imagepng($content, $tempfile); 996 997 $filearea = self::PAGE_IMAGE_FILEAREA; 998 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 999 1000 unlink($tempfile); 1001 rmdir($tmpdir); 1002 imagedestroy($source); 1003 imagedestroy($content); 1004 $file->delete(); 1005 if (!empty($newfile)) { 1006 page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree); 1007 } 1008 return $newfile; 1009 } 1010 } 1011 } 1012 return null; 1013 } 1014 1015 /** 1016 * Convert jpg file to pdf file 1017 * @param int|\assign $assignment Assignment 1018 * @param int $userid User ID 1019 * @param int $attemptnumber Attempt Number 1020 * @param \stored_file $file file to save 1021 * @param null|array $size size of image 1022 * @return \stored_file 1023 * @throws \file_exception 1024 * @throws \stored_file_creation_exception 1025 */ 1026 private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) { 1027 // Temporary file. 1028 $filename = $file->get_filename(); 1029 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR 1030 . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR 1031 . self::hash($assignment, $userid, $attemptnumber)); 1032 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf"; 1033 // Determine orientation. 1034 $orientation = 'P'; 1035 if (!empty($size['width']) && !empty($size['height'])) { 1036 if ($size['width'] > $size['height']) { 1037 $orientation = 'L'; 1038 } 1039 } 1040 // Save JPG image to PDF file. 1041 $pdf = new pdf(); 1042 $pdf->SetHeaderMargin(0); 1043 $pdf->SetFooterMargin(0); 1044 $pdf->SetMargins(0, 0, 0, true); 1045 $pdf->setPrintFooter(false); 1046 $pdf->setPrintHeader(false); 1047 $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO); 1048 $pdf->AddPage($orientation); 1049 $pdf->SetAutoPageBreak(false); 1050 // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size. 1051 if ($orientation == 'P') { 1052 $pdf->Image('@' . $file->get_content(), 0, 0, 210); 1053 } else { 1054 $pdf->Image('@' . $file->get_content(), 0, 0, 297); 1055 } 1056 $pdf->setPageMark(); 1057 $pdf->save_pdf($tempfile); 1058 $filearea = self::TMP_JPG_TO_PDF_FILEAREA; 1059 $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 1060 if (file_exists($tempfile)) { 1061 unlink($tempfile); 1062 rmdir($tmpdir); 1063 } 1064 return $pdffile; 1065 } 1066 1067 /** 1068 * Save rotated image data to file. 1069 * @param int|\assign $assignment Assignment 1070 * @param int $userid User ID 1071 * @param int $attemptnumber Attempt Number 1072 * @param resource $rotateddata image data to save 1073 * @param string $filename name of the image file 1074 * @return \stored_file 1075 * @throws \file_exception 1076 * @throws \stored_file_creation_exception 1077 */ 1078 private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) { 1079 $filearea = self::TMP_ROTATED_JPG_FILEAREA; 1080 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR 1081 . $filearea . DIRECTORY_SEPARATOR 1082 . self::hash($assignment, $userid, $attemptnumber)); 1083 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename); 1084 imagejpeg($rotateddata, $tempfile); 1085 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 1086 if (file_exists($tempfile)) { 1087 unlink($tempfile); 1088 rmdir($tmpdir); 1089 } 1090 return $newfile; 1091 } 1092 1093 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body