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 'isFilePageThumb' => $params['isFilePageThumb'] ?? false,
135 ];
136
137 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
138 $scalerParams['quality'] = 30;
139 }
140
141 // For subclasses that might be paged.
142 if ( $image->isMultipage() && isset( $params['page'] ) ) {
143 $scalerParams['page'] = (int)$params['page'];
144 }
145
146 # Determine scaler type
147 $scaler = $this->getScalerType( $dstPath );
148
149 if ( is_array( $scaler ) ) {
150 $scalerName = get_class( $scaler[0] );
151 } else {
152 $scalerName = $scaler;
153 }
154
155 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
156 "thumbnail of {$image->getPath()} at $dstPath using scaler $scalerName" );
157
158 if ( !$image->mustRender() &&
159 $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
160 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
161 && !isset( $scalerParams['quality'] )
162 ) {
163 # normaliseParams (or the user) wants us to return the unscaled image
164 wfDebug( __METHOD__ . ": returning unscaled image" );
165
166 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
167 }
168
169 if ( $scaler === 'client' ) {
170 # Client-side image scaling, use the source URL
171 # Using the destination URL in a TRANSFORM_LATER request would be incorrect
172 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
173 }
174
175 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
176 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
177 return new TransformTooBigImageAreaError( $params, $maxImageArea );
178 }
179
180 if ( $flags & self::TRANSFORM_LATER ) {
181 wfDebug( __METHOD__ . ": Transforming later per flags." );
182 $newParams = [
183 'width' => $scalerParams['clientWidth'],
184 'height' => $scalerParams['clientHeight']
185 ];
186 if ( isset( $params['quality'] ) ) {
187 $newParams['quality'] = $params['quality'];
188 }
189 if ( isset( $params['page'] ) && $params['page'] ) {
190 $newParams['page'] = $params['page'];
191 }
192 return new ThumbnailImage( $image, $dstUrl, false, $newParams );
193 }
194
195 # Try to make a target path for the thumbnail
196 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
197 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
198 "directory, falling back to client scaling" );
199
200 return $this->getClientScalingThumbnailImage( $image, $scalerParams );
201 }
202
203 # Transform functions and binaries need a FS source file
204 $thumbnailSource = $this->getThumbnailSource( $image, $params );
205
206 // If the source isn't the original, disable EXIF rotation because it's already been applied
207 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
208 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
209 $scalerParams['disableRotation'] = true;
210 }
211
212 $scalerParams['srcPath'] = $thumbnailSource['path'];
213 $scalerParams['srcWidth'] = $thumbnailSource['width'];
214 $scalerParams['srcHeight'] = $thumbnailSource['height'];
215
216 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
217 wfDebugLog( 'thumbnail',
218 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
219 wfHostname(), $image->getName() ) );
220
221 return new MediaTransformError( 'thumbnail_error',
222 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
223 wfMessage( 'filemissing' )
224 );
225 }
226
227 // Try a hook. Called "Bitmap" for historical reasons.
229 $mto = null;
230 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
231 ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
232 if ( $mto !== null ) {
233 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
234 $scaler = 'hookaborted';
235 }
236
237 // $scaler will return a MediaTransformError on failure, or false on success.
238 // If the scaler is successful, it will have created a thumbnail at the destination
239 // path.
240 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
241 // Allow subclasses to specify their own rendering methods.
242 $err = $scaler( $image, $scalerParams );
243 } else {
244 switch ( $scaler ) {
245 case 'hookaborted':
246 # Handled by the hook above
247 $err = $mto->isError() ? $mto : false;
248 break;
249 case 'im':
250 $err = $this->transformImageMagick( $image, $scalerParams );
251 break;
252 case 'custom':
253 $err = $this->transformCustom( $image, $scalerParams );
254 break;
255 case 'imext':
256 $err = $this->transformImageMagickExt( $image, $scalerParams );
257 break;
258 case 'gd':
259 default:
260 $err = $this->transformGd( $image, $scalerParams );
261 break;
262 }
263 }
264
265 // Remove the file if a zero-byte thumbnail was created, or if there was an error
266 // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
267 $removed = $this->removeBadFile( $dstPath, (bool)$err );
268 if ( $err ) {
269 # transform returned MediaTransforError
270 return $err;
271 }
272
273 if ( $removed ) {
274 // Thumbnail was zero-byte and had to be removed
275 return new MediaTransformError( 'thumbnail_error',
276 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
277 wfMessage( 'unknown-error' )
278 );
279 }
280
281 if ( $mto ) {
282 // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
283 return $mto;
284 }
285
286 $newParams = [
287 'width' => $scalerParams['clientWidth'],
288 'height' => $scalerParams['clientHeight']
289 ];
290 if ( isset( $params['quality'] ) ) {
291 $newParams['quality'] = $params['quality'];
292 }
293 if ( isset( $params['page'] ) && $params['page'] ) {
294 $newParams['page'] = $params['page'];
295 }
296 return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
297 }
298
306 protected function getThumbnailSource( $file, $params ) {
307 return $file->getThumbnailSource( $params );
308 }
309
331 abstract protected function getScalerType( $dstPath, $checkDstPath = true );
332
344 protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
345 $params = [
346 'width' => $scalerParams['clientWidth'],
347 'height' => $scalerParams['clientHeight']
348 ];
349
350 $url = $image->getUrl();
351 if ( isset( $scalerParams['isFilePageThumb'] ) && $scalerParams['isFilePageThumb'] ) {
352 // Use a versioned URL on file description pages
353 $url = $image->getFilePageThumbUrl( $url );
354 }
355
356 return new ThumbnailImage( $image, $url, null, $params );
357 }
358
370 protected function transformImageMagick( $image, $params ) {
371 return $this->getMediaTransformError( $params, "Unimplemented" );
372 }
373
385 protected function transformImageMagickExt( $image, $params ) {
386 return $this->getMediaTransformError( $params, "Unimplemented" );
387 }
388
400 protected function transformCustom( $image, $params ) {
401 return $this->getMediaTransformError( $params, "Unimplemented" );
402 }
403
411 public function getMediaTransformError( $params, $errMsg ) {
412 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
413 $params['clientHeight'], $errMsg );
414 }
415
426 protected function transformGd( $image, $params ) {
427 return $this->getMediaTransformError( $params, "Unimplemented" );
428 }
429
436 protected function escapeMagickProperty( $s ) {
437 // Double the backslashes
438 $s = str_replace( '\\', '\\\\', $s );
439 // Double the percents
440 $s = str_replace( '%', '%%', $s );
441 // Escape initial - or @
442 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
443 $s = '\\' . $s;
444 }
445
446 return $s;
447 }
448
465 protected function escapeMagickInput( $path, $scene = false ) {
466 # Die on initial metacharacters (caller should prepend path)
467 $firstChar = substr( $path, 0, 1 );
468 if ( $firstChar === '~' || $firstChar === '@' ) {
469 throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
470 }
471
472 # Escape glob chars
473 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
474
475 return $this->escapeMagickPath( $path, $scene );
476 }
477
485 protected function escapeMagickOutput( $path, $scene = false ) {
486 $path = str_replace( '%', '%%', $path );
487
488 return $this->escapeMagickPath( $path, $scene );
489 }
490
499 protected function escapeMagickPath( $path, $scene = false ) {
500 # Die on format specifiers (other than drive letters). The regex is
501 # meant to match all the formats you get from "convert -list format"
502 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
503 if ( wfIsWindows() && is_dir( $m[0] ) ) {
504 // OK, it's a drive letter
505 // ImageMagick has a similar exception, see IsMagickConflict()
506 } else {
507 throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
508 }
509 }
510
511 # If there are square brackets, add a do-nothing scene specification
512 # to force a literal interpretation
513 if ( $scene === false ) {
514 if ( str_contains( $path, '[' ) ) {
515 $path .= '[0--1]';
516 }
517 } else {
518 $path .= "[$scene]";
519 }
520
521 return $path;
522 }
523
530 protected function getMagickVersion() {
531 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
532 $method = __METHOD__;
533 return $cache->getWithSetCallback(
534 $cache->makeGlobalKey( 'imagemagick-version' ),
535 $cache::TTL_HOUR,
536 static function () use ( $method ) {
537 $imageMagickConvertCommand = MediaWikiServices::getInstance()
538 ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
539
540 $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
541 wfDebug( $method . ": Running convert -version" );
542 $retval = '';
543 $return = wfShellExecWithStderr( $cmd, $retval );
544 $x = preg_match(
545 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
546 );
547 if ( $x != 1 ) {
548 wfDebug( $method . ": ImageMagick version check failed" );
549 return false;
550 }
551
552 return $matches[1];
553 }
554 );
555 }
556
564 public function canRotate() {
565 return false;
566 }
567
576 public function autoRotateEnabled() {
577 return false;
578 }
579
592 public function rotate( $file, $params ) {
593 return new MediaTransformError( 'thumbnail_error', 0, 0,
594 static::class . ' rotation not implemented' );
595 }
596
605 public function mustRender( $file ) {
606 return $this->canRotate() && $this->getRotation( $file ) != 0;
607 }
608
620 public function isImageAreaOkForThumbnaling( $file, &$params ) {
621 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
622
623 # For historical reasons, hook starts with BitmapHandler
624 $checkImageAreaHookResult = null;
625 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
626 $file, $params, $checkImageAreaHookResult );
627
628 if ( $checkImageAreaHookResult !== null ) {
629 // was set by hook, so return that value
630 return (bool)$checkImageAreaHookResult;
631 }
632
633 if ( $maxImageArea === false ) {
634 // Checking is disabled, fine to thumbnail
635 return true;
636 }
637
638 $srcWidth = $file->getWidth( $params['page'] );
639 $srcHeight = $file->getHeight( $params['page'] );
640
641 if ( $srcWidth * $srcHeight > $maxImageArea
642 && !( $file->getMimeType() === 'image/jpeg'
643 && $this->getScalerType( null, false ) === 'im' )
644 ) {
645 # Only ImageMagick can efficiently downsize jpg images without loading
646 # the entire file in memory
647 return false;
648 }
649 return true;
650 }
651}
652
654class_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: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.
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.
getClientScalingThumbnailImage( $image, $scalerParams)
Get a ThumbnailImage that represents an image that will be scaled client side.
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.
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