Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.10% covered (danger)
37.10%
46 / 124
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
JpegHandler
37.40% covered (danger)
37.40%
46 / 123
0.00% covered (danger)
0.00%
0 / 12
392.26
0.00% covered (danger)
0.00%
0 / 1
 normaliseParams
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 validateParam
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 validateQuality
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeParamString
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 parseParamString
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getScriptParams
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getSizeAndMetadata
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 rotate
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 supportsBucketing
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitizeParamsForBucketing
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 transformImageMagick
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 swapICCProfile
81.08% covered (warning)
81.08%
30 / 37
0.00% covered (danger)
0.00%
0 / 1
7.33
1<?php
2/**
3 * Handler for JPEG images.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Media
8 */
9
10namespace MediaWiki\Media;
11
12use MediaWiki\FileRepo\File\File;
13use MediaWiki\MainConfigNames;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Shell\Shell;
16
17/**
18 * JPEG specific handler.
19 * Inherits most stuff from BitmapHandler, just here to do the metadata handler differently.
20 *
21 * Metadata stuff common to Jpeg and built-in Tiff (not PagedTiffHandler) is
22 * in ExifBitmapHandler.
23 *
24 * @ingroup Media
25 */
26class JpegHandler extends ExifBitmapHandler {
27    private const SRGB_EXIF_COLOR_SPACE = 'sRGB';
28    private const SRGB_ICC_PROFILE_DESCRIPTION = 'sRGB IEC61966-2.1';
29
30    /** @inheritDoc */
31    public function normaliseParams( $image, &$params ) {
32        if ( !parent::normaliseParams( $image, $params ) ) {
33            return false;
34        }
35        if ( isset( $params['quality'] ) && !self::validateQuality( $params['quality'] ) ) {
36            return false;
37        }
38        return true;
39    }
40
41    /** @inheritDoc */
42    public function validateParam( $name, $value ) {
43        if ( $name === 'quality' ) {
44            return self::validateQuality( $value );
45        }
46        return parent::validateParam( $name, $value );
47    }
48
49    /** Validate and normalize quality value to be between 1 and 100 (inclusive).
50     * @param string $value Quality value, will be converted to integer or 0 if invalid
51     * @return bool True if the value is valid
52     */
53    private static function validateQuality( $value ) {
54        return $value === 'low';
55    }
56
57    /** @inheritDoc */
58    public function makeParamString( $params ) {
59        // Prepend quality as "qValue-". This has to match parseParamString() below
60        $res = parent::makeParamString( $params );
61        if ( $res && isset( $params['quality'] ) ) {
62            $res = "q{$params['quality']}-$res";
63        }
64        return $res;
65    }
66
67    /** @inheritDoc */
68    public function parseParamString( $str ) {
69        // $str contains "qlow-200px" or "200px" strings because thumb.php would strip the filename
70        // first - check if the string begins with "qlow-", and if so, treat it as quality.
71        // Pass the first portion, or the whole string if "qlow-" not found, to the parent
72        // The parsing must match the makeParamString() above
73        $res = false;
74        $m = false;
75        if ( preg_match( '/q([^-]+)-(.*)$/', $str, $m ) ) {
76            $v = $m[1];
77            if ( self::validateQuality( $v ) ) {
78                $res = parent::parseParamString( $m[2] );
79                if ( $res ) {
80                    $res['quality'] = $v;
81                }
82            }
83        } else {
84            $res = parent::parseParamString( $str );
85        }
86        return $res;
87    }
88
89    /** @inheritDoc */
90    protected function getScriptParams( $params ) {
91        $res = parent::getScriptParams( $params );
92        if ( isset( $params['quality'] ) ) {
93            $res['quality'] = $params['quality'];
94        }
95        return $res;
96    }
97
98    /** @inheritDoc */
99    public function getSizeAndMetadata( $state, $filename ) {
100        try {
101            $meta = BitmapMetadataHandler::Jpeg( $filename );
102            if ( !is_array( $meta ) ) {
103                // This should never happen, but doesn't hurt to be paranoid.
104                throw new InvalidJpegException( 'Metadata array is not an array' );
105            }
106            $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version();
107
108            $info = [
109                'width' => $meta['SOF']['width'] ?? 0,
110                'height' => $meta['SOF']['height'] ?? 0,
111            ];
112            if ( isset( $meta['SOF']['bits'] ) ) {
113                $info['bits'] = $meta['SOF']['bits'];
114            }
115            $info = $this->applyExifRotation( $info, $meta );
116            unset( $meta['SOF'] );
117            $info['metadata'] = $meta;
118            return $info;
119        } catch ( InvalidJpegException $e ) {
120            wfDebug( __METHOD__ . ': ' . $e->getMessage() );
121
122            // This used to return an integer-like string from getMetadata(),
123            // producing a value which could not be unserialized in
124            // img_metadata. The "_error" array key matches the legacy
125            // unserialization for such image rows.
126            return [ 'metadata' => [ '_error' => ExifBitmapHandler::BROKEN_FILE ] ];
127        }
128    }
129
130    /**
131     * @param File $file
132     * @param array{rotation:int,srcPath:string,dstPath:string} $params Rotate parameters.
133     *    'rotation' clockwise rotation in degrees, allowed are multiples of 90
134     * @since 1.21
135     * @return MediaTransformError|false
136     */
137    public function rotate( $file, $params ) {
138        $jpegTran = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::JpegTran );
139
140        $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
141
142        if ( $jpegTran && is_executable( $jpegTran ) ) {
143            $command = Shell::command( $jpegTran,
144                '-rotate',
145                (string)$rotation,
146                '-outfile',
147                $params['dstPath'],
148                $params['srcPath']
149            );
150            $result = $command
151                ->includeStderr()
152                ->execute();
153            if ( $result->getExitCode() !== 0 ) {
154                $this->logErrorForExternalProcess( $result->getExitCode(),
155                    $result->getStdout(),
156                    $command->__toString()
157                );
158
159                return new MediaTransformError( 'thumbnail_error', 0, 0, $result->getStdout() );
160            }
161
162            return false;
163        }
164        return parent::rotate( $file, $params );
165    }
166
167    /** @inheritDoc */
168    public function supportsBucketing() {
169        return true;
170    }
171
172    /** @inheritDoc */
173    public function sanitizeParamsForBucketing( $params ) {
174        $params = parent::sanitizeParamsForBucketing( $params );
175
176        // Quality needs to be cleared for bucketing. Buckets need to be default quality
177        unset( $params['quality'] );
178
179        return $params;
180    }
181
182    /**
183     * @inheritDoc
184     */
185    protected function transformImageMagick( $image, $params ) {
186        $useTinyRGBForJPGThumbnails = MediaWikiServices::getInstance()
187            ->getMainConfig()->get( MainConfigNames::UseTinyRGBForJPGThumbnails );
188
189        $ret = parent::transformImageMagick( $image, $params );
190
191        if ( $ret ) {
192            return $ret;
193        }
194
195        if ( $useTinyRGBForJPGThumbnails ) {
196            // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller
197            // (and free) TinyRGB
198
199            /**
200             * We'll want to replace the color profile for JPGs:
201             * * in the sRGB color space, or with the sRGB profile
202             *   (other profiles will be left untouched)
203             * * without color space or profile, in which case browsers
204             *   should assume sRGB, but don't always do (e.g. on wide-gamut
205             *   monitors (unless it's meant for low bandwidth)
206             * @see https://phabricator.wikimedia.org/T134498
207             */
208            $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ];
209            $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ];
210
211            // we'll also add TinyRGB profile to images lacking a profile, but
212            // only if they're not low quality (which are meant to save bandwidth
213            // and we don't want to increase the filesize by adding a profile)
214            if ( isset( $params['quality'] ) && $params['quality'] > 30 ) {
215                $profiles[] = '-';
216            }
217
218            $this->swapICCProfile(
219                $params['dstPath'],
220                $colorSpaces,
221                $profiles,
222                realpath( __DIR__ ) . '/tinyrgb.icc'
223            );
224        }
225
226        return false;
227    }
228
229    /**
230     * Swaps an embedded ICC profile for another, if found.
231     * Depends on exiftool, no-op if not installed.
232     * @param string $filepath File to be manipulated (will be overwritten)
233     * @param array $colorSpaces Only process files with this/these Color Space(s)
234     * @param array $oldProfileStrings Exact name(s) of color profile to look for
235     *  (the one that will be replaced)
236     * @param string $profileFilepath ICC profile file to apply to the file
237     * @since 1.26
238     * @return bool
239     */
240    public function swapICCProfile( $filepath, array $colorSpaces,
241        array $oldProfileStrings, $profileFilepath
242    ) {
243        $exiftool = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Exiftool );
244
245        if ( !$exiftool || !is_executable( $exiftool ) ) {
246            return false;
247        }
248
249        $result = Shell::command(
250            $exiftool,
251            '-EXIF:ColorSpace',
252            '-ICC_Profile:ProfileDescription',
253            '-S',
254            '-T',
255            $filepath
256        )
257            ->includeStderr()
258            ->execute();
259
260        // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc]
261        $data = explode( "\t", trim( $result->getStdout() ), 3 );
262
263        if ( $result->getExitCode() !== 0 ) {
264            return false;
265        }
266
267        // Make a regex out of the source data to match it to an array of color
268        // spaces in a case-insensitive way
269        $colorSpaceRegex = '/' . preg_quote( $data[0], '/' ) . '/i';
270        if ( !preg_grep( $colorSpaceRegex, $colorSpaces ) ) {
271            // We can't establish that this file matches the color space, don't process it
272            return false;
273        }
274
275        $profileRegex = '/' . preg_quote( $data[1], '/' ) . '/i';
276        if ( !preg_grep( $profileRegex, $oldProfileStrings ) ) {
277            // We can't establish that this file has the expected ICC profile, don't process it
278            return false;
279        }
280
281        $command = Shell::command( $exiftool,
282            '-overwrite_original',
283            '-icc_profile<=' . $profileFilepath,
284            $filepath
285        );
286        $result = $command
287            ->includeStderr()
288            ->execute();
289
290        if ( $result->getExitCode() !== 0 ) {
291            $this->logErrorForExternalProcess( $result->getExitCode(),
292                $result->getStdout(),
293                $command->__toString()
294            );
295
296            return false;
297        }
298
299        return true;
300    }
301}
302
303/** @deprecated class alias since 1.46 */
304class_alias( JpegHandler::class, 'JpegHandler' );