Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
24.14% |
35 / 145 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
VipsScaler | |
24.14% |
35 / 145 |
|
0.00% |
0 / 8 |
1475.48 | |
0.00% |
0 / 1 |
onBitmapHandlerTransform | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
doTransform | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
182 | |||
makeCommands | |
57.14% |
32 / 56 |
|
0.00% |
0 / 1 |
36.15 | |||
makeSharpenMatrix | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
getHandlerOptions | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
272 | |||
setJpegComment | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getVipsHandler | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
onBitmapHandlerCheckImageArea | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | /** |
3 | * PHP wrapper class for VIPS under MediaWiki |
4 | * |
5 | * Copyright © Bryan Tong Minh, 2011 |
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 | * @file |
22 | */ |
23 | |
24 | namespace MediaWiki\Extension\VipsScaler; |
25 | |
26 | use BitmapHandler; |
27 | use File; |
28 | use ImageHandler; |
29 | use MediaHandler; |
30 | use MediaTransformOutput; |
31 | use MediaWiki\Hook\BitmapHandlerCheckImageAreaHook; |
32 | use MediaWiki\Hook\BitmapHandlerTransformHook; |
33 | use MediaWiki\Shell\Shell; |
34 | use ThumbnailImage; |
35 | use TransformationalImageHandler; |
36 | |
37 | /** |
38 | * Wrapper class for VIPS, a free image processing system good at handling |
39 | * large pictures. |
40 | * |
41 | * http://www.vips.ecs.soton.ac.uk/ |
42 | * |
43 | * @author Bryan Tong Minh |
44 | */ |
45 | class VipsScaler implements |
46 | BitmapHandlerTransformHook, |
47 | BitmapHandlerCheckImageAreaHook |
48 | { |
49 | /** |
50 | * Hook to BitmapHandlerTransform. Transforms the file using VIPS if it |
51 | * matches a condition in $wgVipsConditions |
52 | * |
53 | * @param TransformationalImageHandler $handler |
54 | * @param File $file |
55 | * @param array &$params |
56 | * @param MediaTransformOutput|null &$mto |
57 | * @return bool |
58 | */ |
59 | public function onBitmapHandlerTransform( $handler, $file, &$params, &$mto ) { |
60 | // Check $wgVipsConditions |
61 | $options = self::getHandlerOptions( $handler, $file, $params ); |
62 | if ( !$options ) { |
63 | wfDebug( "...\n" ); |
64 | return true; |
65 | } |
66 | return self::doTransform( $handler, $file, $params, $options, $mto ); |
67 | } |
68 | |
69 | /** |
70 | * Performs a transform with VIPS |
71 | * |
72 | * @see VipsScaler::onTransform |
73 | * |
74 | * @param BitmapHandler|MediaHandler $handler |
75 | * @param File $file |
76 | * @param array $params |
77 | * @param array $options |
78 | * @param MediaTransformOutput &$mto |
79 | * @return bool |
80 | */ |
81 | public static function doTransform( $handler, $file, $params, $options, &$mto ) { |
82 | wfDebug( __METHOD__ . ': scaling ' . $file->getName() . " using vips\n" ); |
83 | |
84 | $vipsCommands = self::makeCommands( $handler, $file, $params, $options ); |
85 | if ( count( $vipsCommands ) == 0 ) { |
86 | return true; |
87 | } |
88 | |
89 | $actualSrcPath = $params['srcPath']; |
90 | // Only do this for tiff files, as valid format options in vips is per-format. |
91 | if ( $file->isMultipage() && isset( $params['page'] ) |
92 | && preg_match( '/\.tiff?$/', $actualSrcPath ) |
93 | ) { |
94 | $actualSrcPath .= $vipsCommands[0]->makePageArgument( $params['page'] ); |
95 | } |
96 | // Execute the commands |
97 | /** @var VipsCommand $command */ |
98 | foreach ( $vipsCommands as $i => $command ) { |
99 | // Set input/output files |
100 | if ( $i == 0 && count( $vipsCommands ) == 1 ) { |
101 | // Single command, so output directly to dstPath |
102 | $command->setIO( $actualSrcPath, $params['dstPath'] ); |
103 | } elseif ( $i == 0 ) { |
104 | // First command, input from srcPath, output to temp |
105 | $command->setIO( $actualSrcPath, 'v', VipsCommand::TEMP_OUTPUT ); |
106 | } elseif ( $i + 1 == count( $vipsCommands ) ) { |
107 | // Last command, output to dstPath |
108 | $command->setIO( $vipsCommands[$i - 1], $params['dstPath'] ); |
109 | } else { |
110 | $command->setIO( $vipsCommands[$i - 1], 'v', VipsCommand::TEMP_OUTPUT ); |
111 | } |
112 | |
113 | $retval = $command->execute(); |
114 | if ( $retval != 0 ) { |
115 | wfDebug( __METHOD__ . ": vips command failed!\n" ); |
116 | $error = $command->getErrorString() . "\nError code: $retval"; |
117 | $mto = $handler->getMediaTransformError( $params, $error ); |
118 | return false; |
119 | } |
120 | } |
121 | |
122 | // Set comment |
123 | if ( !empty( $options['setcomment'] ) && !empty( $params['comment'] ) ) { |
124 | self::setJpegComment( $params['dstPath'], $params['comment'] ); |
125 | } |
126 | |
127 | // Set the output variable |
128 | $mto = new ThumbnailImage( $file, $params['dstUrl'], |
129 | $params['clientWidth'], $params['clientHeight'], $params['dstPath'] ); |
130 | |
131 | // Stop processing |
132 | return false; |
133 | } |
134 | |
135 | /** |
136 | * @param BitmapHandler $handler |
137 | * @param File $file |
138 | * @param array $params |
139 | * @param array $options |
140 | * @return array |
141 | */ |
142 | public static function makeCommands( $handler, $file, $params, $options ) { |
143 | global $wgVipsCommand; |
144 | $commands = []; |
145 | |
146 | // Get the proper im_XXX2vips handler |
147 | $vipsHandler = self::getVipsHandler( $file ); |
148 | if ( !$vipsHandler ) { |
149 | return []; |
150 | } |
151 | |
152 | // Check if we need to convert to a .v file first |
153 | if ( !empty( $options['preconvert'] ) ) { |
154 | $commands[] = new VipsCommand( $wgVipsCommand, [ $vipsHandler ] ); |
155 | } |
156 | |
157 | // Do the resizing |
158 | $rotation = 360 - $handler->getRotation( $file ); |
159 | |
160 | wfDebug( __METHOD__ . " rotating '{$file->getName()}' by {$rotation}°\n" ); |
161 | if ( empty( $options['bilinear'] ) ) { |
162 | # Calculate shrink factors. Offsetting by a small amount is required |
163 | # because of rounding down of the target size by VIPS. See 25990#c7 |
164 | |
165 | # No need to invert source and physical dimensions. They already got |
166 | # switched if needed. |
167 | |
168 | # Use sprintf() instead of plain string conversion so that we can |
169 | # control the precision |
170 | $rx = sprintf( "%.18e", $params['srcWidth'] / ( $params['physicalWidth'] + 0.125 ) ); |
171 | $ry = sprintf( "%.18e", $params['srcHeight'] / ( $params['physicalHeight'] + 0.125 ) ); |
172 | |
173 | wfDebug( sprintf( |
174 | "%s to shrink '%s'. Source: %sx%s, Physical: %sx%s. Shrink factors (rx,ry) = %sx%s.\n", |
175 | __METHOD__, $file->getName(), |
176 | $params['srcWidth'], $params['srcHeight'], |
177 | $params['physicalWidth'], $params['physicalHeight'], |
178 | $rx, $ry |
179 | ) ); |
180 | |
181 | $roundedRx = round( (float)$rx ); |
182 | $roundedRy = round( (float)$ry ); |
183 | |
184 | if ( |
185 | floor( $params['srcWidth'] / $roundedRx ) == $params['physicalWidth'] |
186 | && floor( $params['srcHeight'] / $roundedRy ) == $params['physicalHeight'] |
187 | ) { |
188 | // For tiff files, shrink only seems to work properly when given integer shrink factors. |
189 | // Otherwise, in vips 7.34 it segfaults. In 7.38 it works but gives weird artifcats. |
190 | // Docs say non-integer shrink factors give bad results (Although I can only notice a |
191 | // difference in tiffs), so might as well round them in cases where it doesn't matter |
192 | // for all formats. |
193 | $rx = $roundedRx; |
194 | $ry = $roundedRy; |
195 | $shrinkCmd = 'shrink'; |
196 | } elseif ( $file->getMimeType() === 'image/tiff' ) { |
197 | $shrinkCmd = 'im_shrink'; |
198 | } else { |
199 | // For everything else, shrink works best. |
200 | $shrinkCmd = 'shrink'; |
201 | } |
202 | |
203 | $commands[] = new VipsCommand( $wgVipsCommand, [ $shrinkCmd, $rx, $ry ] ); |
204 | } else { |
205 | if ( $rotation % 180 == 90 ) { |
206 | $dstWidth = $params['physicalHeight']; |
207 | $dstHeight = $params['physicalWidth']; |
208 | } else { |
209 | $dstWidth = $params['physicalWidth']; |
210 | $dstHeight = $params['physicalHeight']; |
211 | } |
212 | wfDebug( sprintf( |
213 | "%s to bilinear resize %s. Source: %sx%s, Physical: %sx%s. Destination: %sx%s\n", |
214 | __METHOD__, $file->getName(), |
215 | $params['srcWidth'], $params['srcHeight'], |
216 | $params['physicalWidth'], $params['physicalHeight'], |
217 | $dstWidth, $dstHeight |
218 | ) ); |
219 | |
220 | $commands[] = new VipsCommand( $wgVipsCommand, |
221 | [ 'im_resize_linear', $dstWidth, $dstHeight ] ); |
222 | } |
223 | |
224 | if ( !empty( $options['sharpen'] ) ) { |
225 | $options['convolution'] = self::makeSharpenMatrix( $options['sharpen'] ); |
226 | } |
227 | |
228 | if ( !empty( $options['convolution'] ) ) { |
229 | $commands[] = new VipsConvolution( $wgVipsCommand, |
230 | [ 'im_convf', $options['convolution'] ] ); |
231 | } |
232 | |
233 | # Rotation |
234 | if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) { |
235 | $commands[] = new VipsCommand( $wgVipsCommand, [ "im_rot{$rotation}" ] ); |
236 | } |
237 | |
238 | // Interlace |
239 | if ( isset( $params['interlace'] ) && $params['interlace'] ) { |
240 | [ $major, $minor ] = File::splitMime( $file->getMimeType() ); |
241 | if ( $major == 'image' && in_array( $minor, [ 'jpeg', 'png' ] ) ) { |
242 | $commands[] = new VipsCommand( $wgVipsCommand, [ "{$minor}save", "--interlace" ] ); |
243 | } else { |
244 | // File type unsupported for interlacing, return empty array to cancel processing |
245 | return []; |
246 | } |
247 | } |
248 | |
249 | return $commands; |
250 | } |
251 | |
252 | /** |
253 | * Create a sharpening matrix suitable for im_convf. Uses the ImageMagick |
254 | * sharpening algorithm from SharpenImage() in magick/effect.c |
255 | * |
256 | * @param mixed $params |
257 | * @return array |
258 | */ |
259 | public static function makeSharpenMatrix( $params ) { |
260 | $sigma = $params['sigma']; |
261 | $radius = empty( $params['radius'] ) ? |
262 | # After 3 sigma there should be no significant values anymore |
263 | intval( round( $sigma * 3 ) ) : $params['radius']; |
264 | |
265 | $norm = 0; |
266 | $conv = []; |
267 | |
268 | // Fill the matrix with a negative Gaussian distribution |
269 | $variance = $sigma * $sigma; |
270 | for ( $x = -$radius; $x <= $radius; $x++ ) { |
271 | $row = []; |
272 | for ( $y = -$radius; $y <= $radius; $y++ ) { |
273 | $z = -exp( -( $x * $x + $y * $y ) / ( 2 * $variance ) ) / |
274 | ( 2 * pi() * $variance ); |
275 | $row[] = $z; |
276 | $norm += $z; |
277 | } |
278 | $conv[] = $row; |
279 | } |
280 | |
281 | // Calculate the scaling parameter to ensure that the mean of the |
282 | // matrix is zero |
283 | $scale = -$conv[$radius][$radius] - $norm; |
284 | // Set the center pixel to obtain a sharpening matrix |
285 | $conv[$radius][$radius] = -$norm * 2; |
286 | // Add the matrix descriptor |
287 | array_unshift( $conv, [ $radius * 2 + 1, $radius * 2 + 1, $scale, 0 ] ); |
288 | return $conv; |
289 | } |
290 | |
291 | /** |
292 | * Check the file and params against $wgVipsOptions |
293 | * |
294 | * @param ImageHandler $handler |
295 | * @param File $file |
296 | * @param array $params |
297 | * @return bool|array |
298 | */ |
299 | protected static function getHandlerOptions( $handler, $file, $params ) { |
300 | global $wgVipsOptions; |
301 | |
302 | if ( !isset( $params['page'] ) ) { |
303 | $page = 1; |
304 | } else { |
305 | $page = $params['page']; |
306 | } |
307 | |
308 | # Iterate over conditions |
309 | foreach ( $wgVipsOptions as $option ) { |
310 | if ( isset( $option['conditions'] ) ) { |
311 | $condition = $option['conditions']; |
312 | } else { |
313 | # Unconditionally pass |
314 | return $option; |
315 | } |
316 | |
317 | if ( isset( $condition['mimeType'] ) && |
318 | $file->getMimeType() != $condition['mimeType'] ) { |
319 | continue; |
320 | } |
321 | |
322 | if ( $file->isMultipage() ) { |
323 | $area = $file->getWidth( $page ) * $file->getHeight( $page ); |
324 | } else { |
325 | $area = $handler->getImageArea( $file ); |
326 | } |
327 | if ( isset( $condition['minArea'] ) && $area < $condition['minArea'] ) { |
328 | continue; |
329 | } |
330 | if ( isset( $condition['maxArea'] ) && $area >= $condition['maxArea'] ) { |
331 | continue; |
332 | } |
333 | |
334 | $shrinkFactor = $file->getWidth( $page ) / ( |
335 | ( ( $handler->getRotation( $file ) % 180 ) == 90 ) ? |
336 | $params['physicalHeight'] : $params['physicalWidth'] ); |
337 | if ( isset( $condition['minShrinkFactor'] ) && |
338 | $shrinkFactor < $condition['minShrinkFactor'] ) { |
339 | continue; |
340 | } |
341 | if ( isset( $condition['maxShrinkFactor'] ) && |
342 | $shrinkFactor >= $condition['maxShrinkFactor'] ) { |
343 | continue; |
344 | } |
345 | |
346 | # This condition passed |
347 | return $option; |
348 | } |
349 | # All conditions failed |
350 | return false; |
351 | } |
352 | |
353 | /** |
354 | * Sets the JPEG comment on a file using exiv2. |
355 | * Requires $wgExiv2Command to be setup properly. |
356 | * |
357 | * @todo FIXME need to handle errors such as $wgExiv2Command not available |
358 | * |
359 | * @param string $fileName File where the comment needs to be set |
360 | * @param string $comment The comment |
361 | */ |
362 | public static function setJpegComment( $fileName, $comment ) { |
363 | global $wgExiv2Command; |
364 | |
365 | Shell::command( $wgExiv2Command, 'mo', '-c', $comment, $fileName ) |
366 | ->execute(); |
367 | } |
368 | |
369 | /** |
370 | * Return the appropriate im_XXX2vips handler for this file |
371 | * @param File $file |
372 | * @return mixed String or false |
373 | */ |
374 | public static function getVipsHandler( $file ) { |
375 | [ $major, $minor ] = File::splitMime( $file->getMimeType() ); |
376 | |
377 | if ( $major == 'image' && in_array( $minor, [ 'jpeg', 'png', 'tiff' ] ) ) { |
378 | return "im_{$minor}2vips"; |
379 | } else { |
380 | return false; |
381 | } |
382 | } |
383 | |
384 | /** |
385 | * Hook to BitmapHandlerCheckImageArea. Will set $result to true if the |
386 | * file will by handled by VipsScaler. |
387 | * |
388 | * @param File $file |
389 | * @param array &$params |
390 | * @param mixed &$result |
391 | * @return bool |
392 | */ |
393 | public function onBitmapHandlerCheckImageArea( $file, &$params, &$result ) { |
394 | global $wgMaxImageArea; |
395 | /** @phan-suppress-next-line PhanTypeMismatchArgumentSuperType ImageHandler vs. MediaHandler */ |
396 | if ( self::getHandlerOptions( $file->getHandler(), $file, $params ) !== false ) { |
397 | wfDebug( __METHOD__ . ": Overriding $wgMaxImageArea\n" ); |
398 | $result = true; |
399 | return false; |
400 | } |
401 | return true; |
402 | } |
403 | } |