Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.37% covered (warning)
72.37%
220 / 304
46.88% covered (danger)
46.88%
15 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
PagedTiffHandler
72.37% covered (warning)
72.37%
220 / 304
46.88% covered (danger)
46.88%
15 / 32
423.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mustRender
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMultiPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verifyUpload
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 verifyMetaData
57.14% covered (warning)
57.14%
12 / 21
0.00% covered (danger)
0.00%
0 / 1
10.86
 getParamMap
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 validateParam
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
15.66
 makeParamString
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
4.59
 parseParamString
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getScriptParams
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 normaliseParams
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 getMetadataErrors
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 isMetadataError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 joinMessages
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
12.58
 getScalerType
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 transformIM
65.00% covered (warning)
65.00%
26 / 40
0.00% covered (danger)
0.00%
0 / 1
12.47
 getThumbType
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 pageCount
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 firstPage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 lastPage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 adjustPage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 doThumbError
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getSizeAndMetadata
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 isFileMetadataValid
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 formatMetadata
60.98% covered (warning)
60.98%
25 / 41
0.00% covered (danger)
0.00%
0 / 1
11.80
 getTiffImage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getCachedTiffImage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getPageDimensions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 isExpensiveToThumbnail
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getThumbnailSource
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getIntermediaryStep
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2/**
3 * Copyright © Wikimedia Deutschland, 2009
4 * Authors Hallo Welt! Medienwerkstatt GmbH
5 * Authors Sebastian Ulbricht, Daniel Lynge, Marc Reymann, Markus Glaser
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 */
22
23namespace MediaWiki\Extension\PagedTiffHandler;
24
25use Exception;
26use File;
27use FormatMetadata;
28use IContextSource;
29use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
30use MapCacheLRU;
31use MediaHandlerState;
32use MediaTransformError;
33use MediaTransformOutput;
34use MediaWiki\HookContainer\HookContainer;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Shell\CommandFactory;
37use MediaWiki\User\UserOptionsLookup;
38use Message;
39use RequestContext;
40use Status;
41use TransformationalImageHandler;
42
43class PagedTiffHandler extends TransformationalImageHandler {
44    // TIFF files over 10M are considered expensive to thumbnail
45    private const EXPENSIVE_SIZE_LIMIT = 10485760;
46
47    /**
48     * 1.0: Initial
49     * 1.1: Fixed bugs in imageinfo parser
50     * 1.2: Photoshop quirks (reverted)
51     * 1.3: Handing extra IFDs reported by tiffinfo
52     * 1.4: Allowed page numbering to start from numbers other than 1
53     */
54    public const TIFF_METADATA_VERSION = '1.4';
55
56    /**
57     * Known images cache
58     *
59     * @var MapCacheLRU
60     */
61    private $knownImages;
62
63    /** @var CommandFactory */
64    private $commandFactory;
65
66    /** @var HookContainer */
67    private $hookContainer;
68
69    /** @var UserOptionsLookup */
70    private $userOptionsLookup;
71
72    /** @var StatsdDataFactoryInterface */
73    private $statsdFactory;
74
75    /**
76     * Number of images to keep in $knownImages
77     */
78    private const CACHE_SIZE = 5;
79
80    public function __construct() {
81        $this->knownImages = new MapCacheLRU( self::CACHE_SIZE );
82
83        $services = MediaWikiServices::getInstance();
84        $this->commandFactory = $services->getShellCommandFactory();
85        $this->hookContainer = $services->getHookContainer();
86        $this->userOptionsLookup = $services->getUserOptionsLookup();
87        $this->statsdFactory = $services->getStatsdDataFactory();
88    }
89
90    /**
91     * @return bool
92     */
93    public function isEnabled() {
94        return true;
95    }
96
97    /**
98     * @param File $img
99     * @return bool
100     */
101    public function mustRender( $img ) {
102        return true;
103    }
104
105    /**
106     * Does the file format support multi-page documents?
107     * @param File $img
108     * @return bool
109     */
110    public function isMultiPage( $img ) {
111        return true;
112    }
113
114    /**
115     * Various checks against the uploaded file
116     * - maximum upload size
117     * - maximum number of embedded files
118     * - maximum size of metadata
119     * - identify-errors
120     * - identify-warnings
121     * - check for running-identify-service
122     * @param string $fileName
123     * @return Status
124     */
125    public function verifyUpload( $fileName ) {
126        $status = Status::newGood();
127        $meta = $this->getTiffImage( false, $fileName )->retrieveMetaData();
128        if ( !$meta ) {
129            wfDebug( __METHOD__ . ": unable to retrieve metadata" );
130            $status->fatal( 'tiff_out_of_service' );
131        } else {
132            $ok = $this->verifyMetaData( $meta, $error );
133
134            if ( !$ok ) {
135                $this->getCachedTiffImage( $fileName )->resetMetaData();
136                call_user_func_array( [ $status, 'fatal' ], $error );
137            }
138        }
139
140        return $status;
141    }
142
143    /**
144     * @param array $meta
145     * @param array &$error
146     * @return bool
147     */
148    private function verifyMetaData( $meta, &$error ) {
149        global $wgTiffMaxEmbedFiles, $wgTiffMaxMetaSize;
150
151        $errors = $this->getMetadataErrors( $meta );
152        if ( $errors ) {
153            $error = [ 'tiff_bad_file', $this->joinMessages( $errors ) ];
154
155            wfDebug( __METHOD__ . "{$error[0]} " .
156                $this->joinMessages( $errors, false ) );
157            return false;
158        }
159
160        if ( $meta['page_count'] <= 0 || empty( $meta['page_data'] ) ) {
161            $error = [ 'tiff_page_error', $meta['page_count'] ];
162            wfDebug( __METHOD__ . "{$error[0]}" );
163            return false;
164        }
165        if ( $wgTiffMaxEmbedFiles && $meta['page_count'] > $wgTiffMaxEmbedFiles ) {
166            $error = [ 'tiff_too_much_embed_files', $meta['page_count'], $wgTiffMaxEmbedFiles ];
167            wfDebug( __METHOD__ . "{$error[0]}" );
168            return false;
169        }
170        $len = strlen( serialize( $meta ) );
171        if ( ( $len + 1 ) > $wgTiffMaxMetaSize ) {
172            $error = [ 'tiff_too_much_meta', $len, $wgTiffMaxMetaSize ];
173            wfDebug( __METHOD__ . "{$error[0]}" );
174            return false;
175        }
176
177        wfDebug( __METHOD__ . ": metadata is ok" );
178        return true;
179    }
180
181    /**
182     * Maps MagicWord-IDs to parameters.
183     * In this case, width, page, and lossy.
184     * @return array
185     */
186    public function getParamMap() {
187        return [
188            'img_width' => 'width',
189            'img_page' => 'page',
190            'img_lossy' => 'lossy',
191        ];
192    }
193
194    /**
195     * Checks whether parameters are valid and have valid values.
196     * Check for lossy was added.
197     * @param string $name
198     * @param string $value
199     * @return bool
200     */
201    public function validateParam( $name, $value ) {
202        if ( in_array( $name, [ 'width', 'height', 'page', 'lossy' ] ) ) {
203            if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) {
204                // Extra junk on the end of page, probably actually a caption
205                // e.g. [[File:Foo.tiff|thumb|Page 3 of the document shows foo]]
206                return false;
207            }
208
209            if ( $name == 'lossy' ) {
210                # NOTE: make sure to use === for comparison. in PHP, '' == 0 and 'foo' == 1.
211
212                if ( $value === 1 || $value === 0 || $value === '1' || $value === '0' ) {
213                    return true;
214                }
215
216                if ( $value === 'true' || $value === 'false'
217                    || $value === 'lossy' || $value === 'lossless'
218                ) {
219                    return true;
220                }
221
222                return false;
223            } elseif ( $value <= 0 || $value > 65535 ) { // ImageMagick overflows for values > 65536
224                return false;
225            } else {
226                return true;
227            }
228        } else {
229            return false;
230        }
231    }
232
233    /**
234     * Creates parameter string for file name.
235     * Page number was added.
236     * @param array $params
237     * @return string|false
238     */
239    public function makeParamString( $params ) {
240        if (
241            !isset( $params['width'] ) || !isset( $params['lossy'] ) || !isset( $params['page'] )
242        ) {
243            return false;
244        }
245
246        return "{$params['lossy']}-page{$params['page']}-{$params['width']}px";
247    }
248
249    /**
250     * Parses parameter string into an array.
251     * @param string $str
252     * @return array|false
253     */
254    public function parseParamString( $str ) {
255        if ( preg_match( '/^(\w+)-page(\d+)-(\d+)px$/', $str, $matches ) ) {
256            return [ 'width' => $matches[3], 'page' => $matches[2], 'lossy' => $matches[1] ];
257        }
258
259        return false;
260    }
261
262    /**
263     * The function is used to specify which parameters to File::transform() should be
264     * passed through to thumb.php, in the case where the configuration specifies
265     * thumb.php is to be used (e.g. $wgThumbnailScriptPath !== false). You should
266     * pass through the same parameters as in makeParamString().
267     * @param array $params
268     * @return array
269     */
270    protected function getScriptParams( $params ) {
271        return [
272            'width' => $params['width'],
273            'page' => $params['page'],
274            'lossy' => $params['lossy'],
275        ];
276    }
277
278    /**
279     * Prepares param array and sets standard values.
280     * Adds normalisation for parameter "lossy".
281     * @param File $image
282     * @param array &$params
283     * @return bool
284     */
285    public function normaliseParams( $image, &$params ) {
286        if ( isset( $params['page'] ) ) {
287            $params['page'] = $this->adjustPage( $image, $params['page'] );
288        } else {
289            $params['page'] = $this->firstPage( $image );
290        }
291
292        if ( isset( $params['lossy'] ) ) {
293            if ( in_array( $params['lossy'], [ 1, '1', 'true', 'lossy' ] ) ) {
294                $params['lossy'] = 'lossy';
295            } else {
296                $params['lossy'] = 'lossless';
297            }
298        } else {
299            $page = $params['page'];
300            $data = $image->getMetadataArray();
301
302            if ( !$this->isMetadataError( $data )
303                && strtolower( $data['page_data'][$page]['alpha'] ?? '' ) == 'true'
304            ) {
305                // If there is an alpha channel, use png.
306                $params['lossy'] = 'lossless';
307            } else {
308                $params['lossy'] = 'lossy';
309            }
310        }
311
312        return parent::normaliseParams( $image, $params );
313    }
314
315    /**
316     * @param array|false $metadata
317     * @return bool|string[] a list of errors or an error flag (true = error)
318     */
319    private function getMetadataErrors( $metadata ) {
320        if ( !$metadata ) {
321            return true;
322        } elseif ( !isset( $metadata['errors'] ) ) {
323            return false;
324        }
325
326        return $metadata['errors'];
327    }
328
329    /**
330     * Is metadata an error condition?
331     * @param array|false $metadata Metadata to test
332     * @return bool True if metadata is an error, false if it has normal info
333     */
334    private function isMetadataError( $metadata ) {
335        $errors = $this->getMetadataErrors( $metadata );
336        if ( is_array( $errors ) ) {
337            return count( $errors ) > 0;
338        } else {
339            return $errors;
340        }
341    }
342
343    /**
344     * @param array|string $errors_raw
345     * @param bool $to_html
346     * @return bool|string
347     */
348    private function joinMessages( $errors_raw, $to_html = true ) {
349        if ( is_array( $errors_raw ) ) {
350            if ( !$errors_raw ) {
351                return false;
352            }
353
354            $errors = [];
355            foreach ( $errors_raw as $error ) {
356                if ( $error === false || $error === null || $error === 0 || $error === '' ) {
357                    continue;
358                }
359
360                $error = trim( $error );
361
362                if ( $error === '' ) {
363                    continue;
364                }
365
366                if ( $to_html ) {
367                    $error = htmlspecialchars( $error );
368                }
369
370                $errors[] = $error;
371            }
372
373            if ( $to_html ) {
374                return trim( implode( '<br />', $errors ) );
375            } else {
376                return trim( implode( ";\n", $errors ) );
377            }
378        }
379
380        return $errors_raw;
381    }
382
383    /**
384     * What method to use to scale this file
385     *
386     * @see TransformationalImageHandler::getScalerType
387     * @param string $dstPath Path to store thumbnail
388     * @param bool $checkDstPath Whether to verify destination path exists
389     * @return callable Transform function to call.
390     */
391    protected function getScalerType( $dstPath, $checkDstPath = true ) {
392        if ( !$dstPath && $checkDstPath ) {
393            // We don't have the option of doing client side scaling for this filetype.
394            throw new Exception( "Cannot create thumbnail, no destination path" );
395        }
396
397        return [ $this, 'transformIM' ];
398    }
399
400    /**
401     * Actually scale the file (using ImageMagick).
402     *
403     * @param File $file File object
404     * @param array $scalerParams Scaling options (see TransformationalImageHandler::doTransform)
405     * @return bool|MediaTransformError False on success, an instance of MediaTransformError
406     *   otherwise.
407     * @note Success is noted by $scalerParams['dstPath'] no longer being a 0 byte file.
408     */
409    protected function transformIM( $file, $scalerParams ) {
410        global $wgImageMagickConvertCommand, $wgMaxImageArea;
411
412        $meta = $file->getMetadataArray();
413
414        $errors = $this->getMetadataErrors( $meta );
415
416        if ( $errors ) {
417            $errors = $this->joinMessages( $errors );
418            if ( is_string( $errors ) ) {
419                // TODO: original error as param // TESTME
420                return $this->doThumbError( $scalerParams, 'tiff_bad_file' );
421            } else {
422                return $this->doThumbError( $scalerParams, 'tiff_no_metadata' );
423            }
424        }
425
426        if ( !$this->verifyMetaData( $meta, $error ) ) {
427            return $this->doThumbError( $scalerParams, $error );
428        }
429        if ( !wfMkdirParents( dirname( $scalerParams['dstPath'] ), null, __METHOD__ ) ) {
430            return $this->doThumbError( $scalerParams, 'thumbnail_dest_directory' );
431        }
432
433        // Get params and force width, height and page to be integers
434        $width = intval( $scalerParams['physicalWidth'] );
435        $height = intval( $scalerParams['physicalHeight'] );
436        $page = intval( $scalerParams['page'] );
437        $srcPath = $this->escapeMagickInput( $scalerParams['srcPath'], (string)( $page - 1 ) );
438        $dstPath = $this->escapeMagickOutput( $scalerParams['dstPath'] );
439
440        if ( $wgMaxImageArea
441            && isset( $meta['page_data'][$page]['pixels'] )
442            && $meta['page_data'][$page]['pixels'] > $wgMaxImageArea
443        ) {
444            return $this->doThumbError( $scalerParams, 'tiff_sourcefile_too_large' );
445        }
446
447        $command = $this->commandFactory->create()
448            ->params(
449                $wgImageMagickConvertCommand,
450                $srcPath,
451                '-depth', '8',
452                '-resize', $width,
453                $dstPath
454            )
455            ->includeStderr();
456
457        $result = $command->execute();
458        $exitCode = $result->getExitCode();
459        if ( $exitCode !== 0 ) {
460            $err = $result->getStdout();
461            $cmd = $command->getCommandString();
462            wfDebugLog(
463                'thumbnail',
464                "thumbnail failed on " . wfHostname() . "; error $exitCode \"$err\" from \"$cmd\""
465            );
466            return $this->getMediaTransformError( $scalerParams, $err );
467        } else {
468            return false; /* no error */
469        }
470    }
471
472    /**
473     * Get the thumbnail extension and MIME type for a given source MIME type
474     * @param string $ext
475     * @param string $mime
476     * @param array|null $params
477     * @return array thumbnail extension and MIME type
478     */
479    public function getThumbType( $ext, $mime, $params = null ) {
480        // Make sure the file is actually a tiff image
481        $tiffImageThumbType = parent::getThumbType( $ext, $mime, $params );
482        if ( $tiffImageThumbType[1] !== 'image/tiff' ) {
483            // We have some other file pretending to be a tiff image.
484            return $tiffImageThumbType;
485        }
486
487        if ( isset( $params['lossy'] ) && $params['lossy'] == 'lossy' ) {
488            return [ 'jpg', 'image/jpeg' ];
489        } else {
490            return [ 'png', 'image/png' ];
491        }
492    }
493
494    /**
495     * Returns the number of available pages/embedded files
496     * @param File $image
497     * @return int
498     */
499    public function pageCount( File $image ) {
500        $data = $image->getMetadataArray();
501        if ( $this->isMetadataError( $data ) ) {
502            return 1;
503        }
504
505        return intval( $data['page_count'] );
506    }
507
508    /**
509     * Returns the number of the first page in the file
510     * @param File $image
511     * @return int
512     */
513    private function firstPage( $image ) {
514        $data = $image->getMetadataArray();
515        if ( $this->isMetadataError( $data ) ) {
516            return 1;
517        }
518        return intval( $data['first_page'] );
519    }
520
521    /**
522     * Returns the number of the last page in the file
523     * @param File $image
524     * @return int
525     */
526    private function lastPage( $image ) {
527        $data = $image->getMetadataArray();
528        if ( $this->isMetadataError( $data ) ) {
529            return 1;
530        }
531        return intval( $data['last_page'] );
532    }
533
534    /**
535     * Returns a page number within range.
536     * @param File $image
537     * @param int|string $page
538     * @return int
539     */
540    private function adjustPage( $image, $page ) {
541        $page = intval( $page );
542
543        if ( !$page || $page < $this->firstPage( $image ) ) {
544            $page = $this->firstPage( $image );
545        }
546
547        if ( $page > $this->lastPage( $image ) ) {
548            $page = $this->lastPage( $image );
549        }
550
551        return $page;
552    }
553
554    /**
555     * Returns a new error message.
556     * @param array $params
557     * @param string|array $msg
558     * @return MediaTransformError
559     */
560    protected function doThumbError( $params, $msg ) {
561        global $wgThumbLimits;
562
563        $errorParams = [];
564        if ( empty( $params['width'] ) ) {
565            $user = RequestContext::getMain()->getUser();
566            // no usable width/height in the parameter array
567            // only happens if we don't have image meta-data, and no
568            // size was specified by the user.
569            // we need to pick *some* size, and the preferred
570            // thumbnail size seems sane.
571            $sz = $this->userOptionsLookup->getOption( $user, 'thumbsize' );
572            $errorParams['clientWidth'] = $wgThumbLimits[ $sz ];
573            // we don't have a height or aspect ratio. make it square.
574            $errorParams['clientHeight'] = $wgThumbLimits[ $sz ];
575        } else {
576            $errorParams['clientWidth'] = intval( $params['width'] );
577
578            if ( !empty( $params['height'] ) ) {
579                $errorParams['clientHeight'] = intval( $params['height'] );
580            } else {
581                // we don't have a height or aspect ratio. make it square.
582                $errorParams['clientHeight'] = $errorParams['clientWidth'];
583            }
584        }
585
586        return $this->getMediaTransformError( $errorParams, Message::newFromSpecifier( $msg )->text() );
587    }
588
589    /**
590     * @param MediaHandlerState $state
591     * @param string $path
592     * @return array
593     */
594    public function getSizeAndMetadata( $state, $path ) {
595        $metadata = $state->getHandlerState( 'TiffMetaArray' );
596        if ( !$metadata ) {
597            $metadata = $this->getTiffImage( $state, $path )->retrieveMetaData();
598            $state->setHandlerState( 'TiffMetaArray', $metadata );
599        }
600
601        $gis = getimagesize( $path );
602        if ( $gis ) {
603            [ $width, $height ] = $gis;
604        } else {
605            $width = 0;
606            $height = 0;
607        }
608
609        return [
610            'width' => $width,
611            'height' => $height,
612            'metadata' => $metadata
613        ];
614    }
615
616    /**
617     * Check if the metadata string is valid for this handler.
618     * If it returns false, Image will reload the metadata from the file and update the database
619     * @param File $image
620     * @return bool
621     */
622    public function isFileMetadataValid( $image ) {
623        $metadata = $image->getMetadataArray();
624        if ( isset( $metadata['errors'] ) ) {
625            // In the case of a broken file, we do not want to reload the
626            // metadata on every request.
627            return self::METADATA_GOOD;
628        }
629
630        if ( !isset( $metadata['TIFF_METADATA_VERSION'] )
631            || $metadata['TIFF_METADATA_VERSION'] != self::TIFF_METADATA_VERSION
632        ) {
633            return self::METADATA_BAD;
634        }
635
636        return self::METADATA_GOOD;
637    }
638
639    /**
640     * Get an array structure that looks like this:
641     *
642     * [
643     *  'visible' => [
644     *    'Human-readable name' => 'Human readable value',
645     *    ...
646     *  ],
647     *  'collapsed' => [
648     *    'Human-readable name' => 'Human readable value',
649     *    ...
650     *  ]
651     * ]
652     * The UI will format this into a table where the visible fields are always
653     * visible, and the collapsed fields are optionally visible.
654     *
655     * The function should return false if there is no metadata to display.
656     *
657     * @param File $image
658     * @param bool|IContextSource $context Context to use (optional)
659     * @return array|bool
660     */
661    public function formatMetadata( $image, $context = false ) {
662        $result = [
663            'visible' => [],
664            'collapsed' => []
665        ];
666        $metadata = $image->getMetadata();
667        if ( !$metadata ) {
668            return false;
669        }
670        $exif = unserialize( $metadata );
671        if ( !isset( $exif['exif'] ) || !$exif['exif'] ) {
672            return false;
673        }
674        $exif = $exif['exif'];
675        unset( $exif['MEDIAWIKI_EXIF_VERSION'] );
676        $formatted = FormatMetadata::getFormattedData( $exif, $context );
677
678        // Sort fields into visible and collapsed
679        $visibleFields = $this->visibleMetadataFields();
680        foreach ( $formatted as $name => $value ) {
681            $tag = strtolower( $name );
682            $this->addMeta( $result,
683                in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed',
684                'exif',
685                $tag,
686                $value
687            );
688        }
689        $meta = unserialize( $metadata );
690        $errors_raw = $this->getMetadataErrors( $meta );
691        if ( $errors_raw ) {
692            $errors = $this->joinMessages( $errors_raw );
693            $this->addMeta( $result,
694                'collapsed',
695                'metadata',
696                'error',
697                $errors
698            );
699            // XXX: need translation for <metadata-error>
700        }
701        if ( !empty( $meta['warnings'] ) ) {
702            $warnings = $this->joinMessages( $meta['warnings'] );
703            $this->addMeta( $result,
704                'collapsed',
705                'metadata',
706                'warning',
707                $warnings
708            );
709            // XXX: need translation for <metadata-warning>
710        }
711        return $result;
712    }
713
714    /**
715     * Returns a PagedTiffImage or creates a new one if it doesn't exist.
716     * @param MediaHandlerState|false $state The image object, or false if there isn't one
717     * @param string $path path to the image?
718     * @return PagedTiffImage
719     */
720    private function getTiffImage( $state, $path ) {
721        if ( !$state ) {
722            return $this->getCachedTiffImage( $path );
723        }
724
725        // If there is an Image object, we check whether there's already a TiffImage
726        // instance in there; if not, a new instance is created and stored in the Image object
727        $tiffImage = $state->getHandlerState( 'TiffImage' );
728        if ( !$tiffImage ) {
729            $tiffImage = $this->getCachedTiffImage( $path );
730            $state->setHandlerState( 'TiffImage', $tiffImage );
731        }
732
733        return $tiffImage;
734    }
735
736    /**
737     * Gets a PagedTiffImage from the cache, or creates one
738     * @param string $path path to the image
739     * @return PagedTiffImage
740     */
741    private function getCachedTiffImage( $path ) {
742        $image = $this->knownImages->get( $path );
743        if ( $image === null ) {
744            $image = new PagedTiffImage( $this->commandFactory, $this->statsdFactory, $path );
745            $this->knownImages->set( $path, $image );
746        }
747        return $image;
748    }
749
750    /**
751     * Get an associative array of page dimensions
752     * Currently "width" and "height" are understood, but this might be
753     * expanded in the future.
754     * @param File $image
755     * @param int $page
756     * @return int|false Returns false if unknown or if the document is not multi-page.
757     */
758    public function getPageDimensions( File $image, $page ) {
759        // makeImageLink (Linker.php) sets $page to false if no page parameter
760        // is set in wiki code
761        $page = $this->adjustPage( $image, $page );
762        $data = $image->getMetadataArray();
763        return PagedTiffImage::getPageSize( $data, $page );
764    }
765
766    public function isExpensiveToThumbnail( $file ) {
767        return $file->getSize() > self::EXPENSIVE_SIZE_LIMIT;
768    }
769
770    /**
771     * What source thumbnail to use.
772     *
773     * This does not use MW's builtin bucket system, as it tries to take
774     * advantage of the fact that VIPS can scale integer shrink factors
775     * much more efficiently than non-integral scaling factors.
776     *
777     * @param File $file
778     * @param array $params Parameters to transform file with.
779     * @return array Array with keys path, width and height
780     */
781    protected function getThumbnailSource( $file, $params ) {
782        /** @var MediaTransformOutput */
783        $mto = $this->getIntermediaryStep( $file, $params );
784        if ( $mto && !$mto->isError() ) {
785            return [
786                'path' => $mto->getLocalCopyPath(),
787                'width' => $mto->getWidth(),
788                'height' => $mto->getHeight(),
789                // The path to the temporary file may be deleted when last
790                // instance of the MediaTransformOutput is garbage collected,
791                // so keep a reference around.
792                'mto' => $mto
793            ];
794        } else {
795            return parent::getThumbnailSource( $file, $params );
796        }
797    }
798
799    /**
800     * Get an intermediary sized thumb to do further rendering on
801     *
802     * Tiff files can be huge. This method gets a large thumbnail
803     * to further scale things down. Size is chosen to be
804     * efficient to scale in vips for those who use VipsScaler
805     *
806     * @param File $file
807     * @param array $params Scaling parameters for original thumbnail
808     * @return MediaTransformOutput|MediaTransformError|bool false if no in between step needed,
809     *   MediaTransformError on error. False if the doTransform method returns false
810     *   MediaTransformOutput on success.
811     */
812    private function getIntermediaryStep( $file, $params ) {
813        global $wgTiffIntermediaryScaleStep, $wgThumbnailMinimumBucketDistance;
814
815        $page = intval( $params['page'] );
816        $page = $this->adjustPage( $file, $page );
817        $srcWidth = $file->getWidth( $page );
818        $srcHeight = $file->getHeight( $page );
819
820        if ( $srcWidth <= $wgTiffIntermediaryScaleStep ) {
821            // Image is already smaller than intermediary step or at that step
822            return false;
823        }
824
825        $widthOfFinalThumb = $params['physicalWidth'];
826
827        // Try and get a width that's easy for VipsScaler to work with
828        // i.e. Is an integer shrink factor.
829        $rx = floor( $srcWidth / ( $wgTiffIntermediaryScaleStep + 0.125 ) );
830        $intermediaryWidth = intval( floor( $srcWidth / $rx ) );
831        $intermediaryHeight = intval( floor( $srcHeight / $rx ) );
832
833        // We need both the vertical and horizontal shrink factors to be
834        // integers, and at the same time make sure that both vips and mediawiki
835        // have the same height for a given width (MediaWiki makes the assumption
836        // that the height of an image functionally depends on its width)
837        for ( ; $rx >= 2; $rx-- ) {
838            $intermediaryWidth = intval( floor( $srcWidth / $rx ) );
839            $intermediaryHeight = intval( floor( $srcHeight / $rx ) );
840            if ( $intermediaryHeight ==
841                File::scaleHeight( $srcWidth, $srcHeight, $intermediaryWidth )
842            ) {
843                break;
844            }
845        }
846
847        if (
848            $intermediaryWidth <= $widthOfFinalThumb + $wgThumbnailMinimumBucketDistance || $rx < 2
849        ) {
850            // Need to scale the original full sized thumb
851            return false;
852        }
853
854        static $isInThisFunction;
855
856        if ( $isInThisFunction ) {
857            // Sanity check, should never be reached
858            throw new Exception( "Loop detected in " . __METHOD__ );
859        }
860        $isInThisFunction = true;
861
862        $newParams = [
863            'width' => $intermediaryWidth,
864            'page' => $page,
865            // Render a png, to avoid loss of quality when doing multi-step
866            'lossy' => 'lossless'
867        ];
868
869        // RENDER_NOW causes rendering in this process if
870        // thumb doesn't exist, but unlike RENDER_FORCE, will return
871        // a cached thumb if available.
872        $mto = $file->transform( $newParams, File::RENDER_NOW );
873
874        $isInThisFunction = false;
875        return $mto;
876    }
877}