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