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