Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

integrate embedded images from question text #49

Merged
merged 4 commits into from
Jan 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions essaydownload_form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
6 changes: 6 additions & 0 deletions essaydownload_options.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions lang/en/quiz_essaydownload.php
Original file line number Diff line number Diff line change
Expand Up @@ -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:<ul><li>If you group by question, the archive will have a folder for every question. Inside each folder, you will have a folder for every attempt.</li><li>If you group by attempt, the archive will have a folder for every attempt. Inside each folder, you will have a folder for every question.</li></ul>';
Expand Down
121 changes: 102 additions & 19 deletions report.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div> 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.
Expand All @@ -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
// <context>/question/questiontext/<usage_id>/<slot>/<question_id>/<filename>, with 'question'
// being the component and 'questiontext' the filearea.
$pattern = '<img.+src="' . $wwwroot;
$pattern .= '/pluginfile.php/(?P<context>[0-9]+)/question/questiontext';
$pattern .= '/(?P<usage>[0-9]+)/(?<slot>[0-9]+)/(?<questionid>[0-9]+)';
$pattern .= '/(?<filename>[^\"]+)';

// Find all relevant paths and store their components in an array.
$webpaths = [];
preg_match_all("#$pattern#", $questiontext, $webpaths, PREG_SET_ORDER);

// Iterate over all matches, get the local path and substitute the src attribute accordingly.
$fs = get_file_storage();
foreach ($webpaths as $webpath) {
$file = $fs->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 <img> 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#", '<img src="' . $localpath, $questiontext);
}

return $questiontext;
}

/**
* Prepare a ZIP file containing the requested data and initiate the download.
* user and initiate the download.
Expand Down
Binary file added tests/fixtures/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
142 changes: 142 additions & 0 deletions tests/report_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use quiz_essaydownload_report;

use Generator;
use Throwable;

defined('MOODLE_INTERNAL') || die();

Expand Down Expand Up @@ -1048,6 +1049,62 @@ public function test_pdf_from_html_when_input_is_plaintext_with_newlines(): void
}
}

public function test_pdf_from_html_when_questiontext_is_forced_summary(): void {
$this->resetAfterTest();
$this->setAdminUser();

// Create a course and a quiz with an essay question.
$generator = $this->getDataGenerator();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$course = $generator->create_course();
$quiz = $this->create_test_quiz($course);
quiz_essaydownload_test_helper::add_essay_question($questiongenerator, $quiz, [
'name' => 'My Question Title / Test',
'questiontext' => ['text' => '<p>Go write <strong>your</strong> stuff!</p>', 'format' => FORMAT_HTML],
]);

// Add a student and start 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);

// Submit a response and finish the attempt.
$timenow = time();
$tosubmit = [1 => ['answer' => '<p>Here <strong>we</strong> go.</p>', 'answerformat' => FORMAT_HTML]];
$attemptobj->process_submitted_actions($timenow, false, $tosubmit);
$attemptobj->process_finish($timenow, false);

$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);

// Use reflection to force options.
$reflectedreport = new \ReflectionClass($report);
$reflectedoptions = $reflectedreport->getProperty('options');
$reflectedoptions->setAccessible(true);
$options = new quiz_essaydownload_options('essaydownload', $quiz, $cm, $course);
$options->forceqtsummary = true;
$reflectedoptions->setValue($report, $options);

// Fetch the attemp using the report's API.
$fetchedattempts = $report->get_attempts_and_names($groupstudentjoins);
self::assertCount(1, $fetchedattempts);

// Fetch the details.
$details = $report->get_details_for_attempt(array_keys($fetchedattempts)[0]);

// We expect the result to be an array with one element. The data should match the
// second response.
self::assertCount(1, $details);
foreach ($details as $label => $detail) {
self::assertEquals('Question_1_-_My_Question_Title__Test', $label);
self::assertEquals('Go write YOUR stuff!<br />', trim($detail['questiontext']));
self::assertStringStartsWith('<p>Here <strong>we</strong> go.</p>', $detail['responsetext']);
self::assertCount(0, $detail['attachments']);
}
}

public function test_txt_when_input_is_html(): void {
$this->resetAfterTest();
$this->setAdminUser();
Expand Down Expand Up @@ -1172,4 +1229,89 @@ public function test_workaround_atto_font_size_issue(string $expected, string $i
);
}

public function test_image_in_questiontext(): void {
global $CFG;
$this->resetAfterTest();
$this->setAdminUser();

// Create a course and a quiz with an essay question.
$generator = $this->getDataGenerator();
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
$course = $generator->create_course();
$quiz = $this->create_test_quiz($course);
$cat = $questiongenerator->create_question_category();
$question = $questiongenerator->create_question('essay', null, [
'category' => $cat->id,
'name' => 'My Question Title / Test',
'questiontext' => ['text' => '<p><img src="@@PLUGINFILE@@/image.png"</p>', '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' => '<p>Here <strong>we</strong> go.</p>', '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));
}
}
Loading