Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.14% covered (danger)
24.14%
35 / 145
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
VipsScaler
24.14% covered (danger)
24.14%
35 / 145
0.00% covered (danger)
0.00%
0 / 8
1475.48
0.00% covered (danger)
0.00%
0 / 1
 onBitmapHandlerTransform
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 doTransform
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
182
 makeCommands
57.14% covered (warning)
57.14%
32 / 56
0.00% covered (danger)
0.00%
0 / 1
36.15
 makeSharpenMatrix
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 getHandlerOptions
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
272
 setJpegComment
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getVipsHandler
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 onBitmapHandlerCheckImageArea
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
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
24namespace MediaWiki\Extension\VipsScaler;
25
26use BitmapHandler;
27use File;
28use ImageHandler;
29use MediaHandler;
30use MediaTransformOutput;
31use MediaWiki\Hook\BitmapHandlerCheckImageAreaHook;
32use MediaWiki\Hook\BitmapHandlerTransformHook;
33use MediaWiki\Shell\Shell;
34use ThumbnailImage;
35use 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 */
45class 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}