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