. /** * This file contains the ingest manager for the assignfeedback_editpdf plugin * * @package assignfeedback_editpdf * @copyright 2012 Davo Smith * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace assignfeedback_editpdf; /** * Functions for generating the annotated pdf. * * This class controls the ingest of student submission files to a normalised * PDF 1.4 document with all submission files concatinated together. It also * provides the functions to generate a downloadable pdf with all comments and * annotations embedded. * @copyright 2012 Davo Smith * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class document_services { /** File area for generated pdf */ const FINAL_PDF_FILEAREA = 'download'; /** File area for combined pdf */ const COMBINED_PDF_FILEAREA = 'combined'; /** File area for page images */ const PAGE_IMAGE_FILEAREA = 'pages'; /** File area for readonly page images */ const PAGE_IMAGE_READONLY_FILEAREA = 'readonlypages'; /** File area for the stamps */ const STAMPS_FILEAREA = 'stamps'; /** Filename for combined pdf */ const COMBINED_PDF_FILENAME = 'combined.pdf'; /** * This function will take an int or an assignment instance and * return an assignment instance. It is just for convenience. * @param int|\assign $assignment * @return assign */ private static function get_assignment_from_param($assignment) { global $CFG; require_once($CFG->dirroot . '/mod/assign/locallib.php'); if (!is_object($assignment)) { $cm = \get_coursemodule_from_instance('assign', $assignment, 0, false, MUST_EXIST); $context = \context_module::instance($cm->id); $assignment = new \assign($context, null, null); } return $assignment; } /** * Get a hash that will be unique and can be used in a path name. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) */ private static function hash($assignment, $userid, $attemptnumber) { if (is_object($assignment)) { $assignmentid = $assignment->get_instance()->id; } else { $assignmentid = $assignment; } return sha1($assignmentid . '_' . $userid . '_' . $attemptnumber); } /** * This function will search for all files that can be converted * and concatinated into a PDF (1.4) - for any submission plugin * for this students attempt. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return array(stored_file) */ public static function list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber) { global $USER, $DB; $assignment = self::get_assignment_from_param($assignment); // Capability checks. if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } $files = array(); if ($assignment->get_instance()->teamsubmission) { $submission = $assignment->get_group_submission($userid, 0, false); } else { $submission = $assignment->get_user_submission($userid, false); } $user = $DB->get_record('user', array('id' => $userid)); // User has not submitted anything yet. if (!$submission) { return $files; } // Ask each plugin for it's list of files. foreach ($assignment->get_submission_plugins() as $plugin) { if ($plugin->is_enabled() && $plugin->is_visible()) { $pluginfiles = $plugin->get_files($submission, $user); foreach ($pluginfiles as $filename => $file) { if (($file instanceof \stored_file) && ($file->get_mimetype() === 'application/pdf')) { $files[$filename] = $file; } } } } return $files; } /** * This function return the combined pdf for all valid submission files. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return stored_file */ public static function get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) { global $USER, $DB; $assignment = self::get_assignment_from_param($assignment); // Capability checks. if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } $grade = $assignment->get_user_grade($userid, true, $attemptnumber); if ($assignment->get_instance()->teamsubmission) { $submission = $assignment->get_group_submission($userid, 0, false); } else { $submission = $assignment->get_user_submission($userid, false); } $contextid = $assignment->get_context()->id; $component = 'assignfeedback_editpdf'; $filearea = self::COMBINED_PDF_FILEAREA; $itemid = $grade->id; $filepath = '/'; $filename = self::COMBINED_PDF_FILENAME; $fs = \get_file_storage(); $combinedpdf = $fs->get_file($contextid, $component, $filearea, $itemid, $filepath, $filename); if (!$combinedpdf || ($submission && ($combinedpdf->get_timemodified() < $submission->timemodified))) { return self::generate_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); } return $combinedpdf; } /** * This function will take all of the compatible files for a submission * and combine them into one PDF. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return stored_file */ public static function generate_combined_pdf_for_attempt($assignment, $userid, $attemptnumber) { global $CFG; require_once($CFG->libdir . '/pdflib.php'); $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } $files = self::list_compatible_submission_files_for_attempt($assignment, $userid, $attemptnumber); $pdf = new pdf(); if ($files) { // Create a mega joined PDF. $compatiblepdfs = array(); foreach ($files as $file) { $compatiblepdf = pdf::ensure_pdf_compatible($file); if ($compatiblepdf) { array_push($compatiblepdfs, $compatiblepdf); } } $tmpdir = \make_temp_directory('assignfeedback_editpdf/combined/' . self::hash($assignment, $userid, $attemptnumber)); $tmpfile = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; @unlink($tmpfile); try { $pagecount = $pdf->combine_pdfs($compatiblepdfs, $tmpfile); } catch (\Exception $e) { debugging('TCPDF could not process the pdf files:' . $e->getMessage(), DEBUG_DEVELOPER); // TCPDF does not recover from errors so we need to re-initialise the class. $pagecount = 0; } if ($pagecount == 0) { // We at least want a single blank page. debugging('TCPDF did not produce a valid pdf:' . $tmpfile . '. Replacing with a blank pdf.', DEBUG_DEVELOPER); @unlink($tmpfile); $files = false; } } $pdf->Close(); // No real need to close this pdf, because it has been saved by combine_pdfs(), but for clarity. $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $record = new \stdClass(); $record->contextid = $assignment->get_context()->id; $record->component = 'assignfeedback_editpdf'; $record->filearea = self::COMBINED_PDF_FILEAREA; $record->itemid = $grade->id; $record->filepath = '/'; $record->filename = self::COMBINED_PDF_FILENAME; $fs = \get_file_storage(); $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); // Detect corrupt generated pdfs and replace with a blank one. if ($files) { $verifypdf = new pdf(); $pagecount = $verifypdf->load_pdf($tmpfile); if ($pagecount <= 0) { $files = false; } $verifypdf->Close(); // PDF loaded and never saved/outputted needs to be closed. } if (!$files) { // This was a blank pdf. $blankpdf = new pdf(); $content = $blankpdf->Output(self::COMBINED_PDF_FILENAME, 'S'); $file = $fs->create_file_from_string($record, $content); $blankpdf->Close(); // No real need to close this pdf, because it has been outputted, but for clarity. } else { // This was a combined pdf. $file = $fs->create_file_from_pathname($record, $tmpfile); @unlink($tmpfile); // Test the generated file for correctness. $compatiblepdf = pdf::ensure_pdf_compatible($file); } return $file; } /** * This function will return the number of pages of a pdf. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @param bool $readonly When true we get the number of pages for the readonly version. * @return int number of pages */ public static function page_number_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) { global $CFG; require_once($CFG->libdir . '/pdflib.php'); $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } // When in readonly we can return the number of images in the DB because they should already exist, // if for some reason they do not, then we proceed as for the normal version. if ($readonly) { $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $fs = get_file_storage(); $files = $fs->get_directory_files($assignment->get_context()->id, 'assignfeedback_editpdf', self::PAGE_IMAGE_READONLY_FILEAREA, $grade->id, '/'); $pagecount = count($files); if ($pagecount > 0) { return $pagecount; } } // Get a combined pdf file from all submitted pdf files. $file = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); if (!$file) { \print_error('Could not generate combined pdf.'); } // Store the combined pdf file somewhere to be opened by tcpdf. $tmpdir = \make_temp_directory('assignfeedback_editpdf/pagetotal/' . self::hash($assignment, $userid, $attemptnumber)); $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; $file->copy_content_to($combined); // Copy the file. // Get the total number of pages. $pdf = new pdf(); $pagecount = $pdf->set_pdf($combined); $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed. // Delete temporary folders and files. @unlink($combined); @rmdir($tmpdir); return $pagecount; } /** * This function will generate and return a list of the page images from a pdf. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return array(stored_file) */ public static function generate_page_images_for_attempt($assignment, $userid, $attemptnumber) { global $CFG; require_once($CFG->libdir . '/pdflib.php'); $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } // Need to generate the page images - first get a combined pdf. $file = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); if (!$file) { throw new \moodle_exception('Could not generate combined pdf.'); } $tmpdir = \make_temp_directory('assignfeedback_editpdf/pageimages/' . self::hash($assignment, $userid, $attemptnumber)); $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; $file->copy_content_to($combined); // Copy the file. $pdf = new pdf(); $pdf->set_image_folder($tmpdir); $pagecount = $pdf->set_pdf($combined); $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $record = new \stdClass(); $record->contextid = $assignment->get_context()->id; $record->component = 'assignfeedback_editpdf'; $record->filearea = self::PAGE_IMAGE_FILEAREA; $record->itemid = $grade->id; $record->filepath = '/'; $fs = \get_file_storage(); // Remove the existing content of the filearea. $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); $files = array(); for ($i = 0; $i < $pagecount; $i++) { $image = $pdf->get_image($i); $record->filename = basename($image); $files[$i] = $fs->create_file_from_pathname($record, $tmpdir . '/' . $image); @unlink($tmpdir . '/' . $image); } $pdf->Close(); // PDF loaded and never saved/outputted needs to be closed. @unlink($combined); @rmdir($tmpdir); return $files; } /** * This function returns a list of the page images from a pdf. * * The readonly version is different than the normal one. The readonly version contains a copy * of the pages in the state they were when the PDF was annotated, by doing so we prevent the * the pages that are displayed to change as soon as the submission changes. * * Though there is an edge case, if the PDF was annotated before MDL-45580, then it is possible * that we do not find any readonly version of the pages. In that case, we will get the normal * pages and copy them to the readonly area. This ensures that the pages will remain in that * state until the submission is updated. When the normal files do not exist, we throw an exception * because the readonly pages should only ever be displayed after a teacher has annotated the PDF, * they would not exist until they do. * * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @param bool $readonly If true, then we are requesting the readonly version. * @return array(stored_file) */ public static function get_page_images_for_attempt($assignment, $userid, $attemptnumber, $readonly = false) { $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } if ($assignment->get_instance()->teamsubmission) { $submission = $assignment->get_group_submission($userid, 0, false); } else { $submission = $assignment->get_user_submission($userid, false); } $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $contextid = $assignment->get_context()->id; $component = 'assignfeedback_editpdf'; $itemid = $grade->id; $filepath = '/'; $filearea = self::PAGE_IMAGE_FILEAREA; $fs = \get_file_storage(); // If we are after the readonly pages... if ($readonly) { $filearea = self::PAGE_IMAGE_READONLY_FILEAREA; if ($fs->is_area_empty($contextid, $component, $filearea, $itemid)) { // We have a problem here, we were supposed to find the files. // Attempt to re-generate the pages from the combined images. self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber); self::copy_pages_to_readonly_area($assignment, $grade); } } $files = $fs->get_directory_files($contextid, $component, $filearea, $itemid, $filepath); $pages = array(); if (!empty($files)) { $first = reset($files); if (!$readonly && $first->get_timemodified() < $submission->timemodified) { // Image files are stale, we need to regenerate them, except in readonly mode. // We also need to remove the draft annotations and comments associated with this attempt. $fs->delete_area_files($contextid, $component, $filearea, $itemid); page_editor::delete_draft_content($itemid); $files = array(); } else { // Need to reorder the files following their name. // because get_directory_files() return a different order than generate_page_images_for_attempt(). foreach($files as $file) { // Extract the page number from the file name image_pageXXXX.png. preg_match('/page([\d]+)\./', $file->get_filename(), $matches); if (empty($matches) or !is_numeric($matches[1])) { throw new \coding_exception("'" . $file->get_filename() . "' file hasn't the expected format filename: image_pageXXXX.png."); } $pagenumber = (int)$matches[1]; // Save the page in the ordered array. $pages[$pagenumber] = $file; } ksort($pages); } } if (empty($pages)) { if ($readonly) { // This should never happen, there should be a version of the pages available // whenever we are requesting the readonly version. throw new \moodle_exception('Could not find readonly pages for grade ' . $grade->id); } $pages = self::generate_page_images_for_attempt($assignment, $userid, $attemptnumber); } return $pages; } /** * This function returns sensible filename for a feedback file. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return string */ protected static function get_downloadable_feedback_filename($assignment, $userid, $attemptnumber) { global $DB; $assignment = self::get_assignment_from_param($assignment); $groupmode = groups_get_activity_groupmode($assignment->get_course_module()); $groupname = ''; if ($groupmode) { $groupid = groups_get_activity_group($assignment->get_course_module(), true); $groupname = groups_get_group_name($groupid).'-'; } if ($groupname == '-') { $groupname = ''; } $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $user = $DB->get_record('user', array('id'=>$userid), '*', MUST_EXIST); if ($assignment->is_blind_marking()) { $prefix = $groupname . get_string('participant', 'assign'); $prefix = str_replace('_', ' ', $prefix); $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_'); } else { $prefix = $groupname . fullname($user); $prefix = str_replace('_', ' ', $prefix); $prefix = clean_filename($prefix . '_' . $assignment->get_uniqueid_for_user($userid) . '_'); } $prefix .= $grade->attemptnumber; return $prefix . '.pdf'; } /** * This function takes the combined pdf and embeds all the comments and annotations. * * This also moves the annotations and comments from drafts to not drafts. And it will * copy all the images stored to the readonly area, so that they can be viewed online, and * not be overwritten when a new submission is sent. * * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return stored_file */ public static function generate_feedback_document($assignment, $userid, $attemptnumber) { $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } if (!$assignment->can_grade()) { \print_error('nopermission'); } // Need to generate the page images - first get a combined pdf. $file = self::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber); if (!$file) { throw new \moodle_exception('Could not generate combined pdf.'); } $tmpdir = \make_temp_directory('assignfeedback_editpdf/final/' . self::hash($assignment, $userid, $attemptnumber)); $combined = $tmpdir . '/' . self::COMBINED_PDF_FILENAME; $file->copy_content_to($combined); // Copy the file. $pdf = new pdf(); $fs = \get_file_storage(); $stamptmpdir = \make_temp_directory('assignfeedback_editpdf/stamps/' . self::hash($assignment, $userid, $attemptnumber)); $grade = $assignment->get_user_grade($userid, true, $attemptnumber); // Copy any new stamps to this instance. if ($files = $fs->get_area_files($assignment->get_context()->id, 'assignfeedback_editpdf', 'stamps', $grade->id, "filename", false)) { foreach ($files as $file) { $filename = $stamptmpdir . '/' . $file->get_filename(); $file->copy_content_to($filename); // Copy the file. } } $pagecount = $pdf->set_pdf($combined); $grade = $assignment->get_user_grade($userid, true, $attemptnumber); page_editor::release_drafts($grade->id); for ($i = 0; $i < $pagecount; $i++) { $pdf->copy_page(); $comments = page_editor::get_comments($grade->id, $i, false); $annotations = page_editor::get_annotations($grade->id, $i, false); foreach ($comments as $comment) { $pdf->add_comment($comment->rawtext, $comment->x, $comment->y, $comment->width, $comment->colour); } foreach ($annotations as $annotation) { $pdf->add_annotation($annotation->x, $annotation->y, $annotation->endx, $annotation->endy, $annotation->colour, $annotation->type, $annotation->path, $stamptmpdir); } } fulldelete($stamptmpdir); $filename = self::get_downloadable_feedback_filename($assignment, $userid, $attemptnumber); $filename = clean_param($filename, PARAM_FILE); $generatedpdf = $tmpdir . '/' . $filename; $pdf->save_pdf($generatedpdf); $record = new \stdClass(); $record->contextid = $assignment->get_context()->id; $record->component = 'assignfeedback_editpdf'; $record->filearea = self::FINAL_PDF_FILEAREA; $record->itemid = $grade->id; $record->filepath = '/'; $record->filename = $filename; // Only keep one current version of the generated pdf. $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid); $file = $fs->create_file_from_pathname($record, $generatedpdf); // Cleanup. @unlink($generatedpdf); @unlink($combined); @rmdir($tmpdir); self::copy_pages_to_readonly_area($assignment, $grade); return $file; } /** * Copy the pages image to the readonly area. * * @param int|\assign $assignment The assignment. * @param \stdClass $grade The grade record. * @return void */ public static function copy_pages_to_readonly_area($assignment, $grade) { $fs = get_file_storage(); $assignment = self::get_assignment_from_param($assignment); $contextid = $assignment->get_context()->id; $component = 'assignfeedback_editpdf'; $itemid = $grade->id; // Get all the pages. $originalfiles = $fs->get_area_files($contextid, $component, self::PAGE_IMAGE_FILEAREA, $itemid); if (empty($originalfiles)) { // Nothing to do here... return; } // Delete the old readonly files. $fs->delete_area_files($contextid, $component, self::PAGE_IMAGE_READONLY_FILEAREA, $itemid); // Do the copying. foreach ($originalfiles as $originalfile) { $fs->create_file_from_storedfile(array('filearea' => self::PAGE_IMAGE_READONLY_FILEAREA), $originalfile); } } /** * This function returns the generated pdf (if it exists). * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return stored_file */ public static function get_feedback_document($assignment, $userid, $attemptnumber) { $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $contextid = $assignment->get_context()->id; $component = 'assignfeedback_editpdf'; $filearea = self::FINAL_PDF_FILEAREA; $itemid = $grade->id; $filepath = '/'; $fs = \get_file_storage(); $files = $fs->get_area_files($contextid, $component, $filearea, $itemid, "itemid, filepath, filename", false); if ($files) { return reset($files); } return false; } /** * This function deletes the generated pdf for a student. * @param int|\assign $assignment * @param int $userid * @param int $attemptnumber (-1 means latest attempt) * @return bool */ public static function delete_feedback_document($assignment, $userid, $attemptnumber) { $assignment = self::get_assignment_from_param($assignment); if (!$assignment->can_view_submission($userid)) { \print_error('nopermission'); } if (!$assignment->can_grade()) { \print_error('nopermission'); } $grade = $assignment->get_user_grade($userid, true, $attemptnumber); $contextid = $assignment->get_context()->id; $component = 'assignfeedback_editpdf'; $filearea = self::FINAL_PDF_FILEAREA; $itemid = $grade->id; $fs = \get_file_storage(); return $fs->delete_area_files($contextid, $component, $filearea, $itemid); } }