diff --git a/dataformat/html/classes/writer.php b/dataformat/html/classes/writer.php
index a7b60d4ea0e..49ed857d0fe 100644
--- a/dataformat/html/classes/writer.php
+++ b/dataformat/html/classes/writer.php
@@ -92,6 +92,15 @@ table {
echo \html_writer::end_tag('tr');
}
+ /**
+ * Method to define whether the dataformat supports export of HTML
+ *
+ * @return bool
+ */
+ public function supports_html(): bool {
+ return true;
+ }
+
/**
* Write a single record
*
@@ -99,6 +108,8 @@ table {
* @param int $rownum
*/
public function write_record($record, $rownum) {
+ $record = $this->format_record($record);
+
echo \html_writer::start_tag('tr');
foreach ($record as $cell) {
echo \html_writer::tag('td', $cell);
diff --git a/dataformat/json/classes/writer.php b/dataformat/json/classes/writer.php
index 599f3fee809..effd83ecaa9 100644
--- a/dataformat/json/classes/writer.php
+++ b/dataformat/json/classes/writer.php
@@ -80,7 +80,7 @@ class writer extends \core\dataformat\base {
echo ",";
}
- echo json_encode($record, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ echo json_encode($this->format_record($record), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$this->sheetdatadded = true;
}
diff --git a/dataformat/pdf/classes/writer.php b/dataformat/pdf/classes/writer.php
index 1fce4399d16..2203af5697d 100644
--- a/dataformat/pdf/classes/writer.php
+++ b/dataformat/pdf/classes/writer.php
@@ -93,16 +93,52 @@ class writer extends \core\dataformat\base {
$this->print_heading();
}
+ /**
+ * Method to define whether the dataformat supports export of HTML
+ *
+ * @return bool
+ */
+ public function supports_html(): bool {
+ return true;
+ }
+
+ /**
+ * When exporting images, we need to return their Base64 encoded content. Otherwise TCPDF will create a HTTP
+ * request for them, which will lead to the login page (i.e. not the image it expects) and throw an exception
+ *
+ * Note: ideally we would copy the file to a temp location and return it's path, but a bug in TCPDF currently
+ * prevents that
+ *
+ * @param \stored_file $file
+ * @return string|null
+ */
+ protected function export_html_image_source(\stored_file $file): ?string {
+ // Set upper dimensions for embedded images.
+ $resizedimage = $file->resize_image(400, 300);
+
+ return '@' . base64_encode($resizedimage);
+ }
+
+ /**
+ * Write a single record
+ *
+ * @param array $record
+ * @param int $rownum
+ */
public function write_record($record, $rownum) {
$rowheight = 0;
- // If $record is an object convert it to an array.
- if (is_object($record)) {
- $record = (array)$record;
- }
-
+ $record = $this->format_record($record);
foreach ($record as $cell) {
- $rowheight = max($rowheight, $this->pdf->getStringHeight($this->colwidth, $cell, false, true, '', 1));
+ // We need to calculate the row height (accounting for any content). Unfortunately TCPDF doesn't provide an easy
+ // method to do that, so we create a second PDF inside a transaction, add cell content and use the largest cell by
+ // height. Solution similar to that at https://stackoverflow.com/a/1943096.
+ $pdf2 = clone $this->pdf;
+ $pdf2->startTransaction();
+ $pdf2->AddPage('L');
+ $pdf2->writeHTMLCell($this->colwidth, 0, '', '', $cell, 1, 1, false, true, 'L');
+ $rowheight = max($rowheight, $pdf2->getY() - $pdf2->getMargins()['top']);
+ $pdf2->rollbackTransaction();
}
$margins = $this->pdf->getMargins();
@@ -123,7 +159,7 @@ class writer extends \core\dataformat\base {
// Determine whether we're at the last element of the record.
$nextposition = ($lastkey === $key) ? 1 : 0;
// Write the element.
- $this->pdf->Multicell($this->colwidth, $rowheight, $cell, 1, 'L', false, $nextposition);
+ $this->pdf->writeHTMLCell($this->colwidth, $rowheight, '', '', $cell, 1, $nextposition, false, true, 'L');
}
}
diff --git a/dataformat/pdf/tests/writer_test.php b/dataformat/pdf/tests/writer_test.php
new file mode 100644
index 00000000000..e1218a0639a
--- /dev/null
+++ b/dataformat/pdf/tests/writer_test.php
@@ -0,0 +1,74 @@
+.
+
+/**
+ * Tests for the dataformat_pdf writer
+ *
+ * @package dataformat_pdf
+ * @copyright 2020 Paul Holden
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace dataformat_pdf;
+
+use core\dataformat;
+use context_system;
+use html_writer;
+use moodle_url;
+
+/**
+ * Writer tests
+ *
+ * @package dataformat_pdf
+ * @copyright 2020 Paul Holden
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class writer_testcase extends \advanced_testcase {
+
+ /**
+ * Test writing data whose content contains an image with pluginfile.php source
+ */
+ public function test_write_data_with_pluginfile_image(): void {
+ global $CFG;
+
+ $this->resetAfterTest(true);
+
+ $imagefixture = "{$CFG->dirroot}/lib/filestorage/tests/fixtures/testimage.jpg";
+ $image = get_file_storage()->create_file_from_pathname([
+ 'contextid' => context_system::instance()->id,
+ 'component' => 'dataformat_pdf',
+ 'filearea' => 'test',
+ 'itemid' => 0,
+ 'filepath' => '/',
+ 'filename' => basename($imagefixture),
+
+ ], $imagefixture);
+
+ $imageurl = moodle_url::make_pluginfile_url($image->get_contextid(), $image->get_component(), $image->get_filearea(),
+ $image->get_itemid(), $image->get_filepath(), $image->get_filename());
+
+ // Insert out test image into the data so it is exported.
+ $columns = ['animal', 'image'];
+ $row = ['cat', html_writer::img($imageurl->out(), 'My image')];
+
+ // Export to file. Assert that the exported file exists.
+ $exportfile = dataformat::write_data('My export', 'pdf', $columns, [$row]);
+ $this->assertFileExists($exportfile);
+
+ // The exported file should be a reasonable size (~275kb).
+ $this->assertGreaterThan(270000, filesize($exportfile));
+ }
+}
diff --git a/dataformat/upgrade.txt b/dataformat/upgrade.txt
index cf4d09edc67..89eaa5bce0c 100644
--- a/dataformat/upgrade.txt
+++ b/dataformat/upgrade.txt
@@ -9,6 +9,13 @@ information provided here is intended especially for developers.
* Calls to the following dataformat plugin methods have been removed:
- write_header()
- write_footer()
+* The following methods have been added to the base class to allow instances to define support for exporting
+ HTML content, with additional support for defining how images should be embedded:
+ - supports_html()
+ - export_html_image_source()
+* Dataformat writers should also call the following method to ensure data is properly formatted before being
+ written, which takes into account prior methods defining support for HTML:
+ - format_record()
=== 3.4 ===
* In order to allow multiple sheets in an exported file the functions write_header() and write_footer() have
diff --git a/lib/classes/dataformat/base.php b/lib/classes/dataformat/base.php
index a0dc3686fa6..454ed721757 100644
--- a/lib/classes/dataformat/base.php
+++ b/lib/classes/dataformat/base.php
@@ -145,6 +145,82 @@ abstract class base {
// Override me if needed.
}
+ /**
+ * Method to define whether the dataformat supports export of HTML
+ *
+ * @return bool
+ */
+ public function supports_html(): bool {
+ return false;
+ }
+
+ /**
+ * Apply formatting to the cells of a given record
+ *
+ * @param array|\stdClass $record
+ * @return array
+ */
+ protected function format_record($record): array {
+ $record = (array)$record;
+
+ // If the dataformat supports export of HTML, we need to allow them to manage embedded images.
+ if ($this->supports_html()) {
+ $record = array_map([$this, 'replace_pluginfile_images'], $record);
+ }
+
+ return $record;
+ }
+
+ /**
+ * Given a stored_file, return a suitable source attribute for an img element in the export (or null to use the original)
+ *
+ * @param \stored_file $file
+ * @return string|null
+ */
+ protected function export_html_image_source(\stored_file $file): ?string {
+ return null;
+ }
+
+ /**
+ * We need to locate all img tags within a given cell that match pluginfile URL's. Partly so the exported file will show
+ * the image without requiring the user is logged in; and also to prevent some of the dataformats requesting the file
+ * themselves, which is likely to fail due to them not having an active session
+ *
+ * @param string|null $content
+ * @return string
+ */
+ protected function replace_pluginfile_images(?string $content): string {
+ $content = (string)$content;
+
+ // Examine content to see if it contains any HTML image tags.
+ return preg_replace_callback('/(?
]+src=")(?[^"]*)(?".*>)/i', function(array $matches) {
+ $source = $matches['source'];
+
+ // Now check if the image source looks like a pluginfile URL.
+ if (preg_match('/pluginfile.php\/(?\d+)\/(?[^\/]+)\/(?[^\/]+)\/(?:(?\d+)\/)?' .
+ '(?.*)/u', $source, $args)) {
+
+ $context = $args['context'];
+ $component = clean_param($args['component'], PARAM_COMPONENT);
+ $filearea = clean_param($args['filearea'], PARAM_AREA);
+ $itemid = $args['itemid'] ?: 0;
+ $path = clean_param(urldecode($args['path']), PARAM_PATH);
+
+ // Try and get the matching file from storage, allow the dataformat to define the replacement source.
+ $fullpath = "/{$context}/{$component}/{$filearea}/{$itemid}/{$path}";
+ if ($file = get_file_storage()->get_file_by_hash(sha1($fullpath))) {
+ $exportsource = $this->export_html_image_source($file);
+
+ if ($exportsource) {
+ $source = $exportsource;
+ }
+ }
+ }
+
+ return $matches['pre'] . $source . $matches['post'];
+ }, $content);
+ }
+
/**
* Write a single record
*
diff --git a/lib/classes/dataformat/spout_base.php b/lib/classes/dataformat/spout_base.php
index 02a827f60f9..4c582707ec3 100644
--- a/lib/classes/dataformat/spout_base.php
+++ b/lib/classes/dataformat/spout_base.php
@@ -116,11 +116,11 @@ abstract class spout_base extends \core\dataformat\base {
/**
* Write a single record
*
- * @param object $record
+ * @param array $record
* @param int $rownum
*/
public function write_record($record, $rownum) {
- $row = \Box\Spout\Writer\Common\Creator\WriterEntityFactory::createRowFromArray((array)$record);
+ $row = \Box\Spout\Writer\Common\Creator\WriterEntityFactory::createRowFromArray($this->format_record($record));
$this->writer->addRow($row);
}
diff --git a/lib/tablelib.php b/lib/tablelib.php
index 73db35ac62b..79052b3a86b 100644
--- a/lib/tablelib.php
+++ b/lib/tablelib.php
@@ -2103,7 +2103,7 @@ class table_default_export_format_parent {
*/
class table_dataformat_export_format extends table_default_export_format_parent {
- /** @var $dataformat */
+ /** @var \core\dataformat\base $dataformat */
protected $dataformat;
/** @var $rownum */
@@ -2138,6 +2138,15 @@ class table_dataformat_export_format extends table_default_export_format_parent
\core\session\manager::write_close();
}
+ /**
+ * Whether the current dataformat supports export of HTML
+ *
+ * @return bool
+ */
+ public function supports_html(): bool {
+ return $this->dataformat->supports_html();
+ }
+
/**
* Start document
*
diff --git a/mod/forum/export.php b/mod/forum/export.php
index 14509f50ccd..9aee3f18809 100644
--- a/mod/forum/export.php
+++ b/mod/forum/export.php
@@ -120,7 +120,7 @@ if ($form->is_cancelled()) {
'messageformat', 'messagetrust', 'attachment', 'totalscore', 'mailnow', 'deleted', 'privatereplyto',
'privatereplytofullname', 'wordcount', 'charcount'];
- $canviewfullname = has_capability('moodle/site:viewfullnames', $forum->get_context());
+ $canviewfullname = has_capability('moodle/site:viewfullnames', $context);
$datamapper = $legacydatamapperfactory->get_post_data_mapper();
$exportdata = new ArrayObject($datamapper->to_legacy_objects($posts));
@@ -132,7 +132,7 @@ if ($form->is_cancelled()) {
$dataformat,
$fields,
$iterator,
- function($exportdata) use ($fields, $striphtml, $humandates, $canviewfullname) {
+ function($exportdata) use ($fields, $striphtml, $humandates, $canviewfullname, $context) {
$data = new stdClass();
foreach ($fields as $field) {
@@ -149,6 +149,11 @@ if ($form->is_cancelled()) {
$data->privatereplytofullname = fullname($user, $canviewfullname);
}
+ if ($field == 'message') {
+ $data->message = file_rewrite_pluginfile_urls($data->message, 'pluginfile.php', $context->id, 'mod_forum',
+ 'post', $data->id);
+ }
+
// Convert any boolean fields to their integer equivalent for output.
if (is_bool($data->$field)) {
$data->$field = (int) $data->$field;
@@ -156,11 +161,6 @@ if ($form->is_cancelled()) {
}
if ($striphtml) {
- // The following call to html_to_text uses the option that strips out
- // all URLs, but format_text complains if it finds @@PLUGINFILE@@ tokens.
- // So, we need to replace @@PLUGINFILE@@ with a real URL, but it doesn't
- // matter what. We use http://example.com/.
- $data->message = str_replace('@@PLUGINFILE@@/', 'http://example.com/', $data->message);
$data->message = html_to_text(format_text($data->message, $data->messageformat), 0, false);
$data->messageformat = FORMAT_PLAIN;
}