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