MediaWiki master
TransformationalImageHandler.php
Go to the documentation of this file.
1<?php
20
38 public function normaliseParams( $image, &$params ) {
39 if ( !parent::normaliseParams( $image, $params ) ) {
40 return false;
41 }
42
43 # Obtain the source, pre-rotation dimensions
44 $srcWidth = $image->getWidth( $params['page'] );
45 $srcHeight = $image->getHeight( $params['page'] );
46
47 # Don't make an image bigger than the source
48 if ( $params['physicalWidth'] >= $srcWidth ) {
49 $params['physicalWidth'] = $srcWidth;
50 $params['physicalHeight'] = $srcHeight;
51
52 # Skip scaling limit checks if no scaling is required
53 # due to requested size being bigger than source.
54 if ( !$image->mustRender() ) {
55 return true;
56 }
57 }
58
59 return true;
60 }
61
74 public function extractPreRotationDimensions( $params, $rotation ) {
75 if ( $rotation === 90 || $rotation === 270 ) {
76 // We'll resize before rotation, so swap the dimensions again
77 $width = $params['physicalHeight'];
78 $height = $params['physicalWidth'];
79 } else {
80 $width = $params['physicalWidth'];
81 $height = $params['physicalHeight'];
82 }
83
84 return [ $width, $height ];
85 }
86
101 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
102 if ( !$this->normaliseParams( $image, $params ) ) {
103 return new TransformParameterError( $params );
104 }
105
106 // Create a parameter array to pass to the scaler
107 $scalerParams = [
108 // The size to which the image will be resized
109 'physicalWidth' => $params['physicalWidth'],
110 'physicalHeight' => $params['physicalHeight'],
111 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
112 // The size of the image on the page
113 'clientWidth' => $params['width'],
114 'clientHeight' => $params['height'],
115 // Comment as will be added to the Exif of the thumbnail
116 'comment' => isset( $params['descriptionUrl'] )
117 ? "File source: {$params['descriptionUrl']}"
118 : '',
119 // Properties of the original image
120 'srcWidth' => $image->getWidth(),
121 'srcHeight' => $image->getHeight(),
122 'mimeType' => $image->getMimeType(),
123 'dstPath' => $dstPath,
124 'dstUrl' => $dstUrl,
125 'interlace' => $params['interlace'] ?? false,
126 'isFilePageThumb' => $params['isFilePageThumb'] ?? false,
127 ];
128
129 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
130 $scalerParams['quality'] = 30;
131 }
132
133 // For subclasses that might be paged.
134 if ( $image->isMultipage() && isset( $params['page'] ) ) {
135 $scalerParams['page'] = (int)$params['page'];
136 }
137
138 # Determine scaler type
139 $scaler = $this->getScalerType( $dstPath );
140
141 if ( is_array( $scaler ) ) {
142 $scalerName = get_class( $scaler[0] );
143 } else {
144 $scalerName = $scaler;
145 }
146
147 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
148 "thumbnail of {$image->getPath()} at $dstPath using scaler $scalerName" );
149
150 if ( !$image->mustRender() &&
151 $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
152 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
153 && !isset( $scalerParams['quality'] )
154 ) {
155 # normaliseParams (or the user) wants us to return the unscaled image
156 wfDebug( __METHOD__ . ": returning unscaled image" );
157
158 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
159 }
160
161 if ( $scaler === 'client' ) {
162 # Client-side image scaling, use the source URL
163 # Using the destination URL in a TRANSFORM_LATER request would be incorrect
164 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
165 }
166
167 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
168 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
169 return new TransformTooBigImageAreaError( $params, $maxImageArea );
170 }
171
172 if ( $flags & self::TRANSFORM_LATER ) {
173 wfDebug( __METHOD__ . ": Transforming later per flags." );
174 $newParams = [
175 'width' => $scalerParams['clientWidth'],
176 'height' => $scalerParams['clientHeight']
177 ];
178 if ( isset( $params['quality'] ) ) {
179 $newParams['quality'] = $params['quality'];
180 }
181 if ( isset( $params['page'] ) && $params['page'] ) {
182 $newParams['page'] = $params['page'];
183 }
184 return new ThumbnailImage( $image, $dstUrl, false, $newParams );
185 }
186
187 # Try to make a target path for the thumbnail
188 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
189 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
190 "directory, falling back to client scaling" );
191
192 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
193 }
194
195 # Transform functions and binaries need a FS source file
196 $thumbnailSource = $this->getThumbnailSource( $image, $params );
197
198 // If the source isn't the original, disable EXIF rotation because it's already been applied
199 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
200 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
201 $scalerParams['disableRotation'] = true;
202 }
203
204 $scalerParams['srcPath'] = $thumbnailSource['path'];
205 $scalerParams['srcWidth'] = $thumbnailSource['width'];
206 $scalerParams['srcHeight'] = $thumbnailSource['height'];
207
208 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
209 wfDebugLog( 'thumbnail',
210 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
211 wfHostname(), $image->getName() ) );
212
213 return new MediaTransformError( 'thumbnail_error',
214 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
215 wfMessage( 'filemissing' )
216 );
217 }
218
219 // Try a hook. Called "Bitmap" for historical reasons.
221 $mto = null;
222 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
223 ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
224 if ( $mto !== null ) {
225 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
226 $scaler = 'hookaborted';
227 }
228
229 // $scaler will return a MediaTransformError on failure, or false on success.
230 // If the scaler is successful, it will have created a thumbnail at the destination
231 // path.
232 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
233 // Allow subclasses to specify their own rendering methods.
234 $err = $scaler( $image, $scalerParams );
235 } else {
236 switch ( $scaler ) {
237 case 'hookaborted':
238 # Handled by the hook above
239 $err = $mto->isError() ? $mto : false;
240 break;
241 case 'im':
242 $err = $this->transformImageMagick( $image, $scalerParams );
243 break;
244 case 'custom':
245 $err = $this->transformCustom( $image, $scalerParams );
246 break;
247 case 'imext':
248 $err = $this->transformImageMagickExt( $image, $scalerParams );
249 break;
250 case 'gd':
251 default:
252 $err = $this->transformGd( $image, $scalerParams );
253 break;
254 }
255 }
256
257 // Remove the file if a zero-byte thumbnail was created, or if there was an error
258 // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
259 $removed = $this->removeBadFile( $dstPath, (bool)$err );
260 if ( $err ) {
261 # transform returned MediaTransforError
262 return $err;
263 }
264
265 if ( $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 }
272
273 if ( $mto ) {
274 // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
275 return $mto;
276 }
277
278 $newParams = [
279 'width' => $scalerParams['clientWidth'],
280 'height' => $scalerParams['clientHeight']
281 ];
282 if ( isset( $params['quality'] ) ) {
283 $newParams['quality'] = $params['quality'];
284 }
285 if ( isset( $params['page'] ) && $params['page'] ) {
286 $newParams['page'] = $params['page'];
287 }
288 return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
289 }
290
298 protected function getThumbnailSource( $file, $params ) {
299 return $file->getThumbnailSource( $params );
300 }
301
323 abstract protected function getScalerType( $dstPath, $checkDstPath = true );
324
336 protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
337 $params = [
338 'width' => $scalerParams['clientWidth'],
339 'height' => $scalerParams['clientHeight']
340 ];
341
342 $url = $image->getUrl();
343 if ( isset( $scalerParams['isFilePageThumb'] ) && $scalerParams['isFilePageThumb'] ) {
344 // Use a versioned URL on file description pages
345 $url = $image->getFilePageThumbUrl( $url );
346 }
347
348 return new ThumbnailImage( $image, $url, null, $params );
349 }
350
362 protected function transformImageMagick( $image, $params ) {
363 return $this->getMediaTransformError( $params, "Unimplemented" );
364 }
365
377 protected function transformImageMagickExt( $image, $params ) {
378 return $this->getMediaTransformError( $params, "Unimplemented" );
379 }
380
392 protected function transformCustom( $image, $params ) {
393 return $this->getMediaTransformError( $params, "Unimplemented" );
394 }
395
403 public function getMediaTransformError( $params, $errMsg ) {
404 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
405 $params['clientHeight'], $errMsg );
406 }
407
418 protected function transformGd( $image, $params ) {
419 return $this->getMediaTransformError( $params, "Unimplemented" );
420 }
421
428 protected function escapeMagickProperty( $s ) {
429 // Double the backslashes
430 $s = str_replace( '\\', '\\\\', $s );
431 // Double the percents
432 $s = str_replace( '%', '%%', $s );
433 // Escape initial - or @
434 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
435 $s = '\\' . $s;
436 }
437
438 return $s;
439 }
440
457 protected function escapeMagickInput( $path, $scene = false ) {
458 # Die on initial metacharacters (caller should prepend path)
459 $firstChar = substr( $path, 0, 1 );
460 if ( $firstChar === '~' || $firstChar === '@' ) {
461 throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
462 }
463
464 # Escape glob chars
465 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
466
467 return $this->escapeMagickPath( $path, $scene );
468 }
469
477 protected function escapeMagickOutput( $path, $scene = false ) {
478 $path = str_replace( '%', '%%', $path );
479
480 return $this->escapeMagickPath( $path, $scene );
481 }
482
491 protected function escapeMagickPath( $path, $scene = false ) {
492 # Die on format specifiers (other than drive letters). The regex is
493 # meant to match all the formats you get from "convert -list format"
494 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
495 if ( wfIsWindows() && is_dir( $m[0] ) ) {
496 // OK, it's a drive letter
497 // ImageMagick has a similar exception, see IsMagickConflict()
498 } else {
499 throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
500 }
501 }
502
503 # If there are square brackets, add a do-nothing scene specification
504 # to force a literal interpretation
505 if ( $scene === false ) {
506 if ( str_contains( $path, '[' ) ) {
507 $path .= '[0--1]';
508 }
509 } else {
510 $path .= "[$scene]";
511 }
512
513 return $path;
514 }
515
522 protected function getMagickVersion() {
523 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
524 $method = __METHOD__;
525 return $cache->getWithSetCallback(
526 $cache->makeGlobalKey( 'imagemagick-version' ),
527 $cache::TTL_HOUR,
528 static function () use ( $method ) {
529 $imageMagickConvertCommand = MediaWikiServices::getInstance()
530 ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
531
532 $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
533 wfDebug( $method . ": Running convert -version" );
534 $retval = '';
535 $return = wfShellExecWithStderr( $cmd, $retval );
536 $x = preg_match(
537 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
538 );
539 if ( $x != 1 ) {
540 wfDebug( $method . ": ImageMagick version check failed" );
541 return false;
542 }
543
544 return $matches[1];
545 }
546 );
547 }
548
556 public function canRotate() {
557 return false;
558 }
559
568 public function autoRotateEnabled() {
569 return false;
570 }
571
584 public function rotate( $file, $params ) {
585 return new MediaTransformError( 'thumbnail_error', 0, 0,
586 static::class . ' rotation not implemented' );
587 }
588
597 public function mustRender( $file ) {
598 return $this->canRotate() && $this->getRotation( $file ) != 0;
599 }
600
612 public function isImageAreaOkForThumbnaling( $file, &$params ) {
613 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
614
615 # For historical reasons, hook starts with BitmapHandler
616 $checkImageAreaHookResult = null;
617 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
618 $file, $params, $checkImageAreaHookResult );
619
620 if ( $checkImageAreaHookResult !== null ) {
621 // was set by hook, so return that value
622 return (bool)$checkImageAreaHookResult;
623 }
624
625 if ( $maxImageArea === false ) {
626 // Checking is disabled, fine to thumbnail
627 return true;
628 }
629
630 $srcWidth = $file->getWidth( $params['page'] );
631 $srcHeight = $file->getHeight( $params['page'] );
632
633 if ( $srcWidth * $srcHeight > $maxImageArea
634 && !( $file->getMimeType() === 'image/jpeg'
635 && $this->getScalerType( null, false ) === 'im' )
636 ) {
637 # Only ImageMagick can efficiently downsize jpg images without loading
638 # the entire file in memory
639 return false;
640 }
641 return true;
642 }
643}
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.
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.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:79
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:32
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.