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