diff --git a/essaydownload_form.php b/essaydownload_form.php index 29245ff..52b106c 100644 --- a/essaydownload_form.php +++ b/essaydownload_form.php @@ -181,6 +181,16 @@ protected function standard_preference_fields(MoodleQuickForm $mform) { $mform->disabledIf('fixremfontsize', 'fileformat', 'neq', 'pdf'); $mform->disabledIf('fixremfontsize', 'source', 'neq', 'html'); $mform->addHelpButton('fixremfontsize', 'fixremfontsize', 'quiz_essaydownload'); + $mform->addElement( + 'advcheckbox', + 'forceqtsummary', + '', + get_string('forceqtsummary', 'quiz_essaydownload') + ); + $mform->disabledIf('forceqtsummary', 'fileformat', 'neq', 'pdf'); + $mform->disabledIf('forceqtsummary', 'source', 'neq', 'html'); + $mform->disabledIf('forceqtsummary', 'questiontext'); + $mform->addHelpButton('forceqtsummary', 'forceqtsummary', 'quiz_essaydownload'); } /** diff --git a/essaydownload_options.php b/essaydownload_options.php index 0a80ffe..b81756f 100644 --- a/essaydownload_options.php +++ b/essaydownload_options.php @@ -64,6 +64,9 @@ class quiz_essaydownload_options extends quiz_essaydownload_options_parent_class /** @var int font size for PDF export */ public $fontsize = 12; + /** @var bool whether to force use of summary for question text, even if source is set to HTML */ + public $forceqtsummary = false; + /** @var string how to organise the sub folders in the archive (by question or by attempt) */ public $groupby = 'byattempt'; @@ -134,6 +137,7 @@ public function get_initial_form_data() { $toform->flatarchive = $this->flatarchive; $toform->font = $this->font; $toform->fontsize = $this->fontsize; + $toform->forceqtsummary = $this->forceqtsummary; $toform->groupby = $this->groupby; $toform->includefooter = $this->includefooter; $toform->includestats = $this->includestats; @@ -165,6 +169,7 @@ public function setup_from_form_data($fromform): void { $this->flatarchive = $fromform->flatarchive; $this->font = $fromform->font ?? ''; $this->fontsize = $fromform->fontsize ?? ''; + $this->forceqtsummary = $fromform->forceqtsummary; $this->groupby = $fromform->groupby; $this->includefooter = $fromform->includefooter; $this->includestats = $fromform->includestats; @@ -192,6 +197,7 @@ public function setup_from_params() { $this->flatarchive = optional_param('flatarchive', $this->flatarchive, PARAM_BOOL); $this->font = optional_param('font', $this->font, PARAM_ALPHA); $this->fontsize = optional_param('fontsize', $this->fontsize, PARAM_INT); + $this->forceqtsummary = optional_param('forceqtsummary', $this->forceqtsummary, PARAM_BOOL); $this->groupby = optional_param('groupby', $this->groupby, PARAM_ALPHA); $this->includefooter = optional_param('includefooter', $this->includefooter, PARAM_BOOL); $this->includestats = optional_param('includestats', $this->includestats, PARAM_BOOL); diff --git a/lang/en/quiz_essaydownload.php b/lang/en/quiz_essaydownload.php index 5a5e98f..1b53224 100644 --- a/lang/en/quiz_essaydownload.php +++ b/lang/en/quiz_essaydownload.php @@ -48,6 +48,8 @@ $string['fontsize'] = 'Font size (points)'; $string['fontsize_help'] = 'Note that when using the original HTML formatted text, the actual font size may still be different, according to the formatting'; $string['footer'] = 'Footer'; +$string['forceqtsummary'] = 'Force use of simplified question text'; +$string['forceqtsummary_help'] = 'In some cases, exporting the question text in HTML format can fail, e. g. if it includes images with restricted access. Checking this option will use the simplified summary of the question text, even if HTML is selected as the text source.'; $string['generaloptions'] = 'General options'; $string['groupby'] = 'Group by'; $string['groupby_help'] = 'The archive can be structured by question or by attempt:'; diff --git a/report.php b/report.php index 6147390..0198f6e 100644 --- a/report.php +++ b/report.php @@ -330,35 +330,42 @@ public function get_details_for_attempt(int $attemptid): array { // (because it has been filtered before) and disable filtering. Also, we do not put
tags // around it, as that is done anyway during generation of the PDF. $qa = $quba->get_question_attempt($slot); + $formattingoptions = [ + 'trusted' => true, + 'filter' => false, + 'para' => false, + ]; + // If the source is HTML, we will do that for the response. Otherwise, we might have to convert the summary + // to HTML, depending on the desired output format. if ($this->options->source === 'html') { - $formattingoptions = [ - 'trusted' => true, - 'filter' => false, - 'para' => false, - ]; - $responsehtml = format_text( strval($qa->get_last_qt_var('answer', '')), $qa->get_last_qt_var('answerformat', FORMAT_PLAIN), $formattingoptions ); + $details[$questionfolder]['responsetext'] = $responsehtml; + } else if ($this->options->fileformat === 'pdf') { + $details[$questionfolder]['responsetext'] = format_text($details[$questionfolder]['responsetext'], FORMAT_PLAIN); + } - $questionhtml = format_text( - $questiondefinition->questiontext, - $questiondefinition->questiontextformat, - $formattingoptions + // For the question text, however, we also make sure that the user did not override the source + // by using the 'forceqtsummary' option. + if ($this->options->source === 'html' && !$this->options->forceqtsummary) { + // The question text might contain images with a @@PLUGINFILE@@ URL, so we must run it through + // the attempt's rewrite_pluginfile_urls() function first. Afterwards, we run it through the HTML + // formatter, as with the response text. + $questiontext = $qa->rewrite_pluginfile_urls( + $questiondefinition->questiontext, 'question', 'questiontext', $questiondefinition->id ); + $questionhtml = format_text($questiontext, $questiondefinition->questiontextformat, $formattingoptions); + + // As a last step, we must make sure that possible links to images are changed, because we do not need + // the external URL (for display in a browser), but rather the path to the file on the server. + $questionhtml = $this->replace_image_paths_in_questiontext($questionhtml); - $details[$questionfolder]['responsetext'] = $responsehtml; $details[$questionfolder]['questiontext'] = $questionhtml; - } else { - // If the user did not choose formatted HTML as their source, but wants PDF output, we should now - // call format_text() to convert the plain text summaries into HTML, namely for the linebreaks. - if ($this->options->fileformat === 'pdf') { - foreach (['questiontext', 'responsetext'] as $text) { - $details[$questionfolder][$text] = format_text($details[$questionfolder][$text], FORMAT_PLAIN); - } - } + } else if ($this->options->fileformat === 'pdf') { + $details[$questionfolder]['questiontext'] = format_text($details[$questionfolder]['questiontext'], FORMAT_PLAIN); } // Finally, fetch attachments, if there are. @@ -367,6 +374,82 @@ public function get_details_for_attempt(int $attemptid): array { return $details; } + /** + * When embedding images in the question text, they will be referenced by their public URL, which + * is suitable for displaying the question in a browser. However, when embedding the images in a + * PDF with TCPDF, this will not work. This function will translate the public URL to local file + * paths. + * + * @param string $questiontext the question text possibly containing images + * @return string + */ + protected function replace_image_paths_in_questiontext(string $questiontext): string { + global $CFG; + + // The wwwroot might start with http or https. We substitute this by the regex *pattern* + // https? in order for our regex to match both protocols. + $wwwroot = preg_replace('/^https?/', 'https?', $CFG->wwwroot); + + // The relevant paths come from question_rewrite_question_urls() and will all have the form + // /question/questiontext////, with 'question' + // being the component and 'questiontext' the filearea. + $pattern = 'get_file( + $webpath['context'], 'question', 'questiontext', $webpath['questionid'], '', $webpath['filename'] + ); + + // Fetching the local path could fail in some cases. We don't want an error to be thrown, + // instead we just set the path to the empty string, so the problem is detected in the next step. + try { + $localpath = $fs->get_file_system()->get_local_path_from_storedfile($file); + } catch (TypeError $e) { + $localpath = ''; + } + + // Test whether the file is readable or not. If there was an error somewhere, we'd rather know now. + // In this case, we replace the entire tag by a placeholder containing the filename. + if (!is_readable($localpath)) { + $questiontext = preg_replace("#{$pattern}[^>]*>#", "[{$webpath['filename']}]", $questiontext); + continue; + } + + // TCPDF will "correct" the absolute path and prepend the server's document root. However, in some cases + // that will break things, because the server root might be e. g. /var/www, but the absolute path for our + // Moodle installation could be in /data/moodledata/files/... We try to anticipate that change by adding + // the appropriate number of ..'s to our path. TCPDF's path rewriting only happens, if the document root is + // set, is not just / and does not start with our file path, so we use their checks to know whether we must + // intervene or not. + if (!empty($_SERVER['DOCUMENT_ROOT']) && ($_SERVER['DOCUMENT_ROOT'] != '/')) { + $findroot = strpos($localpath, $_SERVER['DOCUMENT_ROOT']); + if (($findroot === false) || ($findroot > 1)) { + $documentroot = $_SERVER['DOCUMENT_ROOT']; + if (substr($documentroot, -1) == DIRECTORY_SEPARATOR) { + $documentroot = substr($documentroot, 0, -1); + } + $levels = count(explode(DIRECTORY_SEPARATOR, $documentroot)) - 1; + for ($i = 0; $i < $levels; $i++) { + $localpath = '/..' . $localpath; + } + } + } + + $questiontext = preg_replace("#$pattern#", '', 'format' => FORMAT_HTML], + ]); + quiz_add_quiz_question($question->id, $quiz); + + // Prepare image. + $fs = get_file_storage(); + $fileinfo = array( + 'contextid' => $cat->contextid, + 'component' => 'question', + 'filearea' => 'questiontext', + 'itemid' => $question->id, + 'filepath' => '/', + 'filename' => 'image.png' + ); + $file = $fs->create_file_from_pathname($fileinfo, $CFG->dirroot . '/mod/quiz/report/essaydownload/tests/fixtures/image.png'); + + // Add a student submit an attempt. + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + list($quizobj, $quba, $attemptobj) = quiz_essaydownload_test_helper::start_attempt_at_quiz($quiz, $student); + $timenow = time(); + $tosubmit = [1 => ['answer' => '

Here we go.

', 'answerformat' => FORMAT_HTML]]; + $attemptobj->process_submitted_actions($timenow, false, $tosubmit); + $attemptobj->process_finish($timenow, false); + + // Initialize report. + $cm = get_coursemodule_from_id('quiz', $quiz->cmid); + $report = new quiz_essaydownload_report(); + list($currentgroup, $allstudentjoins, $groupstudentjoins, $allowedjoins) = + $report->init('essaydownload', 'quiz_essaydownload_form', $quiz, $cm, $course); + + // Fetch the attempt and details using the report's API. + $fetchedattempts = $report->get_attempts_and_names($groupstudentjoins); + $details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]); + self::assertCount(1, $details); + $questiontext = reset($details)['questiontext']; + + // Try to create a PDF from the question text. + $e = null; + try { + $doc = new customTCPDF('P', 'mm', 'A4'); + $doc->AddPage(); + $doc->writeHTML($questiontext); + $pdfoutput = $doc->Output('', 'S'); + } catch (Throwable $e) { + $pdfoutput = ''; + } + // There should be no error and the PDF should be larger than the image file itself. + self::assertNull($e); + $pdfsize = strlen($pdfoutput); + self::assertGreaterThan($file->get_filesize(), $pdfsize); + + // Now, let's physically remove the file from the data directory. Normally, this is a very bad thing, + // because it leads to inconsistencies. But in this case, we want to see what happens, when things break. + // Also, we are at the end of the test, so a reset is going to happen just after this. + $localpath = $fs->get_file_system()->get_local_path_from_storedfile($file); + unlink($localpath); + + // Trying to generate a PDF again. There should be no error, but the image should be replaced by + // [image.png], so the file size must be smaller. + try { + $doc = new customTCPDF('P', 'mm', 'A4'); + $doc->AddPage(); + $doc->writeHTML($questiontext); + $pdfoutput = $doc->Output('', 'S'); + } catch (\Throwable $e) { + $pdfoutput = ''; + } + self::assertNull($e); + self::assertLessThan($pdfsize, 0.8 * strlen($pdfoutput)); + } }