Merge branch 'MDL-66980' of https://github.com/timhunt/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 21 Oct 2019 18:26:34 +0000 (20:26 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 21 Oct 2019 18:26:34 +0000 (20:26 +0200)
161 files changed:
admin/tool/analytics/amd/build/potential-contexts.min.js [new file with mode: 0644]
admin/tool/analytics/amd/build/potential-contexts.min.js.map [new file with mode: 0644]
admin/tool/analytics/amd/src/potential-contexts.js [new file with mode: 0644]
admin/tool/analytics/classes/external.php [new file with mode: 0644]
admin/tool/analytics/classes/output/form/edit_model.php
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/cli/evaluate_model.php
admin/tool/analytics/createmodel.php
admin/tool/analytics/db/services.php [new file with mode: 0644]
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/tests/external_test.php [new file with mode: 0644]
admin/tool/analytics/version.php
analytics/classes/analysis.php
analytics/classes/local/analyser/base.php
analytics/classes/local/analyser/by_course.php
analytics/classes/local/analyser/sitewide.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/tests/manager_test.php
analytics/tests/prediction_test.php
analytics/upgrade.txt
blocks/calendar_month/tests/behat/block_calendar_month.feature
blocks/calendar_month/tests/behat/block_calendar_month_course.feature
blocks/calendar_month/tests/behat/block_calendar_month_dashboard.feature
blocks/calendar_month/tests/behat/block_calendar_month_frontpage.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_course.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_dashboard.feature
blocks/calendar_upcoming/tests/behat/block_calendar_upcoming_frontpage.feature
calendar/classes/external/event_icon_exporter.php
calendar/classes/local/event/forms/eventtype.php
calendar/classes/local/event/forms/managesubscriptions.php
calendar/export_execute.php
calendar/externallib.php
calendar/lib.php
calendar/tests/behat/category_events.feature
calendar/tests/container_test.php
calendar/tests/event_factory_test.php
calendar/tests/event_mapper_test.php
calendar/tests/helpers.php
calendar/tests/repeat_event_collection_test.php
config-dist.php
course/externallib.php
lang/en/analytics.php
lang/en/calendar.php
lang/en/deprecated.txt
lang/en/question.php
lib/classes/analytics/analyser/users.php
lib/classes/output/icon_system_fontawesome.php
lib/db/install.xml
lib/db/upgrade.php
lib/outputcomponents.php
lib/testing/generator/data_generator.php
lib/tests/analysers_test.php
message/amd/build/message_drawer_view_overview_section.min.js
message/amd/build/message_drawer_view_overview_section.min.js.map
message/amd/src/message_drawer_view_overview_section.js
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/fpdi/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/FpdiTrait.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/LICENSE [deleted file]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/ReaderInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85Exception.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/AsciiHex.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterInterface.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Flate.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FlateException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Lzw.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/LzwException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParser.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParserException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/StreamReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Tokenizer.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfArray.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfBoolean.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfDictionary.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfHexString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObject.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObjectReference.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfName.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNull.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNumeric.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfStream.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfString.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfToken.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfType.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfTypeException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/DataStructure/Rectangle.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/Page.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PageBoundaries.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReader.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/PdfReader/PdfReaderException.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tcpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/TcpdfFpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/FpdfTpl.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/Tfpdf/Fpdi.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/autoload.php [new file with mode: 0644]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCII85.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterASCIIHexDecode.php [deleted file]
mod/assign/feedback/editpdf/fpdi/filters/FilterLZW.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdf_tpl.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_bridge.php [deleted file]
mod/assign/feedback/editpdf/fpdi/fpdi_pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_context.php [deleted file]
mod/assign/feedback/editpdf/fpdi/pdf_parser.php [deleted file]
mod/assign/feedback/editpdf/fpdi/readme_moodle.txt
mod/assign/feedback/editpdf/thirdpartylibs.xml
mod/assign/locallib.php
mod/assign/module.js
mod/assign/renderer.php
mod/assign/styles.css
mod/feedback/classes/completion.php
mod/feedback/lib.php
mod/forum/classes/form/export_form.php
mod/forum/classes/local/vaults/post.php
mod/forum/export.php
mod/forum/lang/en/forum.php
mod/forum/tests/vaults_post_test.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/report/attemptsreport.php
mod/quiz/report/overview/tests/behat/basic.feature
mod/quiz/report/overview/tests/report_test.php
mod/quiz/styles.css
pix/i/courseevent.png
pix/i/courseevent.svg
question/classes/bank/action_column_base.php
question/classes/bank/checkbox_column.php
question/classes/bank/column_base.php
question/classes/bank/copy_action_column.php
question/classes/bank/creator_name_column.php
question/classes/bank/delete_action_column.php
question/classes/bank/edit_action_column.php
question/classes/bank/edit_menu_column.php [new file with mode: 0644]
question/classes/bank/export_xml_action_column.php [new file with mode: 0644]
question/classes/bank/menu_action_column_base.php [new file with mode: 0644]
question/classes/bank/menuable_action.php [new file with mode: 0644]
question/classes/bank/modifier_name_column.php
question/classes/bank/preview_action_column.php
question/classes/bank/question_name_column.php
question/classes/bank/question_text_row.php
question/classes/bank/question_type_column.php
question/classes/bank/row_base.php
question/classes/bank/tags_action_column.php
question/classes/bank/view.php
question/tests/behat/behat_question.php
question/upgrade.txt
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js b/admin/tool/analytics/amd/build/potential-contexts.min.js
new file mode 100644 (file)
index 0000000..c93ee47
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js differ
diff --git a/admin/tool/analytics/amd/build/potential-contexts.min.js.map b/admin/tool/analytics/amd/build/potential-contexts.min.js.map
new file mode 100644 (file)
index 0000000..03b6fb5
Binary files /dev/null and b/admin/tool/analytics/amd/build/potential-contexts.min.js.map differ
diff --git a/admin/tool/analytics/amd/src/potential-contexts.js b/admin/tool/analytics/amd/src/potential-contexts.js
new file mode 100644 (file)
index 0000000..eb35367
--- /dev/null
@@ -0,0 +1,63 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Potential contexts selector module.
+ *
+ * @module     tool_analytics/potential-contexts
+ * @class      potential-contexts
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+    return /** @alias module:tool_analytics/potential-contexts */ {
+
+        processResults: function(selector, results) {
+            var contexts = [];
+            if ($.isArray(results)) {
+                $.each(results, function(index, context) {
+                    contexts.push({
+                        value: context.id,
+                        label: context.name
+                    });
+                });
+                return contexts;
+
+            } else {
+                return results;
+            }
+        },
+
+        transport: function(selector, query, success, failure) {
+            var promise;
+
+            let modelid = $(selector).attr('modelid') || null;
+            promise = Ajax.call([{
+                methodname: 'tool_analytics_potential_contexts',
+                args: {
+                    query: query,
+                    modelid: modelid
+                }
+            }]);
+
+            promise[0].then(success).fail(failure);
+        }
+
+    };
+
+});
diff --git a/admin/tool/analytics/classes/external.php b/admin/tool/analytics/classes/external.php
new file mode 100644 (file)
index 0000000..515c08a
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This is the external API for this component.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_analytics;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+
+/**
+ * This is the external API for this component.
+ *
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class external extends external_api {
+
+    const MAX_CONTEXTS_RETURNED = 100;
+
+    /**
+     * potential_contexts parameters.
+     *
+     * @since  Moodle 3.8
+     * @return external_function_parameters
+     */
+    public static function potential_contexts_parameters() {
+        return new external_function_parameters(
+            array(
+                'query' => new external_value(PARAM_NOTAGS, 'The model id', VALUE_DEFAULT),
+                'modelid' => new external_value(PARAM_INT, 'The model id', VALUE_DEFAULT)
+            )
+        );
+    }
+
+    /**
+     * Return the contexts that match the provided query.
+     *
+     * @since  Moodle 3.8
+     * @param  string|null $query
+     * @param  int|null $modelid
+     * @return array an array of contexts
+     */
+    public static function potential_contexts(?string $query = null, ?int $modelid = null) {
+
+        $params = self::validate_parameters(self::potential_contexts_parameters(), ['modelid' => $modelid, 'query' => $query]);
+
+        \core_analytics\manager::check_can_manage_models();
+
+        if ($params['modelid']) {
+            $model = new \core_analytics\model($params['modelid']);
+            $contexts = ($model->get_analyser(['notimesplitting' => true]))::potential_context_restrictions($params['query']);
+        } else {
+            $contexts = \core_analytics\manager::get_potential_context_restrictions(null, $params['query']);
+        }
+
+        $contextoptions = [];
+        $i = 0;
+        foreach ($contexts as $contextid => $contextname) {
+
+            if ($i === self::MAX_CONTEXTS_RETURNED) {
+                // Limited to MAX_CONTEXTS_RETURNED items.
+                break;
+            }
+
+            $contextoptions[] = ['id' => $contextid, 'name' => $contextname];
+            $i++;
+        }
+
+        return $contextoptions;
+    }
+
+    /**
+     * potential_contexts return
+     *
+     * @since  Moodle 3.8
+     * @return external_description
+     */
+    public static function potential_contexts_returns() {
+        return new external_multiple_structure(
+            new external_single_structure([
+                'id'    => new external_value(PARAM_INT, 'ID of the context'),
+                'name'  => new external_value(PARAM_NOTAGS, 'The context name')
+            ])
+        );
+    }
+}
index c897b5c..b9515cd 100644 (file)
@@ -105,6 +105,28 @@ class edit_model extends \moodleform {
         $mform->addElement('select', 'timesplitting', get_string('timesplittingmethod', 'analytics'), $timesplittings);
         $mform->addHelpButton('timesplitting', 'timesplittingmethod', 'analytics');
 
+        // Contexts restriction.
+        if (!empty($this->_customdata['supportscontexts'])) {
+
+            $options = [
+                'ajax' => 'tool_analytics/potential-contexts',
+                'multiple' => true,
+                'noselectionstring' => get_string('all')
+            ];
+
+            if (!empty($this->_customdata['id'])) {
+                $options['modelid'] = $this->_customdata['id'];
+                $contexts = $this->load_current_contexts();
+            } else {
+                // No need to preload any selected contexts.
+                $contexts = [];
+            }
+
+            $mform->addElement('autocomplete', 'contexts', get_string('contexts', 'tool_analytics'), $contexts, $options);
+            $mform->setType('contexts', PARAM_INT);
+            $mform->addHelpButton('contexts', 'contexts', 'tool_analytics');
+        }
+
         // Predictions processor.
         if (!$this->_customdata['staticmodel']) {
             $defaultprocessor = \core_analytics\manager::get_predictions_processor_name(
@@ -146,20 +168,37 @@ class edit_model extends \moodleform {
     public function validation($data, $files) {
         $errors = parent::validation($data, $files);
 
+        $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
+        $target = \core_analytics\manager::get_target($targetclass);
+
         if (!empty($data['timesplitting'])) {
             $timesplittingclass = \tool_analytics\output\helper::option_to_class($data['timesplitting']);
             if (\core_analytics\manager::is_valid($timesplittingclass, '\core_analytics\local\time_splitting\base') === false) {
                 $errors['timesplitting'] = get_string('errorinvalidtimesplitting', 'analytics');
             }
 
-            $targetclass = \tool_analytics\output\helper::option_to_class($data['target']);
             $timesplitting = \core_analytics\manager::get_time_splitting($timesplittingclass);
-            $target = \core_analytics\manager::get_target($targetclass);
             if (!$target->can_use_timesplitting($timesplitting)) {
                 $errors['timesplitting'] = get_string('invalidtimesplitting', 'tool_analytics');
             }
         }
 
+        if (!empty($data['contexts'])) {
+
+            $analyserclass = $target->get_analyser_class();
+            if (!$potentialcontexts = $analyserclass::potential_context_restrictions()) {
+                $errors['contexts'] = get_string('errornocontextrestrictions', 'analytics');
+            } else {
+
+                // Flip the contexts array so we can just diff by key.
+                $selectedcontexts = array_flip($data['contexts']);
+                $invalidcontexts = array_diff_key($selectedcontexts, $potentialcontexts);
+                if (!empty($invalidcontexts)) {
+                    $errors['contexts'] = get_string('errorinvalidcontexts', 'analytics');
+                }
+            }
+        }
+
         if (!$this->_customdata['staticmodel']) {
             if (empty($data['indicators'])) {
                 $errors['indicators'] = get_string('errornoindicators', 'analytics');
@@ -179,4 +218,18 @@ class edit_model extends \moodleform {
 
         return $errors;
     }
+
+    /**
+     * Load the currently selected context options.
+     *
+     * @return array
+     */
+    protected function load_current_contexts() {
+        $contexts = [];
+        foreach ($this->_customdata['contexts'] as $context) {
+            $contexts[$context->id] = $context->get_context_name(true, true);
+        }
+
+        return $contexts;
+    }
 }
index fd205f1..2011754 100644 (file)
@@ -76,7 +76,8 @@ class invalid_analysables implements \renderable, \templatable {
 
         $offset = $this->page * $this->perpage;
 
-        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator();
+        $contexts = $this->model->get_contexts();
+        $analysables = $this->model->get_analyser(['notimesplitting' => true])->get_analysables_iterator(null, $contexts);
 
         $skipped = 0;
         $enoughresults = false;
index 3094ffa..7d07b6f 100644 (file)
@@ -34,7 +34,6 @@ Options:
 --list                 List models
 --non-interactive      Not interactive questions
 --analysisinterval     Restrict the evaluation to 1 single analysis interval (Optional)
---filter               Analyser dependant. e.g. A courseid would evaluate the model using a single course (Optional)
 --mode                 'configuration' or 'trainedmodel'. You can only use mode=trainedmodel when the trained" .
     " model was imported" . "
 --reuse-prev-analysed  Reuse recently analysed courses instead of analysing the whole site. Set it to false while" .
@@ -42,7 +41,7 @@ Options:
 -h, --help             Print out this help
 
 Example:
-\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters' --filter=123,321
+\$ php admin/tool/analytics/cli/evaluate_model.php --modelid=1 --analysisinterval='\\core\\analytics\\time_splitting\\quarters'
 ";
 
 // Now get cli options.
@@ -55,7 +54,6 @@ list($options, $unrecognized) = cli_get_params(
         'mode'                  => 'configuration',
         'reuse-prev-analysed'   => true,
         'non-interactive'       => false,
-        'filter'                => false
     ),
     array(
         'h' => 'help',
@@ -83,11 +81,6 @@ if ($options['modelid'] === false) {
     exit(0);
 }
 
-// Reformat them as an array.
-if ($options['filter'] !== false) {
-    $options['filter'] = explode(',', $options['filter']);
-}
-
 if ($options['mode'] !== 'configuration' && $options['mode'] !== 'trainedmodel') {
     cli_error('Error: The provided mode is not supported');
 }
@@ -110,7 +103,6 @@ if ($options['reuse-prev-analysed']) {
 $renderer = $PAGE->get_renderer('tool_analytics');
 
 $analyseroptions = array(
-    'filter' => $options['filter'],
     'timesplitting' => $options['analysisinterval'],
     'reuseprevanalysed' => $options['reuse-prev-analysed'],
     'mode' => $options['mode'],
index ad4fc83..678eec8 100644 (file)
@@ -45,6 +45,8 @@ $targets = array_filter(\core_analytics\manager::get_all_targets(), function($ta
     return (!$target->based_on_assumptions());
 });
 
+// Set 'supportscontexts' to true as at this stage we don't know if the contexts are supported by
+// the selected target.
 $customdata = array(
     'trainedmodel' => false,
     'staticmodel' => false,
@@ -52,6 +54,7 @@ $customdata = array(
     'indicators' => \core_analytics\manager::get_all_indicators(),
     'timesplittings' => \core_analytics\manager::get_all_time_splittings(),
     'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+    'supportscontexts' => true,
 );
 $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -86,8 +89,8 @@ if ($mform->is_cancelled()) {
         $indicators = array_diff_key($indicators, $invalidindicators);
     }
 
-    // Update the model with the valid list of indicators.
-    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+    // Update the model with the rest of the data provided in the form.
+    $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
 
     $message = '';
     $messagetype = \core\output\notification::NOTIFY_SUCCESS;
diff --git a/admin/tool/analytics/db/services.php b/admin/tool/analytics/db/services.php
new file mode 100644 (file)
index 0000000..fefbfb8
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tool analytics webservice definitions.
+ *
+ * @package    tool_analytics
+ * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+
+    'tool_analytics_potential_contexts' => array(
+        'classname'   => 'tool_analytics\external',
+        'methodname'  => 'potential_contexts',
+        'description' => 'Retrieve the list of potential contexts for a model.',
+        'type'        => 'read',
+        'ajax'          => true,
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
+    ),
+);
index ca3d1a2..acace09 100644 (file)
@@ -45,6 +45,8 @@ $string['component'] = 'Component';
 $string['componentcore'] = 'Core';
 $string['componentselect'] = 'Select all models provided by the component \'{$a}\'';
 $string['componentselectnone'] = 'Unselect all';
+$string['contexts'] = 'Contexts';
+$string['contexts_help'] = 'The model will be limited to this set of contexts. No context restrictions will be applied if no contexts are selected.';
 $string['createmodel'] = 'Create model';
 $string['currenttimesplitting'] = 'Current analysis interval';
 $string['delete'] = 'Delete';
index 4886ff5..3788f15 100644 (file)
@@ -122,6 +122,7 @@ switch ($action) {
 
         $invalidcurrenttimesplitting = $model->invalid_timesplitting_selected();
         $potentialtimesplittings = $model->get_potential_timesplittings();
+        $analyser = $model->get_analyser();
 
         $customdata = array(
             'id' => $model->get_id(),
@@ -132,7 +133,9 @@ switch ($action) {
             'targetname' => $model->get_target()->get_name(),
             'indicators' => $model->get_potential_indicators(),
             'timesplittings' => $potentialtimesplittings,
-            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors()
+            'predictionprocessors' => \core_analytics\manager::get_all_prediction_processors(),
+            'supportscontexts' => ($analyser)::context_restriction_support(),
+            'contexts' => $model->get_contexts(),
         );
         $mform = new \tool_analytics\output\form\edit_model(null, $customdata);
 
@@ -157,7 +160,7 @@ switch ($action) {
                 $predictionsprocessor = false;
             }
 
-            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor);
+            $model->update($data->enabled, $indicators, $timesplitting, $predictionsprocessor, $data->contexts);
             redirect($returnurl);
         }
 
@@ -168,6 +171,9 @@ switch ($action) {
         $callable = array('\tool_analytics\output\helper', 'class_to_option');
         $modelobj->indicators = array_map($callable, json_decode($modelobj->indicators));
         $modelobj->timesplitting = \tool_analytics\output\helper::class_to_option($modelobj->timesplitting);
+        if ($modelobj->contextids) {
+            $modelobj->contexts = array_map($callable, json_decode($modelobj->contextids));
+        }
         $modelobj->predictionsprocessor = \tool_analytics\output\helper::class_to_option($modelobj->predictionsprocessor);
         $mform->set_data($modelobj);
         $mform->display();
diff --git a/admin/tool/analytics/tests/external_test.php b/admin/tool/analytics/tests/external_test.php
new file mode 100644 (file)
index 0000000..ff26c64
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tool analytics external functions tests.
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_indicator_max.php');
+require_once(__DIR__ . '/../../../../analytics/tests/fixtures/test_target_course_level_shortname.php');
+
+/**
+ * Tool analytics external functions tests
+ *
+ * @package    tool_analytics
+ * @category   external
+ * @copyright  2019 David MonllaĆ³ {@link http://www.davidmonllao.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.8
+ */
+class tool_analytics_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * test_potential_contexts description
+     */
+    public function test_potential_contexts() {
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \tool_analytics\external::potential_contexts());
+
+        // The frontpage is not included.
+        $this->assertCount(0, \tool_analytics\external::potential_contexts('PHPUnit'));
+
+        $target = \core_analytics\manager::get_target('test_target_course_level_shortname');
+        $indicators = ['test_indicator_max' => \core_analytics\manager::get_indicator('test_indicator_max')];
+        $model = \core_analytics\model::create($target, $indicators);
+
+        $this->assertCount(1, \tool_analytics\external::potential_contexts(null, $model->get_id()));
+    }
+
+    /**
+     * test_potential_contexts description
+     *
+     * @expectedException required_capability_exception
+     */
+    public function test_potential_contexts_no_manager() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $this->assertCount(2, \tool_analytics\external::potential_contexts());
+    }
+}
index 03c5187..c2e2b1c 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019052000; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019052002; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019051100; // Requires this Moodle version.
 $plugin->component = 'tool_analytics'; // Full name of the plugin (used for diagnostics).
index ad39b48..f11bfae 100644 (file)
@@ -75,9 +75,10 @@ class analysis {
     /**
      * Runs the analysis.
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return null
      */
-    public function run() {
+    public function run(array $contexts = []) {
 
         $options = $this->analyser->get_options();
 
@@ -89,7 +90,7 @@ class analysis {
         } else {
             $action = 'prediction';
         }
-        $analysables = $this->analyser->get_analysables_iterator($action);
+        $analysables = $this->analyser->get_analysables_iterator($action, $contexts);
 
         $processedanalysables = $this->get_processed_analysables();
 
index 0065f15..99735bd 100644 (file)
@@ -131,9 +131,10 @@ abstract class base {
      * to ease to implementation of get_analysables_iterator: get_iterator_sql and order_sql.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
 
         debugging('Please overwrite get_analysables_iterator with your own implementation, we only keep this default
             implementation for backwards compatibility purposes with get_analysables(). note that $action param will
@@ -266,38 +267,42 @@ abstract class base {
     /**
      * Returns labelled data (training and evaluation).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_labelled_data() {
+    public function get_labelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), true, $this->get_options());
         $analysis = new \core_analytics\analysis($this, true, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns unlabelled data (prediction).
      *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return \stored_file[]
      */
-    public function get_unlabelled_data() {
+    public function get_unlabelled_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_file($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
     /**
      * Returns indicator calculations as an array.
+     *
+     * @param \context[] $contexts Restrict the analysis to these contexts. No context restrictions if null.
      * @return array
      */
-    public function get_static_data() {
+    public function get_static_data(array $contexts = []) {
         // Delegates all processing to the analysis.
         $result = new \core_analytics\local\analysis\result_array($this->get_modelid(), false, $this->get_options());
         $analysis = new \core_analytics\analysis($this, false, $result);
-        $analysis->run();
+        $analysis->run($contexts);
         return $result->get();
     }
 
@@ -422,6 +427,34 @@ abstract class base {
         return false;
     }
 
+    /**
+     * Returns an array of context levels that can be used to restrict the contexts used during analysis.
+     *
+     * The contexts provided to self::get_analysables_iterator will match these contextlevels.
+     *
+     * @return array Array of context levels or an empty array if context restriction is not supported.
+     */
+    public static function context_restriction_support(): array {
+        return [];
+    }
+
+    /**
+     * Returns the possible contexts used by the analyser.
+     *
+     * This method uses separate logic for each context level because to iterate through
+     * the list of contexts calling get_context_name for each of them would be expensive
+     * in performance terms.
+     *
+     * This generic implementation returns all the contexts in the site for the provided context level.
+     * Overwrite it for specific restrictions in your analyser.
+     *
+     * @param  string|null $query Context name filter.
+     * @return int[]
+     */
+    public static function potential_context_restrictions(string $query = null) {
+        return \core_analytics\manager::get_potential_context_restrictions(static::context_restriction_support(), $query);
+    }
+
     /**
      * Get the sql of a default implementation of the iterator.
      *
@@ -431,9 +464,12 @@ abstract class base {
      * @param  int         $contextlevel The context level of the analysable
      * @param  string|null $action
      * @param  string|null $tablealias   The table alias
+     * @param  \context[]  $contexts     Only analysables that depend on the provided contexts. All analysables if empty.
      * @return array                     [0] => sql and [1] => params array
      */
-    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null) {
+    protected function get_iterator_sql(string $tablename, int $contextlevel, ?string $action = null, ?string $tablealias = null,
+            array $contexts = []) {
+        global $DB;
 
         if (!$tablealias) {
             $tablealias = 'analysable';
@@ -452,13 +488,30 @@ abstract class base {
             $params = $params + ['action' => $action];
         }
 
-        // Adding the 1 = 1 just to have the WHERE part so that all further conditions added by callers can be
-        // appended to $sql with and ' AND'.
         $sql = 'SELECT ' . $select . '
                   FROM {' . $tablename . '} ' . $tablealias . '
                   ' . $usedanalysablesjoin . '
-                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id)
-                  WHERE 1 = 1';
+                  JOIN {context} ctx ON (ctx.contextlevel = :contextlevel AND ctx.instanceid = ' . $tablealias . '.id) ';
+
+        if (!$contexts) {
+            // Adding the 1 = 1 just to have the WHERE part so that all further conditions
+            // added by callers can be appended to $sql with and ' AND'.
+            $sql .= 'WHERE 1 = 1';
+        } else {
+
+            $contextsqls = [];
+            foreach ($contexts as $context) {
+                $paramkey1 = 'paramctxlike' . $context->id;
+                $paramkey2 = 'paramctxeq' . $context->id;
+                $contextsqls[] = $DB->sql_like('ctx.path', ':' . $paramkey1);
+                $contextsqls[] = 'ctx.path = :' . $paramkey2;
+
+                // This includes the context itself.
+                $params[$paramkey1] = $context->path . '/%';
+                $params[$paramkey2] = $context->path;
+            }
+            $sql .= 'WHERE (' . implode(' OR ', $contextsqls) . ')';
+        }
 
         return [$sql, $params];
     }
index c0d308b..59b6627 100644 (file)
@@ -39,24 +39,13 @@ abstract class by_course extends base {
      * Return the list of courses to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         global $DB;
 
-        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c');
-
-        // This will be updated to filter by context as part of MDL-64739.
-        if (!empty($this->options['filter'])) {
-            $courses = array();
-            foreach ($this->options['filter'] as $courseid) {
-                $courses[$courseid] = intval($courseid);
-            }
-
-            list($coursesql, $courseparams) = $DB->get_in_or_equal($courses, SQL_PARAMS_NAMED);
-            $sql .= " AND c.id $coursesql";
-            $params = $params + $courseparams;
-        }
+        list($sql, $params) = $this->get_iterator_sql('course', CONTEXT_COURSE, $action, 'c', $contexts);
 
         $ordersql = $this->order_sql('sortorder', 'ASC', 'c');
 
@@ -76,4 +65,13 @@ abstract class by_course extends base {
             return \core_analytics\course::instance($record, $context);
         });
     }
-}
\ No newline at end of file
+
+    /**
+     * Can be limited to course categories or specific courses.
+     *
+     * @return array
+     */
+    public static function context_restriction_support(): array {
+        return [CONTEXT_COURSE, CONTEXT_COURSECAT];
+    }
+}
index da6ea71..28a4db3 100644 (file)
@@ -39,9 +39,10 @@ abstract class sitewide extends base {
      * Return the list of analysables to analyse.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Ignored here.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         // We can safely ignore $action as we have 1 single analysable element in this analyser.
         return new \ArrayIterator([new \core_analytics\site()]);
     }
index 60f1f45..131c782 100644 (file)
@@ -646,6 +646,8 @@ class manager {
             $usedanalysablesanalysableids = array_flip($usedanalysablesanalysableids);
 
             $analyser = $model->get_analyser(array('notimesplitting' => true));
+
+            // We do not honour the list of contexts in this model as it can contain stale records.
             $analysables = $analyser->get_analysables_iterator();
 
             $analysableids = [];
@@ -913,4 +915,79 @@ class manager {
 
         return [$target, $indicators];
     }
+
+    /**
+     * Return the context restrictions that can be applied to the provided context levels.
+     *
+     * @throws \coding_exception
+     * @param  array|null $contextlevels The list of context levels provided by the analyser. Null if all of them.
+     * @param  string|null $query
+     * @return array Associative array with contextid as key and the short version of the context name as value.
+     */
+    public static function get_potential_context_restrictions(?array $contextlevels = null, string $query = null) {
+        global $DB;
+
+        if (empty($contextlevels) && !is_null($contextlevels)) {
+            return false;
+        }
+
+        if (!is_null($contextlevels)) {
+            foreach ($contextlevels as $contextlevel) {
+                if ($contextlevel !== CONTEXT_COURSE && $contextlevel !== CONTEXT_COURSECAT) {
+                    throw new \coding_exception('Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported at the moment.');
+                }
+            }
+        }
+
+        $contexts = [];
+
+        // We have a separate process for each context level for performance reasons (to iterate through mdl_context calling
+        // get_context_name() would be too slow).
+        $contextsystem = \context_system::instance();
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSECAT, $contextlevels)) {
+
+            $sql = "SELECT cc.id, cc.name, ctx.id AS contextid
+                      FROM {course_categories} cc
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = cc.id";
+            $params = ['ctxlevel' => CONTEXT_COURSECAT];
+
+            if ($query) {
+                $sql .= " WHERE " . $DB->sql_like('cc.name', ':query', false, false);
+                $params['query'] = '%' . $query . '%';
+            }
+
+            $coursecats = $DB->get_recordset_sql($sql, $params);
+            foreach ($coursecats as $record) {
+                $contexts[$record->contextid] = get_string('category') . ': ' .
+                    format_string($record->name, true, array('context' => $contextsystem));
+            }
+            $coursecats->close();
+        }
+
+        if (is_null($contextlevels) || in_array(CONTEXT_COURSE, $contextlevels)) {
+
+            $sql = "SELECT c.id, c.shortname, ctx.id AS contextid
+                      FROM {course} c
+                      JOIN {context} ctx ON ctx.contextlevel = :ctxlevel AND ctx.instanceid = c.id
+                      WHERE c.id != :siteid";
+            $params = ['ctxlevel' => CONTEXT_COURSE, 'siteid' => SITEID];
+
+            if ($query) {
+                $sql .= ' AND (' . $DB->sql_like('c.fullname', ':query1', false, false) . ' OR ' .
+                    $DB->sql_like('c.shortname', ':query2', false, false) . ')';
+                $params['query1'] = '%' . $query . '%';
+                $params['query2'] = '%' . $query . '%';
+            }
+
+            $courses = $DB->get_recordset_sql($sql, $params);
+            foreach ($courses as $record) {
+                $contexts[$record->contextid] = get_string('course') . ': ' .
+                    format_string($record->shortname, true, array('context' => $contextsystem));
+            }
+            $courses->close();
+        }
+
+        return $contexts;
+    }
+
 }
index 9a524be..cc92516 100644 (file)
@@ -120,6 +120,11 @@ class model {
      */
     protected $indicators = null;
 
+    /**
+     * @var \context[]
+     */
+    protected $contexts = null;
+
     /**
      * Unique Model id created from site info and last model modification.
      *
@@ -459,9 +464,11 @@ class model {
      * @param \core_analytics\local\indicator\base[]|false $indicators False to respect current indicators
      * @param string|false $timesplittingid False to respect current time splitting method
      * @param string|false $predictionsprocessor False to respect current predictors processor value
+     * @param int[]|false $contextids List of context ids for this model. False to respect the current list of contexts.
      * @return void
      */
-    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false) {
+    public function update($enabled, $indicators = false, $timesplittingid = '', $predictionsprocessor = false,
+            $contextids = false) {
         global $USER, $DB;
 
         \core_analytics\manager::check_can_manage_models();
@@ -486,6 +493,15 @@ class model {
             $predictionsprocessor = $this->model->predictionsprocessor;
         }
 
+        if ($contextids !== false) {
+            $contextsstr = json_encode($contextids);
+
+            // Reset the internal cache.
+            $this->contexts = null;
+        } else {
+            $contextsstr = $this->model->contextids;
+        }
+
         if ($this->model->timesplitting !== $timesplittingid ||
                 $this->model->indicators !== $indicatorsstr ||
                 $this->model->predictionsprocessor !== $predictionsprocessor) {
@@ -514,6 +530,7 @@ class model {
         $this->model->indicators = $indicatorsstr;
         $this->model->timesplitting = $timesplittingid;
         $this->model->predictionsprocessor = $predictionsprocessor;
+        $this->model->contextids = $contextsstr;
         $this->model->timemodified = $now;
         $this->model->usermodified = $USER->id;
 
@@ -603,7 +620,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No datasets generated.
         if (empty($datasets)) {
@@ -695,7 +712,7 @@ class model {
         // Before get_labelled_data call so we get an early exception if it is not ready.
         $predictor = $this->get_predictions_processor();
 
-        $datasets = $this->get_analyser()->get_labelled_data();
+        $datasets = $this->get_analyser()->get_labelled_data($this->get_contexts());
 
         // No training if no files have been provided.
         if (empty($datasets) || empty($datasets[$this->model->timesplitting])) {
@@ -766,7 +783,7 @@ class model {
             // Before get_unlabelled_data call so we get an early exception if it is not ready.
             $predictor = $this->get_predictions_processor();
 
-            $samplesdata = $this->get_analyser()->get_unlabelled_data();
+            $samplesdata = $this->get_analyser()->get_unlabelled_data($this->get_contexts());
 
             // Get the prediction samples file.
             if (empty($samplesdata) || empty($samplesdata[$this->model->timesplitting])) {
@@ -802,7 +819,7 @@ class model {
         } else {
             // Predictions based on assumptions.
 
-            $indicatorcalculations = $this->get_analyser()->get_static_data();
+            $indicatorcalculations = $this->get_analyser()->get_static_data($this->get_contexts());
             // Get the prediction samples file.
             if (empty($indicatorcalculations) || empty($indicatorcalculations[$this->model->timesplitting])) {
 
@@ -1956,6 +1973,30 @@ class model {
         return [$allsampleids, $allsamplesdata];
     }
 
+    /**
+     * Contexts where this model should be active.
+     *
+     * @return \context[] Empty array if there are no context restrictions.
+     */
+    public function get_contexts() {
+        if ($this->contexts !== null) {
+            return $this->contexts;
+        }
+
+        if (!$this->model->contextids) {
+            $this->contexts = [];
+            return $this->contexts;
+        }
+        $contextids = json_decode($this->model->contextids);
+
+        // We don't expect this list to be massive as contexts need to be selected manually using the edit model form.
+        $this->contexts = array_map(function($contextid) {
+            return \context::instance_by_id($contextid, IGNORE_MISSING);
+        }, $contextids);
+
+        return $this->contexts;
+    }
+
     /**
      * Purges the insights cache.
      */
index 02166a8..582c457 100644 (file)
@@ -487,4 +487,31 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->assertNotEmpty($indicators);
         $this->assertContainsOnlyInstancesOf(\core_analytics\local\indicator\base::class, $indicators);
     }
+
+    /**
+     * test_get_potential_context_restrictions description
+     */
+    public function test_get_potential_context_restrictions() {
+        $this->resetAfterTest();
+
+        // No potential context restrictions.
+        $this->assertFalse(\core_analytics\manager::get_potential_context_restrictions([]));
+
+        // Include the all context levels so the misc. category get included.
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions());
+
+        $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->create_category();
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions());
+        $this->assertCount(3, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE, CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE]));
+        $this->assertCount(2, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT]));
+
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Course category 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSECAT], 'Miscellaneous'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course 1'));
+        $this->assertCount(1, \core_analytics\manager::get_potential_context_restrictions([CONTEXT_COURSE], 'Test course'));
+    }
 }
index 2b437f6..1fd9ac2 100644 (file)
@@ -126,6 +126,57 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             array('modelid' => $model->get_id())));
     }
 
+    /**
+     * test_model_contexts
+     */
+    public function test_model_contexts() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $this->setAdminuser();
+
+        $misc = $DB->get_record('course_categories', ['name' => 'Miscellaneous']);
+        $miscctx = \context_coursecat::instance($misc->id);
+
+        $category = $this->getDataGenerator()->create_category();
+        $categoryctx = \context_coursecat::instance($category->id);
+
+        // One course per category.
+        $courseparams = array('shortname' => 'aaaaaa', 'fullname' => 'aaaaaa', 'visible' => 0,
+            'category' => $category->id);
+        $course1 = $this->getDataGenerator()->create_course($courseparams);
+        $course1ctx = \context_course::instance($course1->id);
+        $courseparams = array('shortname' => 'bbbbbb', 'fullname' => 'bbbbbb', 'visible' => 0,
+            'category' => $misc->id);
+        $course2 = $this->getDataGenerator()->create_course($courseparams);
+
+        $model = $this->add_perfect_model('test_static_target_shortname');
+
+        // Just 1 category.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$categoryctx->id]);
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Now with 2 categories.
+        $model->update(true, false, false, false, [$categoryctx->id, $miscctx->id]);
+
+        // The courses in the new category are processed.
+        $this->assertCount(1, $model->predict()->predictions);
+
+        // Clear the predictions generated by the model and predict() again.
+        $model->clear();
+        $this->assertCount(2, $model->predict()->predictions);
+
+        // Course context restriction.
+        $model->update(true, false, '\core\analytics\time_splitting\no_splitting', false, [$course1ctx->id]);
+
+        // Nothing new as the course was already analysed.
+        $result = $model->predict();
+        $this->assertTrue(empty($result->predictions));
+
+        $model->clear();
+        $this->assertCount(1, $model->predict()->predictions);
+    }
+
     /**
      * test_ml_training_and_prediction
      *
index 38395c3..4773d4a 100644 (file)
@@ -30,6 +30,10 @@ information provided here is intended especially for developers.
     * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
       per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
 * \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
+* Analyser's get_analysables_iterator and get_iterator_sql have a new $contexts parameter to limit the returned analysables to
+  the ones that depend on the provided contexts.
+* Analysers can implement a context_restriction_support() method to restrict models to a subset of the
+  contents in the site. Only CONTEXT_COURSE and CONTEXT_COURSECAT are supported.
 
 === 3.7 ===
 
index f8f1597..b921b62 100644 (file)
@@ -26,7 +26,7 @@ Feature: Enable the calendar block in a course and test it's functionality
     Then "Calendar" "block" should exist
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
@@ -52,7 +52,7 @@ Feature: Enable the calendar block in a course and test it's functionality
       | id_eventtype | Course |
       | id_name | Course Event |
     And I am on "Course 1" course homepage
-    And I follow "Hide global events"
+    And I follow "Hide site events"
     And I hover over today in the calendar
     Then I should not see "Site Event"
     And I should see "Course Event"
index 2360572..f0ed21f 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block in a course
   I can add the calendar block to a course
 
   @javascript
-  Scenario: View a global event in the calendar block in a course
+  Scenario: View a site event in the calendar block in a course
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
index aa74c43..e22b4e8 100644 (file)
@@ -5,7 +5,7 @@ Feature: View a site event on the dashboard
   I can view the event in the calendar
 
   @javascript
-  Scenario: View a global event in the calendar block on the dashboard
+  Scenario: View a site event in the calendar block on the dashboard
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index b7ef68e..e67c327 100644 (file)
@@ -5,7 +5,7 @@ Feature: Enable the calendar block on the site front page
   I can add the calendar block on the site front page
 
   @javascript
-  Scenario: View a global event in the calendar block on the front page
+  Scenario: View a site event in the calendar block on the front page
     Given the following "users" exist:
       | username | firstname | lastname | email | idnumber |
       | student1 | Student | 1 | student1@example.com | S1 |
index 9e092bf..1b4d9e0 100644 (file)
@@ -16,7 +16,7 @@ Feature: Enable the upcoming events block in a course
       | teacher1 | C1 | editingteacher |
 
   @javascript
-  Scenario: View a global event in the calendar block
+  Scenario: View a site event in the calendar block
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index 0b7c739..96bd216 100644 (file)
@@ -9,7 +9,7 @@ Feature: View a upcoming site event on the dashboard
       | student1 | Student | 1 | student1@example.com | S1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the dashboard
+  Scenario: View a site event in the upcoming events block on the dashboard
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index dd31e37..8336f52 100644 (file)
@@ -10,7 +10,7 @@ Feature: View a site event on the frontpage
       | teacher1 | Teacher | 1 | teacher1@example.com | T1 |
 
   @javascript
-  Scenario: View a global event in the upcoming events block on the frontpage
+  Scenario: View a site event in the upcoming events block on the frontpage
     Given I log in as "admin"
     And I create a calendar event with form data:
       | id_eventtype | Site |
index 79d30da..1949a9e 100644 (file)
@@ -55,7 +55,7 @@ class event_icon_exporter extends exporter {
         $user = $event->get_user();
         $userid = $user ? $user->get('id') : null;
         $isactivityevent = !empty($coursemodule);
-        $isglobalevent = ($course && $courseid == SITEID);
+        $issiteevent = ($course && $courseid == SITEID);
         $iscategoryevent = ($category && !empty($categoryid));
         $iscourseevent = ($course && !empty($courseid) && $courseid != SITEID && empty($groupid));
         $isgroupevent = ($group && !empty($groupid));
@@ -70,7 +70,7 @@ class event_icon_exporter extends exporter {
             } else {
                 $alttext = get_string('activityevent', 'calendar');
             }
-        } else if ($isglobalevent) {
+        } else if ($issiteevent) {
             $key = 'i/siteevent';
             $component = 'core';
             $alttext = get_string('typesite', 'calendar');
index d615a50..8475e29 100644 (file)
@@ -57,19 +57,19 @@ trait eventtype {
         $options = [];
 
         if (!empty($eventtypes['user'])) {
-            $options['user'] = get_string('user');
+            $options['user'] = get_string('user', 'calendar');
         }
         if (!empty($eventtypes['group'])) {
-            $options['group'] = get_string('group');
+            $options['group'] = get_string('group', 'calendar');
         }
         if (!empty($eventtypes['course'])) {
-            $options['course'] = get_string('course');
+            $options['course'] = get_string('course', 'calendar');
         }
         if (!empty($eventtypes['category'])) {
-            $options['category'] = get_string('category');
+            $options['category'] = get_string('category', 'calendar');
         }
         if (!empty($eventtypes['site'])) {
-            $options['site'] = get_string('site');
+            $options['site'] = get_string('site', 'calendar');
         }
 
         // If we only have one event type and it's 'user' event then don't bother
index 61ef73f..6063f40 100644 (file)
@@ -85,7 +85,7 @@ class managesubscriptions extends \moodleform {
         // Add the select elements for the available event types.
         $this->add_event_type_elements($mform, $eventtypes);
 
-        // Eventtype: 0 = user, 1 = global, anything else = course ID.
+        // Eventtype: 0 = user, 1 = site, anything else = course ID.
         $mform->addElement('submit', 'add', get_string('add'));
 
         // Add the javascript required to enhance this mform.
index 17b5501..a06f328 100644 (file)
@@ -76,7 +76,7 @@ if(!empty($what) && !empty($time)) {
         if ($what == 'all') {
             $users = $user->id;
             $courses[SITEID] = new stdClass;
-            $courses[SITEID]->shortname = get_string('globalevents', 'calendar');
+            $courses[SITEID]->shortname = get_string('siteevents', 'calendar');
             $paramcourses[SITEID] = $courses[SITEID];
             $paramcategory = true;
         } else if ($what == 'groups') {
index 4bc9388..eccd04e 100644 (file)
@@ -147,7 +147,7 @@ class core_calendar_external extends external_api {
                                              "Set to true to return current user's user events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'siteevents' => new external_value(PARAM_BOOL,
-                                             "Set to true to return global events",
+                                             "Set to true to return site events",
                                              VALUE_DEFAULT, true, NULL_ALLOWED),
                                     'timestart' => new external_value(PARAM_INT,
                                              "Time from which events should be returned",
index a5f772e..da878b8 100644 (file)
@@ -70,10 +70,16 @@ define('CALENDAR_TF_24', '%H:%M');
 define('CALENDAR_TF_12', '%I:%M %p');
 
 /**
- * CALENDAR_EVENT_GLOBAL - Global calendar event types
+ * CALENDAR_EVENT_GLOBAL - Site calendar event types
+ * @deprecated since 3.8
  */
 define('CALENDAR_EVENT_GLOBAL', 1);
 
+/**
+ * CALENDAR_EVENT_SITE - Site calendar event types
+ */
+define('CALENDAR_EVENT_SITE', 1);
+
 /**
  * CALENDAR_EVENT_COURSE - Course calendar event types
  */
@@ -1551,8 +1557,8 @@ function calendar_add_event_metadata($event) {
         $event->cmid = $module->id;
     } else if ($event->courseid == SITEID) { // Site event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/siteevent') . '" alt="' .
-            get_string('globalevent', 'calendar') . '" class="icon" />';
-        $event->cssclass = 'calendar_event_global';
+            get_string('siteevent', 'calendar') . '" class="icon" />';
+        $event->cssclass = 'calendar_event_site';
     } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) { // Course event.
         $event->icon = '<img src="' . $OUTPUT->image_url('i/courseevent') . '" alt="' .
             get_string('courseevent', 'calendar') . '" class="icon" />';
@@ -1958,7 +1964,7 @@ function calendar_sub_month($month, $year) {
  * @param int $year the number of the year
  * @param array $eventsbyday event on specific day
  * @param array $durationbyday duration of the event in days
- * @param array $typesbyday event type (eg: global, course, user, or group)
+ * @param array $typesbyday event type (eg: site, course, user, or group)
  * @param array $courses list of courses
  * @return void
  */
@@ -1995,9 +2001,9 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
 
             // Mark the day as having such an event.
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$eventdaystart]['startglobal'] = true;
-                // Set event class for global event.
-                $events[$event->id]->class = 'calendar_event_global';
+                $typesbyday[$eventdaystart]['startsite'] = true;
+                // Set event class for site event.
+                $events[$event->id]->class = 'calendar_event_site';
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$eventdaystart]['startcourse'] = true;
                 // Set event class for course event.
@@ -2036,7 +2042,7 @@ function calendar_events_by_day($events, $month, $year, &$eventsbyday, &$duratio
         for ($i = $lowerbound + 1; $i <= $upperbound; ++$i) {
             $durationbyday[$i][] = $event->id;
             if ($event->courseid == SITEID && $event->groupid == 0) {
-                $typesbyday[$i]['durationglobal'] = true;
+                $typesbyday[$i]['durationsite'] = true;
             } else if ($event->courseid != 0 && $event->courseid != SITEID && $event->groupid == 0) {
                 $typesbyday[$i]['durationcourse'] = true;
             } else if ($event->groupid) {
@@ -2077,7 +2083,7 @@ function calendar_set_filters(array $courseeventsfrom, $ignorefilters = false, s
     if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_COURSE, $user)) {
         $courses = array_keys($courseeventsfrom);
     }
-    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_GLOBAL, $user)) {
+    if ($ignorefilters || calendar_show_event_type(CALENDAR_EVENT_SITE, $user)) {
         $courses[] = SITEID;
     }
     $courses = array_unique($courses);
@@ -2472,7 +2478,7 @@ function calendar_format_event_time($event, $now, $linkparams = null, $usecommon
  * @return bool True if the tyep should be displayed false otherwise
  */
 function calendar_show_event_type($type, $user = null) {
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP + CALENDAR_EVENT_USER;
 
     if (get_user_preferences('calendar_persistflt', 0, $user) === 0) {
         global $SESSION;
@@ -2498,7 +2504,7 @@ function calendar_show_event_type($type, $user = null) {
  */
 function calendar_set_event_type_display($type, $display = null, $user = null) {
     $persist = get_user_preferences('calendar_persistflt', 0, $user);
-    $default = CALENDAR_EVENT_GLOBAL + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
+    $default = CALENDAR_EVENT_SITE + CALENDAR_EVENT_COURSE + CALENDAR_EVENT_GROUP
             + CALENDAR_EVENT_USER + CALENDAR_EVENT_COURSECAT;
     if ($persist === 0) {
         global $SESSION;
index 5382c8f..021126a 100644 (file)
@@ -48,7 +48,7 @@ Feature: Course Category Events
       | student2    | B2i       | student           |
     And the following "events" exist:
       | name        | eventtype |
-      | Site event  | global    |
+      | Site event  | site    |
     And the following "events" exist:
       | name        | eventtype | course |
       | CA1i event  | course    | A1i    |
index 011464b..bc72391 100644 (file)
@@ -532,7 +532,7 @@ class core_calendar_container_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index abf40ea..f1c8b44 100644 (file)
@@ -467,7 +467,7 @@ class core_calendar_event_factory_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index d59bccc..a9a39a5 100644 (file)
@@ -141,7 +141,7 @@ class core_calendar_event_mapper_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->timestart = time();
         $record->timeduration = 0;
         $record->timesort = 0;
index 92f2e2e..9a875f8 100644 (file)
@@ -48,7 +48,7 @@ use core_calendar\local\event\factories\event_factory_interface;
 function create_event($properties) {
     $record = new \stdClass();
     $record->name = 'event name';
-    $record->eventtype = 'global';
+    $record->eventtype = 'site';
     $record->repeat = 0;
     $record->repeats = 0;
     $record->timestart = time();
index 7d335ea..8c963bd 100644 (file)
@@ -155,7 +155,7 @@ class core_calendar_repeat_event_collection_testcase extends advanced_testcase {
     protected function create_event($properties = []) {
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
+        $record->eventtype = 'site';
         $record->repeat = 0;
         $record->repeats = 0;
         $record->timestart = time();
index 4928d85..a16bebe 100644 (file)
@@ -639,9 +639,10 @@ $CFG->admin = 'admin';
 // to check the latest default in question/classes/bank/view.php before setting this.
 //
 //      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
-//              . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
-//              . 'copy_action_column,preview_action_column,delete_action_column,'
-//              . 'creator_name_column,modifier_name_column';
+//              . 'question_name_idnumber_tags_column,'
+//              . 'tags_action_column,edit_action_column,copy_action_column,'
+//              . 'preview_action_column,delete_action_column,export_xml_action_column,'
+//              . 'creator_name_column,modifier_name_column,edit_menu_column';
 //
 // Forum summary report
 //
index da5d4ee..bb2b08a 100644 (file)
@@ -3619,7 +3619,7 @@ class core_course_external extends external_api {
     }
 
     /**
-     * Return structure for edit_module()
+     * Return structure for get_module()
      *
      * @since Moodle 3.3
      * @return external_description
index 1c72cb1..c32225a 100644 (file)
@@ -46,8 +46,10 @@ $string['errorimportmissingcomponents'] = 'The provided model requires the follo
 $string['errorimportversionmismatches'] = 'The version of the following components differs from the version installed on this site: {$a}. You can use the option \'Ignore version mismatches\' to ignore these differences.';
 $string['errorimportmissingclasses'] = 'The following analytics components are not available on this site: {$a->missingclasses}.';
 $string['errorinvalidindicator'] = 'Invalid {$a} indicator';
+$string['errorinvalidcontexts'] = 'Some of the selected contexts can not be used in this target.';
 $string['errorinvalidtarget'] = 'Invalid {$a} target';
 $string['errorinvalidtimesplitting'] = 'Invalid analysis interval; please ensure you add the fully qualified class name.';
+$string['errornocontextrestrictions'] = 'The selected target does not support context restrictions';
 $string['errornoexportconfig'] = 'There was a problem exporting the model configuration.';
 $string['errornoexportconfigrequirements'] = 'Only non-static models with an analysis interval can be exported.';
 $string['errornoindicators'] = 'This model does not have any indicators.';
index 8ed97c4..cea9d7e 100644 (file)
@@ -32,6 +32,7 @@ $string['calendarheading'] = '{$a} Calendar';
 $string['calendarpreferences'] = 'Calendar preferences';
 $string['calendartypes'] = 'Calendar types';
 $string['calendarurl'] = 'Calendar URL: {$a}';
+$string['category'] = 'Category';
 $string['categoryevent'] = 'Category event';
 $string['clickhide'] = 'click to hide';
 $string['clickshow'] = 'click to show';
@@ -142,16 +143,12 @@ $string['forcecalendartype'] = 'Force calendar';
 $string['fri'] = 'Fri';
 $string['friday'] = 'Friday';
 $string['generateurlbutton'] = 'Get calendar URL';
-$string['global'] = 'Global';
-$string['globalevent'] = 'Global event';
-$string['globalevents'] = 'Global events';
 $string['gotoactivity'] = 'Go to activity';
 $string['gotocalendar'] = 'Go to calendar';
 $string['group'] = 'Group';
 $string['groupevent'] = 'Group event';
 $string['groupevents'] = 'Group events';
-$string['eventtypeglobal'] = 'global';
-$string['eventtypesite'] = 'global';
+$string['eventtypesite'] = 'site';
 $string['eventtypecategory'] = 'category';
 $string['eventtypecourse'] = 'course';
 $string['eventtypemodule'] = 'module';
@@ -225,6 +222,8 @@ $string['repeatweeksr'] = 'events';
 $string['sat'] = 'Sat';
 $string['saturday'] = 'Saturday';
 $string['shown'] = 'shown';
+$string['site'] = 'Site';
+$string['siteevent'] = 'Site event';
 $string['siteevents'] = 'Site events';
 $string['spanningevents'] = 'Events underway';
 $string['subscriptions'] = 'Subscriptions';
@@ -271,3 +270,9 @@ $string['when'] = 'When';
 $string['whendate'] = 'When: {$a}';
 $string['yesterday'] = 'Yesterday';
 $string['youcandeleteallrepeats'] = 'This event is part of a repeating event series. You can delete this event only, or all {$a} events in the series at once.';
+
+// Deprecated since Moodle 3.8.
+$string['global'] = 'Global';
+$string['globalevent'] = 'Global event';
+$string['globalevents'] = 'Global events';
+$string['eventtypeglobal'] = 'global';
index 861c15a..f343ba6 100644 (file)
@@ -116,4 +116,8 @@ registersite,core_hub
 updatesite,core_hub
 unregisterexplained,core_hub
 configrequestcategoryselection,core_admin
-requestcategoryselection,core_admin
\ No newline at end of file
+requestcategoryselection,core_admin
+global,core_calendar
+globalevent,core_calendar
+globalevents,core_calendar
+eventtypeglobal,core_calendar
index 8da8aa4..94cc9df 100644 (file)
@@ -157,6 +157,7 @@ $string['eventquestionsexported'] = 'Questions exported';
 $string['eventquestionsimported'] = 'Questions imported';
 $string['eventquestionupdated'] = 'Question updated';
 $string['export'] = 'Export';
+$string['exportasxml'] = 'Export as Moodle XML';
 $string['exportcategory'] = 'Export category';
 $string['exportcategory_help'] = 'This setting determines the category from which the exported questions will be taken.
 
index 40ba586..76bf394 100644 (file)
@@ -39,14 +39,15 @@ class users extends \core_analytics\local\analyser\base {
      * The site users are the analysable elements returned by this analyser.
      *
      * @param string|null $action 'prediction', 'training' or null if no specific action needed.
+     * @param \context[] $contexts Only analysables that depend on the provided contexts. All analysables in the system if empty.
      * @return \Iterator
      */
-    public function get_analysables_iterator(?string $action = null) {
+    public function get_analysables_iterator(?string $action = null, array $contexts = []) {
         global $DB, $CFG;
 
         $siteadmins = explode(',', $CFG->siteadmins);
 
-        list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u');
+        list($sql, $params) = $this->get_iterator_sql('user', CONTEXT_USER, $action, 'u', $contexts);
 
         $sql .= " AND u.deleted = :deleted AND u.confirmed = :confirmed AND u.suspended = :suspended";
         $params = $params + ['deleted' => 0, 'confirmed' => 1, 'suspended' => 0];
index 5bbb841..c344680 100644 (file)
@@ -210,7 +210,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/lock' => 'fa-lock',
             'core:i/categoryevent' => 'fa-cubes',
             'core:i/course' => 'fa-graduation-cap',
-            'core:i/courseevent' => 'fa-university',
+            'core:i/courseevent' => 'fa-graduation-cap',
             'core:i/customfield' => 'fa-hand-o-right',
             'core:i/db' => 'fa-database',
             'core:i/delete' => 'fa-trash',
index b762bfc..a2e970f 100644 (file)
         <FIELD NAME="timesplitting" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="predictionsprocessor" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="version" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="contextids" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The model will be restricted to this contexts"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
index 5f27ae1..d4c526e 100644 (file)
@@ -3619,5 +3619,24 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019101600.01);
     }
 
+    if ($oldversion < 2019101800.02) {
+
+        // Get the table by its previous name.
+        $table = new xmldb_table('analytics_models');
+        if ($dbman->table_exists($table)) {
+
+            // Define field contextids to be added to analytics_models.
+            $field = new xmldb_field('contextids', XMLDB_TYPE_TEXT, null, null, null, null, null, 'version');
+
+            // Conditionally launch add field contextids.
+            if (!$dbman->field_exists($table, $field)) {
+                $dbman->add_field($table, $field);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2019101800.02);
+    }
+
     return true;
 }
index dd0834b..3a450b8 100644 (file)
@@ -4200,32 +4200,32 @@ class action_menu implements renderable, templatable {
 
     /**
      * An icon to use for the toggling the secondary menu (dropdown).
-     * @var actionicon
+     * @var pix_icon
      */
     public $actionicon;
 
     /**
      * Any text to use for the toggling the secondary menu (dropdown).
-     * @var menutrigger
+     * @var string
      */
     public $menutrigger = '';
 
     /**
      * Any extra classes for toggling to the secondary menu.
-     * @var triggerextraclasses
+     * @var string
      */
     public $triggerextraclasses = '';
 
     /**
      * Place the action menu before all other actions.
-     * @var prioritise
+     * @var bool
      */
     public $prioritise = false;
 
     /**
      * Constructs the action menu with the given items.
      *
-     * @param array $actions An array of actions.
+     * @param array $actions An array of actions (action_menu_link|pix_icon|string).
      */
     public function __construct(array $actions = array()) {
         static $initialised = 0;
@@ -4259,7 +4259,6 @@ class action_menu implements renderable, templatable {
      * Sets the label for the menu trigger.
      *
      * @param string $label The text
-     * @return null
      */
     public function set_action_label($label) {
         $this->actionlabel = $label;
@@ -4270,7 +4269,6 @@ class action_menu implements renderable, templatable {
      *
      * @param string $trigger The text
      * @param string $extraclasses Extra classes to style the secondary menu toggle.
-     * @return null
      */
     public function set_menu_trigger($trigger, $extraclasses = '') {
         $this->menutrigger = $trigger;
index cda6868..2e46b8b 100644 (file)
@@ -1145,7 +1145,6 @@ EOD;
         require_once($CFG->dirroot . '/calendar/lib.php');
         $record = new \stdClass();
         $record->name = 'event name';
-        $record->eventtype = 'global';
         $record->repeat = 0;
         $record->repeats = 0;
         $record->timestart = time();
@@ -1176,7 +1175,7 @@ EOD;
                 unset($record->courseid);
                 unset($record->groupid);
                 break;
-            case 'global':
+            case 'site':
                 unset($record->categoryid);
                 unset($record->courseid);
                 unset($record->groupid);
index f52ab0f..2c61603 100644 (file)
@@ -48,16 +48,16 @@ class core_analytics_analysers_testcase extends advanced_testcase {
     public function test_courses_analyser() {
         $this->resetAfterTest(true);
 
-        $course = $this->getDataGenerator()->create_course();
-        $coursecontext = \context_course::instance($course->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $coursecontext = \context_course::instance($course1->id);
 
         $target = new test_target_shortname();
         $analyser = new \core\analytics\analyser\courses(1, $target, [], [], []);
-        $analysable = new \core_analytics\course($course);
+        $analysable = new \core_analytics\course($course1);
 
-        $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course->id));
+        $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($course1->id));
 
-        $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course->id));
+        $this->assertInstanceOf('\context_course', $analyser->sample_access_context($course1->id));
 
         // Just 1 sample per course.
         $class = new ReflectionClass('\core\analytics\analyser\courses');
@@ -66,8 +66,8 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable);
         $this->assertCount(1, $sampleids);
         $sampleid = reset($sampleids);
-        $this->assertEquals($course->id, $sampleid);
-        $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname);
+        $this->assertEquals($course1->id, $sampleid);
+        $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname);
         $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']);
 
         // To compare it later.
@@ -75,6 +75,16 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         list($sampleids, $samplesdata) = $analyser->get_samples(array($sampleid));
         $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']);
         $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname);
+
+        // Context restriction.
+        $category1 = $this->getDataGenerator()->create_category();
+        $category1context = \context_coursecat::instance($category1->id);
+        $category2 = $this->getDataGenerator()->create_category();
+        $category2context = \context_coursecat::instance($category2->id);
+        $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]);
+        $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]);
+        $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context]));
+
     }
 
     /**
@@ -130,24 +140,24 @@ class core_analytics_analysers_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $course = $this->getDataGenerator()->create_course();
-        $coursecontext = \context_course::instance($course->id);
+        $course1 = $this->getDataGenerator()->create_course();
+        $course1context = \context_course::instance($course1->id);
 
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
 
         // Checking that suspended users are also included.
-        $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
-        $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED);
-        $this->getDataGenerator()->enrol_user($user3->id, $course->id, 'editingteacher');
-        $enrol = $DB->get_record('enrol', array('courseid' => $course->id, 'enrol' => 'manual'));
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'student');
+        $this->getDataGenerator()->enrol_user($user2->id, $course1->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED);
+        $this->getDataGenerator()->enrol_user($user3->id, $course1->id, 'editingteacher');
+        $enrol = $DB->get_record('enrol', array('courseid' => $course1->id, 'enrol' => 'manual'));
         $ue1 = $DB->get_record('user_enrolments', array('userid' => $user1->id, 'enrolid' => $enrol->id));
         $ue2 = $DB->get_record('user_enrolments', array('userid' => $user2->id, 'enrolid' => $enrol->id));
 
         $target = new test_target_shortname();
         $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
-        $analysable = new \core_analytics\course($course);
+        $analysable = new \core_analytics\course($course1);
 
         $this->assertInstanceOf('\core_analytics\course', $analyser->get_sample_analysable($ue1->id));
         $this->assertInstanceOf('\context_course', $analyser->sample_access_context($ue1->id));
@@ -165,8 +175,8 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         // Shouldn't matter which one we select.
         $sampleid = $ue1->id;
         $this->assertEquals($ue1, $samplesdata[$sampleid]['user_enrolments']);
-        $this->assertEquals($course->fullname, $samplesdata[$sampleid]['course']->fullname);
-        $this->assertEquals($coursecontext, $samplesdata[$sampleid]['context']);
+        $this->assertEquals($course1->fullname, $samplesdata[$sampleid]['course']->fullname);
+        $this->assertEquals($course1context, $samplesdata[$sampleid]['context']);
         $this->assertEquals($user1->firstname, $samplesdata[$sampleid]['user']->firstname);
 
         // To compare it later.
@@ -176,6 +186,15 @@ class core_analytics_analysers_testcase extends advanced_testcase {
         $this->assertEquals($prevsampledata['context'], $samplesdata[$sampleid]['context']);
         $this->assertEquals($prevsampledata['course']->shortname, $samplesdata[$sampleid]['course']->shortname);
         $this->assertEquals($prevsampledata['user']->firstname, $samplesdata[$sampleid]['user']->firstname);
+
+        // Context restriction.
+        $category1 = $this->getDataGenerator()->create_category();
+        $category1context = \context_coursecat::instance($category1->id);
+        $category2 = $this->getDataGenerator()->create_category();
+        $category2context = \context_coursecat::instance($category2->id);
+        $course2 = $this->getDataGenerator()->create_course(['category' => $category1->id]);
+        $course3 = $this->getDataGenerator()->create_course(['category' => $category2->id]);
+        $this->assertCount(2, $analyser->get_analysables_iterator(false, [$category1context, $category2context]));
     }
 
     /**
index f95afe3..5271dc1 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js and b/message/amd/build/message_drawer_view_overview_section.min.js differ
index 9f43297..32de90f 100644 (file)
Binary files a/message/amd/build/message_drawer_view_overview_section.min.js.map and b/message/amd/build/message_drawer_view_overview_section.min.js.map differ
index e708a44..8fa7398 100644 (file)
@@ -212,7 +212,11 @@ function(
             if (!lastMessage) {
                 return null;
             }
-            var isMedia = lastMessage.text.includes('src');
+            // Check the message html for a src attribute, indicative of media.
+            // Replace <img with <noimg to stop browsers pre-fetching the image as part of tmp element creation.
+            var tmpElement = document.createElement("element");
+            tmpElement.innerHTML = lastMessage.text.replace(/<img /g, '<noimg ');
+            var isMedia = tmpElement.querySelector("[src]") || false;
 
             if (!isMedia) {
                 // Try to get the text value of the content.
index b6cb9ad..155064a 100644 (file)
  */
 
 namespace assignfeedback_editpdf;
+use setasign\Fpdi\TcpdfFpdi;
 
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->libdir.'/pdflib.php');
-require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
+require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/autoload.php');
 
 /**
  * Library code for manipulating PDFs
@@ -37,7 +38,7 @@ require_once($CFG->dirroot.'/mod/assign/feedback/editpdf/fpdi/fpdi.php');
  * @copyright 2012 Davo Smith
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class pdf extends \FPDI {
+class pdf extends TcpdfFpdi {
 
     /** @var int the number of the current page in the PDF being processed */
     protected $currentpage = 0;
@@ -209,14 +210,11 @@ class pdf extends \FPDI {
         // Get the size (and deduce the orientation) of the next page.
         $template = $this->importPage($pageno);
         $size = $this->getTemplateSize($template);
-        $orientation = 'P';
-        if ($size['w'] > $size['h']) {
-            $orientation = 'L';
-        }
+
         // Create a page of the required size / orientation.
-        $this->AddPage($orientation, array($size['w'], $size['h']));
+        $this->AddPage($size['orientation'], array($size['width'], $size['height']));
         // Prevent new page creation when comments are at the bottom of a page.
-        $this->setPageOrientation($orientation, false, 0);
+        $this->setPageOrientation($size['orientation'], false, 0);
         // Fill in the page with the original contents from the student.
         $this->useTemplate($template);
     }
@@ -780,25 +778,23 @@ class pdf extends \FPDI {
         $imagecontent = $imagestoredfile->get_content();
         $this->currentpage++;
         $template = $this->importPage($this->currentpage);
-        $size = $this->getTemplateSize($template);
+        // Defaulting dimensions to 0 because as of 2.x the default has changed to NULL.
+        $size = $this->getTemplateSize($template, 0, 0);
 
         if ($imageinfo["width"] > $imageinfo["height"]) {
-            if ($size['w'] < $size['h']) {
-                $temp = $size['w'];
-                $size['w'] = $size['h'];
-                $size['h'] = $temp;
+            if ($size['width'] < $size['height']) {
+                $temp = $size['width'];
+                $size['width'] = $size['height'];
+                $size['height'] = $temp;
             }
-            $orientation = 'L';
         } else if ($imageinfo["width"] < $imageinfo["height"]) {
-            if ($size['w'] > $size['h']) {
-                $temp = $size['w'];
-                $size['w'] = $size['h'];
-                $size['h'] = $temp;
+            if ($size['width'] > $size['height']) {
+                $temp = $size['width'];
+                $size['width'] = $size['height'];
+                $size['height'] = $temp;
             }
-            $orientation = 'P';
-        } else {
-            $orientation = 'P';
         }
+        $orientation = $size['orientation'];
         $this->SetHeaderMargin(0);
         $this->SetFooterMargin(0);
         $this->SetMargins(0, 0, 0, true);
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdfTpl.php b/mod/assign/feedback/editpdf/fpdi/FpdfTpl.php
new file mode 100644 (file)
index 0000000..a1bfded
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Class FpdfTpl
+ *
+ * This class adds a templating feature to FPDF.
+ *
+ * @package setasign\Fpdi
+ */
+class FpdfTpl extends \Fpdf
+{
+    use FpdfTplTrait;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php b/mod/assign/feedback/editpdf/fpdi/FpdfTplTrait.php
new file mode 100644 (file)
index 0000000..9fb0d9a
--- /dev/null
@@ -0,0 +1,466 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Trait FpdfTplTrait
+ *
+ * This class adds a templating feature to tFPDF.
+ *
+ * @package setasign\Fpdi
+ */
+trait FpdfTplTrait
+{
+    /**
+     * Data of all created templates.
+     *
+     * @var array
+     */
+    protected $templates = [];
+
+    /**
+     * The template id for the currently created template.
+     *
+     * @var null|int
+     */
+    protected $currentTemplateId;
+
+    /**
+     * A counter for template ids.
+     *
+     * @var int
+     */
+    protected $templateId = 0;
+
+    /**
+     * Set the page format of the current page.
+     *
+     * @param array $size An array with two values defining the size.
+     * @param string $orientation "L" for landscape, "P" for portrait.
+     * @throws \BadMethodCallException
+     */
+    public function setPageFormat($size, $orientation)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('The page format cannot be changed when writing to a template.');
+        }
+
+        if (!\in_array($orientation, ['P', 'L'], true)) {
+            throw new \InvalidArgumentException(\sprintf(
+                'Invalid page orientation "%s"! Only "P" and "L" are allowed!',
+                $orientation
+            ));
+        }
+
+        $size = $this->_getpagesize($size);
+
+        if ($orientation != $this->CurOrientation
+            || $size[0] != $this->CurPageSize[0]
+            || $size[1] != $this->CurPageSize[1]
+        ) {
+            // New size or orientation
+            if ($orientation === 'P') {
+                $this->w = $size[0];
+                $this->h = $size[1];
+            } else {
+                $this->w = $size[1];
+                $this->h = $size[0];
+            }
+            $this->wPt = $this->w * $this->k;
+            $this->hPt = $this->h * $this->k;
+            $this->PageBreakTrigger = $this->h - $this->bMargin;
+            $this->CurOrientation = $orientation;
+            $this->CurPageSize = $size;
+
+            $this->PageInfo[$this->page]['size'] = array($this->wPt, $this->hPt);
+        }
+    }
+
+    /**
+     * Draws a template onto the page or another template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param array|float|int $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size
+     * @see FpdfTplTrait::getTemplateSize()
+     */
+    public function useTemplate($tpl, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (!isset($this->templates[$tpl])) {
+            throw new \InvalidArgumentException('Template does not exist!');
+        }
+
+        if (\is_array($x)) {
+            unset($x['tpl']);
+            \extract($x, EXTR_IF_EXISTS);
+            /** @noinspection NotOptimalIfConditionsInspection */
+            /** @noinspection CallableParameterUseCaseInTypeContextInspection */
+            if (\is_array($x)) {
+                $x = 0;
+            }
+        }
+
+        $template = $this->templates[$tpl];
+
+        $originalSize = $this->getTemplateSize($tpl);
+        $newSize = $this->getTemplateSize($tpl, $width, $height);
+        if ($adjustPageSize) {
+            $this->setPageFormat($newSize, $newSize['orientation']);
+        }
+
+        $this->_out(
+        // reset standard values, translate and scale
+            \sprintf(
+                'q 0 J 1 w 0 j 0 G 0 g %.4F 0 0 %.4F %.4F %.4F cm /%s Do Q',
+                ($newSize['width'] / $originalSize['width']),
+                ($newSize['height'] / $originalSize['height']),
+                $x * $this->k,
+                ($this->h - $y - $newSize['height']) * $this->k,
+                $template['id']
+            )
+        );
+
+        return $newSize;
+    }
+
+    /**
+     * Get the size of a template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getTemplateSize($tpl, $width = null, $height = null)
+    {
+        if (!isset($this->templates[$tpl])) {
+            return false;
+        }
+
+        if ($width === null && $height === null) {
+            $width = $this->templates[$tpl]['width'];
+            $height = $this->templates[$tpl]['height'];
+        } elseif ($width === null) {
+            $width = $height * $this->templates[$tpl]['width'] / $this->templates[$tpl]['height'];
+        }
+
+        if ($height === null) {
+            $height = $width * $this->templates[$tpl]['height'] / $this->templates[$tpl]['width'];
+        }
+
+        if ($height <= 0. || $width <= 0.) {
+            throw new \InvalidArgumentException('Width or height parameter needs to be larger than zero.');
+        }
+
+        return [
+            'width' => $width,
+            'height' => $height,
+            0 => $width,
+            1 => $height,
+            'orientation' => $width > $height ? 'L' : 'P'
+        ];
+    }
+
+    /**
+     * Begins a new template.
+     *
+     * @param float|int|null $width The width of the template. If null, the current page width is used.
+     * @param float|int|null $height The height of the template. If null, the current page height is used.
+     * @param bool $groupXObject Define the form XObject as a group XObject to support transparency (if used).
+     * @return int A template identifier.
+     */
+    public function beginTemplate($width = null, $height = null, $groupXObject = false)
+    {
+        if ($width === null) {
+            $width = $this->w;
+        }
+
+        if ($height === null) {
+            $height = $this->h;
+        }
+
+        $templateId = $this->getNextTemplateId();
+
+        // initiate buffer with current state of FPDF
+        $buffer = "2 J\n"
+            . \sprintf('%.2F w', $this->LineWidth * $this->k) . "\n";
+
+        if ($this->FontFamily) {
+            $buffer .= \sprintf("BT /F%d %.2F Tf ET\n", $this->CurrentFont['i'], $this->FontSizePt);
+        }
+
+        if ($this->DrawColor !== '0 G') {
+            $buffer .= $this->DrawColor . "\n";
+        }
+        if ($this->FillColor !== '0 g') {
+            $buffer .= $this->FillColor . "\n";
+        }
+
+        if ($groupXObject && \version_compare('1.4', $this->PDFVersion, '>')) {
+            $this->PDFVersion = '1.4';
+        }
+
+        $this->templates[$templateId] = [
+            'objectNumber' => null,
+            'id' => 'TPL' . $templateId,
+            'buffer' => $buffer,
+            'width' => $width,
+            'height' => $height,
+            'groupXObject' => $groupXObject,
+            'state' => [
+                'x' => $this->x,
+                'y' => $this->y,
+                'AutoPageBreak' => $this->AutoPageBreak,
+                'bMargin' => $this->bMargin,
+                'tMargin' => $this->tMargin,
+                'lMargin' => $this->lMargin,
+                'rMargin' => $this->rMargin,
+                'h' => $this->h,
+                'w' => $this->w,
+                'FontFamily' => $this->FontFamily,
+                'FontStyle' => $this->FontStyle,
+                'FontSizePt' => $this->FontSizePt,
+                'FontSize' => $this->FontSize,
+                'underline' => $this->underline,
+                'TextColor' => $this->TextColor,
+                'DrawColor' => $this->DrawColor,
+                'FillColor' => $this->FillColor,
+                'ColorFlag' => $this->ColorFlag
+            ]
+        ];
+
+        $this->SetAutoPageBreak(false);
+        $this->currentTemplateId = $templateId;
+
+        $this->h = $height;
+        $this->w = $width;
+
+        $this->SetXY($this->lMargin, $this->tMargin);
+        $this->SetRightMargin($this->w - $width + $this->rMargin);
+
+        return $templateId;
+    }
+
+    /**
+     * Ends a template.
+     *
+     * @return bool|int|null A template identifier.
+     */
+    public function endTemplate()
+    {
+        if (null === $this->currentTemplateId) {
+            return false;
+        }
+
+        $templateId = $this->currentTemplateId;
+        $template = $this->templates[$templateId];
+
+        $state = $template['state'];
+        $this->SetXY($state['x'], $state['y']);
+        $this->tMargin = $state['tMargin'];
+        $this->lMargin = $state['lMargin'];
+        $this->rMargin = $state['rMargin'];
+        $this->h = $state['h'];
+        $this->w = $state['w'];
+        $this->SetAutoPageBreak($state['AutoPageBreak'], $state['bMargin']);
+
+        $this->FontFamily = $state['FontFamily'];
+        $this->FontStyle = $state['FontStyle'];
+        $this->FontSizePt = $state['FontSizePt'];
+        $this->FontSize = $state['FontSize'];
+
+        $this->TextColor = $state['TextColor'];
+        $this->DrawColor = $state['DrawColor'];
+        $this->FillColor = $state['FillColor'];
+        $this->ColorFlag = $state['ColorFlag'];
+
+        $this->underline = $state['underline'];
+
+        $fontKey = $this->FontFamily . $this->FontStyle;
+        if ($fontKey) {
+            $this->CurrentFont =& $this->fonts[$fontKey];
+        } else {
+            unset($this->CurrentFont);
+        }
+
+        $this->currentTemplateId = null;
+
+        return $templateId;
+    }
+
+    /**
+     * Get the next template id.
+     *
+     * @return int
+     */
+    protected function getNextTemplateId()
+    {
+        return $this->templateId++;
+    }
+
+    /* overwritten FPDF methods: */
+
+    /**
+     * @inheritdoc
+     */
+    public function AddPage($orientation = '', $size = '', $rotation = 0)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Pages cannot be added when writing to a template.');
+        }
+        parent::AddPage($orientation, $size, $rotation);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function Link($x, $y, $w, $h, $link)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Links cannot be set when writing to a template.');
+        }
+        parent::Link($x, $y, $w, $h, $link);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetLink($link, $y = 0, $page = -1)
+    {
+        if ($this->currentTemplateId !== null) {
+            throw new \BadMethodCallException('Links cannot be set when writing to a template.');
+        }
+        return parent::SetLink($link, $y, $page);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetDrawColor($r, $g = null, $b = null)
+    {
+        parent::SetDrawColor($r, $g, $b);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out($this->DrawColor);
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFillColor($r, $g = null, $b = null)
+    {
+        parent::SetFillColor($r, $g, $b);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out($this->FillColor);
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetLineWidth($width)
+    {
+        parent::SetLineWidth($width);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(\sprintf('%.2F w', $width * $this->k));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFont($family, $style = '', $size = 0)
+    {
+        parent::SetFont($family, $style, $size);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(\sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function SetFontSize($size)
+    {
+        parent::SetFontSize($size);
+        if ($this->page === 0 && $this->currentTemplateId !== null) {
+            $this->_out(sprintf('BT /F%d %.2F Tf ET', $this->CurrentFont['i'], $this->FontSizePt));
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putimages()
+    {
+        parent::_putimages();
+
+        foreach ($this->templates as $key => $template) {
+            $this->_newobj();
+            $this->templates[$key]['objectNumber'] = $this->n;
+
+            $this->_put('<</Type /XObject /Subtype /Form /FormType 1');
+            $this->_put(\sprintf('/BBox[0 0 %.2F %.2F]', $template['width'] * $this->k, $template['height'] * $this->k));
+            $this->_put('/Resources 2 0 R'); // default resources dictionary of FPDF
+
+            if ($this->compress) {
+                $buffer = \gzcompress($template['buffer']);
+                $this->_put('/Filter/FlateDecode');
+            } else {
+                $buffer = $template['buffer'];
+            }
+
+            $this->_put('/Length ' . \strlen($buffer));
+
+            if ($template['groupXObject']) {
+                $this->_put('/Group <</Type/Group/S/Transparency>>');
+            }
+
+            $this->_put('>>');
+            $this->_putstream($buffer);
+            $this->_put('endobj');
+        }
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putxobjectdict()
+    {
+        foreach ($this->templates as $key => $template) {
+            $this->_put('/' . $template['id'] . ' ' . $template['objectNumber'] . ' 0 R');
+        }
+
+        parent::_putxobjectdict();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function _out($s)
+    {
+        if ($this->currentTemplateId !== null) {
+            $this->templates[$this->currentTemplateId]['buffer'] .= $s . "\n";
+        } else {
+            parent::_out($s);
+        }
+    }
+}
\ No newline at end of file
diff --git a/mod/assign/feedback/editpdf/fpdi/Fpdi.php b/mod/assign/feedback/editpdf/fpdi/Fpdi.php
new file mode 100644 (file)
index 0000000..b49c103
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfNull;
+
+/**
+ * Class Fpdi
+ *
+ * This class let you import pages of existing PDF documents into a reusable structure for FPDF.
+ *
+ * @package setasign\Fpdi
+ */
+class Fpdi extends FpdfTpl
+{
+    use FpdiTrait;
+
+    /**
+     * FPDI version
+     *
+     * @string
+     */
+    const VERSION = '2.2.0';
+
+    protected function _enddoc()
+    {
+        parent::_enddoc();
+        $this->cleanUp();
+    }
+
+    /**
+     * Draws an imported page or a template onto the page or another template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|array $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size
+     * @see Fpdi::getTemplateSize()
+     */
+    public function useTemplate($tpl, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (isset($this->importedPages[$tpl])) {
+            $size = $this->useImportedPage($tpl, $x, $y, $width, $height, $adjustPageSize);
+            if ($this->currentTemplateId !== null) {
+                $this->templates[$this->currentTemplateId]['resources']['templates']['importedPages'][$tpl] = $tpl;
+            }
+            return $size;
+        }
+
+        return parent::useTemplate($tpl, $x, $y, $width, $height, $adjustPageSize);
+    }
+
+    /**
+     * Get the size of an imported page or template.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getTemplateSize($tpl, $width = null, $height = null)
+    {
+        $size = parent::getTemplateSize($tpl, $width, $height);
+        if ($size === false) {
+            return $this->getImportedPageSize($tpl, $width, $height);
+        }
+
+        return $size;
+    }
+
+    /**
+     * @inheritdoc
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    protected function _putimages()
+    {
+        $this->currentReaderId = null;
+        parent::_putimages();
+
+        foreach ($this->importedPages as $key => $pageData) {
+            $this->_newobj();
+            $this->importedPages[$key]['objectNumber'] = $this->n;
+            $this->currentReaderId = $pageData['readerId'];
+            $this->writePdfType($pageData['stream']);
+            $this->_put('endobj');
+        }
+
+        foreach (\array_keys($this->readers) as $readerId) {
+            $parser = $this->getPdfReader($readerId)->getParser();
+            $this->currentReaderId = $readerId;
+
+            while (($objectNumber = \array_pop($this->objectsToCopy[$readerId])) !== null) {
+                try {
+                    $object = $parser->getIndirectObject($objectNumber);
+
+                } catch (CrossReferenceException $e) {
+                    if ($e->getCode() === CrossReferenceException::OBJECT_NOT_FOUND) {
+                        $object = PdfIndirectObject::create($objectNumber, 0, new PdfNull());
+                    } else {
+                        throw $e;
+                    }
+                }
+
+                $this->writePdfType($object);
+            }
+        }
+
+        $this->currentReaderId = null;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _putxobjectdict()
+    {
+        foreach ($this->importedPages as $key => $pageData) {
+            $this->_put('/' . $pageData['id'] . ' ' . $pageData['objectNumber'] . ' 0 R');
+        }
+
+        parent::_putxobjectdict();
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function _put($s, $newLine = true)
+    {
+        if ($newLine) {
+            $this->buffer .= $s . "\n";
+        } else {
+            $this->buffer .= $s;
+        }
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdiException.php b/mod/assign/feedback/editpdf/fpdi/FpdiException.php
new file mode 100644 (file)
index 0000000..d126c14
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+/**
+ * Base exception class for the FPDI package.
+ *
+ * @package setasign\Fpdi
+ */
+class FpdiException extends \Exception
+{
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/FpdiTrait.php b/mod/assign/feedback/editpdf/fpdi/FpdiTrait.php
new file mode 100644 (file)
index 0000000..0bdbc03
--- /dev/null
@@ -0,0 +1,562 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\Filter\FilterException;
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\Fpdi\PdfParser\Type\PdfArray;
+use setasign\Fpdi\PdfParser\Type\PdfBoolean;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfHexString;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference;
+use setasign\Fpdi\PdfParser\Type\PdfName;
+use setasign\Fpdi\PdfParser\Type\PdfNull;
+use setasign\Fpdi\PdfParser\Type\PdfNumeric;
+use setasign\Fpdi\PdfParser\Type\PdfStream;
+use setasign\Fpdi\PdfParser\Type\PdfString;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfType;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+use setasign\Fpdi\PdfReader\PageBoundaries;
+use setasign\Fpdi\PdfReader\PdfReader;
+use setasign\Fpdi\PdfReader\PdfReaderException;
+use /* This namespace/class is used by the commercial FPDI PDF-Parser add-on. */
+    /** @noinspection PhpUndefinedClassInspection */
+    /** @noinspection PhpUndefinedNamespaceInspection */
+    setasign\FpdiPdfParser\PdfParser\PdfParser as FpdiPdfParser;
+
+/**
+ * The FpdiTrait
+ *
+ * This trait offers the core functionalities of FPDI. By passing them to a trait we can reuse it with e.g. TCPDF in a
+ * very easy way.
+ *
+ * @package setasign\Fpdi
+ */
+trait FpdiTrait
+{
+    /**
+     * The pdf reader instances.
+     *
+     * @var PdfReader[]
+     */
+    protected $readers = [];
+
+    /**
+     * Instances created internally.
+     *
+     * @var array
+     */
+    protected $createdReaders = [];
+
+    /**
+     * The current reader id.
+     *
+     * @var string
+     */
+    protected $currentReaderId;
+
+    /**
+     * Data of all imported pages.
+     *
+     * @var array
+     */
+    protected $importedPages = [];
+
+    /**
+     * A map from object numbers of imported objects to new assigned object numbers by FPDF.
+     *
+     * @var array
+     */
+    protected $objectMap = [];
+
+    /**
+     * An array with information about objects, which needs to be copied to the resulting document.
+     *
+     * @var array
+     */
+    protected $objectsToCopy = [];
+
+    /**
+     * Release resources and file handles.
+     *
+     * This method is called internally when the document is created successfully. By default it only cleans up
+     * stream reader instances which were created internally.
+     *
+     * @param bool $allReaders
+     */
+    public function cleanUp($allReaders = false)
+    {
+        $readers = $allReaders ? array_keys($this->readers) : $this->createdReaders;
+        foreach ($readers as $id) {
+            $this->readers[$id]->getParser()->getStreamReader()->cleanUp();
+            unset($this->readers[$id]);
+        }
+
+        $this->createdReaders= [];
+    }
+
+    /**
+     * Set the minimal PDF version.
+     *
+     * @param string $pdfVersion
+     */
+    protected function setMinPdfVersion($pdfVersion)
+    {
+        if (\version_compare($pdfVersion, $this->PDFVersion, '>')) {
+            $this->PDFVersion = $pdfVersion;
+        }
+    }
+
+    /** @noinspection PhpUndefinedClassInspection */
+    /**
+     * Get a new pdf parser instance.
+     *
+     * @param StreamReader $streamReader
+     * @return PdfParser|FpdiPdfParser
+     */
+    protected function getPdfParserInstance(StreamReader $streamReader)
+    {
+        /** @noinspection PhpUndefinedClassInspection */
+        if (\class_exists(FpdiPdfParser::class)) {
+            /** @noinspection PhpUndefinedClassInspection */
+            return new FpdiPdfParser($streamReader);
+        }
+
+        return new PdfParser($streamReader);
+    }
+
+    /**
+     * Get an unique reader id by the $file parameter.
+     *
+     * @param string|resource|PdfReader|StreamReader $file An open file descriptor, a path to a file, a PdfReader
+     *                                                     instance or a StreamReader instance.
+     * @return string
+     */
+    protected function getPdfReaderId($file)
+    {
+        if (\is_resource($file)) {
+            $id = (string) $file;
+        } elseif (\is_string($file)) {
+            $id = \realpath($file);
+            if ($id === false) {
+                $id = $file;
+            }
+        } elseif (\is_object($file)) {
+            $id = \spl_object_hash($file);
+        } else {
+            throw new \InvalidArgumentException(
+                \sprintf('Invalid type in $file parameter (%s)', \gettype($file))
+            );
+        }
+
+        /** @noinspection OffsetOperationsInspection */
+        if (isset($this->readers[$id])) {
+            return $id;
+        }
+
+        if (\is_resource($file)) {
+            $streamReader = new StreamReader($file);
+        } elseif (\is_string($file)) {
+            $streamReader = StreamReader::createByFile($file);
+            $this->createdReaders[] = $id;
+        } else {
+            $streamReader = $file;
+        }
+
+        $reader = new PdfReader($this->getPdfParserInstance($streamReader));
+        /** @noinspection OffsetOperationsInspection */
+        $this->readers[$id] = $reader;
+
+        return $id;
+    }
+
+    /**
+     * Get a pdf reader instance by its id.
+     *
+     * @param string $id
+     * @return PdfReader
+     */
+    protected function getPdfReader($id)
+    {
+        if (isset($this->readers[$id])) {
+            return $this->readers[$id];
+        }
+
+        throw new \InvalidArgumentException(
+            \sprintf('No pdf reader with the given id (%s) exists.', $id)
+        );
+    }
+
+    /**
+     * Set the source PDF file.
+     *
+     * @param string|resource|StreamReader $file Path to the file or a stream resource or a StreamReader instance.
+     * @return int The page count of the PDF document.
+     * @throws PdfParserException
+     */
+    public function setSourceFile($file)
+    {
+        $this->currentReaderId = $this->getPdfReaderId($file);
+        $this->objectsToCopy[$this->currentReaderId] = [];
+
+        $reader = $this->getPdfReader($this->currentReaderId);
+        $this->setMinPdfVersion($reader->getPdfVersion());
+
+        return $reader->getPageCount();
+    }
+
+    /**
+     * Imports a page.
+     *
+     * @param int $pageNumber The page number.
+     * @param string $box The page boundary to import. Default set to PageBoundaries::CROP_BOX.
+     * @param bool $groupXObject Define the form XObject as a group XObject to support transparency (if used).
+     * @return string A unique string identifying the imported page.
+     * @throws CrossReferenceException
+     * @throws FilterException
+     * @throws PdfParserException
+     * @throws PdfTypeException
+     * @throws PdfReaderException
+     * @see PageBoundaries
+     */
+    public function importPage($pageNumber, $box = PageBoundaries::CROP_BOX, $groupXObject = true)
+    {
+        if (null === $this->currentReaderId) {
+            throw new \BadMethodCallException('No reader initiated. Call setSourceFile() first.');
+        }
+
+        $pageId = $this->currentReaderId;
+
+        $pageNumber = (int)$pageNumber;
+        $pageId .= '|' . $pageNumber . '|' . ($groupXObject ? '1' : '0');
+
+        // for backwards compatibility with FPDI 1
+        $box = \ltrim($box, '/');
+        if (!PageBoundaries::isValidName($box)) {
+            throw new \InvalidArgumentException(
+                \sprintf('Box name is invalid: "%s"', $box)
+            );
+        }
+
+        $pageId .= '|' . $box;
+
+        if (isset($this->importedPages[$pageId])) {
+            return $pageId;
+        }
+
+        $reader = $this->getPdfReader($this->currentReaderId);
+        $page = $reader->getPage($pageNumber);
+
+        $bbox = $page->getBoundary($box);
+        if ($bbox === false) {
+            throw new PdfReaderException(
+                \sprintf("Page doesn't have a boundary box (%s).", $box),
+                PdfReaderException::MISSING_DATA
+            );
+        }
+
+        $dict = new PdfDictionary();
+        $dict->value['Type'] = PdfName::create('XObject');
+        $dict->value['Subtype'] = PdfName::create('Form');
+        $dict->value['FormType'] = PdfNumeric::create(1);
+        $dict->value['BBox'] = $bbox->toPdfArray();
+
+        if ($groupXObject) {
+            $this->setMinPdfVersion('1.4');
+            $dict->value['Group'] = PdfDictionary::create([
+                'Type' => PdfName::create('Group'),
+                'S' => PdfName::create('Transparency')
+            ]);
+        }
+
+        $resources = $page->getAttribute('Resources');
+        if ($resources !== null) {
+            $dict->value['Resources'] = $resources;
+        }
+
+        list($width, $height) = $page->getWidthAndHeight($box);
+
+        $a = 1;
+        $b = 0;
+        $c = 0;
+        $d = 1;
+        $e = -$bbox->getLlx();
+        $f = -$bbox->getLly();
+
+        $rotation = $page->getRotation();
+
+        if ($rotation !== 0) {
+            $rotation *= -1;
+            $angle = $rotation * M_PI/180;
+            $a = \cos($angle);
+            $b = \sin($angle);
+            $c = -$b;
+            $d = $a;
+
+            switch ($rotation) {
+                case -90:
+                    $e = -$bbox->getLly();
+                    $f = $bbox->getUrx();
+                    break;
+                case -180:
+                    $e = $bbox->getUrx();
+                    $f = $bbox->getUry();
+                    break;
+                case -270:
+                    $e = $bbox->getUry();
+                    $f = -$bbox->getLlx();
+                    break;
+            }
+        }
+
+        // we need to rotate/translate
+        if ($a != 1 || $b != 0 || $c != 0 || $d != 1 || $e != 0 || $f != 0) {
+            $dict->value['Matrix'] = PdfArray::create([
+                PdfNumeric::create($a), PdfNumeric::create($b), PdfNumeric::create($c),
+                PdfNumeric::create($d), PdfNumeric::create($e), PdfNumeric::create($f)
+            ]);
+        }
+
+        // try to use the existing content stream
+        $pageDict = $page->getPageDictionary();
+
+        $contentsObject = PdfType::resolve(PdfDictionary::get($pageDict, 'Contents'), $reader->getParser(), true);
+        $contents =  PdfType::resolve($contentsObject, $reader->getParser());
+
+        // just copy the stream reference if it is only a single stream
+        if (($contentsIsStream = ($contents instanceof PdfStream))
+            || ($contents instanceof PdfArray && \count($contents->value) === 1)
+        ) {
+            if ($contentsIsStream) {
+                /**
+                 * @var PdfIndirectObject $contentsObject
+                 */
+                $stream = $contents;
+            } else {
+                $stream = PdfType::resolve($contents->value[0], $reader->getParser());
+            }
+
+            $filter = PdfDictionary::get($stream->value, 'Filter');
+            if (!$filter instanceof PdfNull) {
+                $dict->value['Filter'] = $filter;
+            }
+            $length = PdfType::resolve(PdfDictionary::get($stream->value, 'Length'), $reader->getParser());
+            $dict->value['Length'] = $length;
+            $stream->value = $dict;
+
+        // otherwise extract it from the array and re-compress the whole stream
+        } else {
+            $streamContent = $this->compress
+                ? \gzcompress($page->getContentStream())
+                : $page->getContentStream();
+
+            $dict->value['Length'] = PdfNumeric::create(\strlen($streamContent));
+            if ($this->compress) {
+                $dict->value['Filter'] = PdfName::create('FlateDecode');
+            }
+
+            $stream = PdfStream::create($dict, $streamContent);
+        }
+
+        $this->importedPages[$pageId] = [
+            'objectNumber' => null,
+            'readerId' => $this->currentReaderId,
+            'id' => 'TPL' . $this->getNextTemplateId(),
+            'width' => $width / $this->k,
+            'height' => $height / $this->k,
+            'stream' => $stream
+        ];
+
+        return $pageId;
+    }
+
+    /**
+     * Draws an imported page onto the page.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $pageId The page id
+     * @param float|int|array $x The abscissa of upper-left corner. Alternatively you could use an assoc array
+     *                           with the keys "x", "y", "width", "height", "adjustPageSize".
+     * @param float|int $y The ordinate of upper-left corner.
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @param bool $adjustPageSize
+     * @return array The size.
+     * @see Fpdi::getTemplateSize()
+     */
+    public function useImportedPage($pageId, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
+    {
+        if (\is_array($x)) {
+            /** @noinspection OffsetOperationsInspection */
+            unset($x['pageId']);
+            \extract($x, EXTR_IF_EXISTS);
+            /** @noinspection NotOptimalIfConditionsInspection */
+            if (\is_array($x)) {
+                $x = 0;
+            }
+        }
+
+        if (!isset($this->importedPages[$pageId])) {
+            throw new \InvalidArgumentException('Imported page does not exist!');
+        }
+
+        $importedPage = $this->importedPages[$pageId];
+
+        $originalSize = $this->getTemplateSize($pageId);
+        $newSize = $this->getTemplateSize($pageId, $width, $height);
+        if ($adjustPageSize) {
+            $this->setPageFormat($newSize, $newSize['orientation']);
+        }
+
+        $this->_out(
+            // reset standard values, translate and scale
+            \sprintf(
+                'q 0 J 1 w 0 j 0 G 0 g %.4F 0 0 %.4F %.4F %.4F cm /%s Do Q',
+                ($newSize['width'] / $originalSize['width']),
+                ($newSize['height'] / $originalSize['height']),
+                $x * $this->k,
+                ($this->h - $y - $newSize['height']) * $this->k,
+                $importedPage['id']
+            )
+        );
+
+        return $newSize;
+    }
+
+    /**
+     * Get the size of an imported page.
+     *
+     * Give only one of the size parameters (width, height) to calculate the other one automatically in view to the
+     * aspect ratio.
+     *
+     * @param mixed $tpl The template id
+     * @param float|int|null $width The width.
+     * @param float|int|null $height The height.
+     * @return array|bool An array with following keys: width, height, 0 (=width), 1 (=height), orientation (L or P)
+     */
+    public function getImportedPageSize($tpl, $width = null, $height = null)
+    {
+        if (isset($this->importedPages[$tpl])) {
+            $importedPage = $this->importedPages[$tpl];
+
+            if ($width === null && $height === null) {
+                $width = $importedPage['width'];
+                $height = $importedPage['height'];
+            } elseif ($width === null) {
+                $width = $height * $importedPage['width'] / $importedPage['height'];
+            }
+
+            if ($height  === null) {
+                $height = $width * $importedPage['height'] / $importedPage['width'];
+            }
+
+            if ($height <= 0. || $width <= 0.) {
+                throw new \InvalidArgumentException('Width or height parameter needs to be larger than zero.');
+            }
+
+            return [
+                'width' => $width,
+                'height' => $height,
+                0 => $width,
+                1 => $height,
+                'orientation' => $width > $height ? 'L' : 'P'
+            ];
+        }
+
+        return false;
+    }
+
+    /**
+     * Writes a PdfType object to the resulting buffer.
+     *
+     * @param PdfType $value
+     * @throws PdfTypeException
+     */
+    protected function writePdfType(PdfType $value)
+    {
+        if ($value instanceof PdfNumeric) {
+            if (\is_int($value->value)) {
+                $this->_put($value->value . ' ', false);
+            } else {
+                $this->_put(\rtrim(\rtrim(\sprintf('%.5F', $value->value), '0'), '.') . ' ', false);
+            }
+
+        } elseif ($value instanceof PdfName) {
+            $this->_put('/' . $value->value . ' ', false);
+
+        } elseif ($value instanceof PdfString) {
+            $this->_put('(' . $value->value . ')', false);
+
+        } elseif ($value instanceof PdfHexString) {
+            $this->_put('<' . $value->value . '>');
+
+        } elseif ($value instanceof PdfBoolean) {
+            $this->_put($value->value ? 'true ' : 'false ', false);
+
+        } elseif ($value instanceof PdfArray) {
+            $this->_put('[', false);
+            foreach ($value->value as $entry) {
+                $this->writePdfType($entry);
+            }
+            $this->_put(']');
+
+        } elseif ($value instanceof PdfDictionary) {
+            $this->_put('<<', false);
+            foreach ($value->value as $name => $entry) {
+                $this->_put('/' . $name . ' ', false);
+                $this->writePdfType($entry);
+            }
+            $this->_put('>>');
+
+        } elseif ($value instanceof PdfToken) {
+            $this->_put($value->value);
+
+        } elseif ($value instanceof PdfNull) {
+            $this->_put('null ');
+
+        } elseif ($value instanceof PdfStream) {
+            /**
+             * @var $value PdfStream
+             */
+            $this->writePdfType($value->value);
+            $this->_put('stream');
+            $this->_put($value->getStream());
+            $this->_put('endstream');
+
+        } elseif ($value instanceof PdfIndirectObjectReference) {
+            if (!isset($this->objectMap[$this->currentReaderId])) {
+                $this->objectMap[$this->currentReaderId] = [];
+            }
+
+            if (!isset($this->objectMap[$this->currentReaderId][$value->value])) {
+                $this->objectMap[$this->currentReaderId][$value->value] = ++$this->n;
+                $this->objectsToCopy[$this->currentReaderId][] = $value->value;
+            }
+
+            $this->_put($this->objectMap[$this->currentReaderId][$value->value] . ' 0 R ', false);
+
+        } elseif ($value instanceof PdfIndirectObject) {
+            /**
+             * @var $value PdfIndirectObject
+             */
+            $n = $this->objectMap[$this->currentReaderId][$value->objectNumber];
+            $this->_newobj($n);
+            $this->writePdfType($value->value);
+            $this->_put('endobj');
+        }
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/LICENSE b/mod/assign/feedback/editpdf/fpdi/LICENSE
deleted file mode 100644 (file)
index e33384d..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)\r
-\r
-Copyright (c) 2017 Setasign - Jan Slabon, https://www.setasign.com\r
-\r
-Permission is hereby granted, free of charge, to any person obtaining a copy\r
-of this software and associated documentation files (the "Software"), to deal\r
-in the Software without restriction, including without limitation the rights\r
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r
-copies of the Software, and to permit persons to whom the Software is\r
-furnished to do so, subject to the following conditions:\r
-\r
-The above copyright notice and this permission notice shall be included in\r
-all copies or substantial portions of the Software.\r
-\r
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\r
-THE SOFTWARE.
\ No newline at end of file
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/AbstractReader.php
new file mode 100644 (file)
index 0000000..1d5c579
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+
+/**
+ * Abstract class for cross-reference reader classes.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+abstract class AbstractReader
+{
+    /**
+     * @var PdfParser
+     */
+    protected $parser;
+
+    /**
+     * @var PdfDictionary
+     */
+    protected $trailer;
+
+    /**
+     * AbstractReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->parser = $parser;
+        $this->readTrailer();
+    }
+
+    /**
+     * Get the trailer dictionary.
+     *
+     * @return PdfDictionary
+     */
+    public function getTrailer()
+    {
+        return $this->trailer;
+    }
+
+    /**
+     * Read the trailer dictionary.
+     *
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function readTrailer()
+    {
+        try {
+            $trailerKeyword = $this->parser->readValue(null, PdfToken::class);
+            if ($trailerKeyword->value !== 'trailer') {
+                throw new CrossReferenceException(
+                    \sprintf(
+                        'Unexpected end of cross reference. "trailer"-keyword expected, got: %s.',
+                        $trailerKeyword->value
+                    ),
+                    CrossReferenceException::UNEXPECTED_END
+                );
+            }
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Unexpected end of cross reference. "trailer"-keyword expected, got an invalid object type.',
+                CrossReferenceException::UNEXPECTED_END,
+                $e
+            );
+        }
+
+        try {
+            $trailer = $this->parser->readValue(null, PdfDictionary::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Unexpected end of cross reference. Trailer not found.',
+                CrossReferenceException::UNEXPECTED_END,
+                $e
+            );
+        }
+
+        $this->trailer = $trailer;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReference.php
new file mode 100644 (file)
index 0000000..95674ce
--- /dev/null
@@ -0,0 +1,320 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfNumeric;
+use setasign\Fpdi\PdfParser\Type\PdfStream;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfTypeException;
+
+/**
+ * Class CrossReference
+ *
+ * This class processes the standard cross reference of a PDF document.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class CrossReference
+{
+    /**
+     * The byte length in which the "startxref" keyword should be searched.
+     *
+     * @var int
+     */
+    static public $trailerSearchLength = 5500;
+
+    /**
+     * @var int
+     */
+    protected $fileHeaderOffset = 0;
+
+    /**
+     * @var PdfParser
+     */
+    protected $parser;
+
+    /**
+     * @var ReaderInterface[]
+     */
+    protected $readers = [];
+
+    /**
+     * CrossReference constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    public function __construct(PdfParser $parser, $fileHeaderOffset = 0)
+    {
+        $this->parser = $parser;
+        $this->fileHeaderOffset = $fileHeaderOffset;
+
+        $offset = $this->findStartXref();
+        $reader = null;
+        /** @noinspection TypeUnsafeComparisonInspection */
+        while ($offset != false) { // By doing an unsafe comparsion we ignore faulty references to byte offset 0
+            try {
+                $reader = $this->readXref($offset + $this->fileHeaderOffset);
+            } catch (CrossReferenceException $e) {
+                // sometimes the file header offset is part of the byte offsets, so let's retry by resetting it to zero.
+                if ($e->getCode() === CrossReferenceException::INVALID_DATA && $this->fileHeaderOffset !== 0) {
+                    $this->fileHeaderOffset = 0;
+                    $reader = $this->readXref($offset + $this->fileHeaderOffset);
+                } else {
+                    throw $e;
+                }
+            }
+
+            $trailer = $reader->getTrailer();
+            $this->checkForEncryption($trailer);
+            $this->readers[] = $reader;
+
+            if (isset($trailer->value['Prev'])) {
+                $offset = $trailer->value['Prev']->value;
+            } else {
+                $offset = false;
+            }
+        }
+
+        // fix faulty sub-section header
+        if ($reader instanceof FixedReader) {
+            /**
+             * @var FixedReader $reader
+             */
+            $reader->fixFaultySubSectionShift();
+        }
+
+        if ($reader === null) {
+            throw new CrossReferenceException('No cross-reference found.', CrossReferenceException::NO_XREF_FOUND);
+        }
+    }
+
+    /**
+     * Get the size of the cross reference.
+     *
+     * @return integer
+     */
+    public function getSize()
+    {
+        return $this->getTrailer()->value['Size']->value;
+    }
+
+    /**
+     * Get the trailer dictionary.
+     *
+     * @return PdfDictionary
+     */
+    public function getTrailer()
+    {
+        return $this->readers[0]->getTrailer();
+    }
+
+    /**
+     * Get the cross reference readser instances.
+     *
+     * @return ReaderInterface[]
+     */
+    public function getReaders()
+    {
+        return $this->readers;
+    }
+
+    /**
+     * Get the offset by an object number.
+     *
+     * @param int $objectNumber
+     * @return integer|bool
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        foreach ($this->getReaders() as $reader) {
+            $offset = $reader->getOffsetFor($objectNumber);
+            if ($offset !== false) {
+                return $offset;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Get an indirect object by its object number.
+     *
+     * @param int $objectNumber
+     * @return PdfIndirectObject
+     * @throws CrossReferenceException
+     */
+    public function getIndirectObject($objectNumber)
+    {
+        $offset = $this->getOffsetFor($objectNumber);
+        if ($offset === false) {
+            throw new CrossReferenceException(
+                \sprintf('Object (id:%s) not found.', $objectNumber),
+                CrossReferenceException::OBJECT_NOT_FOUND
+            );
+        }
+
+        $parser = $this->parser;
+
+        $parser->getTokenizer()->clearStack();
+        $parser->getStreamReader()->reset($offset + $this->fileHeaderOffset);
+
+        try {
+            /** @var PdfIndirectObject $object */
+            $object = $parser->readValue(null, PdfIndirectObject::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                \sprintf('Object (id:%s) not found at location (%s).', $objectNumber, $offset),
+                CrossReferenceException::OBJECT_NOT_FOUND,
+                $e
+            );
+        }
+
+        if ($object->objectNumber !== $objectNumber) {
+            throw new CrossReferenceException(
+                \sprintf('Wrong object found, got %s while %s was expected.', $object->objectNumber, $objectNumber),
+                CrossReferenceException::OBJECT_NOT_FOUND
+            );
+        }
+
+        return $object;
+    }
+
+    /**
+     * Read the cross-reference table at a given offset.
+     *
+     * Internally the method will try to evaluate the best reader for this cross-reference.
+     *
+     * @param int $offset
+     * @return ReaderInterface
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function readXref($offset)
+    {
+        $this->parser->getStreamReader()->reset($offset);
+        $this->parser->getTokenizer()->clearStack();
+        $initValue = $this->parser->readValue();
+        
+        return $this->initReaderInstance($initValue);
+    }
+
+    /**
+     * Get a cross-reference reader instance.
+     *
+     * @param PdfToken|PdfIndirectObject $initValue
+     * @return ReaderInterface|bool
+     * @throws CrossReferenceException
+     * @throws PdfTypeException
+     */
+    protected function initReaderInstance($initValue)
+    {
+        $position = $this->parser->getStreamReader()->getPosition()
+            + $this->parser->getStreamReader()->getOffset() + $this->fileHeaderOffset;
+
+        if ($initValue instanceof PdfToken && $initValue->value === 'xref') {
+            try {
+                return new FixedReader($this->parser);
+            } catch (CrossReferenceException $e) {
+                $this->parser->getStreamReader()->reset($position);
+                $this->parser->getTokenizer()->clearStack();
+
+                return new LineReader($this->parser);
+            }
+        }
+
+        if ($initValue instanceof PdfIndirectObject) {
+            // check for encryption
+            $stream = PdfStream::ensure($initValue->value);
+
+            $type = PdfDictionary::get($stream->value, 'Type');
+            if ($type->value !== 'XRef') {
+                throw new CrossReferenceException(
+                    'The xref position points to an incorrect object type.',
+                    CrossReferenceException::INVALID_DATA
+                );
+            }
+
+            $this->checkForEncryption($stream->value);
+
+            throw new CrossReferenceException(
+                'This PDF document probably uses a compression technique which is not supported by the ' .
+                'free parser shipped with FPDI. (See https://www.setasign.com/fpdi-pdf-parser for more details)',
+                CrossReferenceException::COMPRESSED_XREF
+            );
+        }
+
+        throw new CrossReferenceException(
+            'The xref position points to an incorrect object type.',
+            CrossReferenceException::INVALID_DATA
+        );
+    }
+
+    /**
+     * Check for encryption.
+     *
+     * @param PdfDictionary $dictionary
+     * @throws CrossReferenceException
+     */
+    protected function checkForEncryption(PdfDictionary $dictionary)
+    {
+        if (isset($dictionary->value['Encrypt'])) {
+            throw new CrossReferenceException(
+                'This PDF document is encrypted and cannot be processed with FPDI.',
+                CrossReferenceException::ENCRYPTED
+            );
+        }
+    }
+
+    /**
+     * Find the start position for the first cross-reference.
+     *
+     * @return int The byte-offset position of the first cross-reference.
+     * @throws CrossReferenceException
+     */
+    protected function findStartXref()
+    {
+        $reader = $this->parser->getStreamReader();
+        $reader->reset(-self::$trailerSearchLength, self::$trailerSearchLength);
+
+        $buffer = $reader->getBuffer(false);
+        $pos = \strrpos($buffer, 'startxref');
+        $addOffset = 9;
+        if ($pos === false) {
+            // Some corrupted documents uses startref, instead of startxref
+            $pos = \strrpos($buffer, 'startref');
+            if ($pos === false) {
+                throw new CrossReferenceException(
+                    'Unable to find pointer to xref table',
+                    CrossReferenceException::NO_STARTXREF_FOUND
+                );
+            }
+            $addOffset = 8;
+        }
+
+        $reader->setOffset($pos + $addOffset);
+
+        try {
+            $value = $this->parser->readValue(null, PdfNumeric::class);
+        } catch (PdfTypeException $e) {
+            throw new CrossReferenceException(
+                'Invalid data after startxref keyword.',
+                CrossReferenceException::INVALID_DATA,
+                $e
+            );
+        }
+
+        return $value->value;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/CrossReferenceException.php
new file mode 100644 (file)
index 0000000..3fa9b33
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParserException;
+
+/**
+ * Exception used by the CrossReference and Reader classes.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class CrossReferenceException extends PdfParserException
+{
+    /**
+     * @var int
+     */
+    const INVALID_DATA = 0x0101;
+
+    /**
+     * @var int
+     */
+    const XREF_MISSING = 0x0102;
+
+    /**
+     * @var int
+     */
+    const ENTRIES_TOO_LARGE = 0x0103;
+
+    /**
+     * @var int
+     */
+    const ENTRIES_TOO_SHORT = 0x0104;
+
+    /**
+     * @var int
+     */
+    const NO_ENTRIES = 0x0105;
+
+    /**
+     * @var int
+     */
+    const NO_TRAILER_FOUND = 0x0106;
+
+    /**
+     * @var int
+     */
+    const NO_STARTXREF_FOUND = 0x0107;
+
+    /**
+     * @var int
+     */
+    const NO_XREF_FOUND = 0x0108;
+
+    /**
+     * @var int
+     */
+    const UNEXPECTED_END = 0x0109;
+
+    /**
+     * @var int
+     */
+    const OBJECT_NOT_FOUND = 0x010A;
+
+    /**
+     * @var int
+     */
+    const COMPRESSED_XREF = 0x010B;
+
+    /**
+     * @var int
+     */
+    const ENCRYPTED = 0x010C;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/FixedReader.php
new file mode 100644 (file)
index 0000000..f96b979
--- /dev/null
@@ -0,0 +1,196 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class FixedReader
+ *
+ * This reader allows a very less overhead parsing of single entries of the cross-reference, because the main entries
+ * are only read when needed and not in a single run.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class FixedReader extends AbstractReader implements ReaderInterface
+{
+    /**
+     * @var StreamReader
+     */
+    protected $reader;
+
+    /**
+     * Data of subsections.
+     *
+     * @var array
+     */
+    protected $subSections;
+
+    /**
+     * FixedReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->reader = $parser->getStreamReader();
+        $this->read();
+        parent::__construct($parser);
+    }
+
+    /**
+     * Get all subsection data.
+     *
+     * @return array
+     */
+    public function getSubSections()
+    {
+        return $this->subSections;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        foreach ($this->subSections as $offset => list($startObject, $objectCount)) {
+            if ($objectNumber >= $startObject && $objectNumber < ($startObject + $objectCount)) {
+                $position = $offset + 20 * ($objectNumber - $startObject);
+                $this->reader->ensure($position, 20);
+                $line = $this->reader->readBytes(20);
+                if ($line[17] === 'f') {
+                    return false;
+                }
+
+                return (int) \substr($line, 0, 10);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Read the cross-reference.
+     *
+     * This reader will only read the subsections in this method. The offsets were resolved individually by this
+     * information.
+     *
+     * @throws CrossReferenceException
+     */
+    protected function read()
+    {
+        $subSections = [];
+
+        $startObject = $entryCount = $lastLineStart = null;
+        $validityChecked = false;
+        while (($line = $this->reader->readLine(20)) !== false) {
+            if (\strpos($line, 'trailer') !== false) {
+                $this->reader->reset($lastLineStart);
+                break;
+            }
+
+            // jump over if line content doesn't match the expected string
+            if (\sscanf($line, '%d %d', $startObject, $entryCount) !== 2) {
+                continue;
+            }
+
+            $oldPosition = $this->reader->getPosition();
+            $position = $oldPosition + $this->reader->getOffset();
+
+            if (!$validityChecked && $entryCount > 0) {
+                $nextLine = $this->reader->readBytes(21);
+                /* Check the next line for maximum of 20 bytes and not longer
+                 * By catching 21 bytes and trimming the length should be still 21.
+                 */
+                if (\strlen(\trim($nextLine)) !== 21) {
+                    throw new CrossReferenceException(
+                        'Cross-reference entries are larger than 20 bytes.',
+                        CrossReferenceException::ENTRIES_TOO_LARGE
+                    );
+                }
+
+                /* Check for less than 20 bytes: cut the line to 20 bytes and trim; have to result in exactly 18 bytes.
+                 * If it would have less bytes the substring would get the first bytes of the next line which would
+                 * evaluate to a 20 bytes long string after trimming.
+                 */
+                if (\strlen(\trim(\substr($nextLine, 0, 20))) !== 18) {
+                    throw new CrossReferenceException(
+                        'Cross-reference entries are less than 20 bytes.',
+                        CrossReferenceException::ENTRIES_TOO_SHORT
+                    );
+                }
+
+                $validityChecked = true;
+            }
+
+            $subSections[$position] = [$startObject, $entryCount];
+
+            $lastLineStart = $position + $entryCount * 20;
+            $this->reader->reset($lastLineStart);
+        }
+
+        // reset after the last correct parsed line
+        $this->reader->reset($lastLineStart);
+
+        if (\count($subSections) === 0) {
+            throw new CrossReferenceException(
+                'No entries found in cross-reference.',
+                CrossReferenceException::NO_ENTRIES
+            );
+        }
+
+        $this->subSections = $subSections;
+    }
+
+    /**
+     * Fixes an invalid object number shift.
+     *
+     * This method can be used to repair documents with an invalid subsection header:
+     *
+     * <code>
+     * xref
+     * 1 7
+     * 0000000000 65535 f
+     * 0000000009 00000 n
+     * 0000412075 00000 n
+     * 0000412172 00000 n
+     * 0000412359 00000 n
+     * 0000412417 00000 n
+     * 0000412468 00000 n
+     * </code>
+     *
+     * It shall only be called on the first table.
+     *
+     * @return bool
+     */
+    public function fixFaultySubSectionShift()
+    {
+        $subSections = $this->getSubSections();
+        if (\count($subSections) > 1) {
+            return false;
+        }
+
+        $subSection = \current($subSections);
+        if ($subSection[0] != 1) {
+            return false;
+        }
+
+        if ($this->getOffsetFor(1) === false) {
+            foreach ($subSections as $offset => list($startObject, $objectCount)) {
+                $this->subSections[$offset] = [$startObject - 1, $objectCount];
+            }
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/LineReader.php
new file mode 100644 (file)
index 0000000..31a96be
--- /dev/null
@@ -0,0 +1,173 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class LineReader
+ *
+ * This reader class read all cross-reference entries in a single run.
+ * It supports reading cross-references with e.g. invalid data (e.g. entries with a length < or > 20 bytes).
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+class LineReader extends AbstractReader implements ReaderInterface
+{
+    /**
+     * The object offsets.
+     *
+     * @var array
+     */
+    protected $offsets;
+
+    /**
+     * LineReader constructor.
+     *
+     * @param PdfParser $parser
+     * @throws CrossReferenceException
+     */
+    public function __construct(PdfParser $parser)
+    {
+        $this->read($this->extract($parser->getStreamReader()));
+        parent::__construct($parser);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getOffsetFor($objectNumber)
+    {
+        if (isset($this->offsets[$objectNumber])) {
+            return $this->offsets[$objectNumber][0];
+        }
+
+        return false;
+    }
+
+    /**
+     * Get all found offsets.
+     *
+     * @return array
+     */
+    public function getOffsets()
+    {
+        return $this->offsets;
+    }
+
+    /**
+     * Extracts the cross reference data from the stream reader.
+     *
+     * @param StreamReader $reader
+     * @return string
+     * @throws CrossReferenceException
+     */
+    protected function extract(StreamReader $reader)
+    {
+        $cycles = -1;
+        $bytesPerCycle = 100;
+
+        $reader->reset(null, $bytesPerCycle);
+
+        while (
+            ($trailerPos = \strpos($reader->getBuffer(false), 'trailer', \max($bytesPerCycle * $cycles++, 0))) === false
+        ) {
+            if ($reader->increaseLength($bytesPerCycle) === false) {
+                break;
+            }
+        }
+
+        if ($trailerPos === false) {
+            throw new CrossReferenceException(
+                'Unexpected end of cross reference. "trailer"-keyword not found.',
+                CrossReferenceException::NO_TRAILER_FOUND
+            );
+        }
+
+        $xrefContent = \substr($reader->getBuffer(false), 0, $trailerPos);
+        $reader->reset($reader->getPosition() + $trailerPos);
+
+        return $xrefContent;
+    }
+
+    /**
+     * Read the cross-reference entries.
+     *
+     * @param string $xrefContent
+     * @throws CrossReferenceException
+     */
+    protected function read($xrefContent)
+    {
+        // get eol markers in the first 100 bytes
+        \preg_match_all("/(\r\n|\n|\r)/", \substr($xrefContent, 0, 100), $m);
+
+        if (\count($m[0]) === 0) {
+            throw new CrossReferenceException(
+                'No data found in cross-reference.',
+                CrossReferenceException::INVALID_DATA
+            );
+        }
+
+        // count(array_count_values()) is faster then count(array_unique())
+        // @see https://github.com/symfony/symfony/pull/23731
+        // can be reverted for php7.2
+        $differentLineEndings = \count(\array_count_values($m[0]));
+        if ($differentLineEndings > 1) {
+            $lines = \preg_split("/(\r\n|\n|\r)/", $xrefContent, -1, PREG_SPLIT_NO_EMPTY);
+        } else {
+            $lines = \explode($m[0][0], $xrefContent);
+        }
+
+        unset($differentLineEndings, $m);
+        $linesCount = \count($lines);
+        $start = null;
+        $entryCount = 0;
+
+        $offsets = [];
+
+        /** @noinspection ForeachInvariantsInspection */
+        for ($i = 0; $i < $linesCount; $i++) {
+            $line = \trim($lines[$i]);
+            if ($line) {
+                $pieces = \explode(' ', $line);
+
+                $c = \count($pieces);
+                switch ($c) {
+                    case 2:
+                        $start = (int) $pieces[0];
+                        $entryCount += (int) $pieces[1];
+                        break;
+
+                    /** @noinspection PhpMissingBreakStatementInspection */
+                    case 3:
+                        switch ($pieces[2]) {
+                            case 'n':
+                                $offsets[$start] = [(int) $pieces[0], (int) $pieces[1]];
+                                $start++;
+                                break 2;
+                            case 'f':
+                                $start++;
+                                break 2;
+                        }
+                        // fall through if pieces doesn't match
+
+                    default:
+                        throw new CrossReferenceException(
+                            \sprintf('Unexpected data in xref table (%s)', \implode(' ', $pieces)),
+                            CrossReferenceException::INVALID_DATA
+                        );
+                }
+            }
+        }
+
+        $this->offsets = $offsets;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/ReaderInterface.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/CrossReference/ReaderInterface.php
new file mode 100644 (file)
index 0000000..84b2267
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\CrossReference;
+
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+
+/**
+ * ReaderInterface for cross-reference readers.
+ *
+ * @package setasign\Fpdi\PdfParser\CrossReference
+ */
+interface ReaderInterface
+{
+    /**
+     * Get an offset by an object number.
+     *
+     * @param int $objectNumber
+     * @return int|bool False if the offset was not found.
+     */
+    public function getOffsetFor($objectNumber);
+
+    /**
+     * Get the trailer related to this cross reference.
+     *
+     * @return PdfDictionary
+     */
+    public function getTrailer();
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85.php
new file mode 100644 (file)
index 0000000..3541cca
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Class for handling ASCII base-85 encoded data
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class Ascii85 implements FilterInterface
+{
+    /**
+     * Decode ASCII85 encoded string.
+     *
+     * @param string $data The input string
+     * @return string
+     * @throws Ascii85Exception
+     */
+    public function decode($data)
+    {
+        $out = '';
+        $state = 0;
+        $chn = null;
+
+        $l = \strlen($data);
+
+        /** @noinspection ForeachInvariantsInspection */
+        for ($k = 0; $k < $l; ++$k) {
+            $ch = \ord($data[$k]) & 0xff;
+
+            //Start <~
+            if ($k === 0 && $ch === 60 && isset($data[$k + 1]) && (\ord($data[$k + 1]) & 0xFF) === 126) {
+                $k++;
+                continue;
+            }
+            //End ~>
+            if ($ch === 126 && isset($data[$k + 1]) && (\ord($data[$k + 1]) & 0xFF) === 62) {
+                break;
+            }
+            if (\preg_match('/^\s$/', \chr($ch))) {
+                continue;
+            }
+            if ($ch === 122 /* z */ && $state === 0) {
+                $out .= \chr(0) . \chr(0) . \chr(0) . \chr(0);
+                continue;
+            }
+            if ($ch < 33 /* ! */ || $ch > 117 /* u */) {
+                throw new Ascii85Exception(
+                    'Illegal character found while ASCII85 decode.',
+                    Ascii85Exception::ILLEGAL_CHAR_FOUND
+                );
+            }
+
+            $chn[$state] = $ch - 33;/* ! */
+            $state++;
+
+            if ($state === 5) {
+                $state = 0;
+                $r = 0;
+                for ($j = 0; $j < 5; ++$j) {
+                    /** @noinspection UnnecessaryCastingInspection */
+                    $r = (int)($r * 85 + $chn[$j]);
+                }
+
+                $out .= \chr($r >> 24)
+                    . \chr($r >> 16)
+                    . \chr($r >> 8)
+                    . \chr($r);
+            }
+        }
+
+        if ($state === 1) {
+            throw new Ascii85Exception(
+                'Illegal length while ASCII85 decode.',
+                Ascii85Exception::ILLEGAL_LENGTH
+            );
+        }
+
+        if ($state === 2) {
+            $r = $chn[0] * 85 * 85 * 85 * 85 + ($chn[1] + 1) * 85 * 85 * 85;
+            $out .= \chr($r >> 24);
+
+        } elseif ($state === 3) {
+            $r = $chn[0] * 85 * 85 * 85 * 85 + $chn[1] * 85 * 85 * 85 + ($chn[2] + 1) * 85 * 85;
+            $out .= \chr($r >> 24);
+            $out .= \chr($r >> 16);
+
+        } elseif ($state === 4) {
+            $r = $chn[0] * 85 * 85 * 85 * 85 + $chn[1] * 85 * 85 * 85 + $chn[2] * 85 * 85 + ($chn[3] + 1) * 85;
+            $out .= \chr($r >> 24);
+            $out .= \chr($r >> 16);
+            $out .= \chr($r >> 8);
+        }
+
+        return $out;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85Exception.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Ascii85Exception.php
new file mode 100644 (file)
index 0000000..898276c
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Exception for Ascii85 filter class
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class Ascii85Exception extends FilterException
+{
+    /**
+     * @var integer
+     */
+    const ILLEGAL_CHAR_FOUND = 0x0301;
+
+    /**
+     * @var integer
+     */
+    const ILLEGAL_LENGTH = 0x0302;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/AsciiHex.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/AsciiHex.php
new file mode 100644 (file)
index 0000000..3868584
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Class for handling ASCII hexadecimal encoded data
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class AsciiHex implements FilterInterface
+{
+    /**
+     * Converts an ASCII hexadecimal encoded string into its binary representation.
+     *
+     * @param string $data The input string
+     * @return string
+     */
+    public function decode($data)
+    {
+        $data = \preg_replace('/[^0-9A-Fa-f]/', '', \rtrim($data, '>'));
+        if ((\strlen($data) % 2) === 1) {
+            $data .= '0';
+        }
+
+        return \pack('H*', $data);
+    }
+
+    /**
+     * Converts a string into ASCII hexadecimal representation.
+     *
+     * @param string $data The input string
+     * @param boolean $leaveEOD
+     * @return string
+     */
+    public function encode($data, $leaveEOD = false)
+    {
+        $t = \unpack('H*', $data);
+        return \current($t)
+            . ($leaveEOD ? '' : '>');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterException.php
new file mode 100644 (file)
index 0000000..a41ce69
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+use setasign\Fpdi\PdfParser\PdfParserException;
+
+/**
+ * Exception for filters
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class FilterException extends PdfParserException
+{
+    const UNSUPPORTED_FILTER = 0x0201;
+
+    const NOT_IMPLEMENTED = 0x0202;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterInterface.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FilterInterface.php
new file mode 100644 (file)
index 0000000..0323471
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Interface for filters
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+interface FilterInterface
+{
+    /**
+     * Decode a string.
+     *
+     * @param string $data The input string
+     * @return string
+     */
+    public function decode($data);
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Flate.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Flate.php
new file mode 100644 (file)
index 0000000..c12f619
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Class for handling zlib/deflate encoded data
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class Flate implements FilterInterface
+{
+    /**
+     * Checks whether the zlib extension is loaded.
+     *
+     * Used for testing purpose.
+     *
+     * @return boolean
+     * @internal
+     */
+    protected function extensionLoaded()
+    {
+        return \extension_loaded('zlib');
+    }
+
+    /**
+     * Decodes a flate compressed string.
+     *
+     * @param string $data The input string
+     * @return string
+     * @throws FlateException
+     */
+    public function decode($data)
+    {
+        if ($this->extensionLoaded()) {
+            $oData = $data;
+            $data = @((\strlen($data) > 0) ? \gzuncompress($data) : '');
+            if ($data === false) {
+                // Try this fallback
+                $tries = 1;
+                while ($tries < 10 && ($data === false || \strlen($data) < (\strlen($oData) - $tries - 1))) {
+                    $data = @(\gzinflate(\substr($oData, $tries)));
+                    $tries++;
+                }
+
+                if ($data === false) {
+                    // let's try if the checksum is CRC32
+                    $fh = fopen('php://temp', 'w+b');
+                    \fwrite($fh, "\x1f\x8b\x08\x00\x00\x00\x00\x00" . $oData);
+                    \stream_filter_append($fh, 'zlib.inflate', \STREAM_FILTER_READ, ['window' => 30]);
+                    \fseek($fh, 0);
+                    $data = \stream_get_contents($fh);
+                    \fclose($fh);
+                }
+
+                if (!$data) {
+                    throw new FlateException(
+                        'Error while decompressing stream.',
+                        FlateException::DECOMPRESS_ERROR
+                    );
+                }
+            }
+        } else {
+            throw new FlateException(
+                'To handle FlateDecode filter, enable zlib support in PHP.',
+                FlateException::NO_ZLIB
+            );
+        }
+
+        return $data;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FlateException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/FlateException.php
new file mode 100644 (file)
index 0000000..d44281d
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Exception for flate filter class
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class FlateException extends FilterException
+{
+    /**
+     * @var integer
+     */
+    const NO_ZLIB = 0x0401;
+
+    /**
+     * @var integer
+     */
+    const DECOMPRESS_ERROR = 0x0402;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Lzw.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/Lzw.php
new file mode 100644 (file)
index 0000000..f4df719
--- /dev/null
@@ -0,0 +1,189 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Class for handling LZW encoded data
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class Lzw implements FilterInterface
+{
+    /**
+     * @var null|string
+     */
+    protected $data;
+
+    /**
+     * @var array
+     */
+    protected $sTable = [];
+
+    /**
+     * @var int
+     */
+    protected $dataLength = 0;
+
+    /**
+     * @var int
+     */
+    protected $tIdx;
+
+    /**
+     * @var int
+     */
+    protected $bitsToGet = 9;
+
+    /**
+     * @var int
+     */
+    protected $bytePointer;
+
+    /**
+     * @var int
+     */
+    protected $nextData = 0;
+
+    /**
+     * @var int
+     */
+    protected $nextBits = 0;
+
+    /**
+     * @var array
+     */
+    protected $andTable = [511, 1023, 2047, 4095];
+
+    /**
+     * Method to decode LZW compressed data.
+     *
+     * @param string $data The compressed data
+     * @return string The uncompressed data
+     * @throws LzwException
+     */
+    public function decode($data)
+    {
+        if ($data[0] === "\x00" && $data[1] === "\x01") {
+            throw new LzwException(
+                'LZW flavour not supported.',
+                LzwException::LZW_FLAVOUR_NOT_SUPPORTED
+            );
+        }
+
+        $this->initsTable();
+
+        $this->data = $data;
+        $this->dataLength = \strlen($data);
+
+        // Initialize pointers
+        $this->bytePointer = 0;
+
+        $this->nextData = 0;
+        $this->nextBits = 0;
+
+        $oldCode = 0;
+
+        $uncompData = '';
+
+        while (($code = $this->getNextCode()) !== 257) {
+            if ($code === 256) {
+                $this->initsTable();
+                $code = $this->getNextCode();
+
+                if ($code === 257) {
+                    break;
+                }
+
+                $uncompData .= $this->sTable[$code];
+                $oldCode = $code;
+
+            } else {
+                if ($code < $this->tIdx) {
+                    $string = $this->sTable[$code];
+                    $uncompData .= $string;
+
+                    $this->addStringToTable($this->sTable[$oldCode], $string[0]);
+                    $oldCode = $code;
+                } else {
+                    $string = $this->sTable[$oldCode];
+                    $string .= $string[0];
+                    $uncompData .= $string;
+
+                    $this->addStringToTable($string);
+                    $oldCode = $code;
+                }
+            }
+        }
+
+        return $uncompData;
+    }
+
+    /**
+     * Initialize the string table.
+     */
+    protected function initsTable()
+    {
+        $this->sTable = [];
+
+        for ($i = 0; $i < 256; $i++) {
+            $this->sTable[$i] = \chr($i);
+        }
+
+        $this->tIdx = 258;
+        $this->bitsToGet = 9;
+    }
+
+    /**
+     * Add a new string to the string table.
+     *
+     * @param string $oldString
+     * @param string $newString
+     */
+    protected function addStringToTable($oldString, $newString = '')
+    {
+        $string = $oldString . $newString;
+
+        // Add this new String to the table
+        $this->sTable[$this->tIdx++] = $string;
+
+        if ($this->tIdx === 511) {
+            $this->bitsToGet = 10;
+        } elseif ($this->tIdx === 1023) {
+            $this->bitsToGet = 11;
+        } elseif ($this->tIdx === 2047) {
+            $this->bitsToGet = 12;
+        }
+    }
+
+    /**
+     * Returns the next 9, 10, 11 or 12 bits.
+     *
+     * @return integer
+     */
+    protected function getNextCode()
+    {
+        if ($this->bytePointer === $this->dataLength) {
+            return 257;
+        }
+
+        $this->nextData = ($this->nextData << 8) | (\ord($this->data[$this->bytePointer++]) & 0xff);
+        $this->nextBits += 8;
+
+        if ($this->nextBits < $this->bitsToGet) {
+            $this->nextData = ($this->nextData << 8) | (\ord($this->data[$this->bytePointer++]) & 0xff);
+            $this->nextBits += 8;
+        }
+
+        $code = ($this->nextData >> ($this->nextBits - $this->bitsToGet)) & $this->andTable[$this->bitsToGet - 9];
+        $this->nextBits -= $this->bitsToGet;
+
+        return $code;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/LzwException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Filter/LzwException.php
new file mode 100644 (file)
index 0000000..e822bc9
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Filter;
+
+/**
+ * Exception for LZW filter class
+ *
+ * @package setasign\Fpdi\PdfParser\Filter
+ */
+class LzwException extends FilterException
+{
+    /**
+     * @var integer
+     */
+    const LZW_FLAVOUR_NOT_SUPPORTED = 0x0501;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParser.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParser.php
new file mode 100644 (file)
index 0000000..cccdfb6
--- /dev/null
@@ -0,0 +1,378 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReference;
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\Type\PdfArray;
+use setasign\Fpdi\PdfParser\Type\PdfBoolean;
+use setasign\Fpdi\PdfParser\Type\PdfDictionary;
+use setasign\Fpdi\PdfParser\Type\PdfHexString;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
+use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference;
+use setasign\Fpdi\PdfParser\Type\PdfName;
+use setasign\Fpdi\PdfParser\Type\PdfNull;
+use setasign\Fpdi\PdfParser\Type\PdfNumeric;
+use setasign\Fpdi\PdfParser\Type\PdfString;
+use setasign\Fpdi\PdfParser\Type\PdfToken;
+use setasign\Fpdi\PdfParser\Type\PdfType;
+
+/**
+ * A PDF parser class
+ *
+ * @package setasign\Fpdi\PdfParser
+ */
+class PdfParser
+{
+    /**
+     * @var StreamReader
+     */
+    protected $streamReader;
+
+    /**
+     * @var Tokenizer
+     */
+    protected $tokenizer;
+
+    /**
+     * The file header.
+     *
+     * @var string
+     */
+    protected $fileHeader;
+
+    /**
+     * The offset to the file header.
+     *
+     * @var int
+     */
+    protected $fileHeaderOffset;
+
+    /**
+     * @var CrossReference
+     */
+    protected $xref;
+
+    /**
+     * All read objects.
+     *
+     * @var array
+     */
+    protected $objects = [];
+
+    /**
+     * PdfParser constructor.
+     *
+     * @param StreamReader $streamReader
+     */
+    public function __construct(StreamReader $streamReader)
+    {
+        $this->streamReader = $streamReader;
+        $this->tokenizer = new Tokenizer($streamReader);
+    }
+
+    /**
+     * Removes cycled references.
+     *
+     * @internal
+     */
+    public function cleanUp()
+    {
+        $this->xref = null;
+    }
+
+    /**
+     * Get the stream reader instance.
+     *
+     * @return StreamReader
+     */
+    public function getStreamReader()
+    {
+        return $this->streamReader;
+    }
+
+    /**
+     * Get the tokenizer instance.
+     *
+     * @return Tokenizer
+     */
+    public function getTokenizer()
+    {
+        return $this->tokenizer;
+    }
+
+    /**
+     * Resolves the file header.
+     *
+     * @throws PdfParserException
+     * @return int
+     */
+    protected function resolveFileHeader()
+    {
+        if ($this->fileHeader) {
+            return $this->fileHeaderOffset;
+        }
+
+        $this->streamReader->reset(0);
+        $offset = false;
+        $maxIterations = 1000;
+        while (true) {
+            $buffer = $this->streamReader->getBuffer(false);
+            $offset = \strpos($buffer, '%PDF-');
+            if ($offset === false) {
+                if (!$this->streamReader->increaseLength(100) || (--$maxIterations === 0)) {
+                    throw new PdfParserException(
+                        'Unable to find PDF file header.',
+                        PdfParserException::FILE_HEADER_NOT_FOUND
+                    );
+                }
+                continue;
+            }
+            break;
+        }
+
+        $this->fileHeaderOffset = $offset;
+        $this->streamReader->setOffset($offset);
+
+        $this->fileHeader = \trim($this->streamReader->readLine());
+        return $this->fileHeaderOffset;
+    }
+
+    /**
+     * Get the cross reference instance.
+     *
+     * @return CrossReference
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    public function getCrossReference()
+    {
+        if ($this->xref === null) {
+            $this->xref = new CrossReference($this, $this->resolveFileHeader());
+        }
+
+        return $this->xref;
+    }
+
+    /**
+     * Get the PDF version.
+     *
+     * @return int[] An array of major and minor version.
+     * @throws PdfParserException
+     */
+    public function getPdfVersion()
+    {
+        $this->resolveFileHeader();
+
+        if (\preg_match('/%PDF-(\d)\.(\d)/', $this->fileHeader, $result) === 0) {
+            throw new PdfParserException(
+                'Unable to extract PDF version from file header.',
+                PdfParserException::PDF_VERSION_NOT_FOUND
+            );
+        }
+        list(, $major, $minor) = $result;
+
+        $catalog = $this->getCatalog();
+        if (isset($catalog->value['Version'])) {
+            $versionParts = \explode('.', PdfName::unescape(PdfType::resolve($catalog->value['Version'], $this)->value));
+            if (count($versionParts) === 2) {
+                list($major, $minor) = $versionParts;
+            }
+        }
+
+        return [(int) $major, (int) $minor];
+    }
+
+    /**
+     * Get the catalog dictionary.
+     *
+     * @return PdfDictionary
+     * @throws Type\PdfTypeException
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    public function getCatalog()
+    {
+        $xref = $this->getCrossReference();
+        $trailer = $xref->getTrailer();
+
+        $catalog = PdfType::resolve(PdfDictionary::get($trailer, 'Root'), $this);
+
+        return PdfDictionary::ensure($catalog);
+    }
+
+    /**
+     * Get an indirect object by its object number.
+     *
+     * @param int $objectNumber
+     * @param bool $cache
+     * @return PdfIndirectObject
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    public function getIndirectObject($objectNumber, $cache = false)
+    {
+        $objectNumber = (int) $objectNumber;
+        if (isset($this->objects[$objectNumber])) {
+            return $this->objects[$objectNumber];
+        }
+
+        $xref = $this->getCrossReference();
+        $object = $xref->getIndirectObject($objectNumber);
+
+        if ($cache) {
+            $this->objects[$objectNumber] = $object;
+        }
+
+        return $object;
+    }
+
+    /**
+     * Read a PDF value.
+     *
+     * @param null|bool|string $token
+     * @param null|string $expectedType
+     * @return bool|PdfArray|PdfBoolean|PdfHexString|PdfName|PdfNull|PdfNumeric|PdfString|PdfToken|PdfIndirectObjectReference
+     * @throws Type\PdfTypeException
+     */
+    public function readValue($token = null, $expectedType = null)
+    {
+        if ($token === null) {
+            $token = $this->tokenizer->getNextToken();
+        }
+
+        if ($token === false) {
+            if ($expectedType !== null) {
+                throw new Type\PdfTypeException('Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE);
+            }
+            return false;
+        }
+
+        switch ($token) {
+            case '(':
+                $this->ensureExpectedType($token, $expectedType);
+                return PdfString::parse($this->streamReader);
+
+            case '<':
+                if ($this->streamReader->getByte() === '<') {
+                    $this->ensureExpectedType('<<', $expectedType);
+                    $this->streamReader->addOffset(1);
+                    return PdfDictionary::parse($this->tokenizer, $this->streamReader, $this);
+                }
+
+                $this->ensureExpectedType($token, $expectedType);
+                return PdfHexString::parse($this->streamReader);
+
+            case '/':
+                $this->ensureExpectedType($token, $expectedType);
+                return PdfName::parse($this->tokenizer, $this->streamReader);
+
+            case '[':
+                $this->ensureExpectedType($token, $expectedType);
+                return PdfArray::parse($this->tokenizer, $this);
+
+            default:
+                if (\is_numeric($token)) {
+                    if (($token2 = $this->tokenizer->getNextToken()) !== false) {
+                        if (\is_numeric($token2)) {
+                            if (($token3 = $this->tokenizer->getNextToken()) !== false) {
+                                switch ($token3) {
+                                    case 'obj':
+                                        if ($expectedType !== null && $expectedType !== PdfIndirectObject::class) {
+                                            throw new Type\PdfTypeException(
+                                                'Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE
+                                            );
+                                        }
+
+                                        return PdfIndirectObject::parse(
+                                            $token,
+                                            $token2,
+                                            $this,
+                                            $this->tokenizer,
+                                            $this->streamReader
+                                        );
+                                    case 'R':
+                                        if ($expectedType !== null &&
+                                            $expectedType !== PdfIndirectObjectReference::class
+                                        ) {
+                                            throw new Type\PdfTypeException(
+                                                'Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE
+                                            );
+                                        }
+
+                                        return PdfIndirectObjectReference::create($token, $token2);
+                                }
+
+                                $this->tokenizer->pushStack($token3);
+                            }
+                        }
+
+                        $this->tokenizer->pushStack($token2);
+                    }
+
+                    if ($expectedType !== null && $expectedType !== PdfNumeric::class) {
+                        throw new Type\PdfTypeException(
+                            'Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE
+                        );
+                    }
+                    return PdfNumeric::create($token);
+                }
+
+                if ($token === 'true' || $token === 'false') {
+                    $this->ensureExpectedType($token, $expectedType);
+                    return PdfBoolean::create($token === 'true');
+                }
+
+                if ($token === 'null') {
+                    $this->ensureExpectedType($token, $expectedType);
+                    return new PdfNull();
+                }
+
+                if ($expectedType !== null && $expectedType !== PdfToken::class) {
+                    throw new Type\PdfTypeException(
+                        'Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE
+                    );
+                }
+
+                $v = new PdfToken();
+                $v->value = $token;
+
+                return $v;
+        }
+    }
+
+    /**
+     * Ensures that the token will evaluate to an expected object type (or not).
+     *
+     * @param string $token
+     * @param string|null $expectedType
+     * @return bool
+     * @throws Type\PdfTypeException
+     */
+    private function ensureExpectedType($token, $expectedType)
+    {
+        static $mapping = [
+            '(' => PdfString::class,
+            '<' => PdfHexString::class,
+            '<<' => PdfDictionary::class,
+            '/' => PdfName::class,
+            '[' => PdfArray::class,
+            'true' => PdfBoolean::class,
+            'false' => PdfBoolean::class,
+            'null' => PdfNull::class
+        ];
+
+        if ($expectedType === null || $mapping[$token] === $expectedType) {
+            return true;
+        }
+
+        throw new Type\PdfTypeException('Got unexpected token type.', Type\PdfTypeException::INVALID_DATA_TYPE);
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParserException.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/PdfParserException.php
new file mode 100644 (file)
index 0000000..476bccd
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser;
+
+use setasign\Fpdi\FpdiException;
+
+/**
+ * Exception for the pdf parser class
+ *
+ * @package setasign\Fpdi\PdfParser
+ */
+class PdfParserException extends FpdiException
+{
+    /**
+     * @var int
+     */
+    const NOT_IMPLEMENTED = 0x0001;
+
+    /**
+     * @var int
+     */
+    const IMPLEMENTED_IN_FPDI_PDF_PARSER = 0x0002;
+
+    /**
+     * @var int
+     */
+    const INVALID_DATA_TYPE = 0x0003;
+
+    /**
+     * @var int
+     */
+    const FILE_HEADER_NOT_FOUND = 0x0004;
+
+    /**
+     * @var int
+     */
+    const PDF_VERSION_NOT_FOUND = 0x0005;
+
+    /**
+     * @var int
+     */
+    const INVALID_DATA_SIZE = 0x0006;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/StreamReader.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/StreamReader.php
new file mode 100644 (file)
index 0000000..27de6d9
--- /dev/null
@@ -0,0 +1,468 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser;
+
+/**
+ * A stream reader class
+ *
+ * @package setasign\Fpdi\PdfParser
+ */
+class StreamReader
+{
+    /**
+     * Creates a stream reader instance by a string value.
+     *
+     * @param string $content
+     * @param int $maxMemory
+     * @return StreamReader
+     */
+    public static function createByString($content, $maxMemory = 2097152)
+    {
+        $h = \fopen('php://temp/maxmemory:' . ((int) $maxMemory), 'r+b');
+        \fwrite($h, $content);
+        \rewind($h);
+
+        return new self($h, true);
+    }
+
+    /**
+     * Creates a stream reader instance by a filename.
+     *
+     * @param string $filename
+     * @return StreamReader
+     */
+    public static function createByFile($filename)
+    {
+        $h = \fopen($filename, 'rb');
+        return new self($h, true);
+    }
+
+    /**
+     * Defines whether the stream should be closed when the stream reader instance is deconstructed or not.
+     *
+     * @var bool
+     */
+    protected $closeStream;
+
+    /**
+     * The stream resource.
+     *
+     * @var resource
+     */
+    protected $stream;
+
+    /**
+     * The byte-offset position in the stream.
+     *
+     * @var int
+     */
+    protected $position;
+
+    /**
+     * The byte-offset position in the buffer.
+     *
+     * @var int
+     */
+    protected $offset;
+
+    /**
+     * The buffer length.
+     *
+     * @var int
+     */
+    protected $bufferLength;
+
+    /**
+     * The total length of the stream.
+     *
+     * @var int
+     */
+    protected $totalLength;
+
+    /**
+     * The buffer.
+     *
+     * @var string
+     */
+    protected $buffer;
+
+    /**
+     * StreamReader constructor.
+     *
+     * @param resource $stream
+     * @param bool $closeStream Defines whether to close the stream resource if the instance is destructed or not.
+     */
+    public function __construct($stream, $closeStream = false)
+    {
+        if (!\is_resource($stream)) {
+            throw new \InvalidArgumentException(
+                'No stream given.'
+            );
+        }
+
+        $metaData = \stream_get_meta_data($stream);
+        if (!$metaData['seekable']) {
+            throw new \InvalidArgumentException(
+                'Given stream is not seekable!'
+            );
+        }
+
+        $this->stream = $stream;
+        $this->closeStream = $closeStream;
+        $this->reset();
+    }
+
+    /**
+     * The destructor.
+     */
+    public function __destruct()
+    {
+        $this->cleanUp();
+    }
+
+    /**
+     * Closes the file handle.
+     */
+    public function cleanUp()
+    {
+        if ($this->closeStream && is_resource($this->stream)) {
+            \fclose($this->stream);
+        }
+    }
+
+    /**
+     * Returns the byte length of the buffer.
+     *
+     * @param bool $atOffset
+     * @return int
+     */
+    public function getBufferLength($atOffset = false)
+    {
+        if ($atOffset === false) {
+            return $this->bufferLength;
+        }
+
+        return $this->bufferLength - $this->offset;
+    }
+
+    /**
+     * Get the current position in the stream.
+     *
+     * @return int
+     */
+    public function getPosition()
+    {
+        return $this->position;
+    }
+
+    /**
+     * Returns the current buffer.
+     *
+     * @param bool $atOffset
+     * @return string
+     */
+    public function getBuffer($atOffset = true)
+    {
+        if ($atOffset === false) {
+            return $this->buffer;
+        }
+
+        $string = \substr($this->buffer, $this->offset);
+
+        return (string) $string;
+    }
+
+    /**
+     * Gets a byte at a specific position in the buffer.
+     *
+     * If the position is invalid the method will return false.
+     *
+     * If the $position parameter is set to null the value of $this->offset will be used.
+     *
+     * @param int|null $position
+     * @return string|bool
+     */
+    public function getByte($position = null)
+    {
+        $position = (int) ($position !== null ? $position : $this->offset);
+        if ($position >= $this->bufferLength &&
+            (!$this->increaseLength() || $position >= $this->bufferLength)
+        ) {
+            return false;
+        }
+
+        return $this->buffer[$position];
+    }
+
+    /**
+     * Returns a byte at a specific position, and set the offset to the next byte position.
+     *
+     * If the position is invalid the method will return false.
+     *
+     * If the $position parameter is set to null the value of $this->offset will be used.
+     *
+     * @param int|null $position
+     * @return string|bool
+     */
+    public function readByte($position = null)
+    {
+        if ($position !== null) {
+            $position = (int) $position;
+            // check if needed bytes are available in the current buffer
+            if (!($position >= $this->position && $position < $this->position + $this->bufferLength)) {
+                $this->reset($position);
+                $offset = $this->offset;
+            } else {
+                $offset = $position - $this->position;
+            }
+        } else {
+            $offset = $this->offset;
+        }
+
+        if ($offset >= $this->bufferLength &&
+            ((!$this->increaseLength()) || $offset >= $this->bufferLength)
+        ) {
+            return false;
+        }
+
+        $this->offset = $offset + 1;
+        return $this->buffer[$offset];
+    }
+
+    /**
+     * Read bytes from the current or a specific offset position and set the internal pointer to the next byte.
+     *
+     * If the position is invalid the method will return false.
+     *
+     * If the $position parameter is set to null the value of $this->offset will be used.
+     *
+     * @param int $length
+     * @param int|null $position
+     * @return string
+     */
+    public function readBytes($length, $position = null)
+    {
+        $length = (int) $length;
+        if ($position !== null) {
+            // check if needed bytes are available in the current buffer
+            if (!($position >= $this->position && $position < $this->position + $this->bufferLength)) {
+                $this->reset($position, $length);
+                $offset = $this->offset;
+            } else {
+                $offset = $position - $this->position;
+            }
+        } else {
+            $offset = $this->offset;
+        }
+
+        if (($offset + $length) > $this->bufferLength &&
+            ((!$this->increaseLength($length)) || ($offset + $length) > $this->bufferLength)
+        ) {
+            return false;
+        }
+
+        $bytes = \substr($this->buffer, $offset, $length);
+        $this->offset = $offset + $length;
+
+        return $bytes;
+    }
+
+    /**
+     * Read a line from the current position.
+     *
+     * @param int $length
+     * @return string|bool
+     */
+    public function readLine($length = 1024)
+    {
+        if ($this->ensureContent() === false) {
+            return false;
+        }
+
+        $line = '';
+        while ($this->ensureContent()) {
+            $char = $this->readByte();
+
+            if ($char === "\n") {
+                break;
+            }
+
+            if ($char === "\r") {
+                if ($this->getByte() === "\n") {
+                    $this->addOffset(1);
+                }
+                break;
+            }
+
+            $line .= $char;
+
+            if (\strlen($line) >= $length) {
+                break;
+            }
+        }
+
+        return $line;
+    }
+
+    /**
+     * Set the offset position in the current buffer.
+     *
+     * @param int $offset
+     */
+    public function setOffset($offset)
+    {
+        if ($offset > $this->bufferLength || $offset < 0) {
+            throw new \OutOfRangeException(
+                \sprintf('Offset (%s) out of range (length: %s)', $offset, $this->bufferLength)
+            );
+        }
+
+        $this->offset = (int) $offset;
+    }
+
+    /**
+     * Returns the current offset in the current buffer.
+     *
+     * @return int
+     */
+    public function getOffset()
+    {
+        return $this->offset;
+    }
+
+    /**
+     * Add an offset to the current offset.
+     *
+     * @param int $offset
+     */
+    public function addOffset($offset)
+    {
+        $this->setOffset($this->offset + $offset);
+    }
+
+    /**
+     * Make sure that there is at least one character beyond the current offset in the buffer.
+     *
+     * @return bool
+     */
+    public function ensureContent()
+    {
+        while ($this->offset >= $this->bufferLength) {
+            if (!$this->increaseLength()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns the stream.
+     *
+     * @return resource
+     */
+    public function getStream()
+    {
+        return $this->stream;
+    }
+
+    /**
+     * Gets the total available length.
+     *
+     * @return int
+     */
+    public function getTotalLength()
+    {
+        if ($this->totalLength === null) {
+            $stat = \fstat($this->stream);
+            $this->totalLength = $stat['size'];
+        }
+
+        return $this->totalLength;
+    }
+
+    /**
+     * Resets the buffer to a position and re-read the buffer with the given length.
+     *
+     * If the $pos parameter is negative the start buffer position will be the $pos'th position from
+     * the end of the file.
+     *
+     * If the $pos parameter is negative and the absolute value is bigger then the totalLength of
+     * the file $pos will set to zero.
+     *
+     * @param int|null $pos Start position of the new buffer
+     * @param int $length Length of the new buffer. Mustn't be negative
+     */
+    public function reset($pos = 0, $length = 200)
+    {
+        if ($pos === null) {
+            $pos = $this->position + $this->offset;
+        } elseif ($pos < 0) {
+            $pos = \max(0, $this->getTotalLength() + $pos);
+        }
+
+        \fseek($this->stream, $pos);
+
+        $this->position = $pos;
+        $this->buffer = $length > 0 ? \fread($this->stream, $length) : '';
+        $this->bufferLength = \strlen($this->buffer);
+        $this->offset = 0;
+
+        // If a stream wrapper is in use it is possible that
+        // length values > 8096 will be ignored, so use the
+        // increaseLength()-method to correct that behavior
+        if ($this->bufferLength < $length && $this->increaseLength($length - $this->bufferLength)) {
+            // increaseLength parameter is $minLength, so cut to have only the required bytes in the buffer
+            $this->buffer = \substr($this->buffer, 0, $length);
+            $this->bufferLength = \strlen($this->buffer);
+        }
+    }
+
+    /**
+     * Ensures bytes in the buffer with a specific length and location in the file.
+     *
+     * @param int $pos
+     * @param int $length
+     * @see reset()
+     */
+    public function ensure($pos, $length)
+    {
+        if ($pos >= $this->position
+            && $pos < ($this->position + $this->bufferLength)
+            && ($this->position + $this->bufferLength) >= ($pos + $length)
+        ) {
+            $this->offset = $pos - $this->position;
+        } else {
+            $this->reset($pos, $length);
+        }
+    }
+
+    /**
+     * Forcefully read more data into the buffer.
+     *
+     * @param int $minLength
+     * @return bool Returns false if the stream reaches the end
+     */
+    public function increaseLength($minLength = 100)
+    {
+        $length = \max($minLength, 100);
+
+        if (\feof($this->stream) || $this->getTotalLength() === $this->position + $this->bufferLength) {
+            return false;
+        }
+
+        $newLength = $this->bufferLength + $length;
+        do {
+            $this->buffer .= \fread($this->stream, $newLength - $this->bufferLength);
+            $this->bufferLength = \strlen($this->buffer);
+        } while (($this->bufferLength !== $newLength) && !\feof($this->stream));
+
+        return true;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Tokenizer.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Tokenizer.php
new file mode 100644 (file)
index 0000000..ebf0d0b
--- /dev/null
@@ -0,0 +1,161 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser;
+
+/**
+ * A tokenizer class.
+ *
+ * @package setasign\Fpdi\PdfParser
+ */
+class Tokenizer
+{
+    /**
+     * @var StreamReader
+     */
+    protected $streamReader;
+
+    /**
+     * A token stack.
+     *
+     * @var string[]
+     */
+    protected $stack = [];
+
+    /**
+     * Tokenizer constructor.
+     *
+     * @param StreamReader $streamReader
+     */
+    public function __construct(StreamReader $streamReader)
+    {
+        $this->streamReader = $streamReader;
+    }
+
+    /**
+     * Get the stream reader instance.
+     *
+     * @return StreamReader
+     */
+    public function getStreamReader()
+    {
+        return $this->streamReader;
+    }
+
+    /**
+     * Clear the token stack.
+     */
+    public function clearStack()
+    {
+        $this->stack = [];
+    }
+
+    /**
+     * Push a token onto the stack.
+     *
+     * @param string $token
+     */
+    public function pushStack($token)
+    {
+        $this->stack[] = $token;
+    }
+
+    /**
+     * Get next token.
+     *
+     * @return bool|string
+     */
+    public function getNextToken()
+    {
+        $token = \array_pop($this->stack);
+        if ($token !== null) {
+            return $token;
+        }
+
+        if (($byte = $this->streamReader->readByte()) === false) {
+            return false;
+        }
+
+        if ($byte === "\x20" ||
+            $byte === "\x0A" ||
+            $byte === "\x0D" ||
+            $byte === "\x0C" ||
+            $byte === "\x09" ||
+            $byte === "\x00"
+        ) {
+            if ($this->leapWhiteSpaces() === false) {
+                return false;
+            }
+            $byte = $this->streamReader->readByte();
+        }
+
+        switch ($byte) {
+            case '/':
+            case '[':
+            case ']':
+            case '(':
+            case ')':
+            case '{':
+            case '}':
+            case '<':
+            case '>':
+                return $byte;
+            case '%':
+                $this->streamReader->readLine();
+                return $this->getNextToken();
+        }
+
+        /* This way is faster than checking single bytes.
+         */
+        $bufferOffset = $this->streamReader->getOffset();
+        do {
+            $lastBuffer = $this->streamReader->getBuffer(false);
+            $pos = \strcspn(
+                $lastBuffer,
+                "\x00\x09\x0A\x0C\x0D\x20()<>[]{}/%",
+                $bufferOffset
+            );
+        } while (
+            // Break the loop if a delimiter or white space char is matched
+            // in the current buffer or increase the buffers length
+            $lastBuffer !== false &&
+            (
+                $bufferOffset + $pos === \strlen($lastBuffer) &&
+                $this->streamReader->increaseLength()
+            )
+        );
+
+        $result = \substr($lastBuffer, $bufferOffset - 1, $pos + 1);
+        $this->streamReader->setOffset($bufferOffset + $pos);
+
+        return $result;
+    }
+
+    /**
+     * Leap white spaces.
+     *
+     * @return boolean
+     */
+    public function leapWhiteSpaces()
+    {
+        do {
+            if (!$this->streamReader->ensureContent()) {
+                return false;
+            }
+
+            $buffer = $this->streamReader->getBuffer(false);
+            $matches = \strspn($buffer, "\x20\x0A\x0C\x0D\x09\x00", $this->streamReader->getOffset());
+            if ($matches > 0) {
+                $this->streamReader->addOffset($matches);
+            }
+        } while ($this->streamReader->getOffset() >= $this->streamReader->getBufferLength());
+
+        return true;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfArray.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfArray.php
new file mode 100644 (file)
index 0000000..9cf449a
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\Tokenizer;
+
+/**
+ * Class representing a PDF array object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ * @property array $value The value of the PDF type.
+ */
+class PdfArray extends PdfType
+{
+    /**
+     * Parses an array of the passed tokenizer and parser.
+     *
+     * @param Tokenizer $tokenizer
+     * @param PdfParser $parser
+     * @return bool|self
+     * @throws PdfTypeException
+     */
+    public static function parse(Tokenizer $tokenizer, PdfParser $parser)
+    {
+        $result = [];
+
+        // Recurse into this function until we reach the end of the array.
+        while (($token = $tokenizer->getNextToken()) !== ']') {
+            if ($token === false || ($value = $parser->readValue($token)) === false) {
+                return false;
+            }
+
+            $result[] = $value;
+        }
+
+        $v = new self;
+        $v->value = $result;
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param PdfType[] $values
+     * @return self
+     */
+    public static function create(array $values = [])
+    {
+        $v = new self;
+        $v->value = $values;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed array is a PdfArray instance with a (optional) specific size.
+     *
+     * @param mixed $array
+     * @param null|int $size
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($array, $size = null)
+    {
+        $result = PdfType::ensureType(self::class, $array, 'Array value expected.');
+
+        if ($size !== null && \count($array->value) !== $size) {
+            throw new PdfTypeException(
+                \sprintf('Array with %s entries expected.', $size),
+                PdfTypeException::INVALID_DATA_SIZE
+            );
+        }
+
+        return $result;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfBoolean.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfBoolean.php
new file mode 100644 (file)
index 0000000..5f746b7
--- /dev/null
@@ -0,0 +1,43 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+/**
+ * Class representing a boolean PDF object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfBoolean extends PdfType
+{
+    /**
+     * Helper method to create an instance.
+     *
+     * @param bool $value
+     * @return self
+     */
+    public static function create($value)
+    {
+        $v = new self;
+        $v->value = (boolean) $value;
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfBoolean instance.
+     *
+     * @param mixed $value
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($value)
+    {
+        return PdfType::ensureType(self::class, $value, 'Boolean value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfDictionary.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfDictionary.php
new file mode 100644 (file)
index 0000000..46b669d
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\Fpdi\PdfParser\Tokenizer;
+
+/**
+ * Class representing a PDF dictionary object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfDictionary extends PdfType
+{
+    /**
+     * Parses a dictionary of the passed tokenizer, stream-reader and parser.
+     *
+     * @param Tokenizer $tokenizer
+     * @param StreamReader $streamReader
+     * @param PdfParser $parser
+     * @return bool|self
+     * @throws PdfTypeException
+     */
+    public static function parse(Tokenizer $tokenizer, StreamReader $streamReader, PdfParser $parser)
+    {
+        $entries = [];
+
+        while (true) {
+            $token = $tokenizer->getNextToken();
+            if ($token === '>' && $streamReader->getByte() === '>') {
+                $streamReader->addOffset(1);
+                break;
+            }
+
+            $key = $parser->readValue($token);
+            if ($key === false) {
+                return false;
+            }
+
+            // ensure the first value to be a Name object
+            if (!($key instanceof PdfName)) {
+                $lastToken = null;
+                // ignore all other entries and search for the closing brackets
+                while (($token = $tokenizer->getNextToken()) !== '>' && $token !== false && $lastToken !== '>') {
+                    $lastToken = $token;
+                }
+
+                if ($token === false) {
+                    return false;
+                }
+
+                break;
+            }
+
+
+            $value = $parser->readValue();
+            if ($value === false) {
+                return false;
+            }
+
+            if ($value instanceof PdfNull) {
+                continue;
+            }
+
+            // catch missing value
+            if ($value instanceof PdfToken && $value->value === '>' && $streamReader->getByte() === '>') {
+                $streamReader->addOffset(1);
+                break;
+            }
+
+            $entries[$key->value] = $value;
+        }
+
+        $v = new self;
+        $v->value = $entries;
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param PdfType[] $entries The keys are the name entries of the dictionary.
+     * @return self
+     */
+    public static function create(array $entries = [])
+    {
+        $v = new self;
+        $v->value = $entries;
+
+        return $v;
+    }
+
+    /**
+     * Get a value by its key from a dictionary or a default value.
+     *
+     * @param mixed $dictionary
+     * @param string $key
+     * @param PdfType|mixed|null $default
+     * @return PdfNull|PdfType
+     * @throws PdfTypeException
+     */
+    public static function get($dictionary, $key, PdfType $default = null)
+    {
+        $dictionary = self::ensure($dictionary);
+
+        if (isset($dictionary->value[$key])) {
+            return $dictionary->value[$key];
+        }
+
+        return $default === null
+            ? new PdfNull()
+            : $default;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfDictionary instance.
+     *
+     * @param mixed $dictionary
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($dictionary)
+    {
+        return PdfType::ensureType(self::class, $dictionary, 'Dictionary value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfHexString.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfHexString.php
new file mode 100644 (file)
index 0000000..793fa43
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class representing a hexadecimal encoded PDF string object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfHexString extends PdfType
+{
+    /**
+     * Parses a hexadecimal string object from the stream reader.
+     *
+     * @param StreamReader $streamReader
+     * @return bool|self
+     */
+    public static function parse(StreamReader $streamReader)
+    {
+        $bufferOffset = $streamReader->getOffset();
+
+        /**
+         * @var string $buffer
+         * @var int $pos
+         */
+        while (true) {
+            $buffer = $streamReader->getBuffer(false);
+            $pos = \strpos($buffer, '>', $bufferOffset);
+            if ($pos === false) {
+                if (!$streamReader->increaseLength()) {
+                    return false;
+                }
+                continue;
+            }
+
+            break;
+        }
+
+        $result = \substr($buffer, $bufferOffset, $pos - $bufferOffset);
+        $streamReader->setOffset($pos + 1);
+
+        $v = new self;
+        $v->value = $result;
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param string $string The hex encoded string.
+     * @return self
+     */
+    public static function create($string)
+    {
+        $v = new self;
+        $v->value = $string;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfHexString instance.
+     *
+     * @param mixed $hexString
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($hexString)
+    {
+        return PdfType::ensureType(self::class, $hexString, 'Hex string value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObject.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObject.php
new file mode 100644 (file)
index 0000000..a5bd2ac
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\Fpdi\PdfParser\Tokenizer;
+
+/**
+ * Class representing an indirect object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfIndirectObject extends PdfType
+{
+    /**
+     * Parses an indirect object from a tokenizer, parser and stream-reader.
+     *
+     * @param int $objectNumberToken
+     * @param int $objectGenerationNumberToken
+     * @param PdfParser $parser
+     * @param Tokenizer $tokenizer
+     * @param StreamReader $reader
+     * @return bool|self
+     * @throws PdfTypeException
+     */
+    public static function parse(
+        $objectNumberToken,
+        $objectGenerationNumberToken,
+        PdfParser $parser,
+        Tokenizer $tokenizer,
+        StreamReader $reader
+    ) {
+        $value = $parser->readValue();
+        if ($value === false) {
+            return false;
+        }
+
+        $nextToken = $tokenizer->getNextToken();
+        if ($nextToken === 'stream') {
+            $value = PdfStream::parse($value, $reader, $parser);
+        } elseif ($nextToken !== false) {
+            $tokenizer->pushStack($nextToken);
+        }
+
+        $v = new self;
+        $v->objectNumber = (int) $objectNumberToken;
+        $v->generationNumber = (int) $objectGenerationNumberToken;
+        $v->value = $value;
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param int $objectNumber
+     * @param int $generationNumber
+     * @param PdfType $value
+     * @return self
+     */
+    public static function create($objectNumber, $generationNumber, PdfType $value)
+    {
+        $v = new self;
+        $v->objectNumber = (int) $objectNumber;
+        $v->generationNumber = (int) $generationNumber;
+        $v->value = $value;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfIndirectObject instance.
+     *
+     * @param mixed $indirectObject
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($indirectObject)
+    {
+        return PdfType::ensureType(self::class, $indirectObject, 'Indirect object expected.');
+    }
+
+    /**
+     * The object number.
+     *
+     * @var int
+     */
+    public $objectNumber;
+
+    /**
+     * The generation number.
+     *
+     * @var int
+     */
+    public $generationNumber;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObjectReference.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfIndirectObjectReference.php
new file mode 100644 (file)
index 0000000..c6a67a0
--- /dev/null
@@ -0,0 +1,53 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+/**
+ * Class representing an indirect object reference
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfIndirectObjectReference extends PdfType
+{
+    /**
+     * Helper method to create an instance.
+     *
+     * @param int $objectNumber
+     * @param int $generationNumber
+     * @return self
+     */
+    public static function create($objectNumber, $generationNumber)
+    {
+        $v = new self;
+        $v->value = (int) $objectNumber;
+        $v->generationNumber = (int) $generationNumber;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfIndirectObject instance.
+     *
+     * @param mixed $value
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($value)
+    {
+        return PdfType::ensureType(self::class, $value, 'Indirect reference value expected.');
+    }
+
+    /**
+     * The generation number.
+     *
+     * @var int
+     */
+    public $generationNumber;
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfName.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfName.php
new file mode 100644 (file)
index 0000000..6be7d0e
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\Fpdi\PdfParser\Tokenizer;
+
+/**
+ * Class representing a PDF name object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfName extends PdfType
+{
+    /**
+     * Parses a name object from the passed tokenizer and stream-reader.
+     *
+     * @param Tokenizer $tokenizer
+     * @param StreamReader $streamReader
+     * @return self
+     */
+    public static function parse(Tokenizer $tokenizer, StreamReader $streamReader)
+    {
+        $v = new self;
+        if (\strspn($streamReader->getByte(), "\x00\x09\x0A\x0C\x0D\x20()<>[]{}/%") === 0) {
+            $v->value = (string) $tokenizer->getNextToken();
+            return $v;
+        }
+
+        $v->value = '';
+        return $v;
+    }
+
+    /**
+     * Unescapes a name string.
+     *
+     * @param string $value
+     * @return string
+     */
+    static public function unescape($value)
+    {
+        if (strpos($value, '#') === false)
+            return $value;
+
+        return preg_replace_callback('/#[a-fA-F\d]{2}/', function($matches) {
+            return chr(hexdec($matches[0]));
+        }, $value);
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param string $string
+     * @return self
+     */
+    public static function create($string)
+    {
+        $v = new self;
+        $v->value = $string;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfName instance.
+     *
+     * @param mixed $name
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($name)
+    {
+        return PdfType::ensureType(self::class, $name, 'Name value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNull.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNull.php
new file mode 100644 (file)
index 0000000..3dbe37c
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+/**
+ * Class representing a PDF null object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfNull extends PdfType
+{
+    // empty body
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNumeric.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfNumeric.php
new file mode 100644 (file)
index 0000000..e9bf287
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+/**
+ * Class representing a numeric PDF object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfNumeric extends PdfType
+{
+    /**
+     * Helper method to create an instance.
+     *
+     * @param int|float $value
+     * @return PdfNumeric
+     */
+    public static function create($value)
+    {
+        $v = new self;
+        $v->value = $value + 0;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfNumeric instance.
+     *
+     * @param mixed $value
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($value)
+    {
+        return PdfType::ensureType(self::class, $value, 'Numeric value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfStream.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfStream.php
new file mode 100644 (file)
index 0000000..4b9300b
--- /dev/null
@@ -0,0 +1,320 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\Filter\Ascii85;
+use setasign\Fpdi\PdfParser\Filter\AsciiHex;
+use setasign\Fpdi\PdfParser\Filter\FilterException;
+use setasign\Fpdi\PdfParser\Filter\Flate;
+use setasign\Fpdi\PdfParser\Filter\Lzw;
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\PdfParserException;
+use setasign\Fpdi\PdfParser\StreamReader;
+use setasign\FpdiPdfParser\PdfParser\Filter\Predictor;
+
+/**
+ * Class representing a PDF stream object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfStream extends PdfType
+{
+    /**
+     * Parses a stream from a stream reader.
+     *
+     * @param PdfDictionary $dictionary
+     * @param StreamReader $reader
+     * @param PdfParser $parser Optional to keep backwards compatibility
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function parse(PdfDictionary $dictionary, StreamReader $reader, PdfParser $parser = null)
+    {
+        $v = new self;
+        $v->value = $dictionary;
+        $v->reader = $reader;
+        $v->parser = $parser;
+
+        $offset = $reader->getOffset();
+
+        // Find the first "newline"
+        while (($firstByte = $reader->getByte($offset)) !== false) {
+            if ($firstByte !== "\n" && $firstByte !== "\r") {
+                $offset++;
+            } else {
+                break;
+            }
+        }
+
+        if (false === $firstByte) {
+            throw new PdfTypeException(
+                'Unable to parse stream data. No newline after the stream keyword found.',
+                PdfTypeException::NO_NEWLINE_AFTER_STREAM_KEYWORD
+            );
+        }
+
+        $sndByte = $reader->getByte($offset + 1);
+        if ($firstByte === "\n" || $firstByte === "\r") {
+            $offset++;
+        }
+
+        if ($sndByte === "\n" && $firstByte !== "\n") {
+            $offset++;
+        }
+
+        $reader->setOffset($offset);
+        // let's only save the byte-offset and read the stream only when needed
+        $v->stream = $reader->getPosition() + $reader->getOffset();
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param PdfDictionary $dictionary
+     * @param string $stream
+     * @return self
+     */
+    public static function create(PdfDictionary $dictionary, $stream)
+    {
+        $v = new self;
+        $v->value = $dictionary;
+        $v->stream = (string) $stream;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfStream instance.
+     *
+     * @param mixed $stream
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($stream)
+    {
+        return PdfType::ensureType(self::class, $stream, 'Stream value expected.');
+    }
+
+    /**
+     * The stream or its byte-offset position.
+     *
+     * @var int|string
+     */
+    protected $stream;
+
+    /**
+     * The stream reader instance.
+     *
+     * @var StreamReader
+     */
+    protected $reader;
+
+    /**
+     * The PDF parser instance.
+     *
+     * @var PdfParser
+     */
+    protected $parser;
+
+    /**
+     * Get the stream data.
+     *
+     * @param bool $cache Whether cache the stream data or not.
+     * @return bool|string
+     * @throws PdfTypeException
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    public function getStream($cache = false)
+    {
+        if (\is_int($this->stream)) {
+            $length = PdfDictionary::get($this->value, 'Length');
+            if ($this->parser !== null) {
+                $length = PdfType::resolve($length, $this->parser);
+            }
+
+            if (!($length instanceof PdfNumeric) || $length->value === 0) {
+                $this->reader->reset($this->stream, 100000);
+                $buffer = $this->extractStream();
+            } else {
+                $this->reader->reset($this->stream, $length->value);
+                $buffer = $this->reader->getBuffer(false);
+                if ($this->parser !== null) {
+                    $this->reader->reset($this->stream + strlen($buffer));
+                    $this->parser->getTokenizer()->clearStack();
+                    $token = $this->parser->readValue();
+                    if ($token === false || !($token instanceof PdfToken) || $token->value !== 'endstream') {
+                        $this->reader->reset($this->stream, 100000);
+                        $buffer = $this->extractStream();
+                        $this->reader->reset($this->stream + strlen($buffer));
+                    }
+                }
+            }
+
+            if ($cache === false) {
+                return $buffer;
+            }
+
+            $this->stream = $buffer;
+            $this->reader = null;
+        }
+
+        return $this->stream;
+    }
+
+    /**
+     * Extract the stream "manually".
+     *
+     * @return string
+     * @throws PdfTypeException
+     */
+    protected function extractStream()
+    {
+        while (true) {
+            $buffer = $this->reader->getBuffer(false);
+            $length = \strpos($buffer, 'endstream');
+            if ($length === false) {
+                if (!$this->reader->increaseLength(100000)) {
+                    throw new PdfTypeException('Cannot extract stream.');
+                }
+                continue;
+            }
+            break;
+        }
+
+        $buffer = \substr($buffer, 0, $length);
+        $lastByte = \substr($buffer, -1);
+
+        /* Check for EOL marker =
+         *   CARRIAGE RETURN (\r) and a LINE FEED (\n) or just a LINE FEED (\n},
+         *   and not by a CARRIAGE RETURN (\r) alone
+         */
+        if ($lastByte === "\n") {
+            $buffer = \substr($buffer, 0, -1);
+
+            $lastByte = \substr($buffer, -1);
+            if ($lastByte === "\r") {
+                $buffer = \substr($buffer, 0, -1);
+            }
+        }
+
+        return $buffer;
+    }
+
+    /**
+     * Get the unfiltered stream data.
+     *
+     * @return string
+     * @throws FilterException
+     * @throws PdfParserException
+     */
+    public function getUnfilteredStream()
+    {
+        $stream = $this->getStream();
+        $filters = PdfDictionary::get($this->value, 'Filter');
+        if ($filters instanceof PdfNull) {
+            return $stream;
+        }
+
+        if ($filters instanceof PdfArray) {
+            $filters = $filters->value;
+        } else {
+            $filters = [$filters];
+        }
+
+        $decodeParams = PdfDictionary::get($this->value, 'DecodeParms');
+        if ($decodeParams instanceof PdfArray) {
+            $decodeParams = $decodeParams->value;
+        } else {
+            $decodeParams = [$decodeParams];
+        }
+
+        foreach ($filters as $key => $filter) {
+            if (!($filter instanceof PdfName)) {
+                continue;
+            }
+
+            $decodeParam = null;
+            if (isset($decodeParams[$key])) {
+                $decodeParam = ($decodeParams[$key] instanceof PdfDictionary ? $decodeParams[$key] : null);
+            }
+
+            switch ($filter->value) {
+                case 'FlateDecode':
+                case 'Fl':
+                case 'LZWDecode':
+                case 'LZW':
+                    if (\strpos($filter->value, 'LZW') === 0) {
+                        $filterObject = new Lzw();
+                    } else {
+                        $filterObject = new Flate();
+                    }
+
+                    $stream = $filterObject->decode($stream);
+
+                    if ($decodeParam instanceof PdfDictionary) {
+                        $predictor = PdfDictionary::get($decodeParam, 'Predictor', PdfNumeric::create(1));
+                        if ($predictor->value !== 1) {
+                            if (!\class_exists(Predictor::class)) {
+                                throw new PdfParserException(
+                                    'This PDF document makes use of features which are only implemented in the ' .
+                                    'commercial "FPDI PDF-Parser" add-on (see https://www.setasign.com/fpdi-pdf-' .
+                                    'parser).',
+                                    PdfParserException::IMPLEMENTED_IN_FPDI_PDF_PARSER
+                                );
+                            }
+
+                            $colors = PdfDictionary::get($decodeParam, 'Colors', PdfNumeric::create(1));
+                            $bitsPerComponent = PdfDictionary::get(
+                                $decodeParam,
+                                'BitsPerComponent',
+                                PdfNumeric::create(8)
+                            );
+
+                            $columns = PdfDictionary::get($decodeParam, 'Columns', PdfNumeric::create(1));
+
+                            $filterObject = new Predictor(
+                                $predictor->value,
+                                $colors->value,
+                                $bitsPerComponent->value,
+                                $columns->value
+                            );
+
+                            $stream = $filterObject->decode($stream);
+                        }
+                    }
+
+                    break;
+                case 'ASCII85Decode':
+                case 'A85':
+                    $filterObject = new Ascii85();
+                    $stream = $filterObject->decode($stream);
+                    break;
+
+                case 'ASCIIHexDecode':
+                case 'AHx':
+                    $filterObject = new AsciiHex();
+                    $stream = $filterObject->decode($stream);
+                    break;
+
+                default:
+                    throw new FilterException(
+                        \sprintf('Unsupported filter "%s".', $filter->value),
+                        FilterException::UNSUPPORTED_FILTER
+                    );
+            }
+        }
+
+        return $stream;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfString.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfString.php
new file mode 100644 (file)
index 0000000..f933b1a
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\StreamReader;
+
+/**
+ * Class representing a PDF string object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfString extends PdfType
+{
+    /**
+     * Parses a string object from the stream reader.
+     *
+     * @param StreamReader $streamReader
+     * @return self
+     */
+    public static function parse(StreamReader $streamReader)
+    {
+        $pos = $startPos = $streamReader->getOffset();
+        $openBrackets = 1;
+        do {
+            $buffer = $streamReader->getBuffer(false);
+            for ($length = \strlen($buffer); $openBrackets !== 0 && $pos < $length; $pos++) {
+                switch ($buffer[$pos]) {
+                    case '(':
+                        $openBrackets++;
+                        break;
+                    case ')':
+                        $openBrackets--;
+                        break;
+                    case '\\':
+                        $pos++;
+                }
+            }
+        } while ($openBrackets !== 0 && $streamReader->increaseLength());
+
+        $result = \substr($buffer, $startPos, $openBrackets + $pos - $startPos - 1);
+        $streamReader->setOffset($pos);
+
+        $v = new self;
+        $v->value = $result;
+
+        return $v;
+    }
+
+    /**
+     * Helper method to create an instance.
+     *
+     * @param string $value The string needs to be escaped accordingly.
+     * @return self
+     */
+    public static function create($value)
+    {
+        $v = new self;
+        $v->value = $value;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfString instance.
+     *
+     * @param mixed $string
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($string)
+    {
+        return PdfType::ensureType(self::class, $string, 'String value expected.');
+    }
+
+    /**
+     * Unescapes escaped sequences in a PDF string according to the PDF specification.
+     *
+     * @param string $s
+     * @return string
+     */
+    public static function unescape($s)
+    {
+        $out = '';
+        /** @noinspection ForeachInvariantsInspection */
+        for ($count = 0, $n = \strlen($s); $count < $n; $count++) {
+            if ($s[$count] !== '\\') {
+                $out .= $s[$count];
+            } else {
+                // A backslash at the end of the string - ignore it
+                if ($count === ($n - 1)) {
+                    break;
+                }
+
+                switch ($s[++$count]) {
+                    case ')':
+                    case '(':
+                    case '\\':
+                        $out .= $s[$count];
+                        break;
+
+                    case 'f':
+                        $out .= "\x0C";
+                        break;
+
+                    case 'b':
+                        $out .= "\x08";
+                        break;
+
+                    case 't':
+                        $out .= "\x09";
+                        break;
+
+                    case 'r':
+                        $out .= "\x0D";
+                        break;
+
+                    case 'n':
+                        $out .= "\x0A";
+                        break;
+
+                    case "\r":
+                        if ($count !== $n - 1 && $s[$count + 1] === "\n") {
+                            $count++;
+                        }
+                        break;
+
+                    case "\n":
+                        break;
+
+                    default:
+                        $actualChar = \ord($s[$count]);
+                        // ascii 48 = number 0
+                        // ascii 57 = number 9
+                        if ($actualChar >= 48 &&
+                            $actualChar <= 57) {
+                            $oct = '' . $s[$count];
+
+                            /** @noinspection NotOptimalIfConditionsInspection */
+                            if ($count + 1 < $n &&
+                                \ord($s[$count + 1]) >= 48 &&
+                                \ord($s[$count + 1]) <= 57
+                            ) {
+                                $count++;
+                                $oct .= $s[$count];
+
+                                /** @noinspection NotOptimalIfConditionsInspection */
+                                if ($count + 1 < $n &&
+                                    \ord($s[$count + 1]) >= 48 &&
+                                    \ord($s[$count + 1]) <= 57
+                                ) {
+                                    $oct .= $s[++$count];
+                                }
+                            }
+
+                            $out .= \chr(\octdec($oct));
+                        } else {
+                            // If the character is not one of those defined, the backslash is ignored
+                            $out .= $s[$count];
+                        }
+                }
+            }
+        }
+        return $out;
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfToken.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfToken.php
new file mode 100644 (file)
index 0000000..4abda09
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+/**
+ * Class representing PDF token object
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfToken extends PdfType
+{
+    /**
+     * Helper method to create an instance.
+     *
+     * @param string $token
+     * @return self
+     */
+    public static function create($token)
+    {
+        $v = new self;
+        $v->value = $token;
+
+        return $v;
+    }
+
+    /**
+     * Ensures that the passed value is a PdfToken instance.
+     *
+     * @param mixed $token
+     * @return self
+     * @throws PdfTypeException
+     */
+    public static function ensure($token)
+    {
+        return PdfType::ensureType(self::class, $token, 'Token value expected.');
+    }
+}
diff --git a/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfType.php b/mod/assign/feedback/editpdf/fpdi/PdfParser/Type/PdfType.php
new file mode 100644 (file)
index 0000000..6ce3c8c
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+/**
+ * This file is part of FPDI
+ *
+ * @package   setasign\Fpdi
+ * @copyright Copyright (c) 2019 Setasign - Jan Slabon (https://www.setasign.com)
+ * @license   http://opensource.org/licenses/mit-license The MIT License
+ */
+
+namespace setasign\Fpdi\PdfParser\Type;
+
+use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
+use setasign\Fpdi\PdfParser\PdfParser;
+use setasign\Fpdi\PdfParser\PdfParserException;
+
+/**
+ * A class defining a PDF data type
+ *
+ * @package setasign\Fpdi\PdfParser\Type
+ */
+class PdfType
+{
+    /**
+     * Resolves a PdfType value to its value.
+     *
+     * This method is used to evaluate indirect and direct object references until a final value is reached.
+     *
+     * @param PdfType $value
+     * @param PdfParser $parser
+     * @param bool $stopAtIndirectObject
+     * @return PdfType
+     * @throws CrossReferenceException
+     * @throws PdfParserException
+     */
+    public static function resolve(PdfType $value, PdfParser $parser, $stopAtIndirectObject = false)
+    {
+        if ($value instanceof PdfIndirectObject) {
+            if ($stopAtIndirectObject === true) {
+           &n