MediaWiki master
TransformationalImageHandler.php
Go to the documentation of this file.
1<?php
33
51 public function normaliseParams( $image, &$params ) {
52 if ( !parent::normaliseParams( $image, $params ) ) {
53 return false;
54 }
55
56 # Obtain the source, pre-rotation dimensions
57 $srcWidth = $image->getWidth( $params['page'] );
58 $srcHeight = $image->getHeight( $params['page'] );
59
60 # Don't make an image bigger than the source
61 if ( $params['physicalWidth'] >= $srcWidth ) {
62 $params['physicalWidth'] = $srcWidth;
63 $params['physicalHeight'] = $srcHeight;
64
65 # Skip scaling limit checks if no scaling is required
66 # due to requested size being bigger than source.
67 if ( !$image->mustRender() ) {
68 return true;
69 }
70 }
71
72 return true;
73 }
74
87 public function extractPreRotationDimensions( $params, $rotation ) {
88 if ( $rotation === 90 || $rotation === 270 ) {
89 // We'll resize before rotation, so swap the dimensions again
90 $width = $params['physicalHeight'];
91 $height = $params['physicalWidth'];
92 } else {
93 $width = $params['physicalWidth'];
94 $height = $params['physicalHeight'];
95 }
96
97 return [ $width, $height ];
98 }
99
114 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
115 if ( !$this->normaliseParams( $image, $params ) ) {
116 return new TransformParameterError( $params );
117 }
118
119 // Create a parameter array to pass to the scaler
120 $scalerParams = [
121 // The size to which the image will be resized
122 'physicalWidth' => $params['physicalWidth'],
123 'physicalHeight' => $params['physicalHeight'],
124 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
125 // The size of the image on the page
126 'clientWidth' => $params['width'],
127 'clientHeight' => $params['height'],
128 // Comment as will be added to the Exif of the thumbnail
129 'comment' => isset( $params['descriptionUrl'] )
130 ? "File source: {$params['descriptionUrl']}"
131 : '',
132 // Properties of the original image
133 'srcWidth' => $image->getWidth(),
134 'srcHeight' => $image->getHeight(),
135 'mimeType' => $image->getMimeType(),
136 'dstPath' => $dstPath,
137 'dstUrl' => $dstUrl,
138 'interlace' => $params['interlace'] ?? false,
139 'isFilePageThumb' => $params['isFilePageThumb'] ?? false,
140 ];
141
142 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
143 $scalerParams['quality'] = 30;
144 }
145
146 // For subclasses that might be paged.
147 if ( $image->isMultipage() && isset( $params['page'] ) ) {
148 $scalerParams['page'] = (int)$params['page'];
149 }
150
151 # Determine scaler type
152 $scaler = $this->getScalerType( $dstPath );
153
154 if ( is_array( $scaler ) ) {
155 $scalerName = get_class( $scaler[0] );
156 } else {
157 $scalerName = $scaler;
158 }
159
160 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
161 "thumbnail at $dstPath using scaler $scalerName" );
162
163 if ( !$image->mustRender() &&
164 $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
165 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
166 && !isset( $scalerParams['quality'] )
167 ) {
168 # normaliseParams (or the user) wants us to return the unscaled image
169 wfDebug( __METHOD__ . ": returning unscaled image" );
170
171 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
172 }
173
174 if ( $scaler === 'client' ) {
175 # Client-side image scaling, use the source URL
176 # Using the destination URL in a TRANSFORM_LATER request would be incorrect
177 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
178 }
179
180 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
181 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
182 return new TransformTooBigImageAreaError( $params, $maxImageArea );
183 }
184
185 if ( $flags & self::TRANSFORM_LATER ) {
186 wfDebug( __METHOD__ . ": Transforming later per flags." );
187 $newParams = [
188 'width' => $scalerParams['clientWidth'],
189 'height' => $scalerParams['clientHeight']
190 ];
191 if ( isset( $params['quality'] ) ) {
192 $newParams['quality'] = $params['quality'];
193 }
194 if ( isset( $params['page'] ) && $params['page'] ) {
195 $newParams['page'] = $params['page'];
196 }
197 return new ThumbnailImage( $image, $dstUrl, false, $newParams );
198 }
199
200 # Try to make a target path for the thumbnail
201 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
202 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
203 "directory, falling back to client scaling" );
204
205 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
206 }
207
208 # Transform functions and binaries need a FS source file
209 $thumbnailSource = $this->getThumbnailSource( $image, $params );
210
211 // If the source isn't the original, disable EXIF rotation because it's already been applied
212 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
213 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
214 $scalerParams['disableRotation'] = true;
215 }
216
217 $scalerParams['srcPath'] = $thumbnailSource['path'];
218 $scalerParams['srcWidth'] = $thumbnailSource['width'];
219 $scalerParams['srcHeight'] = $thumbnailSource['height'];
220
221 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
222 wfDebugLog( 'thumbnail',
223 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
224 wfHostname(), $image->getName() ) );
225
226 return new MediaTransformError( 'thumbnail_error',
227 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
228 wfMessage( 'filemissing' )
229 );
230 }
231
232 // Try a hook. Called "Bitmap" for historical reasons.
234 $mto = null;
235 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
236 ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
237 if ( $mto !== null ) {
238 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
239 $scaler = 'hookaborted';
240 }
241
242 // $scaler will return a MediaTransformError on failure, or false on success.
243 // If the scaler is successful, it will have created a thumbnail at the destination
244 // path.
245 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
246 // Allow subclasses to specify their own rendering methods.
247 $err = call_user_func( $scaler, $image, $scalerParams );
248 } else {
249 switch ( $scaler ) {
250 case 'hookaborted':
251 # Handled by the hook above
252 $err = $mto->isError() ? $mto : false;
253 break;
254 case 'im':
255 $err = $this->transformImageMagick( $image, $scalerParams );
256 break;
257 case 'custom':
258 $err = $this->transformCustom( $image, $scalerParams );
259 break;
260 case 'imext':
261 $err = $this->transformImageMagickExt( $image, $scalerParams );
262 break;
263 case 'gd':
264 default:
265 $err = $this->transformGd( $image, $scalerParams );
266 break;
267 }
268 }
269
270 // Remove the file if a zero-byte thumbnail was created, or if there was an error
271 // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
272 $removed = $this->removeBadFile( $dstPath, (bool)$err );
273 if ( $err ) {
274 # transform returned MediaTransforError
275 return $err;
276 }
277
278 if ( $removed ) {
279 // Thumbnail was zero-byte and had to be removed
280 return new MediaTransformError( 'thumbnail_error',
281 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
282 wfMessage( 'unknown-error' )
283 );
284 }
285
286 if ( $mto ) {
287 // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
288 return $mto;
289 }
290
291 $newParams = [
292 'width' => $scalerParams['clientWidth'],
293 'height' => $scalerParams['clientHeight']
294 ];
295 if ( isset( $params['quality'] ) ) {
296 $newParams['quality'] = $params['quality'];
297 }
298 if ( isset( $params['page'] ) && $params['page'] ) {
299 $newParams['page'] = $params['page'];
300 }
301 return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
302 }
303
311 protected function getThumbnailSource( $file, $params ) {
312 return $file->getThumbnailSource( $params );
313 }
314
336 abstract protected function getScalerType( $dstPath, $checkDstPath = true );
337
349 protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
350 $params = [
351 'width' => $scalerParams['clientWidth'],
352 'height' => $scalerParams['clientHeight']
353 ];
354
355 $url = $image->getUrl();
356 if ( isset( $scalerParams['isFilePageThumb'] ) && $scalerParams['isFilePageThumb'] ) {
357 // Use a versioned URL on file description pages
358 $url = $image->getFilePageThumbUrl( $url );
359 }
360
361 return new ThumbnailImage( $image, $url, null, $params );
362 }
363
375 protected function transformImageMagick( $image, $params ) {
376 return $this->getMediaTransformError( $params, "Unimplemented" );
377 }
378
390 protected function transformImageMagickExt( $image, $params ) {
391 return $this->getMediaTransformError( $params, "Unimplemented" );
392 }
393
405 protected function transformCustom( $image, $params ) {
406 return $this->getMediaTransformError( $params, "Unimplemented" );
407 }
408
416 public function getMediaTransformError( $params, $errMsg ) {
417 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
418 $params['clientHeight'], $errMsg );
419 }
420
431 protected function transformGd( $image, $params ) {
432 return $this->getMediaTransformError( $params, "Unimplemented" );
433 }
434
441 protected function escapeMagickProperty( $s ) {
442 // Double the backslashes
443 $s = str_replace( '\\', '\\\\', $s );
444 // Double the percents
445 $s = str_replace( '%', '%%', $s );
446 // Escape initial - or @
447 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
448 $s = '\\' . $s;
449 }
450
451 return $s;
452 }
453
470 protected function escapeMagickInput( $path, $scene = false ) {
471 # Die on initial metacharacters (caller should prepend path)
472 $firstChar = substr( $path, 0, 1 );
473 if ( $firstChar === '~' || $firstChar === '@' ) {
474 throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
475 }
476
477 # Escape glob chars
478 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
479
480 return $this->escapeMagickPath( $path, $scene );
481 }
482
490 protected function escapeMagickOutput( $path, $scene = false ) {
491 $path = str_replace( '%', '%%', $path );
492
493 return $this->escapeMagickPath( $path, $scene );
494 }
495
504 protected function escapeMagickPath( $path, $scene = false ) {
505 # Die on format specifiers (other than drive letters). The regex is
506 # meant to match all the formats you get from "convert -list format"
507 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
508 if ( wfIsWindows() && is_dir( $m[0] ) ) {
509 // OK, it's a drive letter
510 // ImageMagick has a similar exception, see IsMagickConflict()
511 } else {
512 throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
513 }
514 }
515
516 # If there are square brackets, add a do-nothing scene specification
517 # to force a literal interpretation
518 if ( $scene === false ) {
519 if ( strpos( $path, '[' ) !== false ) {
520 $path .= '[0--1]';
521 }
522 } else {
523 $path .= "[$scene]";
524 }
525
526 return $path;
527 }
528
535 protected function getMagickVersion() {
536 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
537 $method = __METHOD__;
538 return $cache->getWithSetCallback(
539 $cache->makeGlobalKey( 'imagemagick-version' ),
540 $cache::TTL_HOUR,
541 static function () use ( $method ) {
542 $imageMagickConvertCommand = MediaWikiServices::getInstance()
543 ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
544
545 $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
546 wfDebug( $method . ": Running convert -version" );
547 $retval = '';
548 $return = wfShellExecWithStderr( $cmd, $retval );
549 $x = preg_match(
550 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
551 );
552 if ( $x != 1 ) {
553 wfDebug( $method . ": ImageMagick version check failed" );
554 return false;
555 }
556
557 return $matches[1];
558 }
559 );
560 }
561
569 public function canRotate() {
570 return false;
571 }
572
581 public function autoRotateEnabled() {
582 return false;
583 }
584
597 public function rotate( $file, $params ) {
598 return new MediaTransformError( 'thumbnail_error', 0, 0,
599 static::class . ' rotation not implemented' );
600 }
601
610 public function mustRender( $file ) {
611 return $this->canRotate() && $this->getRotation( $file ) != 0;
612 }
613
625 public function isImageAreaOkForThumbnaling( $file, &$params ) {
626 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
627
628 # For historical reasons, hook starts with BitmapHandler
629 $checkImageAreaHookResult = null;
630 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
631 $file, $params, $checkImageAreaHookResult );
632
633 if ( $checkImageAreaHookResult !== null ) {
634 // was set by hook, so return that value
635 return (bool)$checkImageAreaHookResult;
636 }
637
638 if ( $maxImageArea === false ) {
639 // Checking is disabled, fine to thumbnail
640 return true;
641 }
642
643 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
644 $srcWidth = $file->getWidth( $params['page'] );
645 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
646 $srcHeight = $file->getHeight( $params['page'] );
647
648 if ( $srcWidth * $srcHeight > $maxImageArea
649 && !( $file->getMimeType() === 'image/jpeg'
650 && $this->getScalerType( null, false ) === 'im' )
651 ) {
652 # Only ImageMagick can efficiently downsize jpg images without loading
653 # the entire file in memory
654 return false;
655 }
656 return true;
657 }
658}
wfIsWindows()
Check if the operating system is Windows.
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfHostname()
Get host name of the current machine, for use in error reporting.
wfShellExecWithStderr( $cmd, &$retval=null, $environ=[], $limits=[])
Execute a shell command, returning both stdout and stderr.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not.
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
array $params
The job parameters.
Media handler abstract base class for images.
getRotation( $file)
On supporting image formats, try to read out the low-level orientation of the file and return the ang...
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
Basic media transform error class.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Executes shell commands.
Definition Shell.php:46
Media transform output for images.
Shortcut class for parameter validation errors.
Shortcut class for parameter file size errors.
Handler for images that need to be transformed.
getMagickVersion()
Retrieve the version of the installed ImageMagick You can use PHPs version_compare() to use this valu...
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick's input filenames.
autoRotateEnabled()
Should we automatically rotate an image based on exif.
isImageAreaOkForThumbnaling( $file, &$params)
Check if the file is smaller than the maximum image area for thumbnailing.
getThumbnailSource( $file, $params)
Get the source file for the transform.
getClientScalingThumbnailImage( $image, $scalerParams)
Get a ThumbnailImage that respresents an image that will be scaled client side.
getScalerType( $dstPath, $checkDstPath=true)
Returns what sort of scaler type should be used.
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
Create a thumbnail.
escapeMagickPath( $path, $scene=false)
Armour a string against ImageMagick's GetPathComponent().
escapeMagickProperty( $s)
Escape a string for ImageMagick's property input (e.g.
canRotate()
Returns whether the current scaler supports rotation.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
transformGd( $image, $params)
Transform an image using the built in GD library.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick's output filename.
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
transformCustom( $image, $params)
Transform an image using a custom command.
mustRender( $file)
Returns whether the file needs to be rendered.
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error 'thumbnail_error'.
rotate( $file, $params)
Rotate a thumbnail.