Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [Versions 400 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 // This should never happen, there should be a version of the pages available 580 // whenever we are requesting the readonly version. 581 if (empty($pages) && $readonly) { 582 throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id); 583 } 584 585 // There are two situations where the number of page images generated does not 586 // match the number of pages in the PDF: 587 // 588 // 1. The document conversion adhoc task was interrupted somehow (node died, solar flare, etc) 589 // 2. The submission has been updated by the student 590 // 591 // In the case of 1. we need to regenerate the pages, see MDL-66626. 592 // In the case of 2. we should do nothing, see MDL-45580. 593 // 594 // To differentiate between 1. and 2. we can check if the submission has been modified since the 595 // pages were generated. If it has, then we're in situation 2. 596 $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false); 597 $submissionmodified = isset($pagemodified) && $submission->timemodified > $pagemodified; 598 if (empty($pages) || (count($pages) != $totalpagesforattempt && !$submissionmodified)) { 599 $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber, $resetrotation); 600 } 601 602 return $pages; 603 } 604 605 /** 606 * This function returns sensible filename for a feedback file. 607 * @param int|\assign $assignment 608 * @param int $userid 609 * @param int $attemptnumber (-1 means latest attempt) 610 * @return string 611 */ 612 protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) { 613 global $DB; 614 615 $assignment = self::get_assignment_from_param($assignment); 616 617 $groupmode = groups_get_activity_groupmode($assignment->get_course_module()); 618 $groupname = ''; 619 if ($groupmode) { 620 $groupid = groups_get_activity_group($assignment->get_course_module(), true); 621 $groupname = groups_get_group_name($groupid).'-'; 622 } 623 if ($groupname == '-') { 624 $groupname = ''; 625 } 626 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 627 $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST); 628 629 if ($assignment->is_blind_marking()) { 630 $prefix = $groupname . get_string('participant', 'assign'); 631 $prefix = str_replace('_', ' ', $prefix); 632 $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_'); 633 } else { 634 $prefix = $groupname . fullname($user); 635 $prefix = str_replace('_', ' ', $prefix); 636 $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_'); 637 } 638 $prefix .= $grade->attemptnumber; 639 640 return $prefix . '.pdf'; 641 } 642 643 /** 644 * This function takes the combined pdf and embeds all the comments and annotations. 645 * 646 * This also moves the annotations and comments from drafts to not drafts. And it will 647 * copy all the images stored to the readonly area, so that they can be viewed online, and 648 * not be overwritten when a new submission is sent. 649 * 650 * @param int|\assign $assignment 651 * @param int $userid 652 * @param int $attemptnumber (-1 means latest attempt) 653 * @return stored_file 654 */ 655 public static function generate_feedback_document($assignment, $userid, $attemptnumber) { 656 657 $assignment = self::get_assignment_from_param($assignment); 658 659 if (!$assignment->can_view_submission($userid)) { 660 print_error('nopermission'); 661 } 662 if (!$assignment->can_grade()) { 663 print_error('nopermission'); 664 } 665 666 // Need to generate the page images - first get a combined pdf. 667 $document = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); 668 669 $status = $document->get_status(); 670 if ($status === combined_document::STATUS_FAILED) { 671 print_error('Could not generate combined pdf.'); 672 } else if ($status === combined_document::STATUS_PENDING_INPUT) { 673 // The conversion is still in progress. 674 return false; 675 } 676 677 $file = $document->get_combined_file(); 678 679 $tmpdir = make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber)); 680 $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; 681 $file->copy_content_to($combined); // Copy the file. 682 683 $pdf = new pdf(); 684 685 $fs = get_file_storage(); 686 $stamptmpdir = make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber)); 687 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 688 // Copy any new stamps to this instance. 689 if ($files = $fs->get_area_files($assignment->get_context()->id, 690 'assignfeedback_editpdf', 691 'stamps', 692 $grade->id, 693 "filename", 694 false)) { 695 foreach ($files as $file) { 696 $filename = $stamptmpdir . '/' . $file->get_filename(); 697 $file->copy_content_to($filename); // Copy the file. 698 } 699 } 700 701 $pagecount = $pdf->set_pdf($combined); 702 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 703 page_editor::release_drafts($grade->id); 704 705 $allcomments = array(); 706 707 for ($i = 0; $i < $pagecount; $i++) { 708 $pagerotation = page_editor::get_page_rotation($grade->id, $i); 709 $pagemargin = $pdf->getBreakMargin(); 710 $autopagebreak = $pdf->getAutoPageBreak(); 711 if (empty($pagerotation) || !$pagerotation->isrotated) { 712 $pdf->copy_page(); 713 } else { 714 $rotatedimagefile = $fs->get_file_by_hash($pagerotation->pathnamehash); 715 if (empty($rotatedimagefile)) { 716 $pdf->copy_page(); 717 } else { 718 $pdf->add_image_page($rotatedimagefile); 719 } 720 } 721 722 $comments = page_editor::get_comments($grade->id, $i, false); 723 $annotations = page_editor::get_annotations($grade->id, $i, false); 724 725 if (!empty($comments)) { 726 $allcomments[$i] = $comments; 727 } 728 729 foreach ($annotations as $annotation) { 730 $pdf->add_annotation($annotation->x, 731 $annotation->y, 732 $annotation->endx, 733 $annotation->endy, 734 $annotation->colour, 735 $annotation->type, 736 $annotation->path, 737 $stamptmpdir); 738 } 739 $pdf->SetAutoPageBreak($autopagebreak, $pagemargin); 740 $pdf->setPageMark(); 741 } 742 743 if (!empty($allcomments)) { 744 // Append all comments to the end of the document. 745 $links = $pdf->append_comments($allcomments); 746 // Add the comment markers with links. 747 foreach ($allcomments as $pageno => $comments) { 748 foreach ($comments as $index => $comment) { 749 $pdf->add_comment_marker($comment->pageno, $index, $comment->x, $comment->y, $links[$pageno][$index], 750 $comment->colour); 751 } 752 } 753 } 754 755 fulldelete($stamptmpdir); 756 757 $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber); 758 $filename = clean_param($filename, PARAM_FILE); 759 760 $generatedpdf = $tmpdir . '/' . $filename; 761 $pdf->save_pdf($generatedpdf); 762 763 $record = new \stdClass(); 764 765 $record->contextid = $assignment->get_context()->id; 766 $record->component = 'assignfeedback_editpdf'; 767 $record->filearea = self::FINAL_PDF_FILEAREA; 768 $record->itemid = $grade->id; 769 $record->filepath = '/'; 770 $record->filename = $filename; 771 772 // Only keep one current version of the generated pdf. 773 $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); 774 775 $file = $fs->create_file_from_pathname($record, $generatedpdf); 776 777 // Cleanup. 778 @unlink($generatedpdf); 779 @unlink($combined); 780 @rmdir($tmpdir); 781 782 self::copy_pages_to_readonly_area($assignment, $grade); 783 784 return $file; 785 } 786 787 /** 788 * Copy the pages image to the readonly area. 789 * 790 * @param int|\assign $assignment The assignment. 791 * @param \stdClass $grade The grade record. 792 * @return void 793 */ 794 public static function copy_pages_to_readonly_area($assignment, $grade) { 795 $fs = get_file_storage(); 796 $assignment = self::get_assignment_from_param($assignment); 797 $contextid = $assignment->get_context()->id; 798 $component = 'assignfeedback_editpdf'; 799 $itemid = $grade->id; 800 801 // Get all the pages. 802 $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid); 803 if (empty($originalfiles)) { 804 // Nothing to do here... 805 return; 806 } 807 808 // Delete the old readonly files. 809 $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid); 810 811 // Do the copying. 812 foreach ($originalfiles as $originalfile) { 813 $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile); 814 } 815 } 816 817 /** 818 * This function returns the generated pdf (if it exists). 819 * @param int|\assign $assignment 820 * @param int $userid 821 * @param int $attemptnumber (-1 means latest attempt) 822 * @return stored_file 823 */ 824 public static function get_feedback_document($assignment, $userid, $attemptnumber) { 825 826 $assignment = self::get_assignment_from_param($assignment); 827 828 if (!$assignment->can_view_submission($userid)) { 829 print_error('nopermission'); 830 } 831 832 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 833 834 $contextid = $assignment->get_context()->id; 835 $component = 'assignfeedback_editpdf'; 836 $filearea = self::FINAL_PDF_FILEAREA; 837 $itemid = $grade->id; 838 $filepath = '/'; 839 840 $fs = get_file_storage(); 841 $files = $fs->get_area_files($contextid, 842 $component, 843 $filearea, 844 $itemid, 845 "itemid, filepath, filename", 846 false); 847 if ($files) { 848 return reset($files); 849 } 850 return false; 851 } 852 853 /** 854 * This function deletes the generated pdf for a student. 855 * @param int|\assign $assignment 856 * @param int $userid 857 * @param int $attemptnumber (-1 means latest attempt) 858 * @return bool 859 */ 860 public static function delete_feedback_document($assignment, $userid, $attemptnumber) { 861 862 $assignment = self::get_assignment_from_param($assignment); 863 864 if (!$assignment->can_view_submission($userid)) { 865 print_error('nopermission'); 866 } 867 if (!$assignment->can_grade()) { 868 print_error('nopermission'); 869 } 870 871 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 872 873 $contextid = $assignment->get_context()->id; 874 $component = 'assignfeedback_editpdf'; 875 $filearea = self::FINAL_PDF_FILEAREA; 876 $itemid = $grade->id; 877 878 $fs = get_file_storage(); 879 return $fs->delete_area_files($contextid, $component, $filearea, $itemid); 880 } 881 882 /** 883 * Get All files in a File area 884 * @param int|\assign $assignment Assignment 885 * @param int $userid User ID 886 * @param int $attemptnumber Attempt Number 887 * @param string $filearea File Area 888 * @param string $filepath File Path 889 * @return array 890 */ 891 private static function get_files($assignment, $userid, $attemptnumber, $filearea, $filepath = '/') { 892 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 893 $itemid = $grade->id; 894 $contextid = $assignment->get_context()->id; 895 $component = self::COMPONENT; 896 $fs = get_file_storage(); 897 $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath); 898 return $files; 899 } 900 901 /** 902 * Save file. 903 * @param int|\assign $assignment Assignment 904 * @param int $userid User ID 905 * @param int $attemptnumber Attempt Number 906 * @param string $filearea File Area 907 * @param string $newfilepath File Path 908 * @param string $storedfilepath stored file path 909 * @return \stored_file 910 * @throws \file_exception 911 * @throws \stored_file_creation_exception 912 */ 913 private static function save_file($assignment, $userid, $attemptnumber, $filearea, $newfilepath, $storedfilepath = '/') { 914 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 915 $itemid = $grade->id; 916 $contextid = $assignment->get_context()->id; 917 918 $record = new \stdClass(); 919 $record->contextid = $contextid; 920 $record->component = self::COMPONENT; 921 $record->filearea = $filearea; 922 $record->itemid = $itemid; 923 $record->filepath = $storedfilepath; 924 $record->filename = basename($newfilepath); 925 926 $fs = get_file_storage(); 927 928 $oldfile = $fs->get_file($record->contextid, $record->component, $record->filearea, 929 $record->itemid, $record->filepath, $record->filename); 930 931 if ($oldfile) { 932 $newhash = \file_storage::hash_from_path($newfilepath); 933 if ($newhash === $oldfile->get_contenthash()) { 934 // Use existing file if contenthash match. 935 return $oldfile; 936 } 937 // Delete existing file. 938 $oldfile->delete(); 939 } 940 941 return $fs->create_file_from_pathname($record, $newfilepath); 942 } 943 944 /** 945 * This function rotate a page, and mark the page as rotated. 946 * @param int|\assign $assignment Assignment 947 * @param int $userid User ID 948 * @param int $attemptnumber Attempt Number 949 * @param int $index Index of Current Page 950 * @param bool $rotateleft To determine whether the page is rotated left or right. 951 * @return null|\stored_file return rotated File 952 * @throws \coding_exception 953 * @throws \file_exception 954 * @throws \moodle_exception 955 * @throws \stored_file_creation_exception 956 */ 957 public static function rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft) { 958 $assignment = self::get_assignment_from_param($assignment); 959 $grade = $assignment->get_user_grade($userid, true, $attemptnumber); 960 // Check permission. 961 if (!$assignment->can_view_submission($userid)) { 962 print_error('nopermission'); 963 } 964 965 $filearea = self::PAGE_IMAGE_FILEAREA; 966 $files = self::get_files($assignment, $userid, $attemptnumber, $filearea); 967 if (!empty($files)) { 968 foreach ($files as $file) { 969 preg_match('/' . pdf::IMAGE_PAGE . '([\d]+)\./', $file->get_filename(), $matches); 970 if (empty($matches) or !is_numeric($matches[1])) { 971 throw new \coding_exception("'" . $file->get_filename() 972 . "' file hasn't the expected format filename: image_pageXXXX.png."); 973 } 974 $pagenumber = (int)$matches[1]; 975 976 if ($pagenumber == $index) { 977 $source = imagecreatefromstring($file->get_content()); 978 $pagerotation = page_editor::get_page_rotation($grade->id, $index); 979 $degree = empty($pagerotation) ? 0 : $pagerotation->degree; 980 if ($rotateleft) { 981 $content = imagerotate($source, 90, 0); 982 $degree = ($degree + 90) % 360; 983 } else { 984 $content = imagerotate($source, -90, 0); 985 $degree = ($degree - 90) % 360; 986 } 987 $filename = $matches[0].'png'; 988 $tmpdir = make_temp_directory(self::COMPONENT . '/' . self::PAGE_IMAGE_FILEAREA . '/' 989 . self::hash($assignment, $userid, $attemptnumber)); 990 $tempfile = $tmpdir . '/' . time() . '_' . $filename; 991 imagepng($content, $tempfile); 992 993 $filearea = self::PAGE_IMAGE_FILEAREA; 994 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 995 996 unlink($tempfile); 997 rmdir($tmpdir); 998 imagedestroy($source); 999 imagedestroy($content); 1000 $file->delete(); 1001 if (!empty($newfile)) { 1002 page_editor::set_page_rotation($grade->id, $pagenumber, true, $newfile->get_pathnamehash(), $degree); 1003 } 1004 return $newfile; 1005 } 1006 } 1007 } 1008 return null; 1009 } 1010 1011 /** 1012 * Convert jpg file to pdf file 1013 * @param int|\assign $assignment Assignment 1014 * @param int $userid User ID 1015 * @param int $attemptnumber Attempt Number 1016 * @param \stored_file $file file to save 1017 * @param null|array $size size of image 1018 * @return \stored_file 1019 * @throws \file_exception 1020 * @throws \stored_file_creation_exception 1021 */ 1022 private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) { 1023 // Temporary file. 1024 $filename = $file->get_filename(); 1025 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR 1026 . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR 1027 . self::hash($assignment, $userid, $attemptnumber)); 1028 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf"; 1029 // Determine orientation. 1030 $orientation = 'P'; 1031 if (!empty($size['width']) && !empty($size['height'])) { 1032 if ($size['width'] > $size['height']) { 1033 $orientation = 'L'; 1034 } 1035 } 1036 // Save JPG image to PDF file. 1037 $pdf = new pdf(); 1038 $pdf->SetHeaderMargin(0); 1039 $pdf->SetFooterMargin(0); 1040 $pdf->SetMargins(0, 0, 0, true); 1041 $pdf->setPrintFooter(false); 1042 $pdf->setPrintHeader(false); 1043 $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO); 1044 $pdf->AddPage($orientation); 1045 $pdf->SetAutoPageBreak(false); 1046 // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size. 1047 if ($orientation == 'P') { 1048 $pdf->Image('@' . $file->get_content(), 0, 0, 210); 1049 } else { 1050 $pdf->Image('@' . $file->get_content(), 0, 0, 297); 1051 } 1052 $pdf->setPageMark(); 1053 $pdf->save_pdf($tempfile); 1054 $filearea = self::TMP_JPG_TO_PDF_FILEAREA; 1055 $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 1056 if (file_exists($tempfile)) { 1057 unlink($tempfile); 1058 rmdir($tmpdir); 1059 } 1060 return $pdffile; 1061 } 1062 1063 /** 1064 * Save rotated image data to file. 1065 * @param int|\assign $assignment Assignment 1066 * @param int $userid User ID 1067 * @param int $attemptnumber Attempt Number 1068 * @param resource $rotateddata image data to save 1069 * @param string $filename name of the image file 1070 * @return \stored_file 1071 * @throws \file_exception 1072 * @throws \stored_file_creation_exception 1073 */ 1074 private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) { 1075 $filearea = self::TMP_ROTATED_JPG_FILEAREA; 1076 $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR 1077 . $filearea . DIRECTORY_SEPARATOR 1078 . self::hash($assignment, $userid, $attemptnumber)); 1079 $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename); 1080 imagejpeg($rotateddata, $tempfile); 1081 $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile); 1082 if (file_exists($tempfile)) { 1083 unlink($tempfile); 1084 rmdir($tmpdir); 1085 } 1086 return $newfile; 1087 } 1088 1089 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body