]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/media/TransformationalImageHandler.php
MediaWiki 1.30.2-scripts2
[autoinstalls/mediawiki.git] / includes / media / TransformationalImageHandler.php
1 <?php
2 /**
3  * Base class for handlers which require transforming images in a
4  * similar way as BitmapHandler does.
5  *
6  * This was split from BitmapHandler on the basis that some extensions
7  * might want to work in a similar way to BitmapHandler, but for
8  * different formats.
9  *
10  * This program is free software; you can redistribute it and/or modify
11  * it under the terms of the GNU General Public License as published by
12  * the Free Software Foundation; either version 2 of the License, or
13  * (at your option) any later version.
14  *
15  * This program is distributed in the hope that it will be useful,
16  * but WITHOUT ANY WARRANTY; without even the implied warranty of
17  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18  * GNU General Public License for more details.
19  *
20  * You should have received a copy of the GNU General Public License along
21  * with this program; if not, write to the Free Software Foundation, Inc.,
22  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23  * http://www.gnu.org/copyleft/gpl.html
24  *
25  * @file
26  * @ingroup Media
27  */
28 use MediaWiki\MediaWikiServices;
29
30 /**
31  * Handler for images that need to be transformed
32  *
33  * @since 1.24
34  * @ingroup Media
35  */
36 abstract class TransformationalImageHandler extends ImageHandler {
37         /**
38          * @param File $image
39          * @param array &$params Transform parameters. Entries with the keys 'width'
40          * and 'height' are the respective screen width and height, while the keys
41          * 'physicalWidth' and 'physicalHeight' indicate the thumbnail dimensions.
42          * @return bool
43          */
44         function normaliseParams( $image, &$params ) {
45                 if ( !parent::normaliseParams( $image, $params ) ) {
46                         return false;
47                 }
48
49                 # Obtain the source, pre-rotation dimensions
50                 $srcWidth = $image->getWidth( $params['page'] );
51                 $srcHeight = $image->getHeight( $params['page'] );
52
53                 # Don't make an image bigger than the source
54                 if ( $params['physicalWidth'] >= $srcWidth ) {
55                         $params['physicalWidth'] = $srcWidth;
56                         $params['physicalHeight'] = $srcHeight;
57
58                         # Skip scaling limit checks if no scaling is required
59                         # due to requested size being bigger than source.
60                         if ( !$image->mustRender() ) {
61                                 return true;
62                         }
63                 }
64
65                 return true;
66         }
67
68         /**
69          * Extracts the width/height if the image will be scaled before rotating
70          *
71          * This will match the physical size/aspect ratio of the original image
72          * prior to application of the rotation -- so for a portrait image that's
73          * stored as raw landscape with 90-degress rotation, the resulting size
74          * will be wider than it is tall.
75          *
76          * @param array $params Parameters as returned by normaliseParams
77          * @param int $rotation The rotation angle that will be applied
78          * @return array ($width, $height) array
79          */
80         public function extractPreRotationDimensions( $params, $rotation ) {
81                 if ( $rotation == 90 || $rotation == 270 ) {
82                         # We'll resize before rotation, so swap the dimensions again
83                         $width = $params['physicalHeight'];
84                         $height = $params['physicalWidth'];
85                 } else {
86                         $width = $params['physicalWidth'];
87                         $height = $params['physicalHeight'];
88                 }
89
90                 return [ $width, $height ];
91         }
92
93         /**
94          * Create a thumbnail.
95          *
96          * This sets up various parameters, and then calls a helper method
97          * based on $this->getScalerType in order to scale the image.
98          *
99          * @param File $image
100          * @param string $dstPath
101          * @param string $dstUrl
102          * @param array $params
103          * @param int $flags
104          * @return MediaTransformError|ThumbnailImage|TransformParameterError
105          */
106         function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
107                 if ( !$this->normaliseParams( $image, $params ) ) {
108                         return new TransformParameterError( $params );
109                 }
110
111                 # Create a parameter array to pass to the scaler
112                 $scalerParams = [
113                         # The size to which the image will be resized
114                         'physicalWidth' => $params['physicalWidth'],
115                         'physicalHeight' => $params['physicalHeight'],
116                         'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
117                         # The size of the image on the page
118                         'clientWidth' => $params['width'],
119                         'clientHeight' => $params['height'],
120                         # Comment as will be added to the Exif of the thumbnail
121                         'comment' => isset( $params['descriptionUrl'] )
122                                 ? "File source: {$params['descriptionUrl']}"
123                                 : '',
124                         # Properties of the original image
125                         'srcWidth' => $image->getWidth(),
126                         'srcHeight' => $image->getHeight(),
127                         'mimeType' => $image->getMimeType(),
128                         'dstPath' => $dstPath,
129                         'dstUrl' => $dstUrl,
130                         'interlace' => isset( $params['interlace'] ) ? $params['interlace'] : false,
131                 ];
132
133                 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
134                         $scalerParams['quality'] = 30;
135                 }
136
137                 // For subclasses that might be paged.
138                 if ( $image->isMultipage() && isset( $params['page'] ) ) {
139                         $scalerParams['page'] = intval( $params['page'] );
140                 }
141
142                 # Determine scaler type
143                 $scaler = $this->getScalerType( $dstPath );
144
145                 if ( is_array( $scaler ) ) {
146                         $scalerName = get_class( $scaler[0] );
147                 } else {
148                         $scalerName = $scaler;
149                 }
150
151                 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
152                         "thumbnail at $dstPath using scaler $scalerName\n" );
153
154                 if ( !$image->mustRender() &&
155                         $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
156                         && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
157                         && !isset( $scalerParams['quality'] )
158                 ) {
159                         # normaliseParams (or the user) wants us to return the unscaled image
160                         wfDebug( __METHOD__ . ": returning unscaled image\n" );
161
162                         return $this->getClientScalingThumbnailImage( $image, $scalerParams );
163                 }
164
165                 if ( $scaler == 'client' ) {
166                         # Client-side image scaling, use the source URL
167                         # Using the destination URL in a TRANSFORM_LATER request would be incorrect
168                         return $this->getClientScalingThumbnailImage( $image, $scalerParams );
169                 }
170
171                 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
172                         global $wgMaxImageArea;
173                         return new TransformTooBigImageAreaError( $params, $wgMaxImageArea );
174                 }
175
176                 if ( $flags & self::TRANSFORM_LATER ) {
177                         wfDebug( __METHOD__ . ": Transforming later per flags.\n" );
178                         $newParams = [
179                                 'width' => $scalerParams['clientWidth'],
180                                 'height' => $scalerParams['clientHeight']
181                         ];
182                         if ( isset( $params['quality'] ) ) {
183                                 $newParams['quality'] = $params['quality'];
184                         }
185                         if ( isset( $params['page'] ) && $params['page'] ) {
186                                 $newParams['page'] = $params['page'];
187                         }
188                         return new ThumbnailImage( $image, $dstUrl, false, $newParams );
189                 }
190
191                 # Try to make a target path for the thumbnail
192                 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
193                         wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
194                                 "directory, falling back to client scaling\n" );
195
196                         return $this->getClientScalingThumbnailImage( $image, $scalerParams );
197                 }
198
199                 # Transform functions and binaries need a FS source file
200                 $thumbnailSource = $this->getThumbnailSource( $image, $params );
201
202                 // If the source isn't the original, disable EXIF rotation because it's already been applied
203                 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
204                         || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
205                         $scalerParams['disableRotation'] = true;
206                 }
207
208                 $scalerParams['srcPath'] = $thumbnailSource['path'];
209                 $scalerParams['srcWidth'] = $thumbnailSource['width'];
210                 $scalerParams['srcHeight'] = $thumbnailSource['height'];
211
212                 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
213                         wfDebugLog( 'thumbnail',
214                                 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
215                                         wfHostname(), $image->getName() ) );
216
217                         return new MediaTransformError( 'thumbnail_error',
218                                 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
219                                 wfMessage( 'filemissing' )
220                         );
221                 }
222
223                 # Try a hook. Called "Bitmap" for historical reasons.
224                 /** @var MediaTransformOutput $mto */
225                 $mto = null;
226                 Hooks::run( 'BitmapHandlerTransform', [ $this, $image, &$scalerParams, &$mto ] );
227                 if ( !is_null( $mto ) ) {
228                         wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto\n" );
229                         $scaler = 'hookaborted';
230                 }
231
232                 // $scaler will return a MediaTransformError on failure, or false on success.
233                 // If the scaler is succesful, it will have created a thumbnail at the destination
234                 // path.
235                 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
236                         // Allow subclasses to specify their own rendering methods.
237                         $err = call_user_func( $scaler, $image, $scalerParams );
238                 } else {
239                         switch ( $scaler ) {
240                                 case 'hookaborted':
241                                         # Handled by the hook above
242                                         $err = $mto->isError() ? $mto : false;
243                                         break;
244                                 case 'im':
245                                         $err = $this->transformImageMagick( $image, $scalerParams );
246                                         break;
247                                 case 'custom':
248                                         $err = $this->transformCustom( $image, $scalerParams );
249                                         break;
250                                 case 'imext':
251                                         $err = $this->transformImageMagickExt( $image, $scalerParams );
252                                         break;
253                                 case 'gd':
254                                 default:
255                                         $err = $this->transformGd( $image, $scalerParams );
256                                         break;
257                         }
258                 }
259
260                 # Remove the file if a zero-byte thumbnail was created, or if there was an error
261                 $removed = $this->removeBadFile( $dstPath, (bool)$err );
262                 if ( $err ) {
263                         # transform returned MediaTransforError
264                         return $err;
265                 } elseif ( $removed ) {
266                         # Thumbnail was zero-byte and had to be removed
267                         return new MediaTransformError( 'thumbnail_error',
268                                 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
269                                 wfMessage( 'unknown-error' )
270                         );
271                 } elseif ( $mto ) {
272                         return $mto;
273                 } else {
274                         $newParams = [
275                                 'width' => $scalerParams['clientWidth'],
276                                 'height' => $scalerParams['clientHeight']
277                         ];
278                         if ( isset( $params['quality'] ) ) {
279                                 $newParams['quality'] = $params['quality'];
280                         }
281                         if ( isset( $params['page'] ) && $params['page'] ) {
282                                 $newParams['page'] = $params['page'];
283                         }
284                         return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
285                 }
286         }
287
288         /**
289          * Get the source file for the transform
290          *
291          * @param File $file
292          * @param array $params
293          * @return array Array with keys  width, height and path.
294          */
295         protected function getThumbnailSource( $file, $params ) {
296                 return $file->getThumbnailSource( $params );
297         }
298
299         /**
300          * Returns what sort of scaler type should be used.
301          *
302          * Values can be one of client, im, custom, gd, imext, or an array
303          * of object, method-name to call that specific method.
304          *
305          * If specifying a custom scaler command with [ Obj, method ],
306          * the method in question should take 2 parameters, a File object,
307          * and a $scalerParams array with various options (See doTransform
308          * for what is in $scalerParams). On error it should return a
309          * MediaTransformError object. On success it should return false,
310          * and simply make sure the thumbnail file is located at
311          * $scalerParams['dstPath'].
312          *
313          * If there is a problem with the output path, it returns "client"
314          * to do client side scaling.
315          *
316          * @param string $dstPath
317          * @param bool $checkDstPath Check that $dstPath is valid
318          * @return string|Callable One of client, im, custom, gd, imext, or a Callable array.
319          */
320         abstract protected function getScalerType( $dstPath, $checkDstPath = true );
321
322         /**
323          * Get a ThumbnailImage that respresents an image that will be scaled
324          * client side
325          *
326          * @param File $image File associated with this thumbnail
327          * @param array $scalerParams Array with scaler params
328          * @return ThumbnailImage
329          *
330          * @todo FIXME: No rotation support
331          */
332         protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
333                 $params = [
334                         'width' => $scalerParams['clientWidth'],
335                         'height' => $scalerParams['clientHeight']
336                 ];
337
338                 return new ThumbnailImage( $image, $image->getUrl(), null, $params );
339         }
340
341         /**
342          * Transform an image using ImageMagick
343          *
344          * This is a stub method. The real method is in BitmapHander.
345          *
346          * @param File $image File associated with this thumbnail
347          * @param array $params Array with scaler params
348          *
349          * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
350          */
351         protected function transformImageMagick( $image, $params ) {
352                 return $this->getMediaTransformError( $params, "Unimplemented" );
353         }
354
355         /**
356          * Transform an image using the Imagick PHP extension
357          *
358          * This is a stub method. The real method is in BitmapHander.
359          *
360          * @param File $image File associated with this thumbnail
361          * @param array $params Array with scaler params
362          *
363          * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
364          */
365         protected function transformImageMagickExt( $image, $params ) {
366                 return $this->getMediaTransformError( $params, "Unimplemented" );
367         }
368
369         /**
370          * Transform an image using a custom command
371          *
372          * This is a stub method. The real method is in BitmapHander.
373          *
374          * @param File $image File associated with this thumbnail
375          * @param array $params Array with scaler params
376          *
377          * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
378          */
379         protected function transformCustom( $image, $params ) {
380                 return $this->getMediaTransformError( $params, "Unimplemented" );
381         }
382
383         /**
384          * Get a MediaTransformError with error 'thumbnail_error'
385          *
386          * @param array $params Parameter array as passed to the transform* functions
387          * @param string $errMsg Error message
388          * @return MediaTransformError
389          */
390         public function getMediaTransformError( $params, $errMsg ) {
391                 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
392                         $params['clientHeight'], $errMsg );
393         }
394
395         /**
396          * Transform an image using the built in GD library
397          *
398          * This is a stub method. The real method is in BitmapHander.
399          *
400          * @param File $image File associated with this thumbnail
401          * @param array $params Array with scaler params
402          *
403          * @return MediaTransformError Error object if error occurred, false (=no error) otherwise
404          */
405         protected function transformGd( $image, $params ) {
406                 return $this->getMediaTransformError( $params, "Unimplemented" );
407         }
408
409         /**
410          * Escape a string for ImageMagick's property input (e.g. -set -comment)
411          * See InterpretImageProperties() in magick/property.c
412          * @param string $s
413          * @return string
414          */
415         function escapeMagickProperty( $s ) {
416                 // Double the backslashes
417                 $s = str_replace( '\\', '\\\\', $s );
418                 // Double the percents
419                 $s = str_replace( '%', '%%', $s );
420                 // Escape initial - or @
421                 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
422                         $s = '\\' . $s;
423                 }
424
425                 return $s;
426         }
427
428         /**
429          * Escape a string for ImageMagick's input filenames. See ExpandFilenames()
430          * and GetPathComponent() in magick/utility.c.
431          *
432          * This won't work with an initial ~ or @, so input files should be prefixed
433          * with the directory name.
434          *
435          * Glob character unescaping is broken in ImageMagick before 6.6.1-5, but
436          * it's broken in a way that doesn't involve trying to convert every file
437          * in a directory, so we're better off escaping and waiting for the bugfix
438          * to filter down to users.
439          *
440          * @param string $path The file path
441          * @param bool|string $scene The scene specification, or false if there is none
442          * @throws MWException
443          * @return string
444          */
445         function escapeMagickInput( $path, $scene = false ) {
446                 # Die on initial metacharacters (caller should prepend path)
447                 $firstChar = substr( $path, 0, 1 );
448                 if ( $firstChar === '~' || $firstChar === '@' ) {
449                         throw new MWException( __METHOD__ . ': cannot escape this path name' );
450                 }
451
452                 # Escape glob chars
453                 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
454
455                 return $this->escapeMagickPath( $path, $scene );
456         }
457
458         /**
459          * Escape a string for ImageMagick's output filename. See
460          * InterpretImageFilename() in magick/image.c.
461          * @param string $path The file path
462          * @param bool|string $scene The scene specification, or false if there is none
463          * @return string
464          */
465         function escapeMagickOutput( $path, $scene = false ) {
466                 $path = str_replace( '%', '%%', $path );
467
468                 return $this->escapeMagickPath( $path, $scene );
469         }
470
471         /**
472          * Armour a string against ImageMagick's GetPathComponent(). This is a
473          * helper function for escapeMagickInput() and escapeMagickOutput().
474          *
475          * @param string $path The file path
476          * @param bool|string $scene The scene specification, or false if there is none
477          * @throws MWException
478          * @return string
479          */
480         protected function escapeMagickPath( $path, $scene = false ) {
481                 # Die on format specifiers (other than drive letters). The regex is
482                 # meant to match all the formats you get from "convert -list format"
483                 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
484                         if ( wfIsWindows() && is_dir( $m[0] ) ) {
485                                 // OK, it's a drive letter
486                                 // ImageMagick has a similar exception, see IsMagickConflict()
487                         } else {
488                                 throw new MWException( __METHOD__ . ': unexpected colon character in path name' );
489                         }
490                 }
491
492                 # If there are square brackets, add a do-nothing scene specification
493                 # to force a literal interpretation
494                 if ( $scene === false ) {
495                         if ( strpos( $path, '[' ) !== false ) {
496                                 $path .= '[0--1]';
497                         }
498                 } else {
499                         $path .= "[$scene]";
500                 }
501
502                 return $path;
503         }
504
505         /**
506          * Retrieve the version of the installed ImageMagick
507          * You can use PHPs version_compare() to use this value
508          * Value is cached for one hour.
509          * @return string|bool Representing the IM version; false on error
510          */
511         protected function getMagickVersion() {
512                 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
513                 $method = __METHOD__;
514                 return $cache->getWithSetCallback(
515                         'imagemagick-version',
516                         $cache::TTL_HOUR,
517                         function () use ( $method ) {
518                                 global $wgImageMagickConvertCommand;
519
520                                 $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . ' -version';
521                                 wfDebug( $method . ": Running convert -version\n" );
522                                 $retval = '';
523                                 $return = wfShellExecWithStderr( $cmd, $retval );
524                                 $x = preg_match(
525                                         '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
526                                 );
527                                 if ( $x != 1 ) {
528                                         wfDebug( $method . ": ImageMagick version check failed\n" );
529                                         return false;
530                                 }
531
532                                 return $matches[1];
533                         }
534                 );
535         }
536
537         /**
538          * Returns whether the current scaler supports rotation.
539          *
540          * @since 1.24 No longer static
541          * @return bool
542          */
543         public function canRotate() {
544                 return false;
545         }
546
547         /**
548          * Should we automatically rotate an image based on exif
549          *
550          * @since 1.24 No longer static
551          * @see $wgEnableAutoRotation
552          * @return bool Whether auto rotation is enabled
553          */
554         public function autoRotateEnabled() {
555                 return false;
556         }
557
558         /**
559          * Rotate a thumbnail.
560          *
561          * This is a stub. See BitmapHandler::rotate.
562          *
563          * @param File $file
564          * @param array $params Rotate parameters.
565          *   'rotation' clockwise rotation in degrees, allowed are multiples of 90
566          * @since 1.24 Is non-static. From 1.21 it was static
567          * @return bool|MediaTransformError
568          */
569         public function rotate( $file, $params ) {
570                 return new MediaTransformError( 'thumbnail_error', 0, 0,
571                         static::class . ' rotation not implemented' );
572         }
573
574         /**
575          * Returns whether the file needs to be rendered. Returns true if the
576          * file requires rotation and we are able to rotate it.
577          *
578          * @param File $file
579          * @return bool
580          */
581         public function mustRender( $file ) {
582                 return $this->canRotate() && $this->getRotation( $file ) != 0;
583         }
584
585         /**
586          * Check if the file is smaller than the maximum image area for thumbnailing.
587          *
588          * Runs the 'BitmapHandlerCheckImageArea' hook.
589          *
590          * @param File $file
591          * @param array &$params
592          * @return bool
593          * @since 1.25
594          */
595         public function isImageAreaOkForThumbnaling( $file, &$params ) {
596                 global $wgMaxImageArea;
597
598                 # For historical reasons, hook starts with BitmapHandler
599                 $checkImageAreaHookResult = null;
600                 Hooks::run(
601                         'BitmapHandlerCheckImageArea',
602                         [ $file, &$params, &$checkImageAreaHookResult ]
603                 );
604
605                 if ( !is_null( $checkImageAreaHookResult ) ) {
606                         // was set by hook, so return that value
607                         return (bool)$checkImageAreaHookResult;
608                 }
609
610                 $srcWidth = $file->getWidth( $params['page'] );
611                 $srcHeight = $file->getHeight( $params['page'] );
612
613                 if ( $srcWidth * $srcHeight > $wgMaxImageArea
614                         && !( $file->getMimeType() == 'image/jpeg'
615                                 && $this->getScalerType( false, false ) == 'im' )
616                 ) {
617                         # Only ImageMagick can efficiently downsize jpg images without loading
618                         # the entire file in memory
619                         return false;
620                 }
621                 return true;
622         }
623 }