Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
37.40% |
46 / 123 |
|
0.00% |
0 / 12 |
CRAP | |
0.00% |
0 / 1 |
JpegHandler | |
37.40% |
46 / 123 |
|
0.00% |
0 / 12 |
392.26 | |
0.00% |
0 / 1 |
normaliseParams | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
validateParam | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
validateQuality | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
makeParamString | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
parseParamString | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
getScriptParams | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getSizeAndMetadata | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
4.00 | |||
rotate | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 | |||
supportsBucketing | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
sanitizeParamsForBucketing | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
transformImageMagick | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
swapICCProfile | |
81.08% |
30 / 37 |
|
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 | |
24 | use MediaWiki\MainConfigNames; |
25 | use MediaWiki\MediaWikiServices; |
26 | use 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 | */ |
37 | class 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 | } |