first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,67 @@
<?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 file contains the class for backup of this feedback plugin
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Provides the information to backup comments feedback.
*
* This just records the text and format.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backup_assignfeedback_comments_subplugin extends backup_subplugin {
/**
* Returns the subplugin information to attach to submission element.
* @return backup_subplugin_element
*/
protected function define_grade_subplugin_structure() {
// Create XML elements.
$subplugin = $this->get_subplugin_element();
$subpluginwrapper = new backup_nested_element($this->get_recommended_name());
$subpluginelement = new backup_nested_element('feedback_comments',
null,
array('commenttext', 'commentformat', 'grade'));
// Connect XML elements into the tree.
$subplugin->add_child($subpluginwrapper);
$subpluginwrapper->add_child($subpluginelement);
// Set source to populate the data.
$subpluginelement->set_source_table('assignfeedback_comments',
array('grade' => backup::VAR_PARENTID));
$subpluginelement->annotate_files(
'assignfeedback_comments',
'feedback',
'grade'
);
return $subplugin;
}
}
@@ -0,0 +1,83 @@
<?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/>.
/**
* Restore subplugin class.
*
* Provides the necessary information needed to restore
* one assign_submission subplugin.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Restore subplugin class.
*
* Provides the necessary information needed to restore
* one assignfeedback subplugin.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class restore_assignfeedback_comments_subplugin extends restore_subplugin {
/**
* Returns the paths to be handled by the subplugin at workshop level
* @return array
*/
protected function define_grade_subplugin_structure() {
$paths = array();
$elename = $this->get_namefor('grade');
// We used get_recommended_name() so this works.
$elepath = $this->get_pathfor('/feedback_comments');
$paths[] = new restore_path_element($elename, $elepath);
return $paths;
}
/**
* Processes one feedback_comments element.
* @param mixed $data
*/
public function process_assignfeedback_comments_grade($data) {
global $DB;
$data = (object)$data;
$data->assignment = $this->get_new_parentid('assign');
$oldgradeid = $data->grade;
// The mapping is set in the restore for the core assign activity
// when a grade node is processed.
$data->grade = $this->get_mappingid('grade', $data->grade);
$DB->insert_record('assignfeedback_comments', $data);
$this->add_related_files(
'assignfeedback_comments',
'feedback',
'grade',
null,
$oldgradeid
);
}
}
@@ -0,0 +1,193 @@
<?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/>.
/**
* Privacy class for requesting user data.
*
* @package assignfeedback_comments
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_comments\privacy;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/assign/locallib.php');
use \core_privacy\local\metadata\collection;
use \core_privacy\local\request\writer;
use \core_privacy\local\request\contextlist;
use \mod_assign\privacy\assign_plugin_request_data;
use \mod_assign\privacy\useridlist;
/**
* Privacy class for requesting user data.
*
* @package assignfeedback_comments
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\mod_assign\privacy\assignfeedback_provider,
\mod_assign\privacy\assignfeedback_user_provider {
/**
* Return meta data about this plugin.
*
* @param collection $collection A list of information to add to.
* @return collection Return the collection after adding to it.
*/
public static function get_metadata(collection $collection): collection {
$data = [
'assignment' => 'privacy:metadata:assignmentid',
'grade' => 'privacy:metadata:gradepurpose',
'commenttext' => 'privacy:metadata:commentpurpose'
];
$collection->add_database_table('assignfeedback_comments', $data, 'privacy:metadata:tablesummary');
$collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
return $collection;
}
/**
* No need to fill in this method as all information can be acquired from the assign_grades table in the mod assign
* provider.
*
* @param int $userid The user ID.
* @param contextlist $contextlist The context list.
*/
public static function get_context_for_userid_within_feedback(int $userid, contextlist $contextlist) {
// This uses the assign_grades table.
}
/**
* This also does not need to be filled in as this is already collected in the mod assign provider.
*
* @param useridlist $useridlist A list of user IDs
*/
public static function get_student_user_ids(useridlist $useridlist) {
// Not required.
}
/**
* If you have tables that contain userids and you can generate entries in your tables without creating an
* entry in the assign_grades table then please fill in this method.
*
* @param \core_privacy\local\request\userlist $userlist The userlist object
*/
public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
// Not required.
}
/**
* Export all user data for this plugin.
*
* @param assign_plugin_request_data $exportdata Data used to determine which context and user to export and other useful
* information to help with exporting.
*/
public static function export_feedback_user_data(assign_plugin_request_data $exportdata) {
// Get that comment information and jam it into that exporter.
$assign = $exportdata->get_assign();
$plugin = $assign->get_plugin_by_type('assignfeedback', 'comments');
$gradeid = $exportdata->get_pluginobject()->id;
$comments = $plugin->get_feedback_comments($gradeid);
if ($comments && !empty($comments->commenttext)) {
$currentpath = array_merge(
$exportdata->get_subcontext(),
[get_string('privacy:commentpath', 'assignfeedback_comments')]
);
$comments->commenttext = writer::with_context($assign->get_context())->rewrite_pluginfile_urls(
$currentpath,
ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA,
$gradeid,
$comments->commenttext
);
$data = (object)
[
'commenttext' => format_text($comments->commenttext, $comments->commentformat,
['context' => $exportdata->get_context()])
];
writer::with_context($exportdata->get_context())->export_data($currentpath, $data);
writer::with_context($exportdata->get_context())->export_area_files($currentpath,
ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA, $gradeid);
}
}
/**
* Any call to this method should delete all user data for the context defined in the deletion_criteria.
*
* @param assign_plugin_request_data $requestdata Data useful for deleting user data from this sub-plugin.
*/
public static function delete_feedback_for_context(assign_plugin_request_data $requestdata) {
$assign = $requestdata->get_assign();
$fs = get_file_storage();
$fs->delete_area_files($requestdata->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA);
$plugin = $assign->get_plugin_by_type('assignfeedback', 'comments');
$plugin->delete_instance();
}
/**
* Calling this function should delete all user data associated with this grade entry.
*
* @param assign_plugin_request_data $requestdata Data useful for deleting user data.
*/
public static function delete_feedback_for_grade(assign_plugin_request_data $requestdata) {
global $DB;
$fs = new \file_storage();
$fs->delete_area_files($requestdata->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA, $requestdata->get_pluginobject()->id);
$DB->delete_records('assignfeedback_comments', ['assignment' => $requestdata->get_assignid(),
'grade' => $requestdata->get_pluginobject()->id]);
}
/**
* Deletes all feedback for the grade ids / userids provided in a context.
* assign_plugin_request_data contains:
* - context
* - assign object
* - grade ids (pluginids)
* - user ids
* @param assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
*/
public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata) {
global $DB;
if (empty($deletedata->get_gradeids())) {
return;
}
list($sql, $params) = $DB->get_in_or_equal($deletedata->get_gradeids(), SQL_PARAMS_NAMED);
$fs = new \file_storage();
$fs->delete_area_files_select(
$deletedata->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA,
$sql,
$params
);
$params['assignment'] = $deletedata->get_assignid();
$DB->delete_records_select('assignfeedback_comments', "assignment = :assignment AND grade $sql", $params);
}
}
@@ -0,0 +1,27 @@
<?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/>.
/**
* Capability definitions for this module.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$capabilities = array(
);
@@ -0,0 +1,41 @@
<?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/>.
/**
* Post-install code for the feedback_comments module.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Set the initial order for the feedback comments plugin (top)
* @return bool
*/
function xmldb_assignfeedback_comments_install() {
global $CFG;
require_once($CFG->dirroot . '/mod/assign/adminlib.php');
// Set the correct initial order for the plugins.
$pluginmanager = new assign_plugin_manager('assignfeedback');
$pluginmanager->move_plugin('comments', 'up');
return true;
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/assign/feedback/comments/db" VERSION="20120423" COMMENT="XMLDB file for Moodle mod/assign/feedback/comments"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="assignfeedback_comments" COMMENT="Text feedback for submitted assignments">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="assignment" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="commenttext" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The feedback text"/>
<FIELD NAME="commentformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The feedback text format"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id" COMMENT="The unique id for this feedback"/>
<KEY NAME="assignment" TYPE="foreign" FIELDS="assignment" REFTABLE="assign" REFFIELDS="id" COMMENT="The assignment instance this feedback relates to."/>
<KEY NAME="grade" TYPE="foreign" FIELDS="grade" REFTABLE="assign_grades" REFFIELDS="id" COMMENT="The grade instance this feedback relates to."/>
</KEYS>
</TABLE>
</TABLES>
</XMLDB>
@@ -0,0 +1,44 @@
<?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/>.
/**
* Upgrade code for the feedback_comments module.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Stub for upgrade code
* @param int $oldversion
* @return bool
*/
function xmldb_assignfeedback_comments_upgrade($oldversion) {
// Automatically generated Moodle v4.1.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.2.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.3.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.4.0 release upgrade line.
// Put any upgrade step following this.
return true;
}
@@ -0,0 +1,39 @@
<?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/>.
/**
* Strings for component 'assignfeedback_comments', language 'en'
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['default'] = 'Enabled by default';
$string['default_help'] = 'If set, this feedback method will be enabled by default for all new assignments.';
$string['enabled'] = 'Feedback comments';
$string['enabled_help'] = 'If enabled, the marker can leave feedback comments for each submission. ';
$string['pluginname'] = 'Feedback comments';
$string['privacy:commentpath'] = 'Feedback comments';
$string['privacy:metadata:assignmentid'] = 'Assignment ID';
$string['privacy:metadata:commentpurpose'] = 'The comment text.';
$string['privacy:metadata:filepurpose'] = 'Feedback files from the teacher for the student.';
$string['privacy:metadata:gradepurpose'] = 'The grade ID associated with the comment.';
$string['privacy:metadata:tablesummary'] = 'This stores comments made by the graders as feedback for the student on their submission.';
$string['commentinline'] = 'Comment inline';
$string['commentinline_help'] = 'If enabled, the submission text will be copied into the feedback comment field during grading, making it easier to comment inline (using a different colour, perhaps) or to edit the original text.';
$string['commentinlinedefault'] = 'Comment inline by default';
$string['commentinlinedefault_help'] = 'If set, this comment inline functionality will be enabled by default for all new assignments.';
+82
View File
@@ -0,0 +1,82 @@
<?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 file contains the moodle hooks for the comments feedback plugin
*
* @package assignfeedback_comments
* @copyright 2018 Mark Nelson <markn@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Serves assignment comment feedback files.
*
* @param mixed $course course or id of the course
* @param mixed $cm course module or id of the course module
* @param context $context
* @param string $filearea
* @param array $args
* @param bool $forcedownload
* @param array $options - List of options affecting file serving.
* @return bool false if file not found, does not return if found - just send the file
*/
function assignfeedback_comments_pluginfile(
$course,
$cm,
context $context,
$filearea,
$args,
$forcedownload,
array $options = []) {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
if ($context->contextlevel != CONTEXT_MODULE) {
return false;
}
require_login($course, false, $cm);
$itemid = (int)array_shift($args);
$record = $DB->get_record('assign_grades', array('id' => $itemid), 'userid,assignment', MUST_EXIST);
$userid = $record->userid;
$assign = new assign($context, $cm, $course);
$instance = $assign->get_instance();
if ($instance->id != $record->assignment) {
return false;
}
if (!$assign->can_view_submission($userid)) {
return false;
}
$relativepath = implode('/', $args);
$fullpath = "/{$context->id}/assignfeedback_comments/$filearea/$itemid/$relativepath";
$fs = get_file_storage();
if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
return false;
}
// Download MUST be forced - security!
send_stored_file($file, 0, 0, true, $options);
}
+673
View File
@@ -0,0 +1,673 @@
<?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 file contains the definition for the library class for comment feedback plugin
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_external\external_single_structure;
use core_external\external_value;
defined('MOODLE_INTERNAL') || die();
// File component for feedback comments.
define('ASSIGNFEEDBACK_COMMENTS_COMPONENT', 'assignfeedback_comments');
// File area for feedback comments.
define('ASSIGNFEEDBACK_COMMENTS_FILEAREA', 'feedback');
/**
* Library class for comment feedback plugin extending feedback plugin base class.
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class assign_feedback_comments extends assign_feedback_plugin {
/**
* Get the name of the online comment feedback plugin.
* @return string
*/
public function get_name() {
return get_string('pluginname', 'assignfeedback_comments');
}
/**
* Get the feedback comment from the database.
*
* @param int $gradeid
* @return stdClass|false The feedback comments for the given grade if it exists.
* False if it doesn't.
*/
public function get_feedback_comments($gradeid) {
global $DB;
return $DB->get_record('assignfeedback_comments', array('grade'=>$gradeid));
}
/**
* Get quickgrading form elements as html.
*
* @param int $userid The user id in the table this quickgrading element relates to
* @param mixed $grade - The grade data - may be null if there are no grades for this user (yet)
* @return mixed - A html string containing the html form elements required for quickgrading
*/
public function get_quickgrading_html($userid, $grade) {
$commenttext = '';
if ($grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
$commenttext = $feedbackcomments->commenttext;
}
}
$pluginname = get_string('pluginname', 'assignfeedback_comments');
$labeloptions = array('for'=>'quickgrade_comments_' . $userid,
'class'=>'accesshide');
$textareaoptions = array('name'=>'quickgrade_comments_' . $userid,
'id'=>'quickgrade_comments_' . $userid,
'class'=>'quickgrade');
return html_writer::tag('label', $pluginname, $labeloptions) .
html_writer::tag('textarea', $commenttext, $textareaoptions);
}
/**
* Has the plugin quickgrading form element been modified in the current form submission?
*
* @param int $userid The user id in the table this quickgrading element relates to
* @param stdClass $grade The grade
* @return boolean - true if the quickgrading form element has been modified
*/
public function is_quickgrading_modified($userid, $grade) {
$commenttext = '';
if ($grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
$commenttext = $feedbackcomments->commenttext;
}
}
// Note that this handles the difference between empty and not in the quickgrading
// form at all (hidden column).
$newvalue = optional_param('quickgrade_comments_' . $userid, false, PARAM_RAW);
return ($newvalue !== false) && ($newvalue != $commenttext);
}
/**
* Has the comment feedback been modified?
*
* @param stdClass $grade The grade object.
* @param stdClass $data Data from the form submission.
* @return boolean True if the comment feedback has been modified, else false.
*/
public function is_feedback_modified(stdClass $grade, stdClass $data) {
$commenttext = '';
if ($grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
$commenttext = $feedbackcomments->commenttext;
}
}
$formtext = $data->assignfeedbackcomments_editor['text'];
// Need to convert the form text to use @@PLUGINFILE@@ and format it so we can compare it with what is stored in the DB.
if (isset($data->assignfeedbackcomments_editor['itemid'])) {
$formtext = file_rewrite_urls_to_pluginfile($formtext, $data->assignfeedbackcomments_editor['itemid']);
$formtext = format_text($formtext, FORMAT_HTML);
}
if ($commenttext == $formtext) {
return false;
} else {
return true;
}
}
/**
* Override to indicate a plugin supports quickgrading.
*
* @return boolean - True if the plugin supports quickgrading
*/
public function supports_quickgrading() {
return true;
}
/**
* Return a list of the text fields that can be imported/exported by this plugin.
*
* @return array An array of field names and descriptions. (name=>description, ...)
*/
public function get_editor_fields() {
return array('comments' => get_string('pluginname', 'assignfeedback_comments'));
}
/**
* Get the saved text content from the editor.
*
* @param string $name
* @param int $gradeid
* @return string
*/
public function get_editor_text($name, $gradeid) {
if ($name == 'comments') {
$feedbackcomments = $this->get_feedback_comments($gradeid);
if ($feedbackcomments) {
return $feedbackcomments->commenttext;
}
}
return '';
}
/**
* Get the saved text content from the editor.
*
* @param string $name
* @param string $value
* @param int $gradeid
* @return string
*/
public function set_editor_text($name, $value, $gradeid) {
global $DB;
if ($name == 'comments') {
$feedbackcomment = $this->get_feedback_comments($gradeid);
if ($feedbackcomment) {
$feedbackcomment->commenttext = $value;
return $DB->update_record('assignfeedback_comments', $feedbackcomment);
} else {
$feedbackcomment = new stdClass();
$feedbackcomment->commenttext = $value;
$feedbackcomment->commentformat = FORMAT_HTML;
$feedbackcomment->grade = $gradeid;
$feedbackcomment->assignment = $this->assignment->get_instance()->id;
return $DB->insert_record('assignfeedback_comments', $feedbackcomment) > 0;
}
}
return false;
}
/**
* Save quickgrading changes.
*
* @param int $userid The user id in the table this quickgrading element relates to
* @param stdClass $grade The grade
* @return boolean - true if the grade changes were saved correctly
*/
public function save_quickgrading_changes($userid, $grade) {
global $DB;
$feedbackcomment = $this->get_feedback_comments($grade->id);
$quickgradecomments = optional_param('quickgrade_comments_' . $userid, null, PARAM_RAW);
if (!$quickgradecomments && $quickgradecomments !== '') {
return true;
}
if ($feedbackcomment) {
$feedbackcomment->commenttext = $quickgradecomments;
return $DB->update_record('assignfeedback_comments', $feedbackcomment);
} else {
$feedbackcomment = new stdClass();
$feedbackcomment->commenttext = $quickgradecomments;
$feedbackcomment->commentformat = FORMAT_HTML;
$feedbackcomment->grade = $grade->id;
$feedbackcomment->assignment = $this->assignment->get_instance()->id;
return $DB->insert_record('assignfeedback_comments', $feedbackcomment) > 0;
}
}
/**
* Save the settings for feedback comments plugin
*
* @param stdClass $data
* @return bool
*/
public function save_settings(stdClass $data) {
$this->set_config('commentinline', !empty($data->assignfeedback_comments_commentinline));
return true;
}
/**
* Get the default setting for feedback comments plugin
*
* @param MoodleQuickForm $mform The form to add elements to
* @return void
*/
public function get_settings(MoodleQuickForm $mform) {
$default = $this->get_config('commentinline');
if ($default === false) {
// Apply the admin default if we don't have a value yet.
$default = get_config('assignfeedback_comments', 'inline');
}
$mform->addElement('selectyesno',
'assignfeedback_comments_commentinline',
get_string('commentinline', 'assignfeedback_comments'));
$mform->addHelpButton('assignfeedback_comments_commentinline', 'commentinline', 'assignfeedback_comments');
$mform->setDefault('assignfeedback_comments_commentinline', $default);
// Disable comment online if comment feedback plugin is disabled.
$mform->hideIf('assignfeedback_comments_commentinline', 'assignfeedback_comments_enabled', 'notchecked');
}
/**
* Convert the text from any submission plugin that has an editor field to
* a format suitable for inserting in the feedback text field.
*
* @param stdClass $submission
* @param stdClass $data - Form data to be filled with the converted submission text and format.
* @param stdClass|null $grade
* @return boolean - True if feedback text was set.
*/
protected function convert_submission_text_to_feedback($submission, $data, $grade) {
global $DB;
$format = false;
$text = '';
foreach ($this->assignment->get_submission_plugins() as $plugin) {
$fields = $plugin->get_editor_fields();
if ($plugin->is_enabled() && $plugin->is_visible() && !$plugin->is_empty($submission) && !empty($fields)) {
$user = $DB->get_record('user', ['id' => $submission->userid]);
// Copy the files to the feedback area.
if ($files = $plugin->get_files($submission, $user)) {
$fs = get_file_storage();
$component = 'assignfeedback_comments';
$filearea = ASSIGNFEEDBACK_COMMENTS_FILEAREA;
$itemid = $grade->id;
$fieldupdates = [
'component' => $component,
'filearea' => $filearea,
'itemid' => $itemid
];
foreach ($files as $file) {
if ($file instanceof stored_file) {
// Before we create it, check that it doesn't already exist.
if (!$fs->file_exists(
$file->get_contextid(),
$component,
$filearea,
$itemid,
$file->get_filepath(),
$file->get_filename())) {
$fs->create_file_from_storedfile($fieldupdates, $file);
}
}
}
}
foreach ($fields as $key => $description) {
$rawtext = clean_text($plugin->get_editor_text($key, $submission->id));
$newformat = $plugin->get_editor_format($key, $submission->id);
if ($format !== false && $newformat != $format) {
// There are 2 or more editor fields using different formats, set to plain as a fallback.
$format = FORMAT_PLAIN;
} else {
$format = $newformat;
}
$text .= $rawtext;
}
}
}
if ($format === false) {
$format = FORMAT_HTML;
}
$data->assignfeedbackcomments = $text;
$data->assignfeedbackcommentsformat = $format;
return true;
}
/**
* Get form elements for the grading page
*
* @param stdClass|null $grade
* @param MoodleQuickForm $mform
* @param stdClass $data
* @return bool true if elements were added to the form
*/
public function get_form_elements_for_user($grade, MoodleQuickForm $mform, stdClass $data, $userid) {
$commentinlinenabled = $this->get_config('commentinline');
$submission = $this->assignment->get_user_submission($userid, false);
$feedbackcomments = false;
if ($grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
}
// Check first for data from last form submission in case grading validation failed.
if (!empty($data->assignfeedbackcomments_editor['text'])) {
$data->assignfeedbackcomments = $data->assignfeedbackcomments_editor['text'];
$data->assignfeedbackcommentsformat = $data->assignfeedbackcomments_editor['format'];
} else if ($feedbackcomments && !empty($feedbackcomments->commenttext)) {
$data->assignfeedbackcomments = $feedbackcomments->commenttext;
$data->assignfeedbackcommentsformat = $feedbackcomments->commentformat;
} else {
// No feedback given yet - maybe we need to copy the text from the submission?
if (!empty($commentinlinenabled) && $submission) {
$this->convert_submission_text_to_feedback($submission, $data, $grade);
} else { // Set it to empty.
$data->assignfeedbackcomments = '';
$data->assignfeedbackcommentsformat = FORMAT_HTML;
}
}
file_prepare_standard_editor(
$data,
'assignfeedbackcomments',
$this->get_editor_options(),
$this->assignment->get_context(),
ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA,
$grade->id
);
$mform->addElement('editor', 'assignfeedbackcomments_editor', $this->get_name(), null, $this->get_editor_options());
return true;
}
/**
* Saving the comment content into database.
*
* @param stdClass $grade
* @param stdClass $data
* @return bool
*/
public function save(stdClass $grade, stdClass $data) {
global $DB;
// Save the files.
$data = file_postupdate_standard_editor(
$data,
'assignfeedbackcomments',
$this->get_editor_options(),
$this->assignment->get_context(),
ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA,
$grade->id
);
$feedbackcomment = $this->get_feedback_comments($grade->id);
if ($feedbackcomment) {
$feedbackcomment->commenttext = $data->assignfeedbackcomments;
$feedbackcomment->commentformat = $data->assignfeedbackcommentsformat;
return $DB->update_record('assignfeedback_comments', $feedbackcomment);
} else {
$feedbackcomment = new stdClass();
$feedbackcomment->commenttext = $data->assignfeedbackcomments;
$feedbackcomment->commentformat = $data->assignfeedbackcommentsformat;
$feedbackcomment->grade = $grade->id;
$feedbackcomment->assignment = $this->assignment->get_instance()->id;
return $DB->insert_record('assignfeedback_comments', $feedbackcomment) > 0;
}
}
/**
* Display the comment in the feedback table.
*
* @param stdClass $grade
* @param bool $showviewlink Set to true to show a link to view the full feedback
* @return string
*/
public function view_summary(stdClass $grade, & $showviewlink) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
$text = $this->rewrite_feedback_comments_urls($feedbackcomments->commenttext, $grade->id);
$text = format_text(
$text,
$feedbackcomments->commentformat,
[
'context' => $this->assignment->get_context()
]
);
// Show the view all link if the text has been shortened.
$short = shorten_text($text, 140);
$showviewlink = $short != $text;
return $short;
}
return '';
}
/**
* Display the comment in the feedback table.
*
* @param stdClass $grade
* @return string
*/
public function view(stdClass $grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
$text = $this->rewrite_feedback_comments_urls($feedbackcomments->commenttext, $grade->id);
$text = format_text(
$text,
$feedbackcomments->commentformat,
[
'context' => $this->assignment->get_context()
]
);
return $text;
}
return '';
}
/**
* Return true if this plugin can upgrade an old Moodle 2.2 assignment of this type
* and version.
*
* @param string $type old assignment subtype
* @param int $version old assignment version
* @return bool True if upgrade is possible
*/
public function can_upgrade($type, $version) {
if (($type == 'upload' || $type == 'uploadsingle' ||
$type == 'online' || $type == 'offline') && $version >= 2011112900) {
return true;
}
return false;
}
/**
* Upgrade the settings from the old assignment to the new plugin based one
*
* @param context $oldcontext - the context for the old assignment
* @param stdClass $oldassignment - the data for the old assignment
* @param string $log - can be appended to by the upgrade
* @return bool was it a success? (false will trigger a rollback)
*/
public function upgrade_settings(context $oldcontext, stdClass $oldassignment, & $log) {
if ($oldassignment->assignmenttype == 'online') {
$this->set_config('commentinline', $oldassignment->var1);
return true;
}
return true;
}
/**
* Upgrade the feedback from the old assignment to the new one
*
* @param context $oldcontext - the database for the old assignment context
* @param stdClass $oldassignment The data record for the old assignment
* @param stdClass $oldsubmission The data record for the old submission
* @param stdClass $grade The data record for the new grade
* @param string $log Record upgrade messages in the log
* @return bool true or false - false will trigger a rollback
*/
public function upgrade(context $oldcontext,
stdClass $oldassignment,
stdClass $oldsubmission,
stdClass $grade,
& $log) {
global $DB;
$feedbackcomments = new stdClass();
$feedbackcomments->commenttext = $oldsubmission->submissioncomment;
$feedbackcomments->commentformat = FORMAT_HTML;
$feedbackcomments->grade = $grade->id;
$feedbackcomments->assignment = $this->assignment->get_instance()->id;
if (!$DB->insert_record('assignfeedback_comments', $feedbackcomments) > 0) {
$log .= get_string('couldnotconvertgrade', 'mod_assign', $grade->userid);
return false;
}
return true;
}
/**
* If this plugin adds to the gradebook comments field, it must specify the format of the text
* of the comment
*
* Only one feedback plugin can push comments to the gradebook and that is chosen by the assignment
* settings page.
*
* @param stdClass $grade The grade
* @return int
*/
public function format_for_gradebook(stdClass $grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
return $feedbackcomments->commentformat;
}
return FORMAT_MOODLE;
}
/**
* If this plugin adds to the gradebook comments field, it must format the text
* of the comment
*
* Only one feedback plugin can push comments to the gradebook and that is chosen by the assignment
* settings page.
*
* @param stdClass $grade The grade
* @return string
*/
public function text_for_gradebook(stdClass $grade) {
$feedbackcomments = $this->get_feedback_comments($grade->id);
if ($feedbackcomments) {
return $feedbackcomments->commenttext;
}
return '';
}
/**
* Return any files this plugin wishes to save to the gradebook.
*
* @param stdClass $grade The assign_grades object from the db
* @return array
*/
public function files_for_gradebook(stdClass $grade): array {
return [
'contextid' => $this->assignment->get_context()->id,
'component' => ASSIGNFEEDBACK_COMMENTS_COMPONENT,
'filearea' => ASSIGNFEEDBACK_COMMENTS_FILEAREA,
'itemid' => $grade->id
];
}
/**
* The assignment has been deleted - cleanup
*
* @return bool
*/
public function delete_instance() {
global $DB;
// Will throw exception on failure.
$DB->delete_records('assignfeedback_comments',
array('assignment'=>$this->assignment->get_instance()->id));
return true;
}
/**
* Returns true if there are no feedback comments for the given grade.
*
* @param stdClass $grade
* @return bool
*/
public function is_empty(stdClass $grade) {
return $this->view($grade) == '';
}
/**
* Get file areas returns a list of areas this plugin stores files
* @return array - An array of fileareas (keys) and descriptions (values)
*/
public function get_file_areas() {
return array(ASSIGNFEEDBACK_COMMENTS_FILEAREA => $this->get_name());
}
/**
* Return a description of external params suitable for uploading an feedback comment from a webservice.
*
* @return \core_external\external_description|null
*/
public function get_external_parameters() {
$editorparams = array('text' => new external_value(PARAM_RAW, 'The text for this feedback.'),
'format' => new external_value(PARAM_INT, 'The format for this feedback'));
$editorstructure = new external_single_structure($editorparams, 'Editor structure', VALUE_OPTIONAL);
return array('assignfeedbackcomments_editor' => $editorstructure);
}
/**
* Return the plugin configs for external functions.
*
* @return array the list of settings
* @since Moodle 3.2
*/
public function get_config_for_external() {
return (array) $this->get_config();
}
/**
* Convert encoded URLs in $text from the @@PLUGINFILE@@/... form to an actual URL.
*
* @param string $text the Text to check
* @param int $gradeid The grade ID which refers to the id in the gradebook
*/
private function rewrite_feedback_comments_urls(string $text, int $gradeid) {
return file_rewrite_pluginfile_urls(
$text,
'pluginfile.php',
$this->assignment->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA,
$gradeid
);
}
/**
* File format options.
*
* @return array
*/
private function get_editor_options() {
global $COURSE;
return [
'subdirs' => 1,
'maxbytes' => $COURSE->maxbytes,
'accepted_types' => '*',
'context' => $this->assignment->get_context(),
'maxfiles' => EDITOR_UNLIMITED_FILES
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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 file defines the admin settings for this plugin
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$settings->add(new admin_setting_configcheckbox('assignfeedback_comments/default',
new lang_string('default', 'assignfeedback_comments'),
new lang_string('default_help', 'assignfeedback_comments'), 1));
$setting = new admin_setting_configcheckbox('assignfeedback_comments/inline',
new lang_string('commentinlinedefault', 'assignfeedback_comments'),
new lang_string('commentinlinedefault_help', 'assignfeedback_comments'), 0);
$setting->set_advanced_flag_options(admin_setting_flag::ENABLED, false);
$setting->set_locked_flag_options(admin_setting_flag::ENABLED, false);
$settings->add($setting);
@@ -0,0 +1,40 @@
@mod @mod_assign @assignfeedback @assignfeedback_comments
Feature: In an assignment, teachers can provide feedback comments on student submissions
In order to provide feedback to students on their assignments
As a teacher,
I need to create feedback comments against their submissions.
Background:
Given the following "courses" exist:
| fullname | shortname | category | groupmode |
| Course 1 | C1 | 0 | 0 |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| student1 | Student | 1 | student1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | teacher |
| student1 | C1 | student |
@javascript @skip_chrome_zerosize
Scenario: Teachers should be able to add and remove feedback comments via the quick grading interface
Given the following "activities" exist:
| activity | course | name | assignsubmission_onlinetext_enabled | assignfeedback_comments_enabled |
| assign | C1 | Test assignment name | 1 | 1 |
And the following "mod_assign > submissions" exist:
| assign | user | onlinetext |
| Test assignment name | student1 | I'm the student1 submission |
And I am on the "Test assignment name" Activity page logged in as teacher1
And I follow "View all submissions"
Then I click on "Quick grading" "checkbox"
And I set the field "Feedback comments" to "Feedback from teacher."
And I press "Save all quick grading changes"
And I should see "The grade changes were saved"
And I press "Continue"
And I should see "Feedback from teacher."
And I set the field "Feedback comments" to ""
And I press "Save all quick grading changes"
And I should see "The grade changes were saved"
And I press "Continue"
And I should not see "Feedback from teacher."
@@ -0,0 +1,33 @@
@mod @mod_assign @assignfeedback @assignfeedback_comments
Feature: Check that any changes to assignment feedback comments are not lost
if the grading form validation fails due to an invalid grade.
In order to ensure that the feedback changes are not lost
As a teacher
I need to grade a student and ensure that all feedback changes are preserved
@javascript
Scenario: Update the grade and feedback for an assignment
Given the following "courses" exist:
| fullname | shortname |
| Course 1 | C1 |
And the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| student1 | Student | 1 | student1@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | name | course | assignfeedback_comments_enabled |
| assign | Test assignment name | C1 | 1 |
And I am on the "Test assignment name" Activity page logged in as teacher1
And I follow "View all submissions"
And I click on "Grade" "link" in the "Student 1" "table_row"
When I set the following fields to these values:
| Grade out of 100 | 101 |
| Feedback comments | Feedback from teacher. |
And I press "Save changes"
Then I should see "Grade must be less than or equal to 100."
And the following fields match these values:
| Feedback comments | Feedback from teacher. |
@@ -0,0 +1,83 @@
<?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/>.
namespace assignfeedback_comments;
use mod_assign_test_generator;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/assign/tests/generator.php');
/**
* Unit tests for assignfeedback_comments
*
* @package assignfeedback_comments
* @copyright 2016 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class feedback_test extends \advanced_testcase {
// Use the generator helper.
use mod_assign_test_generator;
/**
* Test the is_feedback_modified() method for the comments feedback.
*/
public function test_is_feedback_modified(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
$student = $this->getDataGenerator()->create_and_enrol($course, 'student');
$assign = $this->create_instance($course, [
'assignsubmission_onlinetext_enabled' => 1,
'assignfeedback_comments_enabled' => 1,
]);
// Create an online text submission.
$this->add_submission($student, $assign);
$this->setUser($teacher);
// Create formdata.
$grade = $assign->get_user_grade($student->id, true);
$data = (object) [
'assignfeedbackcomments_editor' => [
'text' => '<p>first comment for this test</p>',
'format' => 1,
]
];
// This is the first time that we are submitting feedback, so it is modified.
$plugin = $assign->get_feedback_plugin_by_type('comments');
$this->assertTrue($plugin->is_feedback_modified($grade, $data));
// Save the feedback.
$plugin->save($grade, $data);
// Try again with the same data.
$this->assertFalse($plugin->is_feedback_modified($grade, $data));
// Change the data.
$data->assignfeedbackcomments_editor = [
'text' => '<p>Altered comment for this test</p>',
'format' => 1,
];
$this->assertTrue($plugin->is_feedback_modified($grade, $data));
}
}
@@ -0,0 +1,350 @@
<?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/>.
/**
* Unit tests for assignfeedback_comments.
*
* @package assignfeedback_comments
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_comments\privacy;
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
require_once($CFG->dirroot . '/mod/assign/tests/privacy/provider_test.php');
/**
* Unit tests for mod/assign/feedback/comments/classes/privacy/
*
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider_test extends \mod_assign\privacy\provider_test {
/**
* Convenience function for creating feedback data.
*
* @param object $assign assign object
* @param \stdClass $student user object
* @param \stdClass $teacher user object
* @param string $submissiontext Submission text
* @param string $feedbacktext Feedback text
* @return array Feedback plugin object and the grade object.
*/
protected function create_feedback($assign, $student, $teacher, $submissiontext, $feedbacktext) {
global $CFG;
$submission = new \stdClass();
$submission->assignment = $assign->get_instance()->id;
$submission->userid = $student->id;
$submission->timecreated = time();
$submission->onlinetext_editor = ['text' => $submissiontext,
'format' => FORMAT_MOODLE];
$this->setUser($student);
$notices = [];
$assign->save_submission($submission, $notices);
$grade = $assign->get_user_grade($student->id, true);
$this->setUser($teacher);
$context = \context_user::instance($teacher->id);
$draftitemid = file_get_unused_draft_itemid();
file_prepare_draft_area($draftitemid, $context->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA, $grade->id);
$dummy = array(
'contextid' => $context->id,
'component' => 'user',
'filearea' => 'draft',
'itemid' => $draftitemid,
'filepath' => '/',
'filename' => 'feedback1.txt'
);
$fs = get_file_storage();
$fs->create_file_from_string($dummy, $feedbacktext);
$feedbacktext = $feedbacktext .
" <img src='{$CFG->wwwroot}/draftfile.php/{$context->id}/user/draft/{$draftitemid}/feedback1.txt.png>";
$plugin = $assign->get_feedback_plugin_by_type('comments');
$feedbackdata = new \stdClass();
$feedbackdata->assignfeedbackcomments_editor = [
'text' => $feedbacktext,
'format' => FORMAT_HTML,
'itemid' => $draftitemid
];
$plugin->save($grade, $feedbackdata);
return [$plugin, $grade];
}
/**
* Quick test to make sure that get_metadata returns something.
*/
public function test_get_metadata(): void {
$collection = new \core_privacy\local\metadata\collection('assignfeedback_comments');
$collection = \assignfeedback_comments\privacy\provider::get_metadata($collection);
$this->assertNotEmpty($collection);
}
/**
* Test that feedback comments are exported for a user.
*/
public function test_export_feedback_user_data(): void {
$this->resetAfterTest();
// Create course, assignment, submission, and then a feedback comment.
$course = $this->getDataGenerator()->create_course();
// Student.
$user1 = $this->getDataGenerator()->create_user();
// Teacher.
$user2 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user2->id, $course->id, 'editingteacher');
$assign = $this->create_instance(['course' => $course]);
$context = $assign->get_context();
$feedbacktext = '<p>first comment for this test</p>';
list($plugin, $grade) = $this->create_feedback($assign, $user1, $user2, 'Submission text', $feedbacktext);
$writer = \core_privacy\local\request\writer::with_context($context);
$this->assertFalse($writer->has_any_data());
// The student should be able to see the teachers feedback.
$exportdata = new \mod_assign\privacy\assign_plugin_request_data($context, $assign, $grade, [], $user1);
\assignfeedback_comments\privacy\provider::export_feedback_user_data($exportdata);
$this->assertStringContainsString($feedbacktext, $writer->get_data(['Feedback comments'])->commenttext);
$filespath = [];
$filespath[] = 'Feedback comments';
$feedbackfile = $writer->get_files($filespath)['feedback1.txt'];
$this->assertInstanceOf('stored_file', $feedbackfile);
$this->assertEquals('feedback1.txt', $feedbackfile->get_filename());
// The teacher should also be able to see the feedback that they provided.
$exportdata = new \mod_assign\privacy\assign_plugin_request_data($context, $assign, $grade, [], $user2);
\assignfeedback_comments\privacy\provider::export_feedback_user_data($exportdata);
$this->assertStringContainsString($feedbacktext, $writer->get_data(['Feedback comments'])->commenttext);
$feedbackfile = $writer->get_files($filespath)['feedback1.txt'];
$this->assertInstanceOf('stored_file', $feedbackfile);
$this->assertEquals('feedback1.txt', $feedbackfile->get_filename());
}
/**
* Test that all feedback is deleted for a context.
*/
public function test_delete_feedback_for_context(): void {
$this->resetAfterTest();
// Create course, assignment, submission, and then a feedback comment.
$course = $this->getDataGenerator()->create_course();
// Student.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
// Teacher.
$user3 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user3->id, $course->id, 'editingteacher');
$assign = $this->create_instance(['course' => $course]);
$context = $assign->get_context();
$feedbacktext = '<p>first comment for this test</p>';
list($plugin1, $grade1) = $this->create_feedback($assign, $user1, $user3, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for second student.</p>';
list($plugin2, $grade2) = $this->create_feedback($assign, $user2, $user3, 'Submission text', $feedbacktext);
// Check that we have data.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin1->get_feedback_comments($grade2->id);
$this->assertNotEmpty($feedbackcomments);
$fs = new \file_storage();
$files = $fs->get_area_files($assign->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA);
// 4 including directories.
$this->assertEquals(4, count($files));
// Delete all comments for this context.
$requestdata = new \mod_assign\privacy\assign_plugin_request_data($context, $assign);
provider::delete_feedback_for_context($requestdata);
// Check that the data is now gone.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertEmpty($feedbackcomments);
$feedbackcomments = $plugin1->get_feedback_comments($grade2->id);
$this->assertEmpty($feedbackcomments);
$fs = new \file_storage();
$files = $fs->get_area_files($assign->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA);
$this->assertEquals(0, count($files));
}
/**
* Test that a grade item is deleted for a user.
*/
public function test_delete_feedback_for_grade(): void {
$this->resetAfterTest();
// Create course, assignment, submission, and then a feedback comment.
$course = $this->getDataGenerator()->create_course();
// Student.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
// Teacher.
$user3 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user3->id, $course->id, 'editingteacher');
$assign = $this->create_instance(['course' => $course]);
$context = $assign->get_context();
$feedbacktext = '<p>first comment for this test</p>';
list($plugin1, $grade1) = $this->create_feedback($assign, $user1, $user3, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for second student.</p>';
list($plugin2, $grade2) = $this->create_feedback($assign, $user2, $user3, 'Submission text', $feedbacktext);
// Check that we have data.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin1->get_feedback_comments($grade2->id);
$this->assertNotEmpty($feedbackcomments);
$fs = new \file_storage();
$files = $fs->get_area_files($assign->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA);
// 4 including directories.
$this->assertEquals(4, count($files));
// Delete all comments for this grade object.
$requestdata = new \mod_assign\privacy\assign_plugin_request_data($context, $assign, $grade1, [], $user1);
provider::delete_feedback_for_grade($requestdata);
// These comments should be empty.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertEmpty($feedbackcomments);
// These comments should not.
$feedbackcomments = $plugin1->get_feedback_comments($grade2->id);
$this->assertNotEmpty($feedbackcomments);
$fs = new \file_storage();
$files = $fs->get_area_files($assign->get_context()->id, ASSIGNFEEDBACK_COMMENTS_COMPONENT,
ASSIGNFEEDBACK_COMMENTS_FILEAREA);
// 2 files that were not deleted.
$this->assertEquals(2, count($files));
array_shift($files);
$file = array_shift($files);
$this->assertInstanceOf('stored_file', $file);
$this->assertEquals('feedback1.txt', $file->get_filename());
$this->assertEquals($grade2->id, $file->get_itemid());
}
/**
* Test that a grade item is deleted for a user.
*/
public function test_delete_feedback_for_grades(): void {
$this->resetAfterTest();
// Create course, assignment, submission, and then a feedback comment.
$course = $this->getDataGenerator()->create_course();
// Student.
$user1 = $this->getDataGenerator()->create_user();
$user2 = $this->getDataGenerator()->create_user();
$user3 = $this->getDataGenerator()->create_user();
$user4 = $this->getDataGenerator()->create_user();
// Teacher.
$user5 = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user2->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user3->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user4->id, $course->id, 'student');
$this->getDataGenerator()->enrol_user($user5->id, $course->id, 'editingteacher');
$assign1 = $this->create_instance(['course' => $course]);
$assign2 = $this->create_instance(['course' => $course]);
$feedbacktext = '<p>first comment for this test</p>';
list($plugin1, $grade1) = $this->create_feedback($assign1, $user1, $user5, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for second student.</p>';
list($plugin2, $grade2) = $this->create_feedback($assign1, $user2, $user5, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for third student.</p>';
list($plugin3, $grade3) = $this->create_feedback($assign1, $user3, $user5, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for third student in the second assignment.</p>';
list($plugin4, $grade4) = $this->create_feedback($assign2, $user3, $user5, 'Submission text', $feedbacktext);
$feedbacktext = '<p>Comment for fourth student in the second assignment.</p>';
list($plugin5, $grade5) = $this->create_feedback($assign2, $user4, $user5, 'Submission text', $feedbacktext);
// Check that we have data.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin2->get_feedback_comments($grade2->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin3->get_feedback_comments($grade3->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin4->get_feedback_comments($grade4->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin5->get_feedback_comments($grade5->id);
$this->assertNotEmpty($feedbackcomments);
$fs = new \file_storage();
// 6 including directories for assign 1.
// 4 including directories for assign 2.
$this->assertCount(6, $fs->get_area_files($assign1->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
$this->assertCount(4, $fs->get_area_files($assign2->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
$deletedata = new \mod_assign\privacy\assign_plugin_request_data($assign1->get_context(), $assign1);
$deletedata->set_userids([$user1->id, $user3->id]);
$deletedata->populate_submissions_and_grades();
provider::delete_feedback_for_grades($deletedata);
// Check that grade 1 and grade 3 have been removed.
$feedbackcomments = $plugin1->get_feedback_comments($grade1->id);
$this->assertEmpty($feedbackcomments);
$feedbackcomments = $plugin2->get_feedback_comments($grade2->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin3->get_feedback_comments($grade3->id);
$this->assertEmpty($feedbackcomments);
$feedbackcomments = $plugin4->get_feedback_comments($grade4->id);
$this->assertNotEmpty($feedbackcomments);
$feedbackcomments = $plugin5->get_feedback_comments($grade5->id);
$this->assertNotEmpty($feedbackcomments);
// We have deleted two from assign 1, and none from assign 2.
// 2 including directories for assign 1.
// 4 including directories for assign 2.
$this->assertCount(2, $fs->get_area_files($assign1->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
$this->assertCount(4, $fs->get_area_files($assign2->get_context()->id,
ASSIGNFEEDBACK_COMMENTS_COMPONENT, ASSIGNFEEDBACK_COMMENTS_FILEAREA));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?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 file contains the version information for the comments feedback plugin
*
* @package assignfeedback_comments
* @copyright 2012 NetSpot {@link http://www.netspot.com.au}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2024042200;
$plugin->requires = 2024041600;
$plugin->component = 'assignfeedback_comments';
+279
View File
@@ -0,0 +1,279 @@
<?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/>.
/**
* Process ajax requests
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use \assignfeedback_editpdf\document_services;
use \assignfeedback_editpdf\combined_document;
use \assignfeedback_editpdf\page_editor;
use \assignfeedback_editpdf\comments_quick_list;
define('AJAX_SCRIPT', true);
require('../../../../config.php');
require_once($CFG->dirroot . '/mod/assign/locallib.php');
require_sesskey();
$action = optional_param('action', '', PARAM_ALPHANUM);
$assignmentid = required_param('assignmentid', PARAM_INT);
$userid = required_param('userid', PARAM_INT);
$attemptnumber = required_param('attemptnumber', PARAM_INT);
$readonly = optional_param('readonly', false, PARAM_BOOL);
$cm = \get_coursemodule_from_instance('assign', $assignmentid, 0, false, MUST_EXIST);
$context = \context_module::instance($cm->id);
$assignment = new \assign($context, null, null);
require_login($assignment->get_course(), false, $cm);
if (!$assignment->can_view_submission($userid)) {
throw new \moodle_exception('nopermission');
}
if ($action === 'pollconversions') {
// Poll conversions does not require session lock.
\core\session\manager::write_close();
$draft = true;
if (!has_capability('mod/assign:grade', $context)) {
// A student always sees the readonly version.
$readonly = true;
$draft = false;
require_capability('mod/assign:submit', $context);
}
if ($readonly) {
// Whoever is viewing the readonly version should not use the drafts, but the actual annotations.
$draft = false;
}
// Get a lock for the PDF/Image conversion of the assignment files.
$lockfactory = \core\lock\lock_config::get_lock_factory('assignfeedback_editpdf_pollconversions');
$resource = "user:{$userid},assignmentid:{$assignmentid},attemptnumber:{$attemptnumber}";
$lock = $lockfactory->get_lock($resource, 0);
// Could not get lock, send back JSON to poll again.
if (!$lock) {
echo json_encode([
'status' => 0
]);
die();
}
// Obtained lock, now process the assignment conversion.
try {
$response = (object) [
'status' => -1,
'filecount' => 0,
'pagecount' => 0,
'pageready' => 0,
'partial' => false,
'pages' => [],
];
$combineddocument = document_services::get_combined_document_for_attempt($assignment, $userid, $attemptnumber);
$response->status = $combineddocument->get_status();
$response->filecount = $combineddocument->get_document_count();
$readystatuslist = [combined_document::STATUS_READY, combined_document::STATUS_READY_PARTIAL];
$completestatuslist = [combined_document::STATUS_COMPLETE, combined_document::STATUS_FAILED];
if (in_array($response->status, $readystatuslist)) {
// It seems that the files for this submission haven't been combined in cron yet.
// Try to combine them in the user session.
$combineddocument = document_services::get_combined_pdf_for_attempt($assignment, $userid, $attemptnumber);
$response->status = $combineddocument->get_status();
$response->filecount = $combineddocument->get_document_count();
}
if (in_array($response->status, $completestatuslist)) {
$pages = document_services::get_page_images_for_attempt($assignment,
$userid,
$attemptnumber,
$readonly);
$response->pagecount = $combineddocument->get_page_count();
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
// The readonly files are stored in a different file area.
$filearea = document_services::PAGE_IMAGE_FILEAREA;
if ($readonly) {
$filearea = document_services::PAGE_IMAGE_READONLY_FILEAREA;
}
$response->partial = $combineddocument->is_partial_conversion();
foreach ($pages as $id => $pagefile) {
$index = count($response->pages);
$page = new stdClass();
$comments = page_editor::get_comments($grade->id, $index, $draft);
$page->url = moodle_url::make_pluginfile_url($context->id,
'assignfeedback_editpdf',
$filearea,
$grade->id,
'/',
$pagefile->get_filename())->out();
$page->comments = $comments;
if ($imageinfo = $pagefile->get_imageinfo()) {
$page->width = $imageinfo['width'];
$page->height = $imageinfo['height'];
} else {
$page->width = 0;
$page->height = 0;
}
$annotations = page_editor::get_annotations($grade->id, $index, $draft);
$page->annotations = $annotations;
$response->pages[] = $page;
}
$component = 'assignfeedback_editpdf';
$filearea = document_services::PAGE_IMAGE_FILEAREA;
$filepath = '/';
$fs = get_file_storage();
$files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
$response->pageready = count($files);
}
} catch (\Throwable $e) {
// Release lock, and re-throw exception.
$lock->release();
throw $e;
}
echo json_encode($response);
$lock->release();
die();
} else if ($action == 'savepage') {
require_capability('mod/assign:grade', $context);
$response = new stdClass();
$response->errors = array();
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
$pagejson = required_param('page', PARAM_RAW);
$page = json_decode($pagejson);
$index = required_param('index', PARAM_INT);
$added = page_editor::set_comments($grade->id, $index, $page->comments);
if ($added != count($page->comments)) {
array_push($response->errors, get_string('couldnotsavepage', 'assignfeedback_editpdf', $index+1));
}
$added = page_editor::set_annotations($grade->id, $index, $page->annotations);
if ($added != count($page->annotations)) {
array_push($response->errors, get_string('couldnotsavepage', 'assignfeedback_editpdf', $index+1));
}
echo json_encode($response);
die();
} else if ($action == 'generatepdf') {
require_capability('mod/assign:grade', $context);
$response = new stdClass();
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
$file = document_services::generate_feedback_document($assignment, $userid, $attemptnumber);
$response->url = '';
if ($file) {
$url = moodle_url::make_pluginfile_url($assignment->get_context()->id,
'assignfeedback_editpdf',
document_services::FINAL_PDF_FILEAREA,
$grade->id,
'/',
$file->get_filename(),
false);
$response->url = $url->out(true);
$response->filename = $file->get_filename();
}
echo json_encode($response);
die();
} else if ($action == 'loadquicklist') {
require_capability('mod/assign:grade', $context);
$result = comments_quick_list::get_comments();
echo json_encode($result);
die();
} else if ($action == 'addtoquicklist') {
require_capability('mod/assign:grade', $context);
$comment = required_param('commenttext', PARAM_RAW);
$width = required_param('width', PARAM_INT);
$colour = required_param('colour', PARAM_ALPHA);
$result = comments_quick_list::add_comment($comment, $width, $colour);
echo json_encode($result);
die();
} else if ($action == 'revertchanges') {
require_capability('mod/assign:grade', $context);
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
$result = page_editor::revert_drafts($gradeid);
echo json_encode($result);
die();
} else if ($action == 'removefromquicklist') {
require_capability('mod/assign:grade', $context);
$commentid = required_param('commentid', PARAM_INT);
$result = comments_quick_list::remove_comment($commentid);
echo json_encode($result);
die();
} else if ($action == 'deletefeedbackdocument') {
require_capability('mod/assign:grade', $context);
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
$result = document_services::delete_feedback_document($assignment, $userid, $attemptnumber);
$result = $result && page_editor::unrelease_drafts($grade->id);
echo json_encode($result);
die();
} else if ($action == 'rotatepage') {
require_capability('mod/assign:grade', $context);
$response = new stdClass();
$index = required_param('index', PARAM_INT);
$grade = $assignment->get_user_grade($userid, true, $attemptnumber);
$rotateleft = required_param('rotateleft', PARAM_BOOL);
$filearea = document_services::PAGE_IMAGE_FILEAREA;
$pagefile = document_services::rotate_page($assignment, $userid, $attemptnumber, $index, $rotateleft);
$page = new stdClass();
$page->url = moodle_url::make_pluginfile_url($context->id, document_services::COMPONENT, $filearea,
$grade->id, '/', $pagefile->get_filename())->out();
if ($imageinfo = $pagefile->get_imageinfo()) {
$page->width = $imageinfo['width'];
$page->height = $imageinfo['height'];
} else {
$page->width = 0;
$page->height = 0;
}
$response = (object)['page' => $page];
echo json_encode($response);
die();
}
@@ -0,0 +1,73 @@
<?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/>.
/**
* Process concurrent ajax request.
* ALL RETURNED INFO IS PUBLIC.
*
* @package assignfeedback_editpdf
* @copyright 2013 Jerome Mouneyrac
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define('AJAX_SCRIPT', true);
// To be able to process concurrent ajax request with the generate pdf ajax request we can not use cookie.
define('NO_MOODLE_COOKIES', true);
use \assignfeedback_editpdf\document_services;
require_once('../../../../config.php');
try {
$assignmentid = required_param('assignmentid', PARAM_INT);
$userid = required_param('userid', PARAM_INT);
$attemptnumber = required_param('attemptnumber', PARAM_INT);
// Retrieve the assignments.
require_once($CFG->dirroot . '/mod/assign/locallib.php');
$cm = get_coursemodule_from_instance('assign', $assignmentid, 0, false, MUST_EXIST);
$context = context_module::instance($cm->id);
$assignment = new assign($context, null, null);
// Get the generated images from file API call.
$grade = $assignment->get_user_grade($userid, false, $attemptnumber);
// Check we found a grade.
if (empty($grade)) {
throw new coding_exception('grade not found');
}
// No need to handle the readonly files here, the should be already generated.
$component = 'assignfeedback_editpdf';
$filearea = document_services::PAGE_IMAGE_FILEAREA;
$filepath = '/';
$fs = get_file_storage();
$files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
// The important security part: we ONLY RETURN the total NUMBER of generated images.
echo $OUTPUT->header();
echo json_encode(count($files));
echo $OUTPUT->footer();
} catch (Exception $e) {
// This should never happened!
// Return a 500 HTTP header so Y.io gets it as a failure.
if (substr(php_sapi_name(), 0, 3) == 'cgi') {
header("Status: 500 Internal Server Error");
} else {
header('HTTP/1.0 500 Internal Server Error');
}
throw new moodle_exception('An exception was caught but can not be returned for security purpose.
To easily debug, comment the try catch.');
}
@@ -0,0 +1,79 @@
<?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 file contains the backup code for the feedback_editpdf plugin.
*
* @package assignfeedback_editpdf
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Provides the information to backup feedback pdf annotations.
*
* This just adds its fileareas to the annotations and the comments and annotation data.
*
* @package assignfeedback_editpdf
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class backup_assignfeedback_editpdf_subplugin extends backup_subplugin {
/**
* Returns the subplugin information to attach to feedback element
* @return backup_subplugin_element
*/
protected function define_grade_subplugin_structure() {
// Create XML elements.
$subplugin = $this->get_subplugin_element();
$subpluginwrapper = new backup_nested_element($this->get_recommended_name());
$subpluginelementfiles = new backup_nested_element('feedback_editpdf_files', null, array('gradeid'));
$subpluginelementannotations = new backup_nested_element('feedback_editpdf_annotations');
$subpluginelementannotation = new backup_nested_element('annotation', null, array('gradeid', 'pageno', 'type', 'x', 'y', 'endx', 'endy', 'colour', 'path', 'draft'));
$subpluginelementcomments = new backup_nested_element('feedback_editpdf_comments');
$subpluginelementcomment = new backup_nested_element('comment', null, array('gradeid', 'pageno', 'x', 'y', 'width', 'rawtext', 'colour', 'draft'));
$subpluginelementrotation = new backup_nested_element('feedback_editpdf_rotation');
$subpluginelementpagerotation = new backup_nested_element('pagerotation', null,
array('gradeid', 'pageno', 'pathnamehash', 'isrotated', 'degree'));
// Connect XML elements into the tree.
$subplugin->add_child($subpluginwrapper);
$subpluginelementannotations->add_child($subpluginelementannotation);
$subpluginelementcomments->add_child($subpluginelementcomment);
$subpluginelementrotation->add_child($subpluginelementpagerotation);
$subpluginwrapper->add_child($subpluginelementfiles);
$subpluginwrapper->add_child($subpluginelementannotations);
$subpluginwrapper->add_child($subpluginelementcomments);
$subpluginwrapper->add_child($subpluginelementrotation);
// Set source to populate the data.
$subpluginelementfiles->set_source_sql('SELECT id AS gradeid from {assign_grades} where id = :gradeid', array('gradeid' => backup::VAR_PARENTID));
$subpluginelementannotation->set_source_table('assignfeedback_editpdf_annot', array('gradeid' => backup::VAR_PARENTID));
$subpluginelementcomment->set_source_table('assignfeedback_editpdf_cmnt', array('gradeid' => backup::VAR_PARENTID));
$subpluginelementpagerotation->set_source_table('assignfeedback_editpdf_rot', array('gradeid' => backup::VAR_PARENTID));
// We only need to backup the files in the final pdf area, and the readonly page images - the others can be regenerated.
$subpluginelementfiles->annotate_files('assignfeedback_editpdf',
\assignfeedback_editpdf\document_services::FINAL_PDF_FILEAREA, 'gradeid');
$subpluginelementfiles->annotate_files('assignfeedback_editpdf',
\assignfeedback_editpdf\document_services::PAGE_IMAGE_READONLY_FILEAREA, 'gradeid');
$subpluginelementfiles->annotate_files('assignfeedback_editpdf', 'stamps', 'gradeid');
return $subplugin;
}
}
@@ -0,0 +1,128 @@
<?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 file contains the restore code for the feedback_editpdf plugin.
*
* @package assignfeedback_editpdf
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* Restore subplugin class.
*
* Provides the necessary information needed
* to restore one assign_feedback subplugin.
*
* @package assignfeedback_editpdf
* @copyright 2013 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class restore_assignfeedback_editpdf_subplugin extends restore_subplugin {
/**
* Returns the paths to be handled by the subplugin at assignment level
* @return array
*/
protected function define_grade_subplugin_structure() {
$paths = array();
// We used get_recommended_name() so this works.
// The files node is a placeholder just containing gradeid so we can restore files once per grade.
$elename = $this->get_namefor('files');
$elepath = $this->get_pathfor('/feedback_editpdf_files');
$paths[] = new restore_path_element($elename, $elepath);
// Now we have the list of comments and annotations per grade.
$elename = $this->get_namefor('comment');
$elepath = $this->get_pathfor('/feedback_editpdf_comments/comment');
$paths[] = new restore_path_element($elename, $elepath);
$elename = $this->get_namefor('annotation');
$elepath = $this->get_pathfor('/feedback_editpdf_annotations/annotation');
$paths[] = new restore_path_element($elename, $elepath);
// Rotation details.
$elename = $this->get_namefor('pagerotation');
$elepath = $this->get_pathfor('/feedback_editpdf_rotation/pagerotation');
$paths[] = new restore_path_element($elename, $elepath);
return $paths;
}
/**
* Processes one feedback_editpdf_files element
* @param mixed $data
*/
public function process_assignfeedback_editpdf_files($data) {
$data = (object)$data;
// In this case the id is the old gradeid which will be mapped.
$this->add_related_files('assignfeedback_editpdf',
\assignfeedback_editpdf\document_services::FINAL_PDF_FILEAREA, 'grade', null, $data->gradeid);
$this->add_related_files('assignfeedback_editpdf',
\assignfeedback_editpdf\document_services::PAGE_IMAGE_READONLY_FILEAREA, 'grade', null, $data->gradeid);
$this->add_related_files('assignfeedback_editpdf', 'stamps', 'grade', null, $data->gradeid);
}
/**
* Processes one feedback_editpdf_annotations/annotation element
* @param mixed $data
*/
public function process_assignfeedback_editpdf_annotation($data) {
global $DB;
$data = (object)$data;
$oldgradeid = $data->gradeid;
// The mapping is set in the restore for the core assign activity
// when a grade node is processed.
$data->gradeid = $this->get_mappingid('grade', $data->gradeid);
$DB->insert_record('assignfeedback_editpdf_annot', $data);
}
/**
* Processes one feedback_editpdf_comments/comment element
* @param mixed $data
*/
public function process_assignfeedback_editpdf_comment($data) {
global $DB;
$data = (object)$data;
$oldgradeid = $data->gradeid;
// The mapping is set in the restore for the core assign activity
// when a grade node is processed.
$data->gradeid = $this->get_mappingid('grade', $data->gradeid);
$DB->insert_record('assignfeedback_editpdf_cmnt', $data);
}
/**
* Processes one /feedback_editpdf_rotation/pagerotation element
* @param mixed $data
*/
public function process_assignfeedback_editpdf_pagerotation($data) {
global $DB;
$data = (object)$data;
$oldgradeid = $data->gradeid;
$data->gradeid = $this->get_mappingid('grade', $oldgradeid);
$DB->insert_record('assignfeedback_editpdf_rot', $data);
}
}
@@ -0,0 +1,87 @@
<?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 file contains the annotation class for the assignfeedback_editpdf plugin
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf;
/**
* This class adds and removes annotations from a page of a response.
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class annotation {
/** @var int unique id for this annotation */
public $id = 0;
/** @var int gradeid for this annotation */
public $gradeid = 0;
/** @var int page number for this annotation */
public $pageno = 0;
/** @var int starting location in pixels. Image resolution is 100 pixels per inch */
public $x = 0;
/** @var int ending location in pixels. Image resolution is 100 pixels per inch */
public $endx = 0;
/** @var int starting location in pixels. Image resolution is 100 pixels per inch */
public $y = 0;
/** @var int ending location in pixels. Image resolution is 100 pixels per inch */
public $endy = 0;
/** @var string path information for drawing the annotation. */
public $path = '';
/** @var string colour - One of red, yellow, green, blue, white */
public $colour = 'yellow';
/** @var string type - One of line, oval, rect, etc */
public $type = 'line';
/** @var int draft status, default 1 = true */
public $draft = 1;
/**
* Convert a compatible stdClass into an instance of this class.
* @param \stdClass $record
*/
public function __construct(\stdClass $record = null) {
if ($record) {
$intcols = array('endx', 'endy', 'x', 'y');
foreach ($this as $key => $value) {
if (isset($record->$key)) {
if (in_array($key, $intcols)) {
$this->$key = intval($record->$key);
} else {
$this->$key = $record->$key;
}
}
}
}
}
}
@@ -0,0 +1,446 @@
<?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 file contains the combined document class for the assignfeedback_editpdf plugin.
*
* @package assignfeedback_editpdf
* @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf;
defined('MOODLE_INTERNAL') || die();
/**
* The combined_document class for the assignfeedback_editpdf plugin.
*
* @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class combined_document {
/**
* Status value representing a conversion waiting to start.
*/
const STATUS_PENDING_INPUT = 0;
/**
* Status value representing all documents ready to be combined.
*/
const STATUS_READY = 1;
/**
* Status value representing all documents are ready to be combined as are supported.
*/
const STATUS_READY_PARTIAL = 3;
/**
* Status value representing a successful conversion.
*/
const STATUS_COMPLETE = 2;
/**
* Status value representing a permanent error.
*/
const STATUS_FAILED = -1;
/**
* The list of files which make this document.
*/
protected $sourcefiles = [];
/**
* The resultant combined file.
*/
protected $combinedfile;
/**
* The combination status.
*/
protected $combinationstatus = null;
/**
* The number of pages in the combined PDF.
*/
protected $pagecount = 0;
/**
* Check the current status of the document combination.
* Note that the combined document may not contain all the source files if some of the
* source files were not able to be converted. An example is an audio file with a pdf cover sheet. Only
* the cover sheet will be included in the combined document.
*
* @return int
*/
public function get_status() {
if ($this->combinedfile) {
// The combined file exists. Report success.
return self::STATUS_COMPLETE;
}
if (empty($this->sourcefiles)) {
// There are no source files to combine.
return self::STATUS_FAILED;
}
if (!empty($this->combinationstatus)) {
// The combination is in progress and has set a status.
// Return it instead.
return $this->combinationstatus;
}
$pending = false;
$partial = false;
foreach ($this->sourcefiles as $file) {
// The combined file has not yet been generated.
// Check the status of each source file.
if (is_a($file, \core_files\conversion::class)) {
$status = $file->get('status');
switch ($status) {
case \core_files\conversion::STATUS_IN_PROGRESS:
case \core_files\conversion::STATUS_PENDING:
$pending = true;
break;
// There are 4 status flags, so the only remaining one is complete which is fine.
case \core_files\conversion::STATUS_FAILED:
$partial = true;
break;
}
}
}
if ($pending) {
return self::STATUS_PENDING_INPUT;
} else {
if ($partial) {
return self::STATUS_READY_PARTIAL;
}
return self::STATUS_READY;
}
}
/**
* Set the completed combined file.
*
* @param \stored_file $file The completed document for all files to be combined.
* @return $this
*/
public function set_combined_file($file) {
$this->combinedfile = $file;
return $this;
}
/**
* Return true of the combined file contained only some of the submission files.
*
* @return boolean
*/
public function is_partial_conversion() {
$combinedfile = $this->get_combined_file();
if (empty($combinedfile)) {
return false;
}
$filearea = $combinedfile->get_filearea();
return $filearea == document_services::PARTIAL_PDF_FILEAREA;
}
/**
* Retrieve the completed combined file.
*
* @return stored_file
*/
public function get_combined_file() {
return $this->combinedfile;
}
/**
* Set all source files which are to be combined.
*
* @param \stored_file|conversion[] $files The complete list of all source files to be combined.
* @return $this
*/
public function set_source_files($files) {
$this->sourcefiles = $files;
return $this;
}
/**
* Add an additional source file to the end of the existing list.
*
* @param \stored_file|conversion $file The file to add to the end of the list.
* @return $this
*/
public function add_source_file($file) {
$this->sourcefiles[] = $file;
return $this;
}
/**
* Retrieve the complete list of source files.
*
* @return stored_file|conversion[]
*/
public function get_source_files() {
return $this->sourcefiles;
}
/**
* Refresh the files.
*
* This includes polling any pending conversions to see if they are complete.
*
* @return $this
*/
public function refresh_files() {
$converter = new \core_files\converter();
foreach ($this->sourcefiles as $file) {
if (is_a($file, \core_files\conversion::class)) {
$status = $file->get('status');
switch ($status) {
case \core_files\conversion::STATUS_COMPLETE:
continue 2;
break;
default:
$converter->poll_conversion($conversion);
}
}
}
return $this;
}
/**
* Combine all source files into a single PDF and store it in the
* file_storage API using the supplied contextid and itemid.
*
* @param int $contextid The contextid for the file to be stored under
* @param int $itemid The itemid for the file to be stored under
* @return $this
*/
public function combine_files($contextid, $itemid) {
global $CFG;
$currentstatus = $this->get_status();
$readystatuslist = [self::STATUS_READY, self::STATUS_READY_PARTIAL];
if ($currentstatus === self::STATUS_FAILED) {
$this->store_empty_document($contextid, $itemid);
return $this;
} else if (!in_array($currentstatus, $readystatuslist)) {
// The document is either:
// * already combined; or
// * pending input being fully converted; or
// * unable to continue due to an issue with the input documents.
//
// Exit early as we cannot continue.
return $this;
}
require_once($CFG->libdir . '/pdflib.php');
$pdf = new pdf();
$files = $this->get_source_files();
$compatiblepdfs = [];
foreach ($files as $file) {
// Check that each file is compatible and add it to the list.
// Note: We drop non-compatible files.
$compatiblepdf = false;
if (is_a($file, \core_files\conversion::class)) {
$status = $file->get('status');
if ($status == \core_files\conversion::STATUS_COMPLETE) {
$compatiblepdf = pdf::ensure_pdf_compatible($file->get_destfile());
}
} else {
$compatiblepdf = pdf::ensure_pdf_compatible($file);
}
if ($compatiblepdf) {
$compatiblepdfs[] = $compatiblepdf;
}
}
$tmpdir = make_request_directory();
$tmpfile = $tmpdir . '/' . document_services::COMBINED_PDF_FILENAME;
try {
$pagecount = $pdf->combine_pdfs($compatiblepdfs, $tmpfile);
$pdf->Close();
} catch (\Exception $e) {
// Unable to combine the PDF.
debugging('TCPDF could not process the pdf files:' . $e->getMessage(), DEBUG_DEVELOPER);
$pdf->Close();
return $this->mark_combination_failed();
}
// Verify the PDF.
$verifypdf = new pdf();
$verifypagecount = $verifypdf->load_pdf($tmpfile);
$verifypdf->Close();
if ($verifypagecount <= 0) {
// No pages were found in the combined PDF.
return $this->mark_combination_failed();
}
// Store the newly created file as a stored_file.
$this->store_combined_file($tmpfile, $contextid, $itemid, ($currentstatus == self::STATUS_READY_PARTIAL));
// Note the verified page count.
$this->pagecount = $verifypagecount;
return $this;
}
/**
* Mark the combination attempt as having encountered a permanent failure.
*
* @return $this
*/
protected function mark_combination_failed() {
$this->combinationstatus = self::STATUS_FAILED;
return $this;
}
/**
* Store the combined file in the file_storage API.
*
* @param string $tmpfile The path to the file on disk to be stored.
* @param int $contextid The contextid for the file to be stored under
* @param int $itemid The itemid for the file to be stored under
* @param boolean $partial The combined pdf contains only some of the source files.
* @return $this
*/
protected function store_combined_file($tmpfile, $contextid, $itemid, $partial = false) {
// Store the file.
$record = $this->get_stored_file_record($contextid, $itemid, $partial);
$fs = get_file_storage();
// Delete existing files first.
$fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
// This was a combined pdf.
$file = $fs->create_file_from_pathname($record, $tmpfile);
$this->set_combined_file($file);
return $this;
}
/**
* Store the empty document file in the file_storage API.
*
* @param int $contextid The contextid for the file to be stored under
* @param int $itemid The itemid for the file to be stored under
* @return $this
*/
protected function store_empty_document($contextid, $itemid) {
// Store the file.
$record = $this->get_stored_file_record($contextid, $itemid);
$fs = get_file_storage();
// Delete existing files first.
$fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
$file = $fs->create_file_from_string($record, base64_decode(document_services::BLANK_PDF_BASE64));
$this->pagecount = 1;
$this->set_combined_file($file);
return $this;
}
/**
* Get the total number of pages in the combined document.
*
* If there are no pages, or it is not yet possible to count them a
* value of 0 is returned.
*
* @return int
*/
public function get_page_count() {
if ($this->pagecount) {
return $this->pagecount;
}
$status = $this->get_status();
if ($status === self::STATUS_FAILED) {
// The empty document will be returned.
return 1;
}
if ($status !== self::STATUS_COMPLETE) {
// No pages yet.
return 0;
}
// Load the PDF to determine the page count.
$temparea = make_request_directory();
$tempsrc = $temparea . "/source.pdf";
$this->get_combined_file()->copy_content_to($tempsrc);
$pdf = new pdf();
$pagecount = $pdf->load_pdf($tempsrc);
$pdf->Close();
if ($pagecount <= 0) {
// Something went wrong. Return an empty page count again.
return 0;
}
$this->pagecount = $pagecount;
return $this->pagecount;
}
/**
* Get the total number of documents to be combined.
*
* @return int
*/
public function get_document_count() {
return count($this->sourcefiles);
}
/**
* Helper to fetch the stored_file record.
*
* @param int $contextid The contextid for the file to be stored under
* @param int $itemid The itemid for the file to be stored under
* @param boolean $partial The combined file contains only some of the source files.
* @return stdClass
*/
protected function get_stored_file_record($contextid, $itemid, $partial = false) {
$filearea = document_services::COMBINED_PDF_FILEAREA;
if ($partial) {
$filearea = document_services::PARTIAL_PDF_FILEAREA;
}
return (object) [
'contextid' => $contextid,
'component' => 'assignfeedback_editpdf',
'filearea' => $filearea,
'itemid' => $itemid,
'filepath' => '/',
'filename' => document_services::COMBINED_PDF_FILENAME,
];
}
}
@@ -0,0 +1,79 @@
<?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 file contains the comment class for the assignfeedback_editpdf plugin
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf;
/**
* This class represents a comment box on a page of feedback.
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class comment {
/** @var int unique id for this annotation */
public $id = 0;
/** @var int gradeid for this annotation */
public $gradeid = 0;
/** @var int page number for this annotation */
public $pageno = 0;
/** @var int starting location in pixels. Image resolution is 100 pixels per inch */
public $x = 0;
/** @var int starting location in pixels. Image resolution is 100 pixels per inch */
public $y = 0;
/** @var int width of the comment box */
public $width = 120;
/** @var string The comment text. */
public $rawtext = '';
/** @var string colour - One of red, yellow, green, blue, white */
public $colour = 'yellow';
/** @var int draft status, default 1 = true */
public $draft = 1;
/**
* Convert a compatible stdClass into an instance of a comment.
* @param \stdClass $record
*/
public function __construct(\stdClass $record = null) {
if ($record) {
$intcols = array('width', 'x', 'y');
foreach ($this as $key => $value) {
if (isset($record->$key)) {
if (in_array($key, $intcols)) {
$this->$key = intval($record->$key);
} else {
$this->$key = $record->$key;
}
}
}
}
}
}
@@ -0,0 +1,94 @@
<?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 file contains the functions for managing a users comments quicklist.
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf;
/**
* This class performs crud operations on a users quicklist comments.
*
* No capability checks are done - they should be done by the calling class.
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class comments_quick_list {
/**
* Get all comments for the current user.
* @return array(comment)
*/
public static function get_comments() {
global $DB, $USER;
$comments = array();
$records = $DB->get_records('assignfeedback_editpdf_quick', array('userid'=>$USER->id));
return $records;
}
/**
* Add a comment to the quick list.
* @param string $commenttext
* @param int $width
* @param string $colour
* @return \stdClass - the comment record (with new id set)
*/
public static function add_comment($commenttext, $width, $colour) {
global $DB, $USER;
$comment = new \stdClass();
$comment->userid = $USER->id;
$comment->rawtext = $commenttext;
$comment->width = $width;
$comment->colour = $colour;
$comment->id = $DB->insert_record('assignfeedback_editpdf_quick', $comment);
return $comment;
}
/**
* Get a single comment by id.
* @param int $commentid
* @return comment or false
*/
public static function get_comment($commentid) {
global $DB;
$record = $DB->get_record('assignfeedback_editpdf_quick', array('id'=>$commentid), '*', IGNORE_MISSING);
if ($record) {
return $record;
}
return false;
}
/**
* Remove a comment from the quick list.
* @param int $commentid
* @return bool
*/
public static function remove_comment($commentid) {
global $DB, $USER;
return $DB->delete_records('assignfeedback_editpdf_quick', array('id'=>$commentid, 'userid'=>$USER->id));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,70 @@
<?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/>.
/**
* An event observer.
*
* @package assignfeedback_editpdf
* @copyright 2016 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf\event;
/**
* An event observer.
* @copyright 2016 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class observer {
/**
* Listen to events and queue the submission for processing.
* @param \mod_assign\event\submission_created $event
*/
public static function submission_created(\mod_assign\event\submission_created $event) {
self::queue_conversion($event);
}
/**
* Listen to events and queue the submission for processing.
* @param \mod_assign\event\submission_updated $event
*/
public static function submission_updated(\mod_assign\event\submission_updated $event) {
self::queue_conversion($event);
}
/**
* Queue the submission for processing.
* @param \mod_assign\event\base $event The submission created/updated event.
*/
protected static function queue_conversion($event) {
$assign = $event->get_assign();
$plugin = $assign->get_feedback_plugin_by_type('editpdf');
if (!$plugin->is_visible() || !$plugin->is_enabled()) {
// The plugin is not enabled on this assignment instance, so nothing should be queued.
return;
}
$data = [
'submissionid' => $event->other['submissionid'],
'submissionattempt' => $event->other['submissionattempt'],
];
$task = new \assignfeedback_editpdf\task\convert_submission;
$task->set_custom_data($data);
\core\task\manager::queue_adhoc_task($task, true);
}
}
@@ -0,0 +1,441 @@
<?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 file contains the editor class for the assignfeedback_editpdf plugin
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf;
/**
* This class performs crud operations on comments and annotations from a page of a response.
*
* No capability checks are done - they should be done by the calling class.
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class page_editor {
/**
* Get all comments for a page.
* @param int $gradeid
* @param int $pageno
* @param bool $draft
* @return comment[]
*/
public static function get_comments($gradeid, $pageno, $draft) {
global $DB;
$comments = array();
$params = array('gradeid'=>$gradeid, 'pageno'=>$pageno, 'draft'=>1);
if (!$draft) {
$params['draft'] = 0;
}
// Fetch comments ordered by position on the page.
$records = $DB->get_records('assignfeedback_editpdf_cmnt', $params, 'y, x');
foreach ($records as $record) {
array_push($comments, new comment($record));
}
return $comments;
}
/**
* Set all comments for a page.
* @param int $gradeid
* @param int $pageno
* @param comment[] $comments
* @return int - the number of comments.
*/
public static function set_comments($gradeid, $pageno, $comments) {
global $DB;
$DB->delete_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'pageno'=>$pageno, 'draft'=>1));
$added = 0;
foreach ($comments as $record) {
// Force these.
if (!($record instanceof comment)) {
$comment = new comment($record);
} else {
$comment = $record;
}
if (trim($comment->rawtext) === '') {
continue;
}
$comment->gradeid = $gradeid;
$comment->pageno = $pageno;
$comment->draft = 1;
if (self::add_comment($comment)) {
$added++;
}
}
return $added;
}
/**
* Get a single comment by id.
* @param int $commentid
* @return comment or false
*/
public static function get_comment($commentid) {
$record = $DB->get_record('assignfeedback_editpdf_cmnt', array('id'=>$commentid), '*', IGNORE_MISSING);
if ($record) {
return new comment($record);
}
return false;
}
/**
* Add a comment to a page.
* @param comment $comment
* @return bool
*/
public static function add_comment(comment $comment) {
global $DB;
$comment->id = null;
return $DB->insert_record('assignfeedback_editpdf_cmnt', $comment);
}
/**
* Remove a comment from a page.
* @param int $commentid
* @return bool
*/
public static function remove_comment($commentid) {
global $DB;
return $DB->delete_records('assignfeedback_editpdf_cmnt', array('id'=>$commentid));
}
/**
* Get all annotations for a page.
* @param int $gradeid
* @param int $pageno
* @param bool $draft
* @return annotation[]
*/
public static function get_annotations($gradeid, $pageno, $draft) {
global $DB;
$params = array('gradeid'=>$gradeid, 'pageno'=>$pageno, 'draft'=>1);
if (!$draft) {
$params['draft'] = 0;
}
$annotations = array();
$records = $DB->get_records('assignfeedback_editpdf_annot', $params);
foreach ($records as $record) {
array_push($annotations, new annotation($record));
}
return $annotations;
}
/**
* Set all annotations for a page.
* @param int $gradeid
* @param int $pageno
* @param annotation[] $annotations
* @return int - the number of annotations.
*/
public static function set_annotations($gradeid, $pageno, $annotations) {
global $DB;
$DB->delete_records('assignfeedback_editpdf_annot', array('gradeid' => $gradeid, 'pageno' => $pageno, 'draft' => 1));
$added = 0;
foreach ($annotations as $record) {
// Force these.
if (!($record instanceof annotation)) {
$annotation = new annotation($record);
} else {
$annotation = $record;
}
$annotation->gradeid = $gradeid;
$annotation->pageno = $pageno;
$annotation->draft = 1;
if (self::add_annotation($annotation)) {
$added++;
}
}
return $added;
}
/**
* Get a single annotation by id.
* @param int $annotationid
* @return annotation or false
*/
public static function get_annotation($annotationid) {
global $DB;
$record = $DB->get_record('assignfeedback_editpdf_annot', array('id'=>$annotationid), '*', IGNORE_MISSING);
if ($record) {
return new annotation($record);
}
return false;
}
/**
* Unrelease drafts
* @param int $gradeid
* @return bool
*/
public static function unrelease_drafts($gradeid) {
global $DB;
// Delete the non-draft annotations and comments.
$result = $DB->delete_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'draft'=>0));
$result = $DB->delete_records('assignfeedback_editpdf_annot', array('gradeid'=>$gradeid, 'draft'=>0)) && $result;
return $result;
}
/**
* Release the draft comments and annotations to students.
* @param int $gradeid
* @return bool
*/
public static function release_drafts($gradeid) {
global $DB;
// Delete the previous non-draft annotations and comments.
$DB->delete_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'draft'=>0));
$DB->delete_records('assignfeedback_editpdf_annot', array('gradeid'=>$gradeid, 'draft'=>0));
// Copy all the draft annotations and comments to non-drafts.
$records = $DB->get_records('assignfeedback_editpdf_annot', array('gradeid'=>$gradeid, 'draft'=>1));
foreach ($records as $record) {
unset($record->id);
$record->draft = 0;
$DB->insert_record('assignfeedback_editpdf_annot', $record);
}
$records = $DB->get_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'draft'=>1));
foreach ($records as $record) {
unset($record->id);
$record->draft = 0;
$DB->insert_record('assignfeedback_editpdf_cmnt', $record);
}
return true;
}
/**
* Has annotations or comments.
* @param int $gradeid
* @return bool
*/
public static function has_annotations_or_comments($gradeid, $includedraft) {
global $DB;
$params = array('gradeid'=>$gradeid);
if (!$includedraft) {
$params['draft'] = 0;
}
if ($DB->count_records('assignfeedback_editpdf_cmnt', $params)) {
return true;
}
if ($DB->count_records('assignfeedback_editpdf_annot', $params)) {
return true;
}
return false;
}
/**
* Aborts all draft annotations and reverts to the last version released to students.
* @param int $gradeid
* @return bool
*/
public static function revert_drafts($gradeid) {
global $DB;
// Delete the previous non-draft annotations and comments.
$DB->delete_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'draft'=>1));
$DB->delete_records('assignfeedback_editpdf_annot', array('gradeid'=>$gradeid, 'draft'=>1));
// Copy all the draft annotations and comments to non-drafts.
$records = $DB->get_records('assignfeedback_editpdf_annot', array('gradeid'=>$gradeid, 'draft'=>0));
foreach ($records as $record) {
unset($record->id);
$record->draft = 0;
$DB->insert_record('assignfeedback_editpdf_annot', $record);
}
$records = $DB->get_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$gradeid, 'draft'=>0));
foreach ($records as $record) {
unset($record->id);
$record->draft = 0;
$DB->insert_record('assignfeedback_editpdf_annot', $record);
}
return true;
}
/**
* Add a annotation to a page.
* @param annotation $annotation
* @return bool
*/
public static function add_annotation(annotation $annotation) {
global $DB;
$annotation->id = null;
return $DB->insert_record('assignfeedback_editpdf_annot', $annotation);
}
/**
* Remove a annotation from a page.
* @param int $annotationid
* @return bool
*/
public static function remove_annotation($annotationid) {
global $DB;
return $DB->delete_records('assignfeedback_editpdf_annot', array('id'=>$annotationid));
}
/**
* Copy annotations, comments, pages, and other required content from the source user to the current group member
* being procssed when using applytoall.
*
* @param int|\assign $assignment
* @param \stdClass $grade
* @param int $sourceuserid
* @return bool
*/
public static function copy_drafts_from_to($assignment, $grade, $sourceuserid) {
global $DB;
// Delete any existing annotations and comments from current user.
$DB->delete_records('assignfeedback_editpdf_annot', array('gradeid' => $grade->id));
$DB->delete_records('assignfeedback_editpdf_cmnt', array('gradeid' => $grade->id));
// Get gradeid, annotations and comments from sourceuserid.
$sourceusergrade = $assignment->get_user_grade($sourceuserid, true, $grade->attemptnumber);
$annotations = $DB->get_records('assignfeedback_editpdf_annot', array('gradeid' => $sourceusergrade->id, 'draft' => 1));
$comments = $DB->get_records('assignfeedback_editpdf_cmnt', array('gradeid' => $sourceusergrade->id, 'draft' => 1));
$contextid = $assignment->get_context()->id;
$sourceitemid = $sourceusergrade->id;
// Add annotations and comments to current user to generate feedback file.
foreach ($annotations as $annotation) {
$annotation->gradeid = $grade->id;
$DB->insert_record('assignfeedback_editpdf_annot', $annotation);
}
foreach ($comments as $comment) {
$comment->gradeid = $grade->id;
$DB->insert_record('assignfeedback_editpdf_cmnt', $comment);
}
$fs = get_file_storage();
// Copy the stamp files.
self::replace_files_from_to($fs, $contextid, $sourceitemid, $grade->id, document_services::STAMPS_FILEAREA, true);
// Copy the PAGE_IMAGE_FILEAREA files.
self::replace_files_from_to($fs, $contextid, $sourceitemid, $grade->id, document_services::PAGE_IMAGE_FILEAREA);
return true;
}
/**
* Replace the area files in the specified area with those in the source item id.
*
* @param \file_storage $fs The file storage class
* @param int $contextid The ID of the context for the assignment.
* @param int $sourceitemid The itemid to copy from - typically the source grade id.
* @param int $itemid The itemid to copy to - typically the target grade id.
* @param string $area The file storage area.
* @param bool $includesubdirs Whether to copy the content of sub-directories too.
*/
public static function replace_files_from_to($fs, $contextid, $sourceitemid, $itemid, $area, $includesubdirs = false) {
$component = 'assignfeedback_editpdf';
// Remove the existing files within this area.
$fs->delete_area_files($contextid, $component, $area, $itemid);
// Copy the files from the source area.
if ($files = $fs->get_area_files($contextid, $component, $area, $sourceitemid,
"filename", $includesubdirs)) {
foreach ($files as $file) {
$newrecord = new \stdClass();
$newrecord->contextid = $contextid;
$newrecord->itemid = $itemid;
$fs->create_file_from_storedfile($newrecord, $file);
}
}
}
/**
* Delete the draft annotations and comments.
*
* This is intended to be used when the version of the PDF has changed and the annotations
* might not be relevant any more, therefore we should delete them.
*
* @param int $gradeid The grade ID.
* @return bool
*/
public static function delete_draft_content($gradeid) {
global $DB;
$conditions = array('gradeid' => $gradeid, 'draft' => 1);
$result = $DB->delete_records('assignfeedback_editpdf_annot', $conditions);
$result = $result && $DB->delete_records('assignfeedback_editpdf_cmnt', $conditions);
return $result;
}
/**
* Set page rotation value.
* @param int $gradeid grade id.
* @param int $pageno page number.
* @param bool $isrotated whether the page is rotated or not.
* @param string $pathnamehash path name hash
* @param int $degree rotation degree.
* @throws \dml_exception
*/
public static function set_page_rotation($gradeid, $pageno, $isrotated, $pathnamehash, $degree = 0) {
global $DB;
$oldrecord = self::get_page_rotation($gradeid, $pageno);
if ($oldrecord == null) {
$record = new \stdClass();
$record->gradeid = $gradeid;
$record->pageno = $pageno;
$record->isrotated = $isrotated;
$record->pathnamehash = $pathnamehash;
$record->degree = $degree;
$DB->insert_record('assignfeedback_editpdf_rot', $record, false);
} else {
$oldrecord->isrotated = $isrotated;
$oldrecord->pathnamehash = $pathnamehash;
$oldrecord->degree = $degree;
$DB->update_record('assignfeedback_editpdf_rot', $oldrecord, false);
}
}
/**
* Get Page Rotation Value.
* @param int $gradeid grade id.
* @param int $pageno page number.
* @return mixed
* @throws \dml_exception
*/
public static function get_page_rotation($gradeid, $pageno) {
global $DB;
$result = $DB->get_record('assignfeedback_editpdf_rot', array('gradeid' => $gradeid, 'pageno' => $pageno));
return $result;
}
}
+914
View File
@@ -0,0 +1,914 @@
<?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/>.
/**
* Library code for manipulating PDFs
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
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/autoload.php');
/**
* Library code for manipulating PDFs
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class pdf extends TcpdfFpdi {
/** @var int the number of the current page in the PDF being processed */
protected $currentpage = 0;
/** @var int the total number of pages in the PDF being processed */
protected $pagecount = 0;
/** @var float used to scale the pixel position of annotations (in the database) to the position in the final PDF */
protected $scale = 0.0;
/** @var string the path in which to store generated page images */
protected $imagefolder = null;
/** @var string the path to the PDF currently being processed */
protected $filename = null;
/** @var string the fontname used when the PDF being processed */
protected $fontname = null;
/** No errors */
const GSPATH_OK = 'ok';
/** Not set */
const GSPATH_EMPTY = 'empty';
/** Does not exist */
const GSPATH_DOESNOTEXIST = 'doesnotexist';
/** Is a dir */
const GSPATH_ISDIR = 'isdir';
/** Not executable */
const GSPATH_NOTEXECUTABLE = 'notexecutable';
/** Test file missing */
const GSPATH_NOTESTFILE = 'notestfile';
/** Any other error */
const GSPATH_ERROR = 'error';
/** Min. width an annotation should have */
const MIN_ANNOTATION_WIDTH = 5;
/** Min. height an annotation should have */
const MIN_ANNOTATION_HEIGHT = 5;
/** Blank PDF file used during error. */
const BLANK_PDF = '/mod/assign/feedback/editpdf/fixtures/blank.pdf';
/** Page image file name prefix*/
const IMAGE_PAGE = 'image_page';
/**
* Get the name of the font to use in generated PDF files.
* If $CFG->pdfexportfont is set - use it, otherwise use "freesans" as this
* open licensed font has wide support for different language charsets.
*
* @return string
*/
private function get_export_font_name() {
$fontname = 'freesans';
if (!empty($this->fontname)) {
$fontname = $this->fontname;
}
return $fontname;
}
/**
* Set font name.
*
* @param string $fontname Font name which is
* @return void
*/
public function set_export_font_name($fontname): void {
$this->fontname = $fontname;
}
/**
* Combine the given PDF files into a single PDF. Optionally add a coversheet and coversheet fields.
* @param string[] $pdflist the filenames of the files to combine
* @param string $outfilename the filename to write to
* @return int the number of pages in the combined PDF
*/
public function combine_pdfs($pdflist, $outfilename) {
raise_memory_limit(MEMORY_EXTRA);
$olddebug = error_reporting(0);
$this->setPageUnit('pt');
$this->setPrintHeader(false);
$this->setPrintFooter(false);
$this->scale = 72.0 / 100.0;
// Use font supporting the widest range of characters.
$this->SetFont($this->get_export_font_name(), '', 16.0 * $this->scale, '', true);
$this->SetTextColor(0, 0, 0);
$totalpagecount = 0;
foreach ($pdflist as $file) {
$pagecount = $this->setSourceFile($file);
$totalpagecount += $pagecount;
for ($i = 1; $i<=$pagecount; $i++) {
$this->create_page_from_source($i);
}
}
$this->save_pdf($outfilename);
error_reporting($olddebug);
return $totalpagecount;
}
/**
* The number of the current page in the PDF being processed
* @return int
*/
public function current_page() {
return $this->currentpage;
}
/**
* The total number of pages in the PDF being processed
* @return int
*/
public function page_count() {
return $this->pagecount;
}
/**
* Load the specified PDF and set the initial output configuration
* Used when processing comments and outputting a new PDF
* @param string $filename the path to the PDF to load
* @return int the number of pages in the PDF
*/
public function load_pdf($filename) {
raise_memory_limit(MEMORY_EXTRA);
$olddebug = error_reporting(0);
$this->setPageUnit('pt');
$this->scale = 72.0 / 100.0;
$this->SetFont($this->get_export_font_name(), '', 16.0 * $this->scale, '', true);
$this->SetFillColor(255, 255, 176);
$this->SetDrawColor(0, 0, 0);
$this->SetLineWidth(1.0 * $this->scale);
$this->SetTextColor(0, 0, 0);
$this->setPrintHeader(false);
$this->setPrintFooter(false);
$this->pagecount = $this->setSourceFile($filename);
$this->filename = $filename;
error_reporting($olddebug);
return $this->pagecount;
}
/**
* Sets the name of the PDF to process, but only loads the file if the
* pagecount is zero (in order to count the number of pages)
* Used when generating page images (but not a new PDF)
* @param string $filename the path to the PDF to process
* @param int $pagecount optional the number of pages in the PDF, if known
* @return int the number of pages in the PDF
*/
public function set_pdf($filename, $pagecount = 0) {
if ($pagecount == 0) {
return $this->load_pdf($filename);
} else {
$this->filename = $filename;
$this->pagecount = $pagecount;
return $pagecount;
}
}
/**
* Copy the next page from the source file and set it as the current page
* @return bool true if successful
*/
public function copy_page() {
if (!$this->filename) {
return false;
}
if ($this->currentpage>=$this->pagecount) {
return false;
}
$this->currentpage++;
$this->create_page_from_source($this->currentpage);
return true;
}
/**
* Create a page from a source PDF.
*
* @param int $pageno
*/
protected function create_page_from_source($pageno) {
// Get the size (and deduce the orientation) of the next page.
$template = $this->importPage($pageno);
$size = $this->getTemplateSize($template);
// Create a page of the required size / orientation.
$this->AddPage($size['orientation'], array($size['width'], $size['height']));
// Prevent new page creation when comments are at the bottom of a page.
$this->setPageOrientation($size['orientation'], false, 0);
// Fill in the page with the original contents from the student.
$this->useTemplate($template);
}
/**
* Copy all the remaining pages in the file
*/
public function copy_remaining_pages() {
$morepages = true;
while ($morepages) {
$morepages = $this->copy_page();
}
}
/**
* Append all comments to the end of the document.
*
* @param array $allcomments All comments, indexed by page number (starting at 0).
* @return array|bool An array of links to comments, or false.
*/
public function append_comments($allcomments) {
if (!$this->filename) {
return false;
}
$this->SetFontSize(12 * $this->scale);
$this->SetMargins(100 * $this->scale, 120 * $this->scale, -1, true);
$this->SetAutoPageBreak(true, 100 * $this->scale);
$this->setHeaderFont(array($this->get_export_font_name(), '', 24 * $this->scale, '', true));
$this->setHeaderMargin(24 * $this->scale);
$this->setHeaderData('', 0, '', get_string('commentindex', 'assignfeedback_editpdf'));
// Add a new page to the document with an appropriate header.
$this->setPrintHeader(true);
$this->AddPage();
// Add the comments.
$commentlinks = array();
foreach ($allcomments as $pageno => $comments) {
foreach ($comments as $index => $comment) {
// Create a link to the current location, which will be added to the marker.
$commentlink = $this->AddLink();
$this->SetLink($commentlink, -1);
$commentlinks[$pageno][$index] = $commentlink;
// Also create a link back to the marker, which will be added here.
$markerlink = $this->AddLink();
$this->SetLink($markerlink, $comment->y * $this->scale, $pageno + 1);
$label = get_string('commentlabel', 'assignfeedback_editpdf', array('pnum' => $pageno + 1, 'cnum' => $index + 1));
$this->Cell(50 * $this->scale, 0, $label, 0, 0, '', false, $markerlink);
$this->MultiCell(0, 0, $comment->rawtext, 0, 'L');
$this->Ln(12 * $this->scale);
}
// Add an extra line break between pages.
$this->Ln(12 * $this->scale);
}
return $commentlinks;
}
/**
* Add a comment marker to the specified page.
*
* @param int $pageno The page number to add markers to (starting at 0).
* @param int $index The comment index.
* @param int $x The x-coordinate of the marker (in pixels).
* @param int $y The y-coordinate of the marker (in pixels).
* @param int $link The link identifier pointing to the full comment text.
* @param string $colour The fill colour of the marker (red, yellow, green, blue, white, clear).
* @return bool Success status.
*/
public function add_comment_marker($pageno, $index, $x, $y, $link, $colour = 'yellow') {
if (!$this->filename) {
return false;
}
$fill = '';
$fillopacity = 0.9;
switch ($colour) {
case 'red':
$fill = 'rgb(249, 181, 179)';
break;
case 'green':
$fill = 'rgb(214, 234, 178)';
break;
case 'blue':
$fill = 'rgb(203, 217, 237)';
break;
case 'white':
$fill = 'rgb(255, 255, 255)';
break;
case 'clear':
$fillopacity = 0;
break;
default: /* Yellow */
$fill = 'rgb(255, 236, 174)';
}
$marker = '@<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 12 12" preserveAspectRatio="xMinYMin meet">' .
'<path d="M11 0H1C.4 0 0 .4 0 1v6c0 .6.4 1 1 1h1v4l4-4h5c.6 0 1-.4 1-1V1c0-.6-.4-1-1-1z" fill="' . $fill . '" ' .
'fill-opacity="' . $fillopacity . '" stroke="rgb(153, 153, 153)" stroke-width="0.5"/></svg>';
$label = get_string('commentlabel', 'assignfeedback_editpdf', array('pnum' => $pageno + 1, 'cnum' => $index + 1));
$x *= $this->scale;
$y *= $this->scale;
$size = 24 * $this->scale;
$this->SetDrawColor(51, 51, 51);
$this->SetFontSize(10 * $this->scale);
$this->setPage($pageno + 1);
// Add the marker image.
$this->ImageSVG($marker, $x - 0.5, $y - 0.5, $size, $size, $link);
// Add the label.
$this->MultiCell($size * 0.95, 0, $label, 0, 'C', false, 1, $x, $y, true, 0, false, true, $size * 0.60, 'M', true);
return true;
}
/**
* Add a comment to the current page
* @param string $text the text of the comment
* @param int $x the x-coordinate of the comment (in pixels)
* @param int $y the y-coordinate of the comment (in pixels)
* @param int $width the width of the comment (in pixels)
* @param string $colour optional the background colour of the comment (red, yellow, green, blue, white, clear)
* @return bool true if successful (always)
*/
public function add_comment($text, $x, $y, $width, $colour = 'yellow') {
if (!$this->filename) {
return false;
}
$this->SetDrawColor(51, 51, 51);
switch ($colour) {
case 'red':
$this->SetFillColor(249, 181, 179);
break;
case 'green':
$this->SetFillColor(214, 234, 178);
break;
case 'blue':
$this->SetFillColor(203, 217, 237);
break;
case 'white':
$this->SetFillColor(255, 255, 255);
break;
default: /* Yellow */
$this->SetFillColor(255, 236, 174);
break;
}
$x *= $this->scale;
$y *= $this->scale;
$width *= $this->scale;
$text = str_replace('&lt;', '<', $text);
$text = str_replace('&gt;', '>', $text);
// Draw the text with a border, but no background colour (using a background colour would cause the fill to
// appear behind any existing content on the page, hence the extra filled rectangle drawn below).
$this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
if ($colour != 'clear') {
$newy = $this->GetY();
// Now we know the final size of the comment, draw a rectangle with the background colour.
$this->Rect($x, $y, $width, $newy - $y, 'DF');
// Re-draw the text over the top of the background rectangle.
$this->MultiCell($width, 1.0, $text, 0, 'L', 0, 4, $x, $y); /* width, height, text, border, justify, fill, ln, x, y */
}
return true;
}
/**
* Add an annotation to the current page
* @param int $sx starting x-coordinate (in pixels)
* @param int $sy starting y-coordinate (in pixels)
* @param int $ex ending x-coordinate (in pixels)
* @param int $ey ending y-coordinate (in pixels)
* @param string $colour optional the colour of the annotation (red, yellow, green, blue, white, black)
* @param string $type optional the type of annotation (line, oval, rectangle, highlight, pen, stamp)
* @param int[]|string $path optional for 'pen' annotations this is an array of x and y coordinates for
* the line, for 'stamp' annotations it is the name of the stamp file (without the path)
* @param string $imagefolder - Folder containing stamp images.
* @return bool true if successful (always)
*/
public function add_annotation($sx, $sy, $ex, $ey, $colour, $type, $path, $imagefolder) {
global $CFG;
if (!$this->filename) {
return false;
}
switch ($colour) {
case 'yellow':
$colourarray = array(255, 207, 53);
break;
case 'green':
$colourarray = array(153, 202, 62);
break;
case 'blue':
$colourarray = array(125, 159, 211);
break;
case 'white':
$colourarray = array(255, 255, 255);
break;
case 'black':
$colourarray = array(51, 51, 51);
break;
default: /* Red */
$colour = 'red';
$colourarray = array(239, 69, 64);
break;
}
$this->SetDrawColorArray($colourarray);
$sx *= $this->scale;
$sy *= $this->scale;
$ex *= $this->scale;
$ey *= $this->scale;
$this->SetLineWidth(3.0 * $this->scale);
switch ($type) {
case 'oval':
$rx = abs($sx - $ex) / 2;
$ry = abs($sy - $ey) / 2;
$sx = min($sx, $ex) + $rx;
$sy = min($sy, $ey) + $ry;
// $rx and $ry should be >= min width and height
if ($rx < self::MIN_ANNOTATION_WIDTH) {
$rx = self::MIN_ANNOTATION_WIDTH;
}
if ($ry < self::MIN_ANNOTATION_HEIGHT) {
$ry = self::MIN_ANNOTATION_HEIGHT;
}
$this->Ellipse($sx, $sy, $rx, $ry);
break;
case 'rectangle':
$w = abs($sx - $ex);
$h = abs($sy - $ey);
$sx = min($sx, $ex);
$sy = min($sy, $ey);
// Width or height should be >= min width and height
if ($w < self::MIN_ANNOTATION_WIDTH) {
$w = self::MIN_ANNOTATION_WIDTH;
}
if ($h < self::MIN_ANNOTATION_HEIGHT) {
$h = self::MIN_ANNOTATION_HEIGHT;
}
$this->Rect($sx, $sy, $w, $h);
break;
case 'highlight':
$w = abs($sx - $ex);
$h = 8.0 * $this->scale;
$sx = min($sx, $ex);
$sy = min($sy, $ey) + ($h * 0.5);
$this->SetAlpha(0.5, 'Normal', 0.5, 'Normal');
$this->SetLineWidth(8.0 * $this->scale);
// width should be >= min width
if ($w < self::MIN_ANNOTATION_WIDTH) {
$w = self::MIN_ANNOTATION_WIDTH;
}
$this->Rect($sx, $sy, $w, $h);
$this->SetAlpha(1.0, 'Normal', 1.0, 'Normal');
break;
case 'pen':
if ($path) {
$scalepath = array();
$points = preg_split('/[,:]/', $path);
foreach ($points as $point) {
$scalepath[] = intval($point) * $this->scale;
}
if (!empty($scalepath)) {
$this->PolyLine($scalepath, 'S');
}
}
break;
case 'stamp':
$imgfile = $imagefolder . '/' . clean_filename($path);
$w = abs($sx - $ex);
$h = abs($sy - $ey);
$sx = min($sx, $ex);
$sy = min($sy, $ey);
// Stamp is always more than 40px, so no need to check width/height.
$this->Image($imgfile, $sx, $sy, $w, $h);
break;
default: // Line.
$this->Line($sx, $sy, $ex, $ey);
break;
}
$this->SetDrawColor(0, 0, 0);
$this->SetLineWidth(1.0 * $this->scale);
return true;
}
/**
* Save the completed PDF to the given file
* @param string $filename the filename for the PDF (including the full path)
*/
public function save_pdf($filename) {
$olddebug = error_reporting(0);
$this->Output($filename, 'F');
error_reporting($olddebug);
}
/**
* Set the path to the folder in which to generate page image files
* @param string $folder
*/
public function set_image_folder($folder) {
$this->imagefolder = $folder;
}
/**
* Generate images from the PDF
* @return array Array of filename of the generated images
*/
public function get_images(): array {
$this->precheck_generate_image();
$imagefile = $this->imagefolder . '/' . self::IMAGE_PAGE;
$command = $this->get_command_for_image(-1, $imagefile);
exec($command);
$images = array();
for ($i = 0; $i < $this->pagecount; $i++) {
// Image file is created from 1, so need to change to 0.
$file = $imagefile . ($i + 1) . '.png';
$newfile = $imagefile . $i . '.png';
if (file_exists($file)) {
rename($file, $newfile);
} else {
// Converter added '-' and zerofill for the pagenumber.
$length = strlen($this->pagecount);
$file = $imagefile . '-' . str_pad(($i + 1), $length, '0', STR_PAD_LEFT) . '.png';
if (file_exists($file)) {
rename($file, $newfile);
} else {
$newfile = self::get_error_image($this->imagefolder, $i);
}
}
$images[$i] = basename($newfile);
}
return $images;
}
/**
* Generate an image of the specified page in the PDF
* @param int $pageno the page to generate the image of
* @throws \moodle_exception
* @throws \coding_exception
* @return string the filename of the generated image
*/
public function get_image($pageno) {
$this->precheck_generate_image();
$imagefile = $this->imagefolder . '/' . self::IMAGE_PAGE . $pageno . '.png';
$generate = true;
if (file_exists($imagefile)) {
if (filemtime($imagefile) > filemtime($this->filename)) {
// Make sure the image is newer than the PDF file.
$generate = false;
}
}
if ($generate) {
$command = $this->get_command_for_image($pageno, $imagefile);
$output = null;
$result = exec($command, $output);
if (!file_exists($imagefile)) {
$fullerror = '<pre>'.get_string('command', 'assignfeedback_editpdf')."\n";
$fullerror .= $command . "\n\n";
$fullerror .= get_string('result', 'assignfeedback_editpdf')."\n";
$fullerror .= htmlspecialchars($result, ENT_COMPAT) . "\n\n";
$fullerror .= get_string('output', 'assignfeedback_editpdf')."\n";
$fullerror .= htmlspecialchars(implode("\n", $output), ENT_COMPAT) . '</pre>';
throw new \moodle_exception('errorgenerateimage', 'assignfeedback_editpdf', '', $fullerror);
}
}
return self::IMAGE_PAGE . $pageno . '.png';
}
/**
* Make sure the file name and image folder are ready before generate image.
* @return bool
*/
protected function precheck_generate_image() {
if (!$this->filename) {
throw new \coding_exception('Attempting to generate a page image without first setting the PDF filename');
}
if (!$this->imagefolder) {
throw new \coding_exception('Attempting to generate a page image without first specifying the image output folder');
}
if (!is_dir($this->imagefolder)) {
throw new \coding_exception('The specified image output folder is not a valid folder');
}
return true;
}
/**
* Gets the command to use to extract as image the given $pageno page number
* from a PDF document into the $imagefile file.
* @param int $pageno Page number to extract from document. -1 means for all pages.
* @param string $imagefile Target filename for the PNG image as absolute path.
* @return string The command to use to extract a page as PNG image.
*/
private function get_command_for_image(int $pageno, string $imagefile): string {
global $CFG;
// First, quickest convertion option.
if (!empty($CFG->pathtopdftoppm) && is_executable($CFG->pathtopdftoppm)) {
return $this->get_pdftoppm_command_for_image($pageno, $imagefile);
}
// Otherwise, rely on default behaviour.
return $this->get_gs_command_for_image($pageno, $imagefile);
}
/**
* Gets the pdftoppm command to use to extract as image the given $pageno page number
* from a PDF document into the $imagefile file.
* @param int $pageno Page number to extract from document. -1 means for all pages.
* @param string $imagefile Target filename for the PNG image as absolute path.
* @return string The pdftoppm command to use to extract a page as PNG image.
*/
private function get_pdftoppm_command_for_image(int $pageno, string $imagefile): string {
global $CFG;
$pdftoppmexec = \escapeshellarg($CFG->pathtopdftoppm);
$imageres = \escapeshellarg(100);
$filename = \escapeshellarg($this->filename);
$pagenoinc = \escapeshellarg($pageno + 1);
if ($pageno >= 0) {
// Convert 1 page.
$imagefile = substr($imagefile, 0, -4); // Pdftoppm tool automatically adds extension file.
$frompageno = $pagenoinc;
$topageno = $pagenoinc;
$singlefile = '-singlefile';
} else {
// Convert all pages at once.
$frompageno = 1;
$topageno = $this->pagecount;
$singlefile = '';
}
$imagefilearg = \escapeshellarg($imagefile);
return "$pdftoppmexec -q -r $imageres -f $frompageno -l $topageno -png $singlefile $filename $imagefilearg";
}
/**
* Gets the ghostscript (gs) command to use to extract as image the given $pageno page number
* from a PDF document into the $imagefile file.
* @param int $pageno Page number to extract from document. -1 means for all pages.
* @param string $imagefile Target filename for the PNG image as absolute path.
* @return string The ghostscript (gs) command to use to extract a page as PNG image.
*/
private function get_gs_command_for_image(int $pageno, string $imagefile): string {
global $CFG;
$gsexec = \escapeshellarg($CFG->pathtogs);
$imageres = \escapeshellarg(100);
$imagefilearg = \escapeshellarg($imagefile);
$filename = \escapeshellarg($this->filename);
$pagenoinc = \escapeshellarg($pageno + 1);
if ($pageno >= 0) {
// Convert 1 page.
$firstpage = $pagenoinc;
$lastpage = $pagenoinc;
} else {
// Convert all pages at once.
$imagefilearg = \escapeshellarg($imagefile . '%d.png');
$firstpage = 1;
$lastpage = $this->pagecount;
}
return "$gsexec -q -sDEVICE=png16m -dSAFER -dBATCH -dNOPAUSE -r$imageres -dFirstPage=$firstpage -dLastPage=$lastpage ".
"-dDOINTERPOLATE -dGraphicsAlphaBits=4 -dTextAlphaBits=4 -sOutputFile=$imagefilearg $filename";
}
/**
* Check to see if PDF is version 1.4 (or below); if not: use ghostscript to convert it
*
* @param \stored_file $file
* @return string path to copy or converted pdf (false == fail)
*/
public static function ensure_pdf_compatible(\stored_file $file) {
global $CFG;
// Copy the stored_file to local disk for checking.
$temparea = make_request_directory();
$tempsrc = $temparea . "/source.pdf";
$file->copy_content_to($tempsrc);
return self::ensure_pdf_file_compatible($tempsrc);
}
/**
* Flatten and convert file using ghostscript then load pdf.
*
* @param string $tempsrc The path to the file on disk.
* @return string path to copy or converted pdf (false == fail)
*/
public static function ensure_pdf_file_compatible($tempsrc) {
global $CFG;
$temparea = make_request_directory();
$tempdst = $temparea . "/target.pdf";
$gsexec = \escapeshellarg($CFG->pathtogs);
$tempdstarg = \escapeshellarg($tempdst);
$tempsrcarg = \escapeshellarg($tempsrc);
$command = "$gsexec -q -sDEVICE=pdfwrite -dPreserveAnnots=false -dSAFER -dBATCH -dNOPAUSE "
. "-sOutputFile=$tempdstarg $tempsrcarg";
exec($command);
if (!file_exists($tempdst)) {
// Something has gone wrong in the conversion.
return false;
}
$pdf = new pdf();
$pagecount = 0;
try {
$pagecount = $pdf->load_pdf($tempdst);
} catch (\Exception $e) {
// PDF was not valid - try running it through ghostscript to clean it up.
$pagecount = 0;
}
$pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
if ($pagecount <= 0) {
// Could not parse the converted pdf.
return false;
}
return $tempdst;
}
/**
* Generate an localised error image for the given pagenumber.
*
* @param string $errorimagefolder path of the folder where error image needs to be created.
* @param int $pageno page number for which error image needs to be created.
*
* @return string File name
* @throws \coding_exception
*/
public static function get_error_image($errorimagefolder, $pageno) {
global $CFG;
$errorfile = $CFG->dirroot . self::BLANK_PDF;
if (!file_exists($errorfile)) {
throw new \coding_exception("Blank PDF not found", "File path" . $errorfile);
}
$tmperrorimagefolder = make_request_directory();
$pdf = new pdf();
$pdf->set_pdf($errorfile);
$pdf->copy_page();
$pdf->add_comment(get_string('errorpdfpage', 'assignfeedback_editpdf'), 250, 300, 200, "red");
$generatedpdf = $tmperrorimagefolder . '/' . 'error.pdf';
$pdf->save_pdf($generatedpdf);
$pdf = new pdf();
$pdf->set_pdf($generatedpdf);
$pdf->set_image_folder($tmperrorimagefolder);
$image = $pdf->get_image(0);
$pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
$newimg = self::IMAGE_PAGE . $pageno . '.png';
copy($tmperrorimagefolder . '/' . $image, $errorimagefolder . '/' . $newimg);
return $newimg;
}
/**
* Test that the configured path to ghostscript is correct and working.
* @param bool $generateimage - If true - a test image will be generated to verify the install.
* @return \stdClass
*/
public static function test_gs_path($generateimage = true) {
global $CFG;
$ret = (object)array(
'status' => self::GSPATH_OK,
'message' => null,
);
$gspath = $CFG->pathtogs;
if (empty($gspath)) {
$ret->status = self::GSPATH_EMPTY;
return $ret;
}
if (!file_exists($gspath)) {
$ret->status = self::GSPATH_DOESNOTEXIST;
return $ret;
}
if (is_dir($gspath)) {
$ret->status = self::GSPATH_ISDIR;
return $ret;
}
if (!is_executable($gspath)) {
$ret->status = self::GSPATH_NOTEXECUTABLE;
return $ret;
}
if (!$generateimage) {
return $ret;
}
$testfile = $CFG->dirroot.'/mod/assign/feedback/editpdf/tests/fixtures/testgs.pdf';
if (!file_exists($testfile)) {
$ret->status = self::GSPATH_NOTESTFILE;
return $ret;
}
$testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
$filepath = $testimagefolder . '/' . self::IMAGE_PAGE . '0.png';
// Delete any previous test images, if they exist.
if (file_exists($filepath)) {
unlink($filepath);
}
$pdf = new pdf();
$pdf->set_pdf($testfile);
$pdf->set_image_folder($testimagefolder);
try {
$pdf->get_image(0);
} catch (\moodle_exception $e) {
$ret->status = self::GSPATH_ERROR;
$ret->message = $e->getMessage();
}
$pdf->Close(); // PDF loaded and never saved/outputted needs to be closed.
return $ret;
}
/**
* If the test image has been generated correctly - send it direct to the browser.
*/
public static function send_test_image() {
global $CFG;
header('Content-type: image/png');
require_once($CFG->libdir.'/filelib.php');
$testimagefolder = \make_temp_directory('assignfeedback_editpdf_test');
$testimage = $testimagefolder . '/' . self::IMAGE_PAGE . '0.png';
send_file($testimage, basename($testimage), 0);
die();
}
/**
* This function add an image file to PDF page.
* @param \stored_file $imagestoredfile Image file to be added
*/
public function add_image_page($imagestoredfile) {
$imageinfo = $imagestoredfile->get_imageinfo();
$imagecontent = $imagestoredfile->get_content();
$this->currentpage++;
$template = $this->importPage($this->currentpage);
$size = $this->getTemplateSize($template);
$orientation = 'P';
if ($imageinfo["width"] > $imageinfo["height"]) {
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['width'] > $size['height']) {
$temp = $size['width'];
$size['width'] = $size['height'];
$size['height'] = $temp;
}
}
$this->SetHeaderMargin(0);
$this->SetFooterMargin(0);
$this->SetMargins(0, 0, 0, true);
$this->setPrintFooter(false);
$this->setPrintHeader(false);
$this->AddPage($orientation, $size);
$this->SetAutoPageBreak(false, 0);
$this->Image('@' . $imagecontent, 0, 0, $size['width'], $size['height'],
'', '', '', false, null, '', false, false, 0);
}
}
@@ -0,0 +1,179 @@
<?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/>.
/**
* Privacy class for requesting user data.
*
* @package assignfeedback_editpdf
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf\privacy;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/mod/assign/locallib.php');
use \core_privacy\local\metadata\collection;
use \mod_assign\privacy\assignfeedback_provider;
use \core_privacy\local\request\writer;
use \core_privacy\local\request\contextlist;
use \mod_assign\privacy\assign_plugin_request_data;
use \mod_assign\privacy\useridlist;
/**
* Privacy class for requesting user data.
*
* @package assignfeedback_editpdf
* @copyright 2018 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
\core_privacy\local\metadata\provider,
\mod_assign\privacy\assignfeedback_provider,
\mod_assign\privacy\assignfeedback_user_provider {
/**
* Return meta data about this plugin.
*
* @param collection $collection A list of information to add to.
* @return collection Return the collection after adding to it.
*/
public static function get_metadata(collection $collection): collection {
$quickdata = [
'userid' => 'privacy:metadata:userid',
'rawtext' => 'privacy:metadata:rawtextpurpose',
'colour' => 'privacy:metadata:colourpurpose'
];
$collection->add_database_table('assignfeedback_editpdf_quick', $quickdata, 'privacy:metadata:tablepurpose');
$collection->add_subsystem_link('core_files', [], 'privacy:metadata:filepurpose');
$collection->add_subsystem_link('core_fileconverter', [], 'privacy:metadata:conversionpurpose');
return $collection;
}
/**
* No need to fill in this method as all information can be acquired from the assign_grades table in the mod assign
* provider.
*
* @param int $userid The user ID.
* @param contextlist $contextlist The context list.
*/
public static function get_context_for_userid_within_feedback(int $userid, contextlist $contextlist) {
// This uses the assign_grade table.
}
/**
* This also does not need to be filled in as this is already collected in the mod assign provider.
*
* @param useridlist $useridlist A list of user IDs
*/
public static function get_student_user_ids(useridlist $useridlist) {
// Not required.
}
/**
* If you have tables that contain userids and you can generate entries in your tables without creating an
* entry in the assign_grades table then please fill in this method.
*
* @param \core_privacy\local\request\userlist $userlist The userlist object
*/
public static function get_userids_from_context(\core_privacy\local\request\userlist $userlist) {
// Not required.
}
/**
* Export all user data for this plugin.
*
* @param assign_plugin_request_data $exportdata Data used to determine which context and user to export and other useful
* information to help with exporting.
*/
public static function export_feedback_user_data(assign_plugin_request_data $exportdata) {
$currentpath = $exportdata->get_subcontext();
$currentpath[] = get_string('privacy:path', 'assignfeedback_editpdf');
$assign = $exportdata->get_assign();
$plugin = $assign->get_plugin_by_type('assignfeedback', 'editpdf');
$fileareas = $plugin->get_user_data_file_areas();
$grade = $exportdata->get_pluginobject();
foreach ($fileareas as $filearea => $notused) {
writer::with_context($exportdata->get_context())
->export_area_files($currentpath, 'assignfeedback_editpdf', $filearea, $grade->id);
}
}
/**
* Any call to this method should delete all user data for the context defined in the deletion_criteria.
*
* @param assign_plugin_request_data $requestdata Data useful for deleting user data from this sub-plugin.
*/
public static function delete_feedback_for_context(assign_plugin_request_data $requestdata) {
$assign = $requestdata->get_assign();
$plugin = $assign->get_plugin_by_type('assignfeedback', 'editpdf');
$fileareas = $plugin->get_file_areas();
$fs = get_file_storage();
foreach ($fileareas as $filearea => $notused) {
// Delete pdf files.
$fs->delete_area_files($requestdata->get_context()->id, 'assignfeedback_editpdf', $filearea);
}
// Delete entries from the tables.
$plugin->delete_instance();
}
/**
* Calling this function should delete all user data associated with this grade.
*
* @param assign_plugin_request_data $requestdata Data useful for deleting user data.
*/
public static function delete_feedback_for_grade(assign_plugin_request_data $requestdata) {
$requestdata->set_userids([$requestdata->get_user()->id]);
$requestdata->populate_submissions_and_grades();
self::delete_feedback_for_grades($requestdata);
}
/**
* Deletes all feedback for the grade ids / userids provided in a context.
* assign_plugin_request_data contains:
* - context
* - assign object
* - grade ids (pluginids)
* - user ids
* @param assign_plugin_request_data $deletedata A class that contains the relevant information required for deletion.
*/
public static function delete_feedback_for_grades(assign_plugin_request_data $deletedata) {
global $DB;
if (empty($deletedata->get_gradeids())) {
return;
}
$assign = $deletedata->get_assign();
$plugin = $assign->get_plugin_by_type('assignfeedback', 'editpdf');
$fileareas = $plugin->get_file_areas();
$fs = get_file_storage();
list($sql, $params) = $DB->get_in_or_equal($deletedata->get_gradeids(), SQL_PARAMS_NAMED);
foreach ($fileareas as $filearea => $notused) {
// Delete pdf files.
$fs->delete_area_files_select($deletedata->get_context()->id, 'assignfeedback_editpdf', $filearea, $sql, $params);
}
// Remove table entries.
$DB->delete_records_select('assignfeedback_editpdf_annot', "gradeid $sql", $params);
$DB->delete_records_select('assignfeedback_editpdf_cmnt', "gradeid $sql", $params);
$DB->delete_records_select('assignfeedback_editpdf_rot', "gradeid $sql", $params);
}
}
@@ -0,0 +1,281 @@
<?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 file contains the definition for the library class for edit PDF renderer.
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* A custom renderer class that extends the plugin_renderer_base and is used by the editpdf feedback plugin.
*
* @package assignfeedback_editpdf
* @copyright 2013 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class assignfeedback_editpdf_renderer extends plugin_renderer_base {
/**
* Return the PDF button shortcut.
*
* @param string $name the name of a specific button.
* @return string the specific shortcut.
*/
private function get_shortcut($name) {
$shortcuts = array('navigate-previous-button' => 'j',
'rotateleft' => 'q',
'rotateright' => 'w',
'navigate-page-select' => 'k',
'navigate-next-button' => 'l',
'searchcomments' => 'h',
'expcolcomments' => 'g',
'comment' => 'z',
'commentcolour' => 'x',
'select' => 'c',
'drag' => 'd',
'pen' => 'y',
'line' => 'u',
'rectangle' => 'i',
'oval' => 'o',
'highlight' => 'p',
'annotationcolour' => 'r',
'stamp' => 'n',
'currentstamp' => 'm');
// Return the shortcut.
return $shortcuts[$name];
}
/**
* Render a single colour button.
*
* @param string $icon - The key for the icon
* @param string $tool - The key for the lang string.
* @param string $accesskey Optional - The access key for the button.
* @param bool $disabled Optional - Is this button disabled.
* @return string
*/
private function render_toolbar_button($icon, $tool, $accesskey = null, $disabled=false) {
// Build button alt text.
$alttext = new stdClass();
$alttext->tool = get_string($tool, 'assignfeedback_editpdf');
if (!empty($accesskey)) {
$alttext->shortcut = '(Alt/Shift-Alt/Ctrl-Option + ' . $accesskey . ')';
} else {
$alttext->shortcut = '';
}
$iconalt = get_string('toolbarbutton', 'assignfeedback_editpdf', $alttext);
$iconhtml = $this->image_icon($icon, $iconalt, 'assignfeedback_editpdf');
$iconparams = array('data-tool'=>$tool, 'class'=>$tool . 'button');
if ($disabled) {
$iconparams['disabled'] = 'true';
}
if (!empty($accesskey)) {
$iconparams['accesskey'] = $accesskey;
}
return html_writer::tag('button', $iconhtml, $iconparams);
}
/**
* Render the editpdf widget in the grading form.
*
* @param assignfeedback_editpdf_widget $widget - Renderable widget containing assignment, user and attempt number.
* @return string
*/
public function render_assignfeedback_editpdf_widget(assignfeedback_editpdf_widget $widget) {
global $CFG;
$html = '';
$html .= html_writer::div(get_string('jsrequired', 'assignfeedback_editpdf'), 'hiddenifjs');
$linkid = html_writer::random_id();
$launcheditorstring = $widget->readonly ? get_string('viewfeedbackonline', 'assignfeedback_editpdf') :
get_string('launcheditor', 'assignfeedback_editpdf');
$links = html_writer::link('#', $launcheditorstring, ['id' => $linkid, 'class' => 'd-block mt-2']);
$html .= '<input type="hidden" name="assignfeedback_editpdf_haschanges" value="false"/>';
$html .= html_writer::div($links, 'visibleifjs');
$header = get_string('pluginname', 'assignfeedback_editpdf');
$body = '';
// Create the page navigation.
$navigation1 = '';
$navigation2 = '';
$navigation3 = '';
// Pick the correct arrow icons for right to left mode.
if (right_to_left()) {
$nav_prev = 'nav_next';
$nav_next = 'nav_prev';
} else {
$nav_prev = 'nav_prev';
$nav_next = 'nav_next';
}
$iconshortcut = $this->get_shortcut('navigate-previous-button');
$iconalt = get_string('navigateprevious', 'assignfeedback_editpdf', $iconshortcut);
$iconhtml = $this->image_icon($nav_prev, $iconalt, 'assignfeedback_editpdf');
$navigation1 .= html_writer::tag('button', $iconhtml, array('disabled'=>'true',
'class'=>'navigate-previous-button', 'accesskey' => $this->get_shortcut('navigate-previous-button')));
$navigation1 .= html_writer::tag('select', null, array('disabled'=>'true',
'aria-label' => get_string('gotopage', 'assignfeedback_editpdf'), 'class'=>'navigate-page-select',
'accesskey' => $this->get_shortcut('navigate-page-select')));
$iconshortcut = $this->get_shortcut('navigate-next-button');
$iconalt = get_string('navigatenext', 'assignfeedback_editpdf', $iconshortcut);
$iconhtml = $this->image_icon($nav_next, $iconalt, 'assignfeedback_editpdf');
$navigation1 .= html_writer::tag('button', $iconhtml, array('disabled'=>'true',
'class'=>'navigate-next-button', 'accesskey' => $this->get_shortcut('navigate-next-button')));
$navigation1 = html_writer::div($navigation1, 'navigation', array('role'=>'navigation'));
$navigation2 .= $this->render_toolbar_button('comment_search', 'searchcomments', $this->get_shortcut('searchcomments'));
$navigation2 = html_writer::div($navigation2, 'navigation-search', array('role'=>'navigation'));
$navigation3 .= $this->render_toolbar_button('comment_expcol', 'expcolcomments', $this->get_shortcut('expcolcomments'));
$navigation3 = html_writer::div($navigation3, 'navigation-expcol', array('role' => 'navigation'));
$rotationtools = '';
if (!$widget->readonly) {
$rotationtools .= $this->render_toolbar_button('rotate_left', 'rotateleft', $this->get_shortcut('rotateleft'));
$rotationtools .= $this->render_toolbar_button('rotate_right', 'rotateright', $this->get_shortcut('rotateright'));
$rotationtools = html_writer::div($rotationtools, 'toolbar', array('role' => 'toolbar'));
}
$toolbargroup = '';
$clearfix = html_writer::div('', 'clearfix');
if (!$widget->readonly) {
// Comments.
$toolbar1 = '';
$toolbar1 .= $this->render_toolbar_button('comment', 'comment', $this->get_shortcut('comment'));
$toolbar1 .= $this->render_toolbar_button('background_colour_clear', 'commentcolour', $this->get_shortcut('commentcolour'));
$toolbar1 = html_writer::div($toolbar1, 'toolbar', array('role' => 'toolbar'));
// Select Tool.
$toolbar2 = '';
$toolbar2 .= $this->render_toolbar_button('drag', 'drag', $this->get_shortcut('drag'));
$toolbar2 .= $this->render_toolbar_button('select', 'select', $this->get_shortcut('select'));
$toolbar2 = html_writer::div($toolbar2, 'toolbar', array('role' => 'toolbar'));
// Other Tools.
$toolbar3 = '';
$toolbar3 .= $this->render_toolbar_button('pen', 'pen', $this->get_shortcut('pen'));
$toolbar3 .= $this->render_toolbar_button('line', 'line', $this->get_shortcut('line'));
$toolbar3 .= $this->render_toolbar_button('rectangle', 'rectangle', $this->get_shortcut('rectangle'));
$toolbar3 .= $this->render_toolbar_button('oval', 'oval', $this->get_shortcut('oval'));
$toolbar3 .= $this->render_toolbar_button('highlight', 'highlight', $this->get_shortcut('highlight'));
$toolbar3 .= $this->render_toolbar_button('background_colour_clear', 'annotationcolour', $this->get_shortcut('annotationcolour'));
$toolbar3 = html_writer::div($toolbar3, 'toolbar', array('role' => 'toolbar'));
// Stamps.
$toolbar4 = '';
$toolbar4 .= $this->render_toolbar_button('stamp', 'stamp', $this->get_shortcut('stamp'));
$toolbar4 .= $this->render_toolbar_button('background_colour_clear', 'currentstamp', $this->get_shortcut('currentstamp'));
$toolbar4 = html_writer::div($toolbar4, 'toolbar', array('role'=>'toolbar'));
// Add toolbars to toolbar_group in order of display, and float the toolbar_group right.
$toolbars = $rotationtools . $toolbar1 . $toolbar2 . $toolbar3 . $toolbar4;
$toolbargroup = html_writer::div($toolbars, 'toolbar_group', ['role' => 'toolbar']);
}
$pageheader = html_writer::div($navigation1 .
$navigation2 .
$navigation3 .
$toolbargroup .
$clearfix,
'pageheader');
$body = $pageheader;
// Loading progress bar.
$progressbar = html_writer::div('', 'bar', array('style' => 'width: 0%'));
$progressbar = html_writer::div($progressbar, 'progress progress-info progress-striped active',
array('title' => get_string('loadingeditor', 'assignfeedback_editpdf'),
'role'=> 'progressbar', 'aria-valuenow' => 0, 'aria-valuemin' => 0,
'aria-valuemax' => 100));
$progressbarlabel = html_writer::div(get_string('generatingpdf', 'assignfeedback_editpdf'),
'progressbarlabel');
$loading = html_writer::div($progressbar . $progressbarlabel, 'loading');
$canvas = html_writer::div($loading, 'drawingcanvas');
$canvas = html_writer::div($canvas, 'drawingregion');
// Place for messages, but no warnings displayed yet.
$changesmessage = html_writer::div('', 'warningmessages');
$canvas .= $changesmessage;
$infoicon = $this->image_icon('i/info', '');
$infomessage = html_writer::div($infoicon, 'infoicon');
$canvas .= $infomessage;
$body .= $canvas;
$footer = '';
$editorparams = array(
array(
'header' => $header,
'body' => $body,
'footer' => $footer,
'linkid' => $linkid,
'assignmentid' => $widget->assignment,
'userid' => $widget->userid,
'attemptnumber' => $widget->attemptnumber,
'stampfiles' => $widget->stampfiles,
'readonly' => $widget->readonly,
)
);
$this->page->requires->yui_module('moodle-assignfeedback_editpdf-editor',
'M.assignfeedback_editpdf.editor.init',
$editorparams);
$this->page->requires->strings_for_js(array(
'yellow',
'white',
'red',
'blue',
'green',
'black',
'clear',
'colourpicker',
'loadingeditor',
'pagexofy',
'deletecomment',
'addtoquicklist',
'filter',
'searchcomments',
'commentcontextmenu',
'deleteannotation',
'stamp',
'stamppicker',
'cannotopenpdf',
'pagenumber',
'partialwarning',
'draftchangessaved'
), 'assignfeedback_editpdf');
return $html;
}
}
@@ -0,0 +1,103 @@
<?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/>.
/**
* Bump submission timemodified for conversions that are stale.
*
* @package assignfeedback_editpdf
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Cameron Ball <cameronball@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf\task;
use core\task\adhoc_task;
/**
* Adhoc task to bump the submission timemodified associated with a stale conversion.
*
* @package assignfeedback_editpdf
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Cameron Ball <cameronball@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class bump_submission_for_stale_conversions extends adhoc_task {
/**
* Run the task.
*/
public function execute() {
global $DB;
// Used to only get records after whenever document conversion was enabled for this site.
$earliestconversion = $DB->get_record_sql("SELECT MIN(timecreated) AS min
FROM {files}
WHERE filearea = 'documentconversion'");
if (isset($earliestconversion->min)) {
['sql' => $extensionsql, 'params' => $extensionparams] = array_reduce(
['doc', 'docx', 'rtf', 'xls', 'xlsx', 'ppt', 'pptx', 'html', 'odt', 'ods', 'png', 'jpg', 'txt', 'gif'],
function(array $c, string $ext) use ($DB): array {
return [
'sql' => $c['sql'] . ($c['sql'] ? ' OR ' : '') . $DB->sql_like('f1.filename', ':' . $ext),
'params' => $c['params'] + [$ext => '%.' . $ext]
];
},
['sql' => '', 'params' => []]
);
// A converted file has its filename set to the contenthash of the file it converted.
// Find all files in the relevant file areas for which there is no corresponding
// file with the contenthash as the file name.
//
// Also check if the file has a greater modified time than the submission, if it does
// that means it is both stale (as per the above) and will never be reconverted.
$sql = "SELECT f3.id, f3.timemodified as fmodified, asu.id as submissionid
FROM {files} f1
LEFT JOIN {files} f2 ON f1.contenthash = f2.filename
AND f2.component = 'core' AND f2.filearea = 'documentconversion'
JOIN {assign_submission} asu ON asu.id = f1.itemid
JOIN {assign_grades} asg ON asg.userid = asu.userid AND asg.assignment = asu.assignment
JOIN {files} f3 ON f3.itemid = asg.id
WHERE f1.filearea = 'submission_files'
AND f3.timecreated >= :earliest
AND ($extensionsql)
AND f2.filename IS NULL
AND f3.component = 'assignfeedback_editpdf'
AND f3.filearea = 'combined'
AND f3.filename = 'combined.pdf'
AND f3.timemodified >= asu.timemodified";
$submissionstobump = $DB->get_records_sql($sql, ['earliest' => $earliestconversion->min] + $extensionparams);
foreach ($submissionstobump as $submission) {
// Set the submission modified time to one second later than the
// converted files modified time, this will cause assign to reconvert
// everything and delete the old files when the assignment grader is
// viewed. See get_page_images_for_attempt in document_services.php.
$newmodified = $submission->fmodified + 1;
$record = (object)[
'id' => $submission->submissionid,
'timemodified' => $newmodified
];
mtrace('Set submission ' . $submission->submissionid . ' timemodified to ' . $newmodified);
$DB->update_record('assign_submission', $record);
}
}
}
}
@@ -0,0 +1,136 @@
<?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/>.
namespace assignfeedback_editpdf\task;
use core\task\adhoc_task;
use core\task\manager;
use assignfeedback_editpdf\document_services;
use assignfeedback_editpdf\combined_document;
use assignfeedback_editpdf\pdf;
use context_module;
use moodle_exception;
use assign;
/**
* An adhoc task to convert submissions to pdf in the background.
*
* @copyright 2022 Mikhail Golenkov <mikhailgolenkov@catalyst-au.net>
* @package assignfeedback_editpdf
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class convert_submission extends adhoc_task {
/**
* Run the task.
*/
public function execute() {
global $CFG, $DB;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
$data = $this->get_custom_data();
$submission = $DB->get_record('assign_submission', ['id' => $data->submissionid], '*', IGNORE_MISSING);
if (!$submission) {
mtrace('Submission no longer exists');
return;
}
// Early exit if ghostscript isn't correctly configured.
$result = pdf::test_gs_path(false);
if ($result->status !== pdf::GSPATH_OK) {
$statusstring = get_string('test_' . $result->status, 'assignfeedback_editpdf');
throw new moodle_exception('pathtogserror', 'assignfeedback_editpdf', '', $statusstring, $result->status);
}
$cm = get_coursemodule_from_instance('assign', $submission->assignment, 0, false, MUST_EXIST);
$context = context_module::instance($cm->id);
$assign = new assign($context, null, null);
if ($submission->userid) {
$users = [$submission->userid];
} else {
$users = [];
$members = $assign->get_submission_group_members($submission->groupid, true);
foreach ($members as $member) {
$users[] = $member->id;
}
}
$conversionrequirespolling = false;
foreach ($users as $userid) {
mtrace('Converting submission for user id ' . $userid);
// If the assignment is not vieweable, we should not try to convert the documents
// for this submission, as it will cause the adhoc task to fail with a permission
// error.
//
// Comments on MDL-56810 indicate that submission conversion should not be attempted
// if the submission is not viewable due to the user not being enrolled.
if (!$assign->can_view_submission($userid)) {
continue;
}
// Note: Before MDL-71468, the scheduled task version of this
// task would stop attempting to poll the conversion after a
// configured number of attempts were made to poll it, see:
//
// mod/assign/feedback/editpdf/classes/task/convert_submissions.php@MOODLE_400_STABLE
//
// This means that currently this adhoc task, if it fails, will retry forever. But
// the fail-delay mechanism will ensure that it eventually only tries once per day.
//
// See MDL-75457 for details on re-implementing the conversionattemptlimit.
//
// Also note: This code must not be in a try/catch - an exception needs to be thrown to
// allow the task API to mark the task as failed and update its faildelay. Using
// manager::adhoc_task_failed in the catch block will not work, as the task API
// will later assume the task completed successfully (as no exception was thrown) and
// complete it (removing it from the adhoc task queue).
$combineddocument = document_services::get_combined_pdf_for_attempt($assign, $userid, $data->submissionattempt);
switch ($combineddocument->get_status()) {
case combined_document::STATUS_READY:
case combined_document::STATUS_READY_PARTIAL:
case combined_document::STATUS_PENDING_INPUT:
// The document has not been converted yet or is somehow still ready.
$conversionrequirespolling = true;
continue 2;
case combined_document::STATUS_FAILED:
// Although STATUS_FAILED indicates a "permanent error" it should be possible
// in some cases to fix the underlying problem, allowing the conversion to
// complete. So we throw an exception here, allowing the adhoc task to retry.
//
// Currently this can result in the task trying indefinitely (although it will
// settle on trying once per day due to the faildelay exponential backoff)
// however once the conversionattepmtlimit is re-implemented in MDL-75457 the
// task will eventually get dropped.
throw new \moodle_exception('documentcombinationfailed');
}
document_services::get_page_images_for_attempt($assign, $userid, $data->submissionattempt, false);
document_services::get_page_images_for_attempt($assign, $userid, $data->submissionattempt, true);
}
if ($conversionrequirespolling) {
mtrace('Conversion still in progress. Requeueing self to check again.');
$task = new self;
$task->set_custom_data($data);
$task->set_next_run_time(time() + MINSECS);
manager::queue_adhoc_task($task);
} else {
mtrace('The document has been successfully converted');
}
}
}
@@ -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/>.
/**
* Cleans up orphaned feedback pdf files and table entries.
*
* @package assignfeedback_editpdf
* @copyright 2022 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace assignfeedback_editpdf\task;
use core\task\adhoc_task;
/**
* Cleans up orphaned feedback pdf files and table entries.
*
* @package assignfeedback_editpdf
* @copyright 2022 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class remove_orphaned_editpdf_files extends adhoc_task {
/**
* Run the task.
*/
public function execute() {
$this->remove_files_and_entries();
$this->remove_rotated_table_entries();
}
/**
* Removes edit pdf feedback files and table entries that have been orphaned.
*/
private function remove_files_and_entries(): void {
global $DB;
// Patiently remove all orphaned temporary pdf files.
$sql = "SELECT DISTINCT f.contextid, f.component, f.filearea, f.itemid
FROM {files} f
LEFT JOIN {assign_grades} g ON g.id = f.itemid
WHERE f.component = :assigneditpdf
AND NOT (filearea = :stamps AND f.itemid = 0)
AND g.id IS NULL";
$params = ['assigneditpdf' => 'assignfeedback_editpdf', 'stamps' => 'stamps'];
$results = $DB->get_recordset_sql($sql, $params);
foreach ($results as $record) {
$fs = get_file_storage();
$fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
}
$results->close();
}
/**
* Removes orphaned entries in the feedback edit pdf rotation table.
*/
private function remove_rotated_table_entries(): void {
global $DB;
$rotatesql = "SELECT er.id AS erid
FROM {assignfeedback_editpdf_rot} er
LEFT JOIN {assign_grades} g ON g.id = er.gradeid
WHERE g.id IS NULL";
$DB->delete_records_subquery('assignfeedback_editpdf_rot', 'id', 'erid', $rotatesql);
}
}
@@ -0,0 +1,71 @@
<?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 file contains the definition for the library class for edit PDF renderer.
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* A custom renderer class that extends the plugin_renderer_base and is used by the editpdf feedback plugin.
*
* @package assignfeedback_editpdf
* @copyright 2013 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class assignfeedback_editpdf_widget implements renderable {
/** @var int $assignment - Assignment instance id */
public $assignment = 0;
/** @var int $userid - The user id we are grading */
public $userid = 0;
/** @var mixed $attemptnumber - The attempt number we are grading */
public $attemptnumber = 0;
/** @var moodle_url $downloadurl */
public $downloadurl = null;
/** @var string $downloadfilename */
public $downloadfilename = null;
/** @var string[] $stampfiles */
public $stampfiles = array();
/** @var bool $readonly */
public $readonly = true;
/**
* Constructor
* @param int $assignment - Assignment instance id
* @param int $userid - The user id we are grading
* @param int $attemptnumber - The attempt number we are grading
* @param moodle_url $downloadurl - A url to download the current generated pdf.
* @param string $downloadfilename - Name of the generated pdf.
* @param string[] $stampfiles - The file names of the stamps.
* @param bool $readonly - Show the readonly interface (no tools).
*/
public function __construct($assignment, $userid, $attemptnumber, $downloadurl,
$downloadfilename, $stampfiles, $readonly) {
$this->assignment = $assignment;
$this->userid = $userid;
$this->attemptnumber = $attemptnumber;
$this->downloadurl = $downloadurl;
$this->downloadfilename = $downloadfilename;
$this->stampfiles = $stampfiles;
$this->readonly = $readonly;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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/>.
/**
* EditPDF event handler definition.
*
* @package assignfeedback_editpdf
* @category event
* @copyright 2016 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// List of observers.
$observers = array(
array(
'eventname' => '\mod_assign\event\submission_created',
'callback' => '\assignfeedback_editpdf\event\observer::submission_created',
),
array(
'eventname' => '\mod_assign\event\submission_updated',
'callback' => '\assignfeedback_editpdf\event\observer::submission_updated',
),
);
@@ -0,0 +1,53 @@
<?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/>.
/**
* Install code for the feedback_editpdf module.
*
* @package assignfeedback_editpdf
* @copyright 2013 Jerome Mouneyrac
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
/**
* EditPDF install code
*/
function xmldb_assignfeedback_editpdf_install() {
global $CFG;
// List of default stamps.
$defaultstamps = array('smile.png', 'sad.png', 'tick.png', 'cross.png');
// Stamp file object.
$filerecord = new stdClass;
$filerecord->component = 'assignfeedback_editpdf';
$filerecord->contextid = context_system::instance()->id;
$filerecord->userid = get_admin()->id;
$filerecord->filearea = 'stamps';
$filerecord->filepath = '/';
$filerecord->itemid = 0;
$fs = get_file_storage();
// Load all default stamps.
foreach ($defaultstamps as $stamp) {
$filerecord->filename = $stamp;
$fs->create_file_from_pathname($filerecord,
$CFG->dirroot . '/mod/assign/feedback/editpdf/pix/' . $filerecord->filename);
}
}
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" ?>
<XMLDB PATH="mod/assign/feedback/editpdf/db" VERSION="20190107" COMMENT="XMLDB file for Moodle mod/assign/feedback/editpdf"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../../../../lib/xmldb/xmldb.xsd"
>
<TABLES>
<TABLE NAME="assignfeedback_editpdf_cmnt" COMMENT="Stores comments added to pdfs">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="gradeid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="x" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="x-position of the top-left corner of the comment (in pixels - image resolution is set to 100 pixels per inch)"/>
<FIELD NAME="y" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="y-position of the top-left corner of the comment (in pixels - image resolution is set to 100 pixels per inch)"/>
<FIELD NAME="width" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="120" SEQUENCE="false" COMMENT="width, in pixels, of the comment box"/>
<FIELD NAME="rawtext" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Raw text of the comment"/>
<FIELD NAME="pageno" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The page in the PDF that this comment appears on"/>
<FIELD NAME="colour" TYPE="char" LENGTH="10" NOTNULL="false" DEFAULT="black" SEQUENCE="false" COMMENT="Can be red, yellow, green, blue, white, black"/>
<FIELD NAME="draft" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Is this a draft comment?"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="gradeid" TYPE="foreign" FIELDS="gradeid" REFTABLE="assign_grades" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="gradeid_pageno" UNIQUE="false" FIELDS="gradeid, pageno"/>
</INDEXES>
</TABLE>
<TABLE NAME="assignfeedback_editpdf_annot" COMMENT="stores annotations added to pdfs submitted by students">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="gradeid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="pageno" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The page in the PDF that this annotation appears on"/>
<FIELD NAME="x" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="x-position of the start of the annotation (in pixels - image resolution is set to 100 pixels per inch)"/>
<FIELD NAME="y" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="y-position of the start of the annotation (in pixels - image resolution is set to 100 pixels per inch)"/>
<FIELD NAME="endx" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="x-position of the end of the annotation"/>
<FIELD NAME="endy" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="y-position of the end of the annotation"/>
<FIELD NAME="path" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="SVG path describing the freehand line"/>
<FIELD NAME="type" TYPE="char" LENGTH="10" NOTNULL="false" DEFAULT="line" SEQUENCE="false" COMMENT="line, oval, rect, etc."/>
<FIELD NAME="colour" TYPE="char" LENGTH="10" NOTNULL="false" DEFAULT="black" SEQUENCE="false" COMMENT="Can be red, yellow, green, blue, white, black"/>
<FIELD NAME="draft" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Is this a draft annotation?"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="gradeid" TYPE="foreign" FIELDS="gradeid" REFTABLE="assign_grades" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="gradeid_pageno" UNIQUE="false" FIELDS="gradeid, pageno"/>
</INDEXES>
</TABLE>
<TABLE NAME="assignfeedback_editpdf_quick" COMMENT="Stores teacher specified quicklist comments">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="rawtext" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
<FIELD NAME="width" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="120" SEQUENCE="false"/>
<FIELD NAME="colour" TYPE="char" LENGTH="10" NOTNULL="false" DEFAULT="yellow" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
</KEYS>
</TABLE>
<TABLE NAME="assignfeedback_editpdf_rot" COMMENT="Stores rotation information of a page.">
<FIELDS>
<FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
<FIELD NAME="gradeid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="pageno" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Page number"/>
<FIELD NAME="pathnamehash" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="File path hash of the rotated page"/>
<FIELD NAME="isrotated" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether the page is rotated or not"/>
<FIELD NAME="degree" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Rotation degree"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id"/>
<KEY NAME="gradeid" TYPE="foreign" FIELDS="gradeid" REFTABLE="assign_grades" REFFIELDS="id"/>
</KEYS>
<INDEXES>
<INDEX NAME="gradeid_pageno" UNIQUE="true" FIELDS="gradeid, pageno"/>
</INDEXES>
</TABLE>
</TABLES>
</XMLDB>
@@ -0,0 +1,55 @@
<?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/>.
/**
* Upgrade code for the feedback_editpdf module.
*
* @package assignfeedback_editpdf
* @copyright 2013 Jerome Mouneyrac
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* EditPDF upgrade code
* @param int $oldversion
* @return bool
*/
function xmldb_assignfeedback_editpdf_upgrade($oldversion) {
global $CFG, $DB;
$dbman = $DB->get_manager();
// Automatically generated Moodle v4.1.0 release upgrade line.
// Put any upgrade step following this.
if ($oldversion < 2022112801) {
$task = new \assignfeedback_editpdf\task\remove_orphaned_editpdf_files();
\core\task\manager::queue_adhoc_task($task);
upgrade_plugin_savepoint(true, 2022112801, 'assignfeedback', 'editpdf');
}
// Automatically generated Moodle v4.2.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.3.0 release upgrade line.
// Put any upgrade step following this.
// Automatically generated Moodle v4.4.0 release upgrade line.
// Put any upgrade step following this.
return true;
}
Binary file not shown.
@@ -0,0 +1,21 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
class FpdfTpl extends \FPDF
{
use FpdfTplTrait;
}
@@ -0,0 +1,473 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi;
/**
* Trait FpdfTplTrait
*
* This trait adds a templating feature to FPDF and tFPDF.
*/
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 PhpConditionAlreadyCheckedInspection */
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,
'hPt' => $this->hPt,
'w' => $this->w,
'wPt' => $this->wPt,
'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->hPt = $height / $this->k;
$this->w = $width;
$this->wPt = $width / $this->k;
$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 ($this->currentTemplateId === null) {
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->hPt = $state['hPt'];
$this->w = $state['w'];
$this->wPt = $state['wPt'];
$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));
}
}
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);
}
}
}
@@ -0,0 +1,192 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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;
use setasign\Fpdi\PdfParser\Type\PdfType;
/**
* This trait is used for the implementation of FPDI in FPDF and tFPDF.
*/
trait FpdfTrait
{
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;
}
/**
* @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 $pageData) {
$this->_put('/' . $pageData['id'] . ' ' . $pageData['objectNumber'] . ' 0 R');
}
parent::_putxobjectdict();
}
/**
* @param int $n
* @return void
* @throws PdfParser\Type\PdfTypeException
*/
protected function _putlinks($n)
{
foreach ($this->PageLinks[$n] as $pl) {
$this->_newobj();
$rect = sprintf('%.2F %.2F %.2F %.2F', $pl[0], $pl[1], $pl[0] + $pl[2], $pl[1] - $pl[3]);
$this->_put('<</Type /Annot /Subtype /Link /Rect [' . $rect . ']', false);
if (is_string($pl[4])) {
$this->_put('/A <</S /URI /URI ' . $this->_textstring($pl[4]) . '>>');
if (isset($pl['importedLink'])) {
$values = $pl['importedLink']['pdfObject']->value;
foreach ($values as $name => $entry) {
$this->_put('/' . $name . ' ', false);
$this->writePdfType($entry);
}
if (isset($pl['quadPoints'])) {
$s = '/QuadPoints[';
foreach ($pl['quadPoints'] as $value) {
$s .= sprintf('%.2F ', $value);
}
$s .= ']';
$this->_put($s);
}
} else {
$this->_put('/Border [0 0 0]', false);
}
$this->_put('>>');
} else {
$this->_put('/Border [0 0 0] ', false);
$l = $this->links[$pl[4]];
if (isset($this->PageInfo[$l[0]]['size'])) {
$h = $this->PageInfo[$l[0]]['size'][1];
} else {
$h = ($this->DefOrientation === 'P')
? $this->DefPageSize[1] * $this->k
: $this->DefPageSize[0] * $this->k;
}
$this->_put(sprintf(
'/Dest [%d 0 R /XYZ 0 %.2F null]>>',
$this->PageInfo[$l[0]]['n'],
$h - $l[1] * $this->k
));
}
$this->_put('endobj');
}
}
protected function _put($s, $newLine = true)
{
if ($newLine) {
$this->buffer .= $s . "\n";
} else {
$this->buffer .= $s;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
class Fpdi extends FpdfTpl
{
use FpdiTrait;
use FpdfTrait;
/**
* FPDI version
*
* @string
*/
const VERSION = '2.6.0';
}
@@ -0,0 +1,18 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi;
/**
* Base exception class for the FPDI package.
*/
class FpdiException extends \Exception
{
}
@@ -0,0 +1,655 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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\DataStructure\Rectangle;
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.
*/
trait FpdiTrait
{
/**
* The pdf reader instances.
*
* @var PdfReader[]
*/
protected $readers = [];
/**
* Instances created internally.
*
* @var array
*/
protected $createdReaders = [];
/**
* The current reader id.
*
* @var string|null
*/
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
* @param array $parserParams Individual parameters passed to the parser instance.
* @return PdfParser|FpdiPdfParser
*/
protected function getPdfParserInstance(StreamReader $streamReader, array $parserParams = [])
{
// note: if you get an exception here - turn off errors/warnings on not found classes for your autoloader.
// psr-4 (https://www.php-fig.org/psr/psr-4/) says: Autoloader implementations MUST NOT throw
// exceptions, MUST NOT raise errors of any level, and SHOULD NOT return a value.
/** @noinspection PhpUndefinedClassInspection */
if (\class_exists(FpdiPdfParser::class)) {
/** @noinspection PhpUndefinedClassInspection */
return new FpdiPdfParser($streamReader, $parserParams);
}
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.
* @param array $parserParams Individual parameters passed to the parser instance.
* @return string
*/
protected function getPdfReaderId($file, array $parserParams = [])
{
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, $parserParams));
/** @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)
{
return $this->setSourceFileWithParserParams($file);
}
/**
* Set the source PDF file with parameters which are passed to the parser instance.
*
* This method allows us to pass e.g. authentication information to the parser instance.
*
* @param string|resource|StreamReader $file Path to the file or a stream resource or a StreamReader instance.
* @param array $parserParams Individual parameters passed to the parser instance.
* @return int The page count of the PDF document.
* @throws CrossReferenceException
* @throws PdfParserException
* @throws PdfTypeException
*/
public function setSourceFileWithParserParams($file, array $parserParams = [])
{
$this->currentReaderId = $this->getPdfReaderId($file, $parserParams);
$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).
* @param bool $importExternalLinks Define whether external links are imported or not.
* @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,
$importExternalLinks = false
) {
if ($this->currentReaderId === null) {
throw new \BadMethodCallException('No reader initiated. Call setSourceFile() first.');
}
$pageId = $this->currentReaderId;
$pageNumber = (int)$pageNumber;
$pageId .= '|' . $pageNumber . '|' . ($groupXObject ? '1' : '0') . '|' . ($importExternalLinks ? '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();
try {
$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);
}
// Catch faulty pages and use an empty content stream
} catch (FpdiException $e) {
$dict->value['Length'] = PdfNumeric::create(0);
$stream = PdfStream::create($dict, '');
}
$externalLinks = [];
if ($importExternalLinks) {
$externalLinks = $page->getExternalLinks($box);
}
$this->importedPages[$pageId] = [
'objectNumber' => null,
'readerId' => $this->currentReaderId,
'id' => 'TPL' . $this->getNextTemplateId(),
'width' => $width / $this->k,
'height' => $height / $this->k,
'stream' => $stream,
'externalLinks' => $externalLinks
];
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']);
}
$scaleX = ($newSize['width'] / $originalSize['width']);
$scaleY = ($newSize['height'] / $originalSize['height']);
$xPt = $x * $this->k;
$yPt = $y * $this->k;
$newHeightPt = $newSize['height'] * $this->k;
$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',
$scaleX,
$scaleY,
$xPt,
$this->hPt - $yPt - $newHeightPt,
$importedPage['id']
)
);
if (count($importedPage['externalLinks']) > 0) {
foreach ($importedPage['externalLinks'] as $externalLink) {
// mPDF uses also 'externalLinks' but doesn't come with a rect-value
if (!isset($externalLink['rect'])) {
continue;
}
/** @var Rectangle $rect */
$rect = $externalLink['rect'];
$this->Link(
$x + $rect->getLlx() / $this->k * $scaleX,
$y + $newSize['height'] - ($rect->getLly() + $rect->getHeight()) / $this->k * $scaleY,
$rect->getWidth() / $this->k * $scaleX,
$rect->getHeight() / $this->k * $scaleY,
$externalLink['uri']
);
$this->adjustLastLink($externalLink, $xPt, $scaleX, $yPt, $newHeightPt, $scaleY, $importedPage);
}
}
return $newSize;
}
/**
* This method will add additional data to the last created link/annotation.
*
* It is separated because TCPDF uses its own logic to handle link annotations.
* This method is overwritten in the TCPDF implementation.
*
* @param array $externalLink
* @param float|int $xPt
* @param float|int $scaleX
* @param float|int $yPt
* @param float|int $newHeightPt
* @param float|int $scaleY
* @param array $importedPage
* @return void
*/
protected function adjustLastLink($externalLink, $xPt, $scaleX, $yPt, $newHeightPt, $scaleY, $importedPage)
{
// let's create a relation of the newly created link to the data of the external link
$lastLink = count($this->PageLinks[$this->page]);
$this->PageLinks[$this->page][$lastLink - 1]['importedLink'] = $externalLink;
if (count($externalLink['quadPoints']) > 0) {
$quadPoints = [];
for ($i = 0, $n = count($externalLink['quadPoints']); $i < $n; $i += 2) {
$quadPoints[] = $xPt + $externalLink['quadPoints'][$i] * $scaleX;
$quadPoints[] = $this->hPt - $yPt - $newHeightPt + $externalLink['quadPoints'][$i + 1] * $scaleY;
}
$this->PageLinks[$this->page][$lastLink - 1]['quadPoints'] = $quadPoints;
}
}
/**
* 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 . '>', false);
} 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 ', false);
} elseif ($value instanceof 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) {
$n = $this->objectMap[$this->currentReaderId][$value->objectNumber];
$this->_newobj($n);
$this->writePdfType($value->value);
// add newline before "endobj" for all objects in view to PDF/A conformance
if (
!(
($value->value instanceof PdfArray) ||
($value->value instanceof PdfDictionary) ||
($value->value instanceof PdfToken) ||
($value->value instanceof PdfStream)
)
) {
$this->_put("\n", false);
}
$this->_put('endobj');
}
}
}
@@ -0,0 +1,97 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi;
use setasign\Fpdi\Math\Matrix;
use setasign\Fpdi\Math\Vector;
/**
* A simple graphic state class which holds the current transformation matrix.
*/
class GraphicsState
{
/**
* @var Matrix
*/
protected $ctm;
/**
* @param Matrix|null $ctm
*/
public function __construct(Matrix $ctm = null)
{
if ($ctm === null) {
$ctm = new Matrix();
}
$this->ctm = $ctm;
}
/**
* @param Matrix $matrix
* @return $this
*/
public function add(Matrix $matrix)
{
$this->ctm = $matrix->multiply($this->ctm);
return $this;
}
/**
* @param int|float $x
* @param int|float $y
* @param int|float $angle
* @return $this
*/
public function rotate($x, $y, $angle)
{
if (abs($angle) < 1e-5) {
return $this;
}
$angle = deg2rad($angle);
$c = cos($angle);
$s = sin($angle);
$this->add(new Matrix($c, $s, -$s, $c, $x, $y));
return $this->translate(-$x, -$y);
}
/**
* @param int|float $shiftX
* @param int|float $shiftY
* @return $this
*/
public function translate($shiftX, $shiftY)
{
return $this->add(new Matrix(1, 0, 0, 1, $shiftX, $shiftY));
}
/**
* @param int|float $scaleX
* @param int|float $scaleY
* @return $this
*/
public function scale($scaleX, $scaleY)
{
return $this->add(new Matrix($scaleX, 0, 0, $scaleY, 0, 0));
}
/**
* @param Vector $vector
* @return Vector
*/
public function toUserSpace(Vector $vector)
{
return $vector->multiplyWithMatrix($this->ctm);
}
}
@@ -0,0 +1,116 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\Math;
/**
* A simple 2D-Matrix class
*/
class Matrix
{
/**
* @var float
*/
protected $a;
/**
* @var float
*/
protected $b;
/**
* @var float
*/
protected $c;
/**
* @var float
*/
protected $d;
/**
* @var float
*/
protected $e;
/**
* @var float
*/
protected $f;
/**
* @param int|float $a
* @param int|float $b
* @param int|float $c
* @param int|float $d
* @param int|float $e
* @param int|float $f
*/
public function __construct($a = 1, $b = 0, $c = 0, $d = 1, $e = 0, $f = 0)
{
$this->a = (float)$a;
$this->b = (float)$b;
$this->c = (float)$c;
$this->d = (float)$d;
$this->e = (float)$e;
$this->f = (float)$f;
}
/**
* @return float[]
*/
public function getValues()
{
return [$this->a, $this->b, $this->c, $this->d, $this->e, $this->f];
}
/**
* @param Matrix $by
* @return Matrix
*/
public function multiply(self $by)
{
$a =
$this->a * $by->a
+ $this->b * $by->c
//+ 0 * $by->e
;
$b =
$this->a * $by->b
+ $this->b * $by->d
//+ 0 * $by->f
;
$c =
$this->c * $by->a
+ $this->d * $by->c
//+ 0 * $by->e
;
$d =
$this->c * $by->b
+ $this->d * $by->d
//+ 0 * $by->f
;
$e =
$this->e * $by->a
+ $this->f * $by->c
+ /*1 * */$by->e;
$f =
$this->e * $by->b
+ $this->f * $by->d
+ /*1 * */$by->f;
return new self($a, $b, $c, $d, $e, $f);
}
}
@@ -0,0 +1,66 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\Math;
/**
* A simple 2D-Vector class
*/
class Vector
{
/**
* @var float
*/
protected $x;
/**
* @var float
*/
protected $y;
/**
* @param int|float $x
* @param int|float $y
*/
public function __construct($x = .0, $y = .0)
{
$this->x = (float)$x;
$this->y = (float)$y;
}
/**
* @return float
*/
public function getX()
{
return $this->x;
}
/**
* @return float
*/
public function getY()
{
return $this->y;
}
/**
* @param Matrix $matrix
* @return Vector
*/
public function multiplyWithMatrix(Matrix $matrix)
{
list($a, $b, $c, $d, $e, $f) = $matrix->getValues();
$x = $a * $this->x + $c * $this->y + $e;
$y = $b * $this->x + $d * $this->y + $f;
return new self($x, $y);
}
}
@@ -0,0 +1,95 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
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;
}
}
@@ -0,0 +1,326 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
class CrossReference
{
/**
* The byte length in which the "startxref" keyword should be searched.
*
* @var int
*/
public static $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);
} 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) {
try {
$stream = PdfStream::ensure($initValue->value);
} catch (PdfTypeException $e) {
throw new CrossReferenceException(
'Invalid object type at xref reference offset.',
CrossReferenceException::INVALID_DATA,
$e
);
}
$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;
}
}
@@ -0,0 +1,79 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
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;
}
@@ -0,0 +1,200 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
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
* @return int|false
*/
public function getOffsetFor($objectNumber)
{
foreach ($this->subSections as $offset => list($startObject, $objectCount)) {
/**
* @var int $startObject
* @var int $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;
}
}
@@ -0,0 +1,168 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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).
*/
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
* @return int|false
*/
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)
{
$bytesPerCycle = 100;
$reader->reset(null, $bytesPerCycle);
$cycles = 0;
do {
// 6 = length of "trailer" - 1
$pos = \max(($bytesPerCycle * $cycles) - 6, 0);
$trailerPos = \strpos($reader->getBuffer(false), 'trailer', $pos);
$cycles++;
} while ($trailerPos === false && $reader->increaseLength($bytesPerCycle) !== false);
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);
if (!\is_array($lines)) {
$this->offsets = [];
return;
}
$start = 0;
$offsets = [];
// trim all lines and remove empty lines
$lines = \array_filter(\array_map('\trim', $lines));
foreach ($lines as $line) {
$pieces = \explode(' ', $line);
switch (\count($pieces)) {
case 2:
$start = (int) $pieces[0];
break;
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;
}
}
@@ -0,0 +1,34 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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.
*/
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();
}
@@ -0,0 +1,102 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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;
$data = \preg_replace('/\s/', '', $data);
$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 ($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;
}
}
@@ -0,0 +1,27 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Filter;
/**
* Exception for Ascii85 filter class
*/
class Ascii85Exception extends FilterException
{
/**
* @var integer
*/
const ILLEGAL_CHAR_FOUND = 0x0301;
/**
* @var integer
*/
const ILLEGAL_LENGTH = 0x0302;
}
@@ -0,0 +1,47 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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 ? '' : '>');
}
}
@@ -0,0 +1,23 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
class FilterException extends PdfParserException
{
const UNSUPPORTED_FILTER = 0x0201;
const NOT_IMPLEMENTED = 0x0202;
}
@@ -0,0 +1,25 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Filter;
/**
* Interface for filters
*/
interface FilterInterface
{
/**
* Decode a string.
*
* @param string $data The input string
* @return string
*/
public function decode($data);
}
@@ -0,0 +1,77 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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|false $data The input string
* @return string
* @throws FlateException
*/
public function decode($data)
{
if ($this->extensionLoaded()) {
$oData = $data;
$data = (($data !== '') ? @\gzuncompress($data) : '');
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);
// "window" == 31 -> 16 + (8 to 15): Uses the low 4 bits of the value as the window size logarithm.
// The input must include a gzip header and trailer (via 16).
stream_filter_append($fh, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 31]);
fseek($fh, 0);
$data = @stream_get_contents($fh);
fclose($fh);
if ($data) {
return $data;
}
// Try this fallback (remove the zlib stream header)
$data = @(gzinflate(substr($oData, 2)));
if ($data === false) {
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;
}
}
@@ -0,0 +1,27 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Filter;
/**
* Exception for flate filter class
*/
class FlateException extends FilterException
{
/**
* @var integer
*/
const NO_ZLIB = 0x0401;
/**
* @var integer
*/
const DECOMPRESS_ERROR = 0x0402;
}
@@ -0,0 +1,178 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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;
$prevCode = 0;
$uncompData = '';
while (($code = $this->getNextCode()) !== 257) {
if ($code === 256) {
$this->initsTable();
} elseif ($prevCode === 256) {
$uncompData .= $this->sTable[$code];
} elseif ($code < $this->tIdx) {
$string = $this->sTable[$code];
$uncompData .= $string;
$this->addStringToTable($this->sTable[$prevCode], $string[0]);
} else {
$string = $this->sTable[$prevCode];
$string .= $string[0];
$uncompData .= $string;
$this->addStringToTable($string);
}
$prevCode = $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 int
*/
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;
}
}
@@ -0,0 +1,22 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Filter;
/**
* Exception for LZW filter class
*/
class LzwException extends FilterException
{
/**
* @var integer
*/
const LZW_FLAVOUR_NOT_SUPPORTED = 0x0501;
}
@@ -0,0 +1,435 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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\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;
/**
* A PDF parser class
*/
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|null
*/
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);
$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()
{
$trailer = $this->getCrossReference()->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];
}
$object = $this->getCrossReference()->getIndirectObject($objectNumber);
if ($cache) {
$this->objects[$objectNumber] = $object;
}
return $object;
}
/**
* Read a PDF value.
*
* @param null|bool|string $token
* @param null|string $expectedType
* @return false|PdfArray|PdfBoolean|PdfDictionary|PdfHexString|PdfIndirectObject|PdfIndirectObjectReference|PdfName|PdfNull|PdfNumeric|PdfStream|PdfString|PdfToken
* @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 $this->parsePdfString();
case '<':
if ($this->streamReader->getByte() === '<') {
$this->ensureExpectedType('<<', $expectedType);
$this->streamReader->addOffset(1);
return $this->parsePdfDictionary();
}
$this->ensureExpectedType($token, $expectedType);
return $this->parsePdfHexString();
case '/':
$this->ensureExpectedType($token, $expectedType);
return $this->parsePdfName();
case '[':
$this->ensureExpectedType($token, $expectedType);
return $this->parsePdfArray();
default:
if (\is_numeric($token)) {
if (($token2 = $this->tokenizer->getNextToken()) !== false) {
if (\is_numeric($token2) && ($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 $this->parsePdfIndirectObject((int)$token, (int)$token2);
case 'R':
if (
$expectedType !== null &&
$expectedType !== PdfIndirectObjectReference::class
) {
throw new Type\PdfTypeException(
'Got unexpected token type.',
Type\PdfTypeException::INVALID_DATA_TYPE
);
}
return PdfIndirectObjectReference::create((int)$token, (int)$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 + 0);
}
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;
}
}
/**
* @return PdfString
*/
protected function parsePdfString()
{
return PdfString::parse($this->streamReader);
}
/**
* @return false|PdfHexString
*/
protected function parsePdfHexString()
{
return PdfHexString::parse($this->streamReader);
}
/**
* @return bool|PdfDictionary
* @throws PdfTypeException
*/
protected function parsePdfDictionary()
{
return PdfDictionary::parse($this->tokenizer, $this->streamReader, $this);
}
/**
* @return PdfName
*/
protected function parsePdfName()
{
return PdfName::parse($this->tokenizer, $this->streamReader);
}
/**
* @return false|PdfArray
* @throws PdfTypeException
*/
protected function parsePdfArray()
{
return PdfArray::parse($this->tokenizer, $this);
}
/**
* @param int $objectNumber
* @param int $generationNumber
* @return false|PdfIndirectObject
* @throws Type\PdfTypeException
*/
protected function parsePdfIndirectObject($objectNumber, $generationNumber)
{
return PdfIndirectObject::parse(
$objectNumber,
$generationNumber,
$this,
$this->tokenizer,
$this->streamReader
);
}
/**
* 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
*/
protected 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);
}
}
@@ -0,0 +1,49 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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;
}
@@ -0,0 +1,477 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser;
/**
* A stream reader class
*/
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!'
);
}
if (fseek($stream, 0) === -1) {
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|false
*/
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;
}
}
@@ -0,0 +1,154 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser;
/**
* A tokenizer class.
*/
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 (\in_array($byte, ["\x20", "\x0A", "\x0D", "\x0C", "\x09", "\x00"], true)) {
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;
}
}
@@ -0,0 +1,85 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*
* @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 false|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;
}
}
@@ -0,0 +1,42 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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 = (bool) $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.');
}
}
@@ -0,0 +1,134 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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|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.');
}
}
@@ -0,0 +1,77 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
class PdfHexString extends PdfType
{
/**
* Parses a hexadecimal string object from the stream reader.
*
* @param StreamReader $streamReader
* @return false|self
*/
public static function parse(StreamReader $streamReader)
{
$bufferOffset = $streamReader->getOffset();
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.');
}
}
@@ -0,0 +1,103 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
class PdfIndirectObject extends PdfType
{
/**
* Parses an indirect object from a tokenizer, parser and stream-reader.
*
* @param int $objectNumber
* @param int $objectGenerationNumber
* @param PdfParser $parser
* @param Tokenizer $tokenizer
* @param StreamReader $reader
* @return self|false
* @throws PdfTypeException
*/
public static function parse(
$objectNumber,
$objectGenerationNumber,
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) $objectNumber;
$v->generationNumber = (int) $objectGenerationNumber;
$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;
}
@@ -0,0 +1,52 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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;
}
@@ -0,0 +1,82 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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
*/
public static function unescape($value)
{
if (strpos($value, '#') === false) {
return $value;
}
return preg_replace_callback('/#([a-fA-F\d]{2})/', function ($matches) {
return chr(hexdec($matches[1]));
}, $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.');
}
}
@@ -0,0 +1,19 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
class PdfNull extends PdfType
{
// empty body
}
@@ -0,0 +1,43 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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.');
}
}
@@ -0,0 +1,352 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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) {
$offset++;
if ($firstByte === "\n" || $firstByte === "\r") {
break;
}
}
if ($firstByte === false) {
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);
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|null
*/
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);
}
}
// There are streams in the wild, which have only white signs in them but need to be parsed manually due
// to a problem encountered before (e.g. Length === 0). We should set them to empty streams to avoid problems
// in further processing (e.g. applying of filters).
if (trim($buffer) === '') {
$buffer = '';
}
return $buffer;
}
/**
* Get all filters defined for this stream.
*
* @return PdfType[]
* @throws PdfTypeException
*/
public function getFilters()
{
$filters = PdfDictionary::get($this->value, 'Filter');
if ($filters instanceof PdfNull) {
return [];
}
if ($filters instanceof PdfArray) {
$filters = $filters->value;
} else {
$filters = [$filters];
}
return $filters;
}
/**
* Get the unfiltered stream data.
*
* @return string
* @throws FilterException
* @throws PdfParserException
*/
public function getUnfilteredStream()
{
$stream = $this->getStream();
$filters = $this->getFilters();
if ($filters === []) {
return $stream;
}
$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;
case 'Crypt':
if (!$decodeParam instanceof PdfDictionary) {
break;
}
// Filter is "Identity"
$name = PdfDictionary::get($decodeParam, 'Name');
if (!$name instanceof PdfName || $name->value !== 'Identity') {
break;
}
throw new FilterException(
'Support for Crypt filters other than "Identity" is not implemented.',
FilterException::UNSUPPORTED_FILTER
);
default:
throw new FilterException(
\sprintf('Unsupported filter "%s".', $filter->value),
FilterException::UNSUPPORTED_FILTER
);
}
}
return $stream;
}
}
@@ -0,0 +1,202 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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.');
}
/**
* Escapes sequences in a string according to the PDF specification.
*
* @param string $s
* @return string
*/
public static function escape($s)
{
// Still a bit faster, than direct replacing
if (
\strpos($s, '\\') !== false ||
\strpos($s, ')') !== false ||
\strpos($s, '(') !== false ||
\strpos($s, "\x0D") !== false ||
\strpos($s, "\x0A") !== false ||
\strpos($s, "\x09") !== false ||
\strpos($s, "\x08") !== false ||
\strpos($s, "\x0C") !== false
) {
// is faster than strtr(...)
return \str_replace(
['\\', ')', '(', "\x0D", "\x0A", "\x09", "\x08", "\x0C"],
['\\\\', '\\)', '\\(', '\r', '\n', '\t', '\b', '\f'],
$s
);
}
return $s;
}
/**
* 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;
}
}
@@ -0,0 +1,43 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Type;
/**
* Class representing PDF token object
*/
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.');
}
}
@@ -0,0 +1,106 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (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
*/
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) {
return $value;
}
return self::resolve($value->value, $parser, $stopAtIndirectObject);
}
if ($value instanceof PdfIndirectObjectReference) {
return self::resolve($parser->getIndirectObject($value->value), $parser, $stopAtIndirectObject);
}
return $value;
}
/**
* Ensure that a value is an instance of a specific PDF type.
*
* @param string $type
* @param PdfType $value
* @param string $errorMessage
* @return mixed
* @throws PdfTypeException
*/
protected static function ensureType($type, $value, $errorMessage)
{
if (!($value instanceof $type)) {
throw new PdfTypeException(
$errorMessage,
PdfTypeException::INVALID_DATA_TYPE
);
}
return $value;
}
/**
* Flatten indirect object references to direct objects.
*
* @param PdfType $value
* @param PdfParser $parser
* @return PdfType
* @throws CrossReferenceException
* @throws PdfParserException
*/
public static function flatten(PdfType $value, PdfParser $parser)
{
if ($value instanceof PdfIndirectObjectReference) {
return self::flatten(self::resolve($value, $parser), $parser);
}
if ($value instanceof PdfDictionary || $value instanceof PdfArray) {
foreach ($value->value as $key => $_value) {
$value->value[$key] = self::flatten($_value, $parser);
}
}
if ($value instanceof PdfStream) {
throw new PdfTypeException('There is a stream object found which cannot be flattened to a direct object.');
}
return $value;
}
/**
* The value of the PDF type.
*
* @var mixed
*/
public $value;
}
@@ -0,0 +1,24 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfParser\Type;
use setasign\Fpdi\PdfParser\PdfParserException;
/**
* Exception class for pdf type classes
*/
class PdfTypeException extends PdfParserException
{
/**
* @var int
*/
const NO_NEWLINE_AFTER_STREAM_KEYWORD = 0x0601;
}
@@ -0,0 +1,179 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfReader\DataStructure;
use setasign\Fpdi\Math\Vector;
use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
use setasign\Fpdi\PdfParser\PdfParser;
use setasign\Fpdi\PdfParser\PdfParserException;
use setasign\Fpdi\PdfParser\Type\PdfArray;
use setasign\Fpdi\PdfParser\Type\PdfNumeric;
use setasign\Fpdi\PdfParser\Type\PdfType;
use setasign\Fpdi\PdfParser\Type\PdfTypeException;
/**
* Class representing a rectangle
*/
class Rectangle
{
/**
* @var int|float
*/
protected $llx;
/**
* @var int|float
*/
protected $lly;
/**
* @var int|float
*/
protected $urx;
/**
* @var int|float
*/
protected $ury;
/**
* Create a rectangle instance by a PdfArray.
*
* @param PdfArray|mixed $array
* @param PdfParser $parser
* @return Rectangle
* @throws PdfTypeException
* @throws CrossReferenceException
* @throws PdfParserException
*/
public static function byPdfArray($array, PdfParser $parser)
{
$array = PdfArray::ensure(PdfType::resolve($array, $parser), 4)->value;
$ax = PdfNumeric::ensure(PdfType::resolve($array[0], $parser))->value;
$ay = PdfNumeric::ensure(PdfType::resolve($array[1], $parser))->value;
$bx = PdfNumeric::ensure(PdfType::resolve($array[2], $parser))->value;
$by = PdfNumeric::ensure(PdfType::resolve($array[3], $parser))->value;
return new self($ax, $ay, $bx, $by);
}
public static function byVectors(Vector $ll, Vector $ur)
{
return new self($ll->getX(), $ll->getY(), $ur->getX(), $ur->getY());
}
/**
* Rectangle constructor.
*
* @param float|int $ax
* @param float|int $ay
* @param float|int $bx
* @param float|int $by
*/
public function __construct($ax, $ay, $bx, $by)
{
$this->llx = \min($ax, $bx);
$this->lly = \min($ay, $by);
$this->urx = \max($ax, $bx);
$this->ury = \max($ay, $by);
}
/**
* Get the width of the rectangle.
*
* @return float|int
*/
public function getWidth()
{
return $this->urx - $this->llx;
}
/**
* Get the height of the rectangle.
*
* @return float|int
*/
public function getHeight()
{
return $this->ury - $this->lly;
}
/**
* Get the lower left abscissa.
*
* @return float|int
*/
public function getLlx()
{
return $this->llx;
}
/**
* Get the lower left ordinate.
*
* @return float|int
*/
public function getLly()
{
return $this->lly;
}
/**
* Get the upper right abscissa.
*
* @return float|int
*/
public function getUrx()
{
return $this->urx;
}
/**
* Get the upper right ordinate.
*
* @return float|int
*/
public function getUry()
{
return $this->ury;
}
/**
* Get the rectangle as an array.
*
* @return array
*/
public function toArray()
{
return [
$this->llx,
$this->lly,
$this->urx,
$this->ury
];
}
/**
* Get the rectangle as a PdfArray.
*
* @return PdfArray
*/
public function toPdfArray()
{
$array = new PdfArray();
$array->value[] = PdfNumeric::create($this->llx);
$array->value[] = PdfNumeric::create($this->lly);
$array->value[] = PdfNumeric::create($this->urx);
$array->value[] = PdfNumeric::create($this->ury);
return $array;
}
}
@@ -0,0 +1,420 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfReader;
use setasign\Fpdi\FpdiException;
use setasign\Fpdi\GraphicsState;
use setasign\Fpdi\Math\Vector;
use setasign\Fpdi\PdfParser\Filter\FilterException;
use setasign\Fpdi\PdfParser\PdfParser;
use setasign\Fpdi\PdfParser\PdfParserException;
use setasign\Fpdi\PdfParser\Type\PdfArray;
use setasign\Fpdi\PdfParser\Type\PdfDictionary;
use setasign\Fpdi\PdfParser\Type\PdfHexString;
use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
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\PdfType;
use setasign\Fpdi\PdfParser\Type\PdfTypeException;
use setasign\Fpdi\PdfReader\DataStructure\Rectangle;
use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
/**
* Class representing a page of a PDF document
*/
class Page
{
/**
* @var PdfIndirectObject
*/
protected $pageObject;
/**
* @var PdfDictionary
*/
protected $pageDictionary;
/**
* @var PdfParser
*/
protected $parser;
/**
* Inherited attributes
*
* @var null|array
*/
protected $inheritedAttributes;
/**
* Page constructor.
*
* @param PdfIndirectObject $page
* @param PdfParser $parser
*/
public function __construct(PdfIndirectObject $page, PdfParser $parser)
{
$this->pageObject = $page;
$this->parser = $parser;
}
/**
* Get the indirect object of this page.
*
* @return PdfIndirectObject
*/
public function getPageObject()
{
return $this->pageObject;
}
/**
* Get the dictionary of this page.
*
* @return PdfDictionary
* @throws PdfParserException
* @throws PdfTypeException
* @throws CrossReferenceException
*/
public function getPageDictionary()
{
if ($this->pageDictionary === null) {
$this->pageDictionary = PdfDictionary::ensure(PdfType::resolve($this->getPageObject(), $this->parser));
}
return $this->pageDictionary;
}
/**
* Get a page attribute.
*
* @param string $name
* @param bool $inherited
* @return PdfType|null
* @throws PdfParserException
* @throws PdfTypeException
* @throws CrossReferenceException
*/
public function getAttribute($name, $inherited = true)
{
$dict = $this->getPageDictionary();
if (isset($dict->value[$name])) {
return $dict->value[$name];
}
$inheritedKeys = ['Resources', 'MediaBox', 'CropBox', 'Rotate'];
if ($inherited && \in_array($name, $inheritedKeys, true)) {
if ($this->inheritedAttributes === null) {
$this->inheritedAttributes = [];
$inheritedKeys = \array_filter($inheritedKeys, function ($key) use ($dict) {
return !isset($dict->value[$key]);
});
if (\count($inheritedKeys) > 0) {
$parentDict = PdfType::resolve(PdfDictionary::get($dict, 'Parent'), $this->parser);
while ($parentDict instanceof PdfDictionary) {
foreach ($inheritedKeys as $index => $key) {
if (isset($parentDict->value[$key])) {
$this->inheritedAttributes[$key] = $parentDict->value[$key];
unset($inheritedKeys[$index]);
}
}
/** @noinspection NotOptimalIfConditionsInspection */
if (isset($parentDict->value['Parent']) && \count($inheritedKeys) > 0) {
$parentDict = PdfType::resolve(PdfDictionary::get($parentDict, 'Parent'), $this->parser);
} else {
break;
}
}
}
}
if (isset($this->inheritedAttributes[$name])) {
return $this->inheritedAttributes[$name];
}
}
return null;
}
/**
* Get the rotation value.
*
* @return int
* @throws PdfParserException
* @throws PdfTypeException
* @throws CrossReferenceException
*/
public function getRotation()
{
$rotation = $this->getAttribute('Rotate');
if ($rotation === null) {
return 0;
}
$rotation = PdfNumeric::ensure(PdfType::resolve($rotation, $this->parser))->value % 360;
if ($rotation < 0) {
$rotation += 360;
}
return $rotation;
}
/**
* Get a boundary of this page.
*
* @param string $box
* @param bool $fallback
* @return bool|Rectangle
* @throws PdfParserException
* @throws PdfTypeException
* @throws CrossReferenceException
* @see PageBoundaries
*/
public function getBoundary($box = PageBoundaries::CROP_BOX, $fallback = true)
{
$value = $this->getAttribute($box);
if ($value !== null) {
return Rectangle::byPdfArray($value, $this->parser);
}
if ($fallback === false) {
return false;
}
switch ($box) {
case PageBoundaries::BLEED_BOX:
case PageBoundaries::TRIM_BOX:
case PageBoundaries::ART_BOX:
return $this->getBoundary(PageBoundaries::CROP_BOX, true);
case PageBoundaries::CROP_BOX:
return $this->getBoundary(PageBoundaries::MEDIA_BOX, true);
}
return false;
}
/**
* Get the width and height of this page.
*
* @param string $box
* @param bool $fallback
* @return array|bool
* @throws PdfParserException
* @throws PdfTypeException
* @throws CrossReferenceException
*/
public function getWidthAndHeight($box = PageBoundaries::CROP_BOX, $fallback = true)
{
$boundary = $this->getBoundary($box, $fallback);
if ($boundary === false) {
return false;
}
$rotation = $this->getRotation();
$interchange = ($rotation / 90) % 2;
return [
$interchange ? $boundary->getHeight() : $boundary->getWidth(),
$interchange ? $boundary->getWidth() : $boundary->getHeight()
];
}
/**
* Get the raw content stream.
*
* @return string
* @throws PdfReaderException
* @throws PdfTypeException
* @throws FilterException
* @throws PdfParserException
*/
public function getContentStream()
{
$dict = $this->getPageDictionary();
$contents = PdfType::resolve(PdfDictionary::get($dict, 'Contents'), $this->parser);
if ($contents instanceof PdfNull) {
return '';
}
if ($contents instanceof PdfArray) {
$result = [];
foreach ($contents->value as $content) {
$content = PdfType::resolve($content, $this->parser);
if (!($content instanceof PdfStream)) {
continue;
}
$result[] = $content->getUnfilteredStream();
}
return \implode("\n", $result);
}
if ($contents instanceof PdfStream) {
return $contents->getUnfilteredStream();
}
throw new PdfReaderException(
'Array or stream expected.',
PdfReaderException::UNEXPECTED_DATA_TYPE
);
}
/**
* Get information of all external links on this page.
*
* All coordinates are normalized in view to rotation and translation of the boundary-box, so that their
* origin is lower-left.
*
* @return array
*/
public function getExternalLinks($box = PageBoundaries::CROP_BOX)
{
try {
$dict = $this->getPageDictionary();
$annotations = PdfType::resolve(PdfDictionary::get($dict, 'Annots'), $this->parser);
} catch (FpdiException $e) {
return [];
}
if (!$annotations instanceof PdfArray) {
return [];
}
$links = [];
foreach ($annotations->value as $entry) {
try {
$annotation = PdfType::resolve($entry, $this->parser);
$value = PdfType::resolve(PdfDictionary::get($annotation, 'Subtype'), $this->parser);
if (!$value instanceof PdfName || $value->value !== 'Link') {
continue;
}
$dest = PdfType::resolve(PdfDictionary::get($annotation, 'Dest'), $this->parser);
if (!$dest instanceof PdfNull) {
continue;
}
$action = PdfType::resolve(PdfDictionary::get($annotation, 'A'), $this->parser);
if (!$action instanceof PdfDictionary) {
continue;
}
$actionType = PdfType::resolve(PdfDictionary::get($action, 'S'), $this->parser);
if (!$actionType instanceof PdfName || $actionType->value !== 'URI') {
continue;
}
$uri = PdfType::resolve(PdfDictionary::get($action, 'URI'), $this->parser);
if ($uri instanceof PdfString) {
$uriValue = PdfString::unescape($uri->value);
} elseif ($uri instanceof PdfHexString) {
$uriValue = \hex2bin($uri->value);
} else {
continue;
}
$rect = PdfType::resolve(PdfDictionary::get($annotation, 'Rect'), $this->parser);
if (!$rect instanceof PdfArray || count($rect->value) !== 4) {
continue;
}
$rect = Rectangle::byPdfArray($rect, $this->parser);
if ($rect->getWidth() === 0 || $rect->getHeight() === 0) {
continue;
}
$bbox = $this->getBoundary($box);
$rotation = $this->getRotation();
$gs = new GraphicsState();
$gs->translate(-$bbox->getLlx(), -$bbox->getLly());
$gs->rotate($bbox->getLlx(), $bbox->getLly(), -$rotation);
switch ($rotation) {
case 90:
$gs->translate(-$bbox->getWidth(), 0);
break;
case 180:
$gs->translate(-$bbox->getWidth(), -$bbox->getHeight());
break;
case 270:
$gs->translate(0, -$bbox->getHeight());
break;
}
$normalizedRect = Rectangle::byVectors(
$gs->toUserSpace(new Vector($rect->getLlx(), $rect->getLly())),
$gs->toUserSpace(new Vector($rect->getUrx(), $rect->getUry()))
);
$quadPoints = PdfType::resolve(PdfDictionary::get($annotation, 'QuadPoints'), $this->parser);
$normalizedQuadPoints = [];
if ($quadPoints instanceof PdfArray) {
$quadPointsCount = count($quadPoints->value);
if ($quadPointsCount % 8 === 0) {
for ($i = 0; ($i + 1) < $quadPointsCount; $i += 2) {
$x = PdfNumeric::ensure(PdfType::resolve($quadPoints->value[$i], $this->parser));
$y = PdfNumeric::ensure(PdfType::resolve($quadPoints->value[$i + 1], $this->parser));
$v = $gs->toUserSpace(new Vector($x->value, $y->value));
$normalizedQuadPoints[] = $v->getX();
$normalizedQuadPoints[] = $v->getY();
}
}
}
// we remove unsupported/unneeded values here
unset(
$annotation->value['P'],
$annotation->value['NM'],
$annotation->value['AP'],
$annotation->value['AS'],
$annotation->value['Type'],
$annotation->value['Subtype'],
$annotation->value['Rect'],
$annotation->value['A'],
$annotation->value['QuadPoints'],
$annotation->value['Rotate'],
$annotation->value['M'],
$annotation->value['StructParent'],
$annotation->value['OC']
);
// ...and flatten the PDF object to eliminate any indirect references.
// Indirect references are a problem when writing the output in FPDF
// because FPDF uses pre-calculated object numbers while FPDI creates
// them at runtime.
$annotation = PdfType::flatten($annotation, $this->parser);
$links[] = [
'rect' => $normalizedRect,
'quadPoints' => $normalizedQuadPoints,
'uri' => $uriValue,
'pdfObject' => $annotation
];
} catch (FpdiException $e) {
continue;
}
}
return $links;
}
}
@@ -0,0 +1,94 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfReader;
/**
* An abstract class for page boundary constants and some helper methods
*/
abstract class PageBoundaries
{
/**
* MediaBox
*
* The media box defines the boundaries of the physical medium on which the page is to be printed.
*
* @see PDF 32000-1:2008 - 14.11.2 Page Boundaries
* @var string
*/
const MEDIA_BOX = 'MediaBox';
/**
* CropBox
*
* The crop box defines the region to which the contents of the page shall be clipped (cropped) when displayed or
* printed.
*
* @see PDF 32000-1:2008 - 14.11.2 Page Boundaries
* @var string
*/
const CROP_BOX = 'CropBox';
/**
* BleedBox
*
* The bleed box defines the region to which the contents of the page shall be clipped when output in a
* production environment.
*
* @see PDF 32000-1:2008 - 14.11.2 Page Boundaries
* @var string
*/
const BLEED_BOX = 'BleedBox';
/**
* TrimBox
*
* The trim box defines the intended dimensions of the finished page after trimming.
*
* @see PDF 32000-1:2008 - 14.11.2 Page Boundaries
* @var string
*/
const TRIM_BOX = 'TrimBox';
/**
* ArtBox
*
* The art box defines the extent of the pages meaningful content (including potential white space) as intended
* by the pages creator.
*
* @see PDF 32000-1:2008 - 14.11.2 Page Boundaries
* @var string
*/
const ART_BOX = 'ArtBox';
/**
* All page boundaries
*
* @var array
*/
public static $all = array(
self::MEDIA_BOX,
self::CROP_BOX,
self::BLEED_BOX,
self::TRIM_BOX,
self::ART_BOX
);
/**
* Checks if a name is a valid page boundary name.
*
* @param string $name The boundary name
* @return boolean A boolean value whether the name is valid or not.
*/
public static function isValidName($name)
{
return \in_array($name, self::$all, true);
}
}
@@ -0,0 +1,240 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfReader;
use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
use setasign\Fpdi\PdfParser\PdfParser;
use setasign\Fpdi\PdfParser\PdfParserException;
use setasign\Fpdi\PdfParser\Type\PdfArray;
use setasign\Fpdi\PdfParser\Type\PdfDictionary;
use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
use setasign\Fpdi\PdfParser\Type\PdfIndirectObjectReference;
use setasign\Fpdi\PdfParser\Type\PdfNumeric;
use setasign\Fpdi\PdfParser\Type\PdfType;
use setasign\Fpdi\PdfParser\Type\PdfTypeException;
/**
* A PDF reader class
*/
class PdfReader
{
/**
* @var PdfParser
*/
protected $parser;
/**
* @var int
*/
protected $pageCount;
/**
* Indirect objects of resolved pages.
*
* @var PdfIndirectObjectReference[]|PdfIndirectObject[]
*/
protected $pages = [];
/**
* PdfReader constructor.
*
* @param PdfParser $parser
*/
public function __construct(PdfParser $parser)
{
$this->parser = $parser;
}
/**
* PdfReader destructor.
*/
public function __destruct()
{
if ($this->parser !== null) {
$this->parser->cleanUp();
}
}
/**
* Get the pdf parser instance.
*
* @return PdfParser
*/
public function getParser()
{
return $this->parser;
}
/**
* Get the PDF version.
*
* @return string
* @throws PdfParserException
*/
public function getPdfVersion()
{
return \implode('.', $this->parser->getPdfVersion());
}
/**
* Get the page count.
*
* @return int
* @throws PdfTypeException
* @throws CrossReferenceException
* @throws PdfParserException
*/
public function getPageCount()
{
if ($this->pageCount === null) {
$catalog = $this->parser->getCatalog();
$pages = PdfType::resolve(PdfDictionary::get($catalog, 'Pages'), $this->parser);
$count = PdfType::resolve(PdfDictionary::get($pages, 'Count'), $this->parser);
$this->pageCount = PdfNumeric::ensure($count)->value;
}
return $this->pageCount;
}
/**
* Get a page instance.
*
* @param int $pageNumber
* @return Page
* @throws PdfTypeException
* @throws CrossReferenceException
* @throws PdfParserException
* @throws \InvalidArgumentException
*/
public function getPage($pageNumber)
{
if (!\is_numeric($pageNumber)) {
throw new \InvalidArgumentException(
'Page number needs to be a number.'
);
}
if ($pageNumber < 1 || $pageNumber > $this->getPageCount()) {
throw new \InvalidArgumentException(
\sprintf(
'Page number "%s" out of available page range (1 - %s)',
$pageNumber,
$this->getPageCount()
)
);
}
$this->readPages();
$page = $this->pages[$pageNumber - 1];
if ($page instanceof PdfIndirectObjectReference) {
$readPages = function ($kids) use (&$readPages) {
$kids = PdfArray::ensure($kids);
/** @noinspection LoopWhichDoesNotLoopInspection */
foreach ($kids->value as $reference) {
$reference = PdfIndirectObjectReference::ensure($reference);
$object = $this->parser->getIndirectObject($reference->value);
$type = PdfDictionary::get($object->value, 'Type');
if ($type->value === 'Pages') {
return $readPages(PdfDictionary::get($object->value, 'Kids'));
}
return $object;
}
throw new PdfReaderException(
'Kids array cannot be empty.',
PdfReaderException::KIDS_EMPTY
);
};
$page = $this->parser->getIndirectObject($page->value);
$dict = PdfType::resolve($page, $this->parser);
$type = PdfDictionary::get($dict, 'Type');
if ($type->value === 'Pages') {
$kids = PdfType::resolve(PdfDictionary::get($dict, 'Kids'), $this->parser);
try {
$page = $this->pages[$pageNumber - 1] = $readPages($kids);
} catch (PdfReaderException $e) {
if ($e->getCode() !== PdfReaderException::KIDS_EMPTY) {
throw $e;
}
// let's reset the pages array and read all page objects
$this->pages = [];
$this->readPages(true);
// @phpstan-ignore-next-line
$page = $this->pages[$pageNumber - 1];
}
} else {
$this->pages[$pageNumber - 1] = $page;
}
}
return new Page($page, $this->parser);
}
/**
* Walk the page tree and resolve all indirect objects of all pages.
*
* @param bool $readAll
* @throws CrossReferenceException
* @throws PdfParserException
* @throws PdfTypeException
*/
protected function readPages($readAll = false)
{
if (\count($this->pages) > 0) {
return;
}
$expectedPageCount = $this->getPageCount();
$readPages = function ($kids, $count) use (&$readPages, $readAll, $expectedPageCount) {
$kids = PdfArray::ensure($kids);
$isLeaf = ($count->value === \count($kids->value));
foreach ($kids->value as $reference) {
$reference = PdfIndirectObjectReference::ensure($reference);
if (!$readAll && $isLeaf) {
$this->pages[] = $reference;
continue;
}
$object = $this->parser->getIndirectObject($reference->value);
$type = PdfDictionary::get($object->value, 'Type');
if ($type->value === 'Pages') {
$readPages(PdfDictionary::get($object->value, 'Kids'), PdfDictionary::get($object->value, 'Count'));
} else {
$this->pages[] = $object;
}
// stop if all pages are read - faulty documents exists with additional entries with invalid data.
if (count($this->pages) === $expectedPageCount) {
break;
}
}
};
$catalog = $this->parser->getCatalog();
$pages = PdfType::resolve(PdfDictionary::get($catalog, 'Pages'), $this->parser);
$count = PdfType::resolve(PdfDictionary::get($pages, 'Count'), $this->parser);
$kids = PdfType::resolve(PdfDictionary::get($pages, 'Kids'), $this->parser);
$readPages($kids, $count);
}
}
@@ -0,0 +1,34 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\PdfReader;
use setasign\Fpdi\FpdiException;
/**
* Exception for the pdf reader class
*/
class PdfReaderException extends FpdiException
{
/**
* @var int
*/
const KIDS_EMPTY = 0x0101;
/**
* @var int
*/
const UNEXPECTED_DATA_TYPE = 0x0102;
/**
* @var int
*/
const MISSING_DATA = 0x0103;
}
@@ -0,0 +1,391 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\Tcpdf;
use setasign\Fpdi\FpdiException;
use setasign\Fpdi\FpdiTrait;
use setasign\Fpdi\PdfParser\CrossReference\CrossReferenceException;
use setasign\Fpdi\PdfParser\Filter\AsciiHex;
use setasign\Fpdi\PdfParser\PdfParserException;
use setasign\Fpdi\PdfParser\Type\PdfArray;
use setasign\Fpdi\PdfParser\Type\PdfDictionary;
use setasign\Fpdi\PdfParser\Type\PdfHexString;
use setasign\Fpdi\PdfParser\Type\PdfIndirectObject;
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\PdfType;
use setasign\Fpdi\PdfParser\Type\PdfTypeException;
/**
* Class Fpdi
*
* This class let you import pages of existing PDF documents into a reusable structure for TCPDF.
*
* @method _encrypt_data(int $n, string $s) string
*/
class Fpdi extends \pdf
{
use FpdiTrait {
writePdfType as fpdiWritePdfType;
useImportedPage as fpdiUseImportedPage;
}
/**
* FPDI version
*
* @string
*/
const VERSION = '2.6.0';
/**
* A counter for template ids.
*
* @var int
*/
protected $templateId = 0;
/**
* The currently used object number.
*
* @var int|null
*/
protected $currentObjectNumber;
protected function _enddoc()
{
parent::_enddoc();
$this->cleanUp();
}
/**
* Get the next template id.
*
* @return int
*/
protected function getNextTemplateId()
{
return $this->templateId++;
}
/**
* Draws an imported page 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 FpdiTrait::getTemplateSize()
*/
public function useTemplate($tpl, $x = 0, $y = 0, $width = null, $height = null, $adjustPageSize = false)
{
return $this->useImportedPage($tpl, $x, $y, $width, $height, $adjustPageSize);
}
/**
* 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)
{
$size = $this->fpdiUseImportedPage($pageId, $x, $y, $width, $height, $adjustPageSize);
if ($this->inxobj) {
$importedPage = $this->importedPages[$pageId];
$this->xobjects[$this->xobjid]['importedPages'][$importedPage['id']] = $pageId;
}
return $size;
}
/**
* 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 getTemplateSize($tpl, $width = null, $height = null)
{
return $this->getImportedPageSize($tpl, $width, $height);
}
/**
* @inheritdoc
* @return string
*/
protected function _getxobjectdict()
{
$out = parent::_getxobjectdict();
foreach ($this->importedPages as $pageData) {
$out .= '/' . $pageData['id'] . ' ' . $pageData['objectNumber'] . ' 0 R ';
}
return $out;
}
/**
* @inheritdoc
* @throws CrossReferenceException
* @throws PdfParserException
*/
protected function _putxobjects()
{
foreach ($this->importedPages as $key => $pageData) {
$this->currentObjectNumber = $this->_newobj();
$this->importedPages[$key]['objectNumber'] = $this->currentObjectNumber;
$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);
}
}
// let's prepare resources for imported pages in templates
foreach ($this->xobjects as $xObjectId => $data) {
if (!isset($data['importedPages'])) {
continue;
}
foreach ($data['importedPages'] as $id => $pageKey) {
$page = $this->importedPages[$pageKey];
$this->xobjects[$xObjectId]['xobjects'][$id] = ['n' => $page['objectNumber']];
}
}
parent::_putxobjects();
$this->currentObjectNumber = null;
}
/**
* Append content to the buffer of TCPDF.
*
* @param string $s
* @param bool $newLine
*/
protected function _put($s, $newLine = true)
{
if ($newLine) {
$this->setBuffer($s . "\n");
} else {
$this->setBuffer($s);
}
}
/**
* Begin a new object and return the object number.
*
* @param int|string $objid Object ID (leave empty to get a new ID).
* @return int object number
*/
protected function _newobj($objid = '')
{
$this->_out($this->_getobj($objid));
return $this->n;
}
/**
* Writes a PdfType object to the resulting buffer.
*
* @param PdfType $value
* @throws PdfTypeException
*/
protected function writePdfType(PdfType $value)
{
if (!$this->encrypted) {
$this->fpdiWritePdfType($value);
return;
}
if ($value instanceof PdfString) {
$string = PdfString::unescape($value->value);
$string = $this->_encrypt_data($this->currentObjectNumber, $string);
$value->value = PdfString::escape($string);
} elseif ($value instanceof PdfHexString) {
$filter = new AsciiHex();
$string = $filter->decode($value->value);
$string = $this->_encrypt_data($this->currentObjectNumber, $string);
$value->value = $filter->encode($string, true);
} elseif ($value instanceof PdfStream) {
$stream = $value->getStream();
$stream = $this->_encrypt_data($this->currentObjectNumber, $stream);
$dictionary = $value->value;
$dictionary->value['Length'] = PdfNumeric::create(\strlen($stream));
$value = PdfStream::create($dictionary, $stream);
} elseif ($value instanceof PdfIndirectObject) {
/**
* @var PdfIndirectObject $value
*/
$this->currentObjectNumber = $this->objectMap[$this->currentReaderId][$value->objectNumber];
}
$this->fpdiWritePdfType($value);
}
/**
* This method will add additional data to the last created link/annotation.
*
* It will copy styling properties (supported by TCPDF) of the imported link.
*
* @param array $externalLink
* @param float|int $xPt
* @param float|int $scaleX
* @param float|int $yPt
* @param float|int $newHeightPt
* @param float|int $scaleY
* @param array $importedPage
* @return void
*/
protected function adjustLastLink($externalLink, $xPt, $scaleX, $yPt, $newHeightPt, $scaleY, $importedPage)
{
$parser = $this->getPdfReader($importedPage['readerId'])->getParser();
if ($this->inxobj) {
// store parameters for later use on template
$lastAnnotationKey = count($this->xobjects[$this->xobjid]['annotations']) - 1;
$lastAnnotationOpt = &$this->xobjects[$this->xobjid]['annotations'][$lastAnnotationKey]['opt'];
} else {
$lastAnnotationKey = count($this->PageAnnots[$this->page]) - 1;
$lastAnnotationOpt = &$this->PageAnnots[$this->page][$lastAnnotationKey]['opt'];
}
// ensure we have a default value - otherwise TCPDF will set it to 4 throughout
$lastAnnotationOpt['f'] = 0;
// values in this dictonary are all direct objects and we don't need to resolve them here again.
$values = $externalLink['pdfObject']->value;
foreach ($values as $key => $value) {
try {
switch ($key) {
case 'BS':
$value = PdfDictionary::ensure($value);
$bs = [];
if (isset($value->value['W'])) {
$bs['w'] = PdfNumeric::ensure($value->value['W'])->value;
}
if (isset($value->value['S'])) {
$bs['s'] = PdfName::ensure($value->value['S'])->value;
}
if (isset($value->value['D'])) {
$d = [];
foreach (PdfArray::ensure($value->value['D'])->value as $item) {
$d[] = PdfNumeric::ensure($item)->value;
}
$bs['d'] = $d;
}
$lastAnnotationOpt['bs'] = $bs;
break;
case 'Border':
$borderArray = PdfArray::ensure($value)->value;
if (count($borderArray) < 3) {
continue 2;
}
$border = [
PdfNumeric::ensure($borderArray[0])->value,
PdfNumeric::ensure($borderArray[1])->value,
PdfNumeric::ensure($borderArray[2])->value,
];
if (isset($borderArray[3])) {
$dashArray = [];
foreach (PdfArray::ensure($borderArray[3])->value as $item) {
$dashArray[] = PdfNumeric::ensure($item)->value;
}
$border[] = $dashArray;
}
$lastAnnotationOpt['border'] = $border;
break;
case 'C':
$c = [];
$colors = PdfArray::ensure(PdfType::resolve($value, $parser))->value;
$m = count($colors) === 4 ? 100 : 255;
foreach ($colors as $item) {
$c[] = PdfNumeric::ensure($item)->value * $m;
}
$lastAnnotationOpt['c'] = $c;
break;
case 'F':
$lastAnnotationOpt['f'] = $value->value;
break;
case 'BE':
// is broken in current TCPDF version: "bc" key is checked but "bs" is used.
break;
}
// let's silence invalid/not supported values
} catch (FpdiException $e) {
continue;
}
}
// QuadPoints are not supported by TCPDF
// if (count($externalLink['quadPoints']) > 0) {
// $quadPoints = [];
// for ($i = 0, $n = count($externalLink['quadPoints']); $i < $n; $i += 2) {
// $quadPoints[] = $xPt + $externalLink['quadPoints'][$i] * $scaleX;
// $quadPoints[] = $this->hPt - $yPt - $newHeightPt + $externalLink['quadPoints'][$i + 1] * $scaleY;
// }
//
// ????? = $quadPoints;
// }
}
}
@@ -0,0 +1,23 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi;
/**
* Class TcpdfFpdi
*
* This class let you import pages of existing PDF documents into a reusable structure for TCPDF.
*
* @deprecated Class was moved to \setasign\Fpdi\Tcpdf\Fpdi
*/
class TcpdfFpdi extends \setasign\Fpdi\Tcpdf\Fpdi
{
// this class is moved to \setasign\Fpdi\Tcpdf\Fpdi
}
@@ -0,0 +1,23 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\Tfpdf;
use setasign\Fpdi\FpdfTplTrait;
/**
* Class FpdfTpl
*
* We need to change some access levels and implement the setPageFormat() method to bring back compatibility to tFPDF.
*/
class FpdfTpl extends \tFPDF
{
use FpdfTplTrait;
}
@@ -0,0 +1,32 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
namespace setasign\Fpdi\Tfpdf;
use setasign\Fpdi\FpdfTrait;
use setasign\Fpdi\FpdiTrait;
/**
* Class Fpdi
*
* This class let you import pages of existing PDF documents into a reusable structure for tFPDF.
*/
class Fpdi extends FpdfTpl
{
use FpdiTrait;
use FpdfTrait;
/**
* FPDI version
*
* @string
*/
const VERSION = '2.6.0';
}
@@ -0,0 +1,21 @@
<?php
/**
* This file is part of FPDI
*
* @package setasign\Fpdi
* @copyright Copyright (c) 2023 Setasign GmbH & Co. KG (https://www.setasign.com)
* @license http://opensource.org/licenses/mit-license The MIT License
*/
spl_autoload_register(static function ($class) {
if (strpos($class, 'setasign\Fpdi\\') === 0) {
$filename = str_replace('\\', DIRECTORY_SEPARATOR, substr($class, 14)) . '.php';
$fullpath = __DIR__ . DIRECTORY_SEPARATOR . $filename;
if (is_file($fullpath)) {
/** @noinspection PhpIncludeInspection */
require_once $fullpath;
}
}
});
@@ -0,0 +1,19 @@
FPDI
==================================
No changes from the upstream version have been made. Both FPDI and FPDF_TPL have
been downloaded and unzipped to this directory.
Information
-----------
URL: http://www.setasign.de/products/pdf-php-solutions/fpdi/
Download from: http://www.setasign.de/products/pdf-php-solutions/fpdi/downloads
Documentation: http://www.setasign.de/products/pdf-php-solutions/fpdi/manuals/
License: The MIT License (MIT)
Installation
------------
1) Download the latest version of fpdi from the url above.
2) Unzip the src directory files into this directory.
3) Update mod/assign/feedback/editpdf/fpdi/Tcpdf/Fpdi.php(or whichever file it has been replaced with) to extend 'pdf' instead of 'TCPDF'.
@@ -0,0 +1,112 @@
<?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/>.
/**
* Strings for component 'assignfeedback_editpdf', language 'en'
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$string['addtoquicklist'] = 'Add to quicklist';
$string['annotationcolour'] = 'Annotation colour';
$string['black'] = 'Black';
$string['blue'] = 'Blue';
$string['cannotopenpdf'] = 'Cannot open the PDF. The file may be corrupt, or in an unsupported format.';
$string['clear'] = 'Clear';
$string['colourpicker'] = 'Colour picker';
$string['commentcolour'] = 'Comment colour';
$string['comment'] = 'Comments';
$string['commentindex'] = 'Index of comments';
$string['commentlabel'] = '{$a->pnum}.{$a->cnum}';
$string['command'] = 'Command:';
$string['commentcontextmenu'] = 'Comment context menu';
$string['couldnotsavepage'] = 'Could not save page {$a}';
$string['currentstamp'] = 'Stamp';
$string['default'] = 'Enabled by default';
$string['default_help'] = 'If set, this feedback method will be enabled by default for all new assignments.';
$string['deleteannotation'] = 'Delete annotation';
$string['deletecomment'] = 'Delete comment';
$string['deletefeedback'] = 'Delete feedback PDF';
$string['downloadablefilename'] = 'feedback.pdf';
$string['downloadfeedback'] = 'Download feedback PDF';
$string['drag'] = 'Drag';
$string['errorgenerateimage'] = 'Error generating image with ghostscript, debugging info: {$a}';
$string['errorpdfpage'] = 'There was an error while generating this page.';
$string['editpdf'] = 'Annotate PDF';
$string['editpdf_help'] = 'Annotate student submissions directly in the browser and produce an edited downloadable PDF.';
$string['enabled'] = 'Annotate PDF';
$string['enabled_help'] = 'If enabled, the teacher will be able to create annotated PDF files when marking assignment submissions. This allows the teacher to add comments, drawing and stamps directly on top of the student\'s work. The annotating is done in the browser and no extra software is required.';
$string['expcolcomments'] = 'Expand/collapse all comments';
$string['filter'] = 'Filter comments...';
$string['generatefeedback'] = 'Generate feedback PDF';
$string['gotopage'] = 'Go to page';
$string['green'] = 'Green';
$string['gsimage'] = 'Ghostscript test image';
$string['pathtogserror'] = 'The configured path to ghostscript is not correctly set: {$a}';
$string['pathtogspathdesc'] = 'Please note that annotate PDF requires the path to ghostscript to be set in {$a}.';
$string['highlight'] = 'Highlight';
$string['jsrequired'] = 'JavaScript is required to annotate a PDF. Please enable JavaScript in your browser to use this feature.';
$string['launcheditor'] = 'Launch PDF editor...';
$string['line'] = 'Line';
$string['loadingeditor'] = 'Loading PDF editor';
$string['navigatenext'] = 'Next page (Alt/Shift-Alt/Ctrl-Option + {$a})';
$string['navigateprevious'] = 'Previous page (Alt/Shift-Alt/Ctrl-Option + {$a})';
$string['oval'] = 'Oval';
$string['output'] = 'Output:';
$string['pagenumber'] = 'Page {$a}';
$string['pagexofy'] = 'Page {$a->page} of {$a->total}';
$string['pen'] = 'Pen';
$string['partialwarning'] = 'Some of the files in this submission can only be accessed by direct download.';
$string['pluginname'] = 'Annotate PDF';
$string['privacy:metadata:colourpurpose'] = 'Colour of the comment or annotation';
$string['privacy:metadata:conversionpurpose'] = 'Files are converted to PDFs to allow for annotations.';
$string['privacy:metadata:filepurpose'] = 'Stores an annotated PDF with feedback for the user.';
$string['privacy:metadata:rawtextpurpose'] = 'Stores raw text for the quick data.';
$string['privacy:metadata:tablepurpose'] = 'Stores teacher specified quicklist comments';
$string['privacy:metadata:userid'] = 'The user ID';
$string['privacy:path'] = 'PDF Feedback';
$string['generatingpdf'] = 'Generating the PDF...';
$string['rectangle'] = 'Rectangle';
$string['red'] = 'Red';
$string['result'] = 'Result:';
$string['searchcomments'] = 'Search comments';
$string['select'] = 'Select';
$string['stamppicker'] = 'Stamp picker';
$string['stampsdesc'] = 'Stamps must be image files (recommended size: 40x40). These images can be used with the stamp tool to annotate the PDF.';
$string['stamps'] = 'Stamps';
$string['stamp'] = 'Stamp';
$string['test_doesnotexist'] = 'The ghostscript path points to a non-existent file';
$string['test_empty'] = 'The ghostscript path is empty - please enter the correct path';
$string['testgs'] = 'Test ghostscript path';
$string['test_isdir'] = 'The ghostscript path points to a folder, please include the ghostscript program in the path you specify';
$string['test_notestfile'] = 'The test PDF is missing';
$string['test_notexecutable'] = 'The ghostscript points to a file that is not executable';
$string['test_ok'] = 'The ghostscript path appears to be OK - please check you can see the message in the image below';
$string['test_doesnotexist'] = 'The ghostscript path points to a non-existent file';
$string['test_empty'] = 'The ghostscript path is empty - please enter the correct path';
$string['toolbarbutton'] = '{$a->tool} {$a->shortcut}';
$string['tool'] = 'Tool';
$string['viewfeedbackonline'] = 'View annotated PDF...';
$string['white'] = 'White';
$string['yellow'] = 'Yellow';
$string['draftchangessaved'] = 'Draft annotations saved';
$string['preparesubmissionsforannotation'] = 'Prepare submissions for annotation';
$string['rotateleft'] = 'Rotate 90 degrees to the left';
$string['rotateright'] = 'Rotate 90 degrees to the right';
+134
View File
@@ -0,0 +1,134 @@
<?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 file contains the version information for the comments feedback plugin
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->dirroot . '/mod/assign/locallib.php');
/**
* Serves assignment feedback and other files.
*
* @param mixed $course course or id of the course
* @param mixed $cm course module or id of the course module
* @param context $context
* @param string $filearea
* @param array $args
* @param bool $forcedownload
* @param array $options - List of options affecting file serving.
* @return bool false if file not found, does not return if found - just send the file
*/
function assignfeedback_editpdf_pluginfile(
$course,
$cm,
context $context,
$filearea,
$args,
$forcedownload,
array $options = array()
) {
global $DB;
if ($filearea === 'systemstamps') {
if ($context->contextlevel !== CONTEXT_SYSTEM) {
return false;
}
$filename = array_pop($args);
$filepath = '/' . implode('/', $args) . '/';
$fs = get_file_storage();
$file = $fs->get_file($context->id, 'assignfeedback_editpdf', $filearea, 0, $filepath, $filename);
if (!$file) {
return false;
}
$options['cacheability'] = 'public';
$options['immutable'] = true;
send_stored_file($file, null, 0, false, $options);
}
if ($context->contextlevel == CONTEXT_MODULE) {
require_login($course, false, $cm);
$itemid = (int)array_shift($args);
$assign = new assign($context, $cm, $course);
$record = $DB->get_record('assign_grades', array('id' => $itemid), 'userid,assignment', MUST_EXIST);
$userid = $record->userid;
if ($assign->get_instance()->id != $record->assignment) {
return false;
}
// Rely on mod_assign checking permissions.
if (!$assign->can_view_submission($userid)) {
return false;
}
$relativepath = implode('/', $args);
$fullpath = "/{$context->id}/assignfeedback_editpdf/$filearea/$itemid/$relativepath";
$fs = get_file_storage();
if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
return false;
}
// Download MUST be forced - security!
send_stored_file($file, 0, 0, true, $options);// Check if we want to retrieve the stamps.
}
}
/**
* Files API hook to remove stale conversion records.
*
* When a file is update, its contenthash will change, but its ID
* remains the same. The document converter API records source file
* IDs and destination file IDs. When a file is updated, the document
* converter API has no way of knowing that the content of the file
* has changed, so it just serves the previously stored destination
* file.
*
* In this hook we check if the contenthash has changed, and if it has
* we delete the existing conversion so that a new one will be created.
*
* @param stdClass $file The updated file record.
* @param stdClass $filepreupdate The file record pre-update.
*/
function assignfeedback_editpdf_after_file_updated(stdClass $file, stdClass $filepreupdate) {
$contenthashchanged = $file->contenthash !== $filepreupdate->contenthash;
if ($contenthashchanged && $file->component == 'assignsubmission_file' && $file->filearea == 'submission_files') {
$fs = get_file_storage();
$file = $fs->get_file_by_id($file->id);
$conversions = \core_files\conversion::get_conversions_for_file($file, 'pdf');
foreach ($conversions as $conversion) {
if ($conversion->get('id')) {
$conversion->delete();
}
}
}
}
+487
View File
@@ -0,0 +1,487 @@
<?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 file contains the definition for the library class for PDF feedback plugin
*
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
use \assignfeedback_editpdf\document_services;
use \assignfeedback_editpdf\page_editor;
/**
* library class for editpdf feedback plugin extending feedback plugin base class
*
* @package assignfeedback_editpdf
* @copyright 2012 Davo Smith
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class assign_feedback_editpdf extends assign_feedback_plugin {
/** @var boolean|null $enabledcache Cached lookup of the is_enabled function */
private $enabledcache = null;
/**
* Get the name of the file feedback plugin
* @return string
*/
public function get_name() {
return get_string('pluginname', 'assignfeedback_editpdf');
}
/**
* Create a widget for rendering the editor.
*
* @param int $userid
* @param stdClass $grade
* @param bool $readonly
* @return assignfeedback_editpdf_widget
*/
public function get_widget($userid, $grade, $readonly) {
$attempt = -1;
if ($grade && isset($grade->attemptnumber)) {
$attempt = $grade->attemptnumber;
} else {
$grade = $this->assignment->get_user_grade($userid, true);
}
$feedbackfile = document_services::get_feedback_document(
$this->assignment->get_instance()->id,
$userid,
$attempt
);
$stampfiles = array();
$fs = get_file_storage();
$syscontext = context_system::instance();
$asscontext = $this->assignment->get_context();
// Three file areas are used for stamps.
// Current stamps are those configured as a site administration setting to be available for new uses.
// When a stamp is removed from this filearea it is no longer available for new grade items.
$currentstamps = $fs->get_area_files($syscontext->id, 'assignfeedback_editpdf', 'stamps', 0, 'filename', false);
// Grade stamps are those which have been assigned for a specific grade item.
// The stamps associated with a grade item are always used for that grade item, even if the stamp is removed
// from the list of current stamps.
$gradestamps = $fs->get_area_files($asscontext->id, 'assignfeedback_editpdf', 'stamps', $grade->id, 'filename', false);
// The system stamps are perpetual and always exist.
// They allow Moodle to serve a common URL for all users for any possible combination of stamps.
// Files in the perpetual stamp filearea are within the system context, in itemid 0, and use the original stamps
// contenthash as a folder name. This ensures that the combination of stamp filename, and stamp file content is
// unique.
$systemstamps = $fs->get_area_files($syscontext->id, 'assignfeedback_editpdf', 'systemstamps', 0, 'filename', false);
// First check that all current stamps are listed in the grade stamps.
foreach ($currentstamps as $stamp) {
// Ensure that the current stamp is in the list of perpetual stamps.
$systempathnamehash = $this->get_system_stamp_path($stamp);
if (!array_key_exists($systempathnamehash, $systemstamps)) {
$filerecord = (object) [
'filearea' => 'systemstamps',
'filepath' => '/' . $stamp->get_contenthash() . '/',
];
$newstamp = $fs->create_file_from_storedfile($filerecord, $stamp);
$systemstamps[$newstamp->get_pathnamehash()] = $newstamp;
}
// Ensure that the current stamp is in the list of stamps for the current grade item.
$gradeitempathhash = $this->get_assignment_stamp_path($stamp, $grade->id);
if (!array_key_exists($gradeitempathhash, $gradestamps)) {
$filerecord = (object) [
'contextid' => $asscontext->id,
'filearea' => 'stamps',
'itemid' => $grade->id,
];
$newstamp = $fs->create_file_from_storedfile($filerecord, $stamp);
$gradestamps[$newstamp->get_pathnamehash()] = $newstamp;
}
}
foreach ($gradestamps as $stamp) {
// All gradestamps should be available in the systemstamps filearea, but some legacy stamps may not be.
// These need to be copied over.
// Note: This should ideally be performed as an upgrade step, but there may be other cases that these do not
// exist, for example restored backups.
// In any case this is a cheap operation as it is solely performing an array lookup.
$systempathnamehash = $this->get_system_stamp_path($stamp);
if (!array_key_exists($systempathnamehash, $systemstamps)) {
$filerecord = (object) [
'contextid' => $syscontext->id,
'itemid' => 0,
'filearea' => 'systemstamps',
'filepath' => '/' . $stamp->get_contenthash() . '/',
];
$systemstamp = $fs->create_file_from_storedfile($filerecord, $stamp);
$systemstamps[$systemstamp->get_pathnamehash()] = $systemstamp;
}
// Always serve the perpetual system stamp.
// This ensures that the stamp is highly cached and reduces the hit on the application server.
$gradestamp = $systemstamps[$systempathnamehash];
$url = moodle_url::make_pluginfile_url(
$gradestamp->get_contextid(),
$gradestamp->get_component(),
$gradestamp->get_filearea(),
null,
$gradestamp->get_filepath(),
$gradestamp->get_filename(),
false
);
array_push($stampfiles, $url->out());
}
$url = false;
$filename = '';
if ($feedbackfile) {
$url = moodle_url::make_pluginfile_url(
$this->assignment->get_context()->id,
'assignfeedback_editpdf',
document_services::FINAL_PDF_FILEAREA,
$grade->id,
'/',
$feedbackfile->get_filename(),
false
);
$filename = $feedbackfile->get_filename();
}
$widget = new assignfeedback_editpdf_widget(
$this->assignment->get_instance()->id,
$userid,
$attempt,
$url,
$filename,
$stampfiles,
$readonly
);
return $widget;
}
/**
* Get the pathnamehash for the specified stamp if in the system stamps.
*
* @param stored_file $file
* @return string
*/
protected function get_system_stamp_path(stored_file $stamp): string {
$systemcontext = context_system::instance();
return file_storage::get_pathname_hash(
$systemcontext->id,
'assignfeedback_editpdf',
'systemstamps',
0,
'/' . $stamp->get_contenthash() . '/',
$stamp->get_filename()
);
}
/**
* Get the pathnamehash for the specified stamp if in the current assignment stamps.
*
* @param stored_file $file
* @param int $gradeid
* @return string
*/
protected function get_assignment_stamp_path(stored_file $stamp, int $gradeid): string {
return file_storage::get_pathname_hash(
$this->assignment->get_context()->id,
'assignfeedback_editpdf',
'stamps',
$gradeid,
$stamp->get_filepath(),
$stamp->get_filename()
);
}
/**
* Get form elements for grading form
*
* @param stdClass $grade
* @param MoodleQuickForm $mform
* @param stdClass $data
* @param int $userid
* @return bool true if elements were added to the form
*/
public function get_form_elements_for_user($grade, MoodleQuickForm $mform, stdClass $data, $userid) {
global $PAGE;
$attempt = -1;
if ($grade) {
$attempt = $grade->attemptnumber;
}
$renderer = $PAGE->get_renderer('assignfeedback_editpdf');
// Links to download the generated pdf...
if ($attempt > -1 && page_editor::has_annotations_or_comments($grade->id, false)) {
$html = $this->assignment->render_area_files('assignfeedback_editpdf',
document_services::FINAL_PDF_FILEAREA,
$grade->id);
$mform->addElement('static', 'editpdf_files', get_string('downloadfeedback', 'assignfeedback_editpdf'), $html);
}
$widget = $this->get_widget($userid, $grade, false);
$html = $renderer->render($widget);
$mform->addElement('static', 'editpdf', get_string('editpdf', 'assignfeedback_editpdf'), $html);
$mform->addHelpButton('editpdf', 'editpdf', 'assignfeedback_editpdf');
$mform->addElement('hidden', 'editpdf_source_userid', $userid);
$mform->setType('editpdf_source_userid', PARAM_INT);
$mform->setConstant('editpdf_source_userid', $userid);
}
/**
* Check to see if the grade feedback for the pdf has been modified.
*
* @param stdClass $grade Grade object.
* @param stdClass $data Data from the form submission (not used).
* @return boolean True if the pdf has been modified, else false.
*/
public function is_feedback_modified(stdClass $grade, stdClass $data) {
// We only need to know if the source user's PDF has changed. If so then all
// following users will have the same status. If it's only an individual annotation
// then only one user will come through this method.
// Source user id is only added to the form if there was a pdf.
if (!empty($data->editpdf_source_userid)) {
$sourceuserid = $data->editpdf_source_userid;
// Retrieve the grade information for the source user.
$sourcegrade = $this->assignment->get_user_grade($sourceuserid, true, $grade->attemptnumber);
$pagenumbercount = document_services::page_number_for_attempt($this->assignment, $sourceuserid, $sourcegrade->attemptnumber);
for ($i = 0; $i < $pagenumbercount; $i++) {
// Select all annotations.
$draftannotations = page_editor::get_annotations($sourcegrade->id, $i, true);
$nondraftannotations = page_editor::get_annotations($grade->id, $i, false);
// Check to see if the count is the same.
if (count($draftannotations) != count($nondraftannotations)) {
// The count is different so we have a modification.
return true;
} else {
$matches = 0;
// Have a closer look and see if the draft files match all the non draft files.
foreach ($nondraftannotations as $ndannotation) {
foreach ($draftannotations as $dannotation) {
foreach ($ndannotation as $key => $value) {
// As the $draft was included in the class annotation,
// it is necessary to omit it in the condition below as well,
// otherwise, an error would be raised.
if ($key != 'id' && $key != 'draft' && $value != $dannotation->{$key}) {
continue 2;
}
}
$matches++;
}
}
if ($matches !== count($nondraftannotations)) {
return true;
}
}
// Select all comments.
$draftcomments = page_editor::get_comments($sourcegrade->id, $i, true);
$nondraftcomments = page_editor::get_comments($grade->id, $i, false);
if (count($draftcomments) != count($nondraftcomments)) {
return true;
} else {
// Go for a closer inspection.
$matches = 0;
foreach ($nondraftcomments as $ndcomment) {
foreach ($draftcomments as $dcomment) {
foreach ($ndcomment as $key => $value) {
// As the $draft was included in the class comment,
// it is necessary to omit it in the condition below as well,
// otherwise, an error would be raised.
if ($key != 'id' && $key != 'draft' && $value != $dcomment->{$key}) {
continue 2;
}
}
$matches++;
}
}
if ($matches !== count($nondraftcomments)) {
return true;
}
}
}
}
return false;
}
/**
* Generate the pdf.
*
* @param stdClass $grade
* @param stdClass $data
* @return bool
*/
public function save(stdClass $grade, stdClass $data) {
// Source user id is only added to the form if there was a pdf.
if (!empty($data->editpdf_source_userid)) {
$sourceuserid = $data->editpdf_source_userid;
// Copy drafts annotations and comments if current user is different to sourceuserid.
if ($sourceuserid != $grade->userid) {
page_editor::copy_drafts_from_to($this->assignment, $grade, $sourceuserid);
}
}
if (page_editor::has_annotations_or_comments($grade->id, true)) {
document_services::generate_feedback_document($this->assignment, $grade->userid, $grade->attemptnumber);
}
return true;
}
/**
* Display the list of files in the feedback status table.
*
* @param stdClass $grade
* @param bool $showviewlink (Always set to false).
* @return string
*/
public function view_summary(stdClass $grade, & $showviewlink) {
$showviewlink = false;
return $this->view($grade);
}
/**
* Display the list of files in the feedback status table.
*
* @param stdClass $grade
* @return string
*/
public function view(stdClass $grade) {
global $PAGE;
$html = '';
// Show a link to download the pdf.
if (page_editor::has_annotations_or_comments($grade->id, false)) {
$html = $this->assignment->render_area_files('assignfeedback_editpdf',
document_services::FINAL_PDF_FILEAREA,
$grade->id);
// Also show the link to the read-only interface.
$renderer = $PAGE->get_renderer('assignfeedback_editpdf');
$widget = $this->get_widget($grade->userid, $grade, true);
$html .= $renderer->render($widget);
}
return $html;
}
/**
* Return true if there are no released comments/annotations.
*
* @param stdClass $grade
*/
public function is_empty(stdClass $grade) {
global $DB;
$comments = $DB->count_records('assignfeedback_editpdf_cmnt', array('gradeid'=>$grade->id, 'draft'=>0));
$annotations = $DB->count_records('assignfeedback_editpdf_annot', array('gradeid'=>$grade->id, 'draft'=>0));
return $comments == 0 && $annotations == 0;
}
/**
* The assignment has been deleted - remove the plugin specific data
*
* @return bool
*/
public function delete_instance() {
global $DB;
$grades = $DB->get_records('assign_grades', array('assignment'=>$this->assignment->get_instance()->id), '', 'id');
if ($grades) {
list($gradeids, $params) = $DB->get_in_or_equal(array_keys($grades), SQL_PARAMS_NAMED);
$DB->delete_records_select('assignfeedback_editpdf_annot', 'gradeid ' . $gradeids, $params);
$DB->delete_records_select('assignfeedback_editpdf_cmnt', 'gradeid ' . $gradeids, $params);
$DB->delete_records_select('assignfeedback_editpdf_rot', 'gradeid ' . $gradeids, $params);
}
return true;
}
/**
* Determine if ghostscript is available and working.
*
* @return bool
*/
public function is_available() {
if ($this->enabledcache === null) {
$testpath = assignfeedback_editpdf\pdf::test_gs_path(false);
$this->enabledcache = ($testpath->status == assignfeedback_editpdf\pdf::GSPATH_OK);
}
return $this->enabledcache;
}
/**
* Prevent enabling this plugin if ghostscript is not available.
*
* @return bool false
*/
public function is_configurable() {
return $this->is_available();
}
/**
* Get file areas returns a list of areas this plugin stores files.
*
* @return array - An array of fileareas (keys) and descriptions (values)
*/
public function get_file_areas() {
return [
document_services::FINAL_PDF_FILEAREA => $this->get_name(),
document_services::COMBINED_PDF_FILEAREA => $this->get_name(),
document_services::PARTIAL_PDF_FILEAREA => $this->get_name(),
document_services::IMPORT_HTML_FILEAREA => $this->get_name(),
document_services::PAGE_IMAGE_FILEAREA => $this->get_name(),
document_services::PAGE_IMAGE_READONLY_FILEAREA => $this->get_name(),
document_services::STAMPS_FILEAREA => $this->get_name(),
document_services::TMP_JPG_TO_PDF_FILEAREA => $this->get_name(),
document_services::TMP_ROTATED_JPG_FILEAREA => $this->get_name()
];
}
/**
* Get all file areas for user data related to this plugin.
*
* @return array - An array of user data fileareas (keys) and descriptions (values)
*/
public function get_user_data_file_areas(): array {
return [
document_services::FINAL_PDF_FILEAREA => $this->get_name(),
];
}
/**
* This plugin will inject content into the review panel with javascript.
* @return bool true
*/
public function supports_review_panel() {
return true;
}
/**
* Return the plugin configs for external functions.
*
* @return array the list of settings
* @since Moodle 3.2
*/
public function get_config_for_external() {
return (array) $this->get_config();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M16 15c0 .5-.5 1-1 1H1c-.5 0-1-.5-1-1V1c0-.5.5-1 1-1h14c.5 0 1 .5 1 1v14z" fill="#cbd9ed"/></svg>

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

@@ -0,0 +1,3 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M15 0H1C.5 0 0 .5 0 1v14c0 .5.5 1 1 1h14c.5 0 1-.5 1-1V1c0-.5-.5-1-1-1zm0 8H8v7H2c-.5 0-1-.5-1-1V8h7V1h6c.5 0 1 .5 1 1v6z" fill="#888"/></svg>

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 B

Some files were not shown because too many files have changed in this diff Show More