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