MediaWiki master
TransformationalImageHandler.php
Go to the documentation of this file.
1<?php
15namespace MediaWiki\Media;
16
17use InvalidArgumentException;
23
41 public function normaliseParams( $image, &$params ) {
42 if ( !parent::normaliseParams( $image, $params ) ) {
43 return false;
44 }
45
46 # Obtain the source, pre-rotation dimensions
47 $srcWidth = $image->getWidth( $params['page'] );
48 $srcHeight = $image->getHeight( $params['page'] );
49
50 $params['physicalWidth'] = $this->getSteppedThumbWidth(
51 $image, $params['physicalWidth'], $srcWidth, $srcHeight
52 );
53 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $params['physicalWidth'] );
54
55 # Don't make an image bigger than the source
56 if ( $params['physicalWidth'] >= $srcWidth ) {
57 $params['physicalWidth'] = $srcWidth;
58 $params['physicalHeight'] = $srcHeight;
59
60 # Skip scaling limit checks if no scaling is required
61 # due to requested size being bigger than source.
62 if ( !$image->mustRender() ) {
63 return true;
64 }
65 }
66
67 return true;
68 }
69
82 public function extractPreRotationDimensions( $params, $rotation ) {
83 if ( $rotation === 90 || $rotation === 270 ) {
84 // We'll resize before rotation, so swap the dimensions again
85 $width = $params['physicalHeight'];
86 $height = $params['physicalWidth'];
87 } else {
88 $width = $params['physicalWidth'];
89 $height = $params['physicalHeight'];
90 }
91
92 return [ $width, $height ];
93 }
94
109 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
110 if ( !$this->normaliseParams( $image, $params ) ) {
111 return new TransformParameterError( $params );
112 }
113
114 // Create a parameter array to pass to the scaler
115 $scalerParams = [
116 // The size to which the image will be resized
117 'physicalWidth' => $params['physicalWidth'],
118 'physicalHeight' => $params['physicalHeight'],
119 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
120 // The size of the image on the page
121 'clientWidth' => $params['width'],
122 'clientHeight' => $params['height'],
123 // Comment as will be added to the Exif of the thumbnail
124 'comment' => isset( $params['descriptionUrl'] )
125 ? "File source: {$params['descriptionUrl']}"
126 : '',
127 // Properties of the original image
128 'srcWidth' => $image->getWidth(),
129 'srcHeight' => $image->getHeight(),
130 'mimeType' => $image->getMimeType(),
131 'dstPath' => $dstPath,
132 'dstUrl' => $dstUrl,
133 'interlace' => $params['interlace'] ?? false,
134 ];
135
136 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
137 $scalerParams['quality'] = 30;
138 }
139
140 // For subclasses that might be paged.
141 if ( $image->isMultipage() && isset( $params['page'] ) ) {
142 $scalerParams['page'] = (int)$params['page'];
143 }
144
145 # Determine scaler type
146 $scaler = $this->getScalerType( $dstPath );
147
148 if ( is_array( $scaler ) ) {
149 $scalerName = get_class( $scaler[0] );
150 } else {
151 $scalerName = $scaler;
152 }
153
154 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
155 "thumbnail of {$image->getPath()} at $dstPath using scaler $scalerName" );
156
157 if ( !$image->mustRender() &&
158 $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
159 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
160 && !isset( $scalerParams['quality'] )
161 ) {
162 # normaliseParams (or the user) wants us to return the unscaled image
163 wfDebug( __METHOD__ . ": returning unscaled image" );
164
165 return $this->getClientScalingThumbnailImage( $image, $params );
166 }
167
168 if ( $scaler === 'client' ) {
169 # Client-side image scaling, use the source URL
170 # Using the destination URL in a TRANSFORM_LATER request would be incorrect
171 return $this->getClientScalingThumbnailImage( $image, $params );
172 }
173
174 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
175 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
176 return new TransformTooBigImageAreaError( $params, $maxImageArea );
177 }
178
179 if ( $flags & self::TRANSFORM_LATER ) {
180 wfDebug( __METHOD__ . ": Transforming later per flags." );
181 $newParams = [
182 'width' => $scalerParams['clientWidth'],
183 'height' => $scalerParams['clientHeight']
184 ];
185 if ( isset( $params['quality'] ) ) {
186 $newParams['quality'] = $params['quality'];
187 }
188 if ( isset( $params['page'] ) && $params['page'] ) {
189 $newParams['page'] = $params['page'];
190 }
191 return new ThumbnailImage( $image, $dstUrl, false, $newParams );
192 }
193
194 # Try to make a target path for the thumbnail
195 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
196 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
197 "directory, falling back to client scaling" );
198
199 return $this->getClientScalingThumbnailImage( $image, $params );
200 }
201
202 # Transform functions and binaries need a FS source file
203 $thumbnailSource = $this->getThumbnailSource( $image, $params );
204
205 // If the source isn't the original, disable EXIF rotation because it's already been applied
206 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
207 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
208 $scalerParams['disableRotation'] = true;
209 }
210
211 $scalerParams['srcPath'] = $thumbnailSource['path'];
212 $scalerParams['srcWidth'] = $thumbnailSource['width'];
213 $scalerParams['srcHeight'] = $thumbnailSource['height'];
214
215 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
216 wfDebugLog( 'thumbnail',
217 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
218 wfHostname(), $image->getName() ) );
219
220 return new MediaTransformError( 'thumbnail_error',
221 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
222 wfMessage( 'filemissing' )
223 );
224 }
225
226 // Try a hook. Called "Bitmap" for historical reasons.
228 $mto = null;
229 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
230 ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
231 if ( $mto !== null ) {
232 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
233 $scaler = 'hookaborted';
234 }
235
236 // $scaler will return a MediaTransformError on failure, or false on success.
237 // If the scaler is successful, it will have created a thumbnail at the destination
238 // path.
239 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
240 // Allow subclasses to specify their own rendering methods.
241 $err = $scaler( $image, $scalerParams );
242 } else {
243 switch ( $scaler ) {
244 case 'hookaborted':
245 # Handled by the hook above
246 $err = $mto->isError() ? $mto : false;
247 break;
248 case 'im':
249 $err = $this->transformImageMagick( $image, $scalerParams );
250 break;
251 case 'custom':
252 $err = $this->transformCustom( $image, $scalerParams );
253 break;
254 case 'imext':
255 $err = $this->transformImageMagickExt( $image, $scalerParams );
256 break;
257 case 'gd':
258 default:
259 $err = $this->transformGd( $image, $scalerParams );
260 break;
261 }
262 }
263
264 // Remove the file if a zero-byte thumbnail was created, or if there was an error
265 // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
266 $removed = $this->removeBadFile( $dstPath, (bool)$err );
267 if ( $err ) {
268 // transform returned MediaTransformError
269 return $err;
270 }
271
272 if ( $removed ) {
273 // Thumbnail was zero-byte and had to be removed
274 return new MediaTransformError( 'thumbnail_error',
275 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
276 wfMessage( 'unknown-error' )
277 );
278 }
279
280 if ( $mto ) {
281 // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
282 return $mto;
283 }
284
285 $newParams = [
286 'width' => $scalerParams['clientWidth'],
287 'height' => $scalerParams['clientHeight']
288 ];
289 if ( isset( $params['quality'] ) ) {
290 $newParams['quality'] = $params['quality'];
291 }
292 if ( isset( $params['page'] ) && $params['page'] ) {
293 $newParams['page'] = $params['page'];
294 }
295 return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
296 }
297
305 protected function getThumbnailSource( $file, $params ) {
306 return $file->getThumbnailSource( $params );
307 }
308
330 abstract protected function getScalerType( $dstPath, $checkDstPath = true );
331
343 protected function getClientScalingThumbnailImage( $image, $params ) {
344 $url = $image->modifyClientThumbUrl( $image->getUrl(), $params );
345 return new ThumbnailImage( $image, $url, null, $params );
346 }
347
359 protected function transformImageMagick( $image, $params ) {
360 return $this->getMediaTransformError( $params, "Unimplemented" );
361 }
362
374 protected function transformImageMagickExt( $image, $params ) {
375 return $this->getMediaTransformError( $params, "Unimplemented" );
376 }
377
389 protected function transformCustom( $image, $params ) {
390 return $this->getMediaTransformError( $params, "Unimplemented" );
391 }
392
400 public function getMediaTransformError( $params, $errMsg ) {
401 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
402 $params['clientHeight'], $errMsg );
403 }
404
415 protected function transformGd( $image, $params ) {
416 return $this->getMediaTransformError( $params, "Unimplemented" );
417 }
418
425 protected function escapeMagickProperty( $s ) {
426 // Double the backslashes
427 $s = str_replace( '\\', '\\\\', $s );
428 // Double the percents
429 $s = str_replace( '%', '%%', $s );
430 // Escape initial - or @
431 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
432 $s = '\\' . $s;
433 }
434
435 return $s;
436 }
437
454 protected function escapeMagickInput( $path, $scene = false ) {
455 # Die on initial metacharacters (caller should prepend path)
456 $firstChar = substr( $path, 0, 1 );
457 if ( $firstChar === '~' || $firstChar === '@' ) {
458 throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
459 }
460
461 # Escape glob chars
462 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
463
464 return $this->escapeMagickPath( $path, $scene );
465 }
466
474 protected function escapeMagickOutput( $path, $scene = false ) {
475 $path = str_replace( '%', '%%', $path );
476
477 return $this->escapeMagickPath( $path, $scene );
478 }
479
488 protected function escapeMagickPath( $path, $scene = false ) {
489 # Die on format specifiers (other than drive letters). The regex is
490 # meant to match all the formats you get from "convert -list format"
491 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
492 if ( wfIsWindows() && is_dir( $m[0] ) ) {
493 // OK, it's a drive letter
494 // ImageMagick has a similar exception, see IsMagickConflict()
495 } else {
496 throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
497 }
498 }
499
500 # If there are square brackets, add a do-nothing scene specification
501 # to force a literal interpretation
502 if ( $scene === false ) {
503 if ( str_contains( $path, '[' ) ) {
504 $path .= '[0--1]';
505 }
506 } else {
507 $path .= "[$scene]";
508 }
509
510 return $path;
511 }
512
519 protected function getMagickVersion() {
520 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
521 $method = __METHOD__;
522 return $cache->getWithSetCallback(
523 $cache->makeGlobalKey( 'imagemagick-version' ),
524 $cache::TTL_HOUR,
525 static function () use ( $method ) {
526 $imageMagickConvertCommand = MediaWikiServices::getInstance()
527 ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
528
529 $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
530 wfDebug( $method . ": Running convert -version" );
531 $retval = '';
532 $return = wfShellExecWithStderr( $cmd, $retval );
533 $x = preg_match(
534 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
535 );
536 if ( $x != 1 ) {
537 wfDebug( $method . ": ImageMagick version check failed" );
538 return false;
539 }
540
541 return $matches[1];
542 }
543 );
544 }
545
553 public function canRotate() {
554 return false;
555 }
556
565 public function autoRotateEnabled() {
566 return false;
567 }
568
581 public function rotate( $file, $params ) {
582 return new MediaTransformError( 'thumbnail_error', 0, 0,
583 static::class . ' rotation not implemented' );
584 }
585
594 public function mustRender( $file ) {
595 return $this->canRotate() && $this->getRotation( $file ) != 0;
596 }
597
609 public function isImageAreaOkForThumbnaling( $file, &$params ) {
610 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
611
612 # For historical reasons, hook starts with BitmapHandler
613 $checkImageAreaHookResult = null;
614 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
615 $file, $params, $checkImageAreaHookResult );
616
617 if ( $checkImageAreaHookResult !== null ) {
618 // was set by hook, so return that value
619 return (bool)$checkImageAreaHookResult;
620 }
621
622 if ( $maxImageArea === false ) {
623 // Checking is disabled, fine to thumbnail
624 return true;
625 }
626
627 $srcWidth = $file->getWidth( $params['page'] );
628 $srcHeight = $file->getHeight( $params['page'] );
629
630 if ( $srcWidth * $srcHeight > $maxImageArea
631 && !( $file->getMimeType() === 'image/jpeg'
632 && $this->getScalerType( null, false ) === 'im' )
633 ) {
634 # Only ImageMagick can efficiently downsize jpg images without loading
635 # the entire file in memory
636 return false;
637 }
638 return true;
639 }
640}
641
643class_alias( TransformationalImageHandler::class, 'TransformationalImageHandler' );
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.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
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.
const ImageMagickConvertCommand
Name constant for the ImageMagickConvertCommand setting, for use with Config::get()
const MaxImageArea
Name constant for the MaxImageArea setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Media handler abstract base class for images.
getSteppedThumbWidth(File $image, int $requestWidth, int $srcWidth, int $srcHeight)
Adjust the thumbnail size to fit the width steps defined in config via $wgThumbnailSteps.
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
getRotation( $file)
On supporting image formats, try to read out the low-level orientation of the file and return the ang...
Basic media transform error class.
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.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error 'thumbnail_error'.
canRotate()
Returns whether the current scaler supports rotation.
transformCustom( $image, $params)
Transform an image using a custom command.
escapeMagickProperty( $s)
Escape a string for ImageMagick's property input (e.g.
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick's input filenames.
isImageAreaOkForThumbnaling( $file, &$params)
Check if the file is smaller than the maximum image area for thumbnailing.
getMagickVersion()
Retrieve the version of the installed ImageMagick You can use PHPs version_compare() to use this valu...
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
getScalerType( $dstPath, $checkDstPath=true)
Returns what sort of scaler type should be used.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
transformGd( $image, $params)
Transform an image using the built in GD library.
getThumbnailSource( $file, $params)
Get the source file for the transform.
autoRotateEnabled()
Should we automatically rotate an image based on exif.
escapeMagickPath( $path, $scene=false)
Armour a string against ImageMagick's GetPathComponent().
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
Create a thumbnail.
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
getClientScalingThumbnailImage( $image, $params)
Get a ThumbnailImage that represents an image that will be scaled client side.
mustRender( $file)
Returns whether the file needs to be rendered.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick's output filename.
Executes shell commands.
Definition Shell.php:32