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