Merge branch 'MDL-59629-master' of git://github.com/peterRd/moodle
[moodle.git] / mod / assign / feedback / editpdf / classes / combined_document.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * This file contains the combined document class for the assignfeedback_editpdf plugin.
19  *
20  * @package   assignfeedback_editpdf
21  * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace assignfeedback_editpdf;
27 defined('MOODLE_INTERNAL') || die();
29 /**
30  * The combined_document class for the assignfeedback_editpdf plugin.
31  *
32  * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
33  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
34  */
35 class combined_document {
37     /**
38      * Status value representing a conversion waiting to start.
39      */
40     const STATUS_PENDING_INPUT = 0;
42     /**
43      * Status value representing all documents ready to be combined.
44      */
45     const STATUS_READY = 1;
47     /**
48      * Status value representing all documents are ready to be combined as are supported.
49      */
50     const STATUS_READY_PARTIAL = 3;
52     /**
53      * Status value representing a successful conversion.
54      */
55     const STATUS_COMPLETE = 2;
57     /**
58      * Status value representing a permanent error.
59      */
60     const STATUS_FAILED = -1;
62     /**
63      * The list of files which make this document.
64      */
65     protected $sourcefiles = [];
67     /**
68      * The resultant combined file.
69      */
70     protected $combinedfile;
72     /**
73      * The combination status.
74      */
75     protected $combinationstatus = null;
77     /**
78      * The number of pages in the combined PDF.
79      */
80     protected $pagecount = 0;
82     /**
83      * Check the current status of the document combination.
84      * Note that the combined document may not contain all the source files if some of the
85      * source files were not able to be converted. An example is an audio file with a pdf cover sheet. Only
86      * the cover sheet will be included in the combined document.
87      *
88      * @return  int
89      */
90     public function get_status() {
91         if ($this->combinedfile) {
92             // The combined file exists. Report success.
93             return self::STATUS_COMPLETE;
94         }
96         if (empty($this->sourcefiles)) {
97             // There are no source files to combine.
98             return self::STATUS_FAILED;
99         }
101         if (!empty($this->combinationstatus)) {
102             // The combination is in progress and has set a status.
103             // Return it instead.
104             return $this->combinationstatus;
105         }
107         $pending = false;
108         $partial = false;
109         foreach ($this->sourcefiles as $file) {
110             // The combined file has not yet been generated.
111             // Check the status of each source file.
112             if (is_a($file, \core_files\conversion::class)) {
113                 $status = $file->get('status');
114                 switch ($status) {
115                     case \core_files\conversion::STATUS_IN_PROGRESS:
116                     case \core_files\conversion::STATUS_PENDING:
117                         $pending = true;
118                         break;
120                     // There are 4 status flags, so the only remaining one is complete which is fine.
121                     case \core_files\conversion::STATUS_FAILED:
122                         $partial = true;
123                         break;
124                 }
125             }
126         }
127         if ($pending) {
128             return self::STATUS_PENDING_INPUT;
129         } else {
130             if ($partial) {
131                 return self::STATUS_READY_PARTIAL;
132             }
133             return self::STATUS_READY;
134         }
135     }
136     /**
137      * Set the completed combined file.
138      *
139      * @param   stored_file $file The completed document for all files to be combined.
140      * @return  $this
141      */
142     public function set_combined_file($file) {
143         $this->combinedfile = $file;
145         return $this;
146     }
148     /**
149      * Return true of the combined file contained only some of the submission files.
150      *
151      * @return  boolean
152      */
153     public function is_partial_conversion() {
154         $combinedfile = $this->get_combined_file();
155         if (empty($combinedfile)) {
156             return false;
157         }
158         $filearea = $combinedfile->get_filearea();
159         return $filearea == document_services::PARTIAL_PDF_FILEAREA;
160     }
162     /**
163      * Retrieve the completed combined file.
164      *
165      * @return  stored_file
166      */
167     public function get_combined_file() {
168         return $this->combinedfile;
169     }
171     /**
172      * Set all source files which are to be combined.
173      *
174      * @param   stored_file|conversion[] $files The complete list of all source files to be combined.
175      * @return  $this
176      */
177     public function set_source_files($files) {
178         $this->sourcefiles = $files;
180         return $this;
181     }
183     /**
184      * Add an additional source file to the end of the existing list.
185      *
186      * @param   stored_file|conversion $file The file to add to the end of the list.
187      * @return  $this
188      */
189     public function add_source_file($file) {
190         $this->sourcefiles[] = $file;
192         return $this;
193     }
195     /**
196      * Retrieve the complete list of source files.
197      *
198      * @return  stored_file|conversion[]
199      */
200     public function get_source_files() {
201         return $this->sourcefiles;
202     }
204     /**
205      * Refresh the files.
206      *
207      * This includes polling any pending conversions to see if they are complete.
208      *
209      * @return  $this
210      */
211     public function refresh_files() {
212         $converter = new \core_files\converter();
213         foreach ($this->sourcefiles as $file) {
214             if (is_a($file, \core_files\conversion::class)) {
215                 $status = $file->get('status');
216                 switch ($status) {
217                     case \core_files\conversion::STATUS_COMPLETE:
218                         continue 2;
219                         break;
220                     default:
221                         $converter->poll_conversion($conversion);
222                 }
223             }
224         }
226         return $this;
227     }
229     /**
230      * Combine all source files into a single PDF and store it in the
231      * file_storage API using the supplied contextid and itemid.
232      *
233      * @param   int $contextid The contextid for the file to be stored under
234      * @param   int $itemid The itemid for the file to be stored under
235      * @return  $this
236      */
237     public function combine_files($contextid, $itemid) {
238         global $CFG;
240         $currentstatus = $this->get_status();
241         $readystatuslist = [self::STATUS_READY, self::STATUS_READY_PARTIAL];
242         if ($currentstatus === self::STATUS_FAILED) {
243             $this->store_empty_document($contextid, $itemid);
245             return $this;
246         } else if (!in_array($currentstatus, $readystatuslist)) {
247             // The document is either:
248             // * already combined; or
249             // * pending input being fully converted; or
250             // * unable to continue due to an issue with the input documents.
251             //
252             // Exit early as we cannot continue.
253             return $this;
254         }
256         require_once($CFG->libdir . '/pdflib.php');
258         $pdf = new pdf();
259         $files = $this->get_source_files();
260         $compatiblepdfs = [];
262         foreach ($files as $file) {
263             // Check that each file is compatible and add it to the list.
264             // Note: We drop non-compatible files.
265             $compatiblepdf = false;
266             if (is_a($file, \core_files\conversion::class)) {
267                 $status = $file->get('status');
268                 if ($status == \core_files\conversion::STATUS_COMPLETE) {
269                     $compatiblepdf = pdf::ensure_pdf_compatible($file->get_destfile());
270                 }
271             } else {
272                 $compatiblepdf = pdf::ensure_pdf_compatible($file);
273             }
275             if ($compatiblepdf) {
276                 $compatiblepdfs[] = $compatiblepdf;
277             }
278         }
280         $tmpdir = make_request_directory();
281         $tmpfile = $tmpdir . '/' . document_services::COMBINED_PDF_FILENAME;
283         try {
284             $pagecount = $pdf->combine_pdfs($compatiblepdfs, $tmpfile);
285             $pdf->Close();
286         } catch (\Exception $e) {
287             // Unable to combine the PDF.
288             debugging('TCPDF could not process the pdf files:' . $e->getMessage(), DEBUG_DEVELOPER);
290             $pdf->Close();
291             return $this->mark_combination_failed();
292         }
294         // Verify the PDF.
295         $verifypdf = new pdf();
296         $verifypagecount = $verifypdf->load_pdf($tmpfile);
297         $verifypdf->Close();
299         if ($verifypagecount <= 0) {
300             // No pages were found in the combined PDF.
301             return $this->mark_combination_failed();
302         }
304         // Store the newly created file as a stored_file.
305         $this->store_combined_file($tmpfile, $contextid, $itemid, ($currentstatus == self::STATUS_READY_PARTIAL));
307         // Note the verified page count.
308         $this->pagecount = $verifypagecount;
310         return $this;
311     }
313     /**
314      * Mark the combination attempt as having encountered a permanent failure.
315      *
316      * @return  $this
317      */
318     protected function mark_combination_failed() {
319         $this->combinationstatus = self::STATUS_FAILED;
321         return $this;
322     }
324     /**
325      * Store the combined file in the file_storage API.
326      *
327      * @param   string $tmpfile The path to the file on disk to be stored.
328      * @param   int $contextid The contextid for the file to be stored under
329      * @param   int $itemid The itemid for the file to be stored under
330      * @param   boolean $partial The combined pdf contains only some of the source files.
331      * @return  $this
332      */
333     protected function store_combined_file($tmpfile, $contextid, $itemid, $partial = false) {
334         // Store the file.
335         $record = $this->get_stored_file_record($contextid, $itemid, $partial);
336         $fs = get_file_storage();
338         // Delete existing files first.
339         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
341         // This was a combined pdf.
342         $file = $fs->create_file_from_pathname($record, $tmpfile);
344         $this->set_combined_file($file);
346         return $this;
347     }
349     /**
350      * Store the empty document file in the file_storage API.
351      *
352      * @param   int $contextid The contextid for the file to be stored under
353      * @param   int $itemid The itemid for the file to be stored under
354      * @return  $this
355      */
356     protected function store_empty_document($contextid, $itemid) {
357         // Store the file.
358         $record = $this->get_stored_file_record($contextid, $itemid);
359         $fs = get_file_storage();
361         // Delete existing files first.
362         $fs->delete_area_files($record->contextid, $record->component, $record->filearea, $record->itemid);
364         $file = $fs->create_file_from_string($record, base64_decode(document_services::BLANK_PDF_BASE64));
365         $this->pagecount = 1;
367         $this->set_combined_file($file);
369         return $this;
370     }
372     /**
373      * Get the total number of pages in the combined document.
374      *
375      * If there are no pages, or it is not yet possible to count them a
376      * value of 0 is returned.
377      *
378      * @return  int
379      */
380     public function get_page_count() {
381         if ($this->pagecount) {
382             return $this->pagecount;
383         }
385         $status = $this->get_status();
387         if ($status === self::STATUS_FAILED) {
388             // The empty document will be returned.
389             return 1;
390         }
392         if ($status !== self::STATUS_COMPLETE) {
393             // No pages yet.
394             return 0;
395         }
397         // Load the PDF to determine the page count.
398         $temparea = make_request_directory();
399         $tempsrc = $temparea . "/source.pdf";
400         $this->get_combined_file()->copy_content_to($tempsrc);
402         $pdf = new pdf();
403         $pagecount = $pdf->load_pdf($tempsrc);
404         $pdf->Close();
406         if ($pagecount <= 0) {
407             // Something went wrong. Return an empty page count again.
408             return 0;
409         }
411         $this->pagecount = $pagecount;
412         return $this->pagecount;
413     }
415     /**
416      * Get the total number of documents to be combined.
417      *
418      * @return  int
419      */
420     public function get_document_count() {
421         return count($this->sourcefiles);
422     }
424     /**
425      * Helper to fetch the stored_file record.
426      *
427      * @param   int $contextid The contextid for the file to be stored under
428      * @param   int $itemid The itemid for the file to be stored under
429      * @param   boolean $partial The combined file contains only some of the source files.
430      * @return  stdClass
431      */
432     protected function get_stored_file_record($contextid, $itemid, $partial = false) {
433         $filearea = document_services::COMBINED_PDF_FILEAREA;
434         if ($partial) {
435             $filearea = document_services::PARTIAL_PDF_FILEAREA;
436         }
437         return (object) [
438             'contextid' => $contextid,
439             'component' => 'assignfeedback_editpdf',
440             'filearea' => $filearea,
441             'itemid' => $itemid,
442             'filepath' => '/',
443             'filename' => document_services::COMBINED_PDF_FILENAME,
444         ];
445     }