1<?php
2/**
3 * Base WordPress Image Editor
4 *
5 * @package WordPress
6 * @subpackage Image_Editor
7 */
8
9/**
10 * Base image editor class from which implementations extend
11 *
12 * @since 3.5.0
13 */
14abstract class WP_Image_Editor {
15	protected $file              = null;
16	protected $size              = null;
17	protected $mime_type         = null;
18	protected $output_mime_type  = null;
19	protected $default_mime_type = 'image/jpeg';
20	protected $quality           = false;
21
22	// Deprecated since 5.8.1. See get_default_quality() below.
23	protected $default_quality = 82;
24
25	/**
26	 * Each instance handles a single file.
27	 *
28	 * @param string $file Path to the file to load.
29	 */
30	public function __construct( $file ) {
31		$this->file = $file;
32	}
33
34	/**
35	 * Checks to see if current environment supports the editor chosen.
36	 * Must be overridden in a subclass.
37	 *
38	 * @since 3.5.0
39	 *
40	 * @abstract
41	 *
42	 * @param array $args
43	 * @return bool
44	 */
45	public static function test( $args = array() ) {
46		return false;
47	}
48
49	/**
50	 * Checks to see if editor supports the mime-type specified.
51	 * Must be overridden in a subclass.
52	 *
53	 * @since 3.5.0
54	 *
55	 * @abstract
56	 *
57	 * @param string $mime_type
58	 * @return bool
59	 */
60	public static function supports_mime_type( $mime_type ) {
61		return false;
62	}
63
64	/**
65	 * Loads image from $this->file into editor.
66	 *
67	 * @since 3.5.0
68	 * @abstract
69	 *
70	 * @return true|WP_Error True if loaded; WP_Error on failure.
71	 */
72	abstract public function load();
73
74	/**
75	 * Saves current image to file.
76	 *
77	 * @since 3.5.0
78	 * @abstract
79	 *
80	 * @param string $destfilename
81	 * @param string $mime_type
82	 * @return array|WP_Error {'path'=>string, 'file'=>string, 'width'=>int, 'height'=>int, 'mime-type'=>string}
83	 */
84	abstract public function save( $destfilename = null, $mime_type = null );
85
86	/**
87	 * Resizes current image.
88	 *
89	 * At minimum, either a height or width must be provided.
90	 * If one of the two is set to null, the resize will
91	 * maintain aspect ratio according to the provided dimension.
92	 *
93	 * @since 3.5.0
94	 * @abstract
95	 *
96	 * @param int|null $max_w Image width.
97	 * @param int|null $max_h Image height.
98	 * @param bool     $crop
99	 * @return true|WP_Error
100	 */
101	abstract public function resize( $max_w, $max_h, $crop = false );
102
103	/**
104	 * Resize multiple images from a single source.
105	 *
106	 * @since 3.5.0
107	 * @abstract
108	 *
109	 * @param array $sizes {
110	 *     An array of image size arrays. Default sizes are 'small', 'medium', 'large'.
111	 *
112	 *     @type array $size {
113	 *         @type int  $width  Image width.
114	 *         @type int  $height Image height.
115	 *         @type bool $crop   Optional. Whether to crop the image. Default false.
116	 *     }
117	 * }
118	 * @return array An array of resized images metadata by size.
119	 */
120	abstract public function multi_resize( $sizes );
121
122	/**
123	 * Crops Image.
124	 *
125	 * @since 3.5.0
126	 * @abstract
127	 *
128	 * @param int  $src_x   The start x position to crop from.
129	 * @param int  $src_y   The start y position to crop from.
130	 * @param int  $src_w   The width to crop.
131	 * @param int  $src_h   The height to crop.
132	 * @param int  $dst_w   Optional. The destination width.
133	 * @param int  $dst_h   Optional. The destination height.
134	 * @param bool $src_abs Optional. If the source crop points are absolute.
135	 * @return true|WP_Error
136	 */
137	abstract public function crop( $src_x, $src_y, $src_w, $src_h, $dst_w = null, $dst_h = null, $src_abs = false );
138
139	/**
140	 * Rotates current image counter-clockwise by $angle.
141	 *
142	 * @since 3.5.0
143	 * @abstract
144	 *
145	 * @param float $angle
146	 * @return true|WP_Error
147	 */
148	abstract public function rotate( $angle );
149
150	/**
151	 * Flips current image.
152	 *
153	 * @since 3.5.0
154	 * @abstract
155	 *
156	 * @param bool $horz Flip along Horizontal Axis
157	 * @param bool $vert Flip along Vertical Axis
158	 * @return true|WP_Error
159	 */
160	abstract public function flip( $horz, $vert );
161
162	/**
163	 * Streams current image to browser.
164	 *
165	 * @since 3.5.0
166	 * @abstract
167	 *
168	 * @param string $mime_type The mime type of the image.
169	 * @return true|WP_Error True on success, WP_Error object on failure.
170	 */
171	abstract public function stream( $mime_type = null );
172
173	/**
174	 * Gets dimensions of image.
175	 *
176	 * @since 3.5.0
177	 *
178	 * @return array {
179	 *     Dimensions of the image.
180	 *
181	 *     @type int $width  The image width.
182	 *     @type int $height The image height.
183	 * }
184	 */
185	public function get_size() {
186		return $this->size;
187	}
188
189	/**
190	 * Sets current image size.
191	 *
192	 * @since 3.5.0
193	 *
194	 * @param int $width
195	 * @param int $height
196	 * @return true
197	 */
198	protected function update_size( $width = null, $height = null ) {
199		$this->size = array(
200			'width'  => (int) $width,
201			'height' => (int) $height,
202		);
203		return true;
204	}
205
206	/**
207	 * Gets the Image Compression quality on a 1-100% scale.
208	 *
209	 * @since 4.0.0
210	 *
211	 * @return int Compression Quality. Range: [1,100]
212	 */
213	public function get_quality() {
214		if ( ! $this->quality ) {
215			$this->set_quality();
216		}
217
218		return $this->quality;
219	}
220
221	/**
222	 * Sets Image Compression quality on a 1-100% scale.
223	 *
224	 * @since 3.5.0
225	 *
226	 * @param int $quality Compression Quality. Range: [1,100]
227	 * @return true|WP_Error True if set successfully; WP_Error on failure.
228	 */
229	public function set_quality( $quality = null ) {
230		// Use the output mime type if present. If not, fall back to the input/initial mime type.
231		$mime_type = ! empty( $this->output_mime_type ) ? $this->output_mime_type : $this->mime_type;
232		// Get the default quality setting for the mime type.
233		$default_quality = $this->get_default_quality( $mime_type );
234
235		if ( null === $quality ) {
236			/**
237			 * Filters the default image compression quality setting.
238			 *
239			 * Applies only during initial editor instantiation, or when set_quality() is run
240			 * manually without the `$quality` argument.
241			 *
242			 * The WP_Image_Editor::set_quality() method has priority over the filter.
243			 *
244			 * @since 3.5.0
245			 *
246			 * @param int    $quality   Quality level between 1 (low) and 100 (high).
247			 * @param string $mime_type Image mime type.
248			 */
249			$quality = apply_filters( 'wp_editor_set_quality', $default_quality, $mime_type );
250
251			if ( 'image/jpeg' === $mime_type ) {
252				/**
253				 * Filters the JPEG compression quality for backward-compatibility.
254				 *
255				 * Applies only during initial editor instantiation, or when set_quality() is run
256				 * manually without the `$quality` argument.
257				 *
258				 * The WP_Image_Editor::set_quality() method has priority over the filter.
259				 *
260				 * The filter is evaluated under two contexts: 'image_resize', and 'edit_image',
261				 * (when a JPEG image is saved to file).
262				 *
263				 * @since 2.5.0
264				 *
265				 * @param int    $quality Quality level between 0 (low) and 100 (high) of the JPEG.
266				 * @param string $context Context of the filter.
267				 */
268				$quality = apply_filters( 'jpeg_quality', $quality, 'image_resize' );
269			}
270
271			if ( $quality < 0 || $quality > 100 ) {
272				$quality = $default_quality;
273			}
274		}
275
276		// Allow 0, but squash to 1 due to identical images in GD, and for backward compatibility.
277		if ( 0 === $quality ) {
278			$quality = 1;
279		}
280
281		if ( ( $quality >= 1 ) && ( $quality <= 100 ) ) {
282			$this->quality = $quality;
283			return true;
284		} else {
285			return new WP_Error( 'invalid_image_quality', __( 'Attempted to set image quality outside of the range [1,100].' ) );
286		}
287	}
288
289	/**
290	 * Returns the default compression quality setting for the mime type.
291	 *
292	 * @since 5.8.1
293	 *
294	 * @param string $mime_type
295	 * @return int The default quality setting for the mime type.
296	 */
297	protected function get_default_quality( $mime_type ) {
298		switch ( $mime_type ) {
299			case 'image/webp':
300				$quality = 86;
301				break;
302			case 'image/jpeg':
303			default:
304				$quality = $this->default_quality;
305		}
306
307		return $quality;
308	}
309
310	/**
311	 * Returns preferred mime-type and extension based on provided
312	 * file's extension and mime, or current file's extension and mime.
313	 *
314	 * Will default to $this->default_mime_type if requested is not supported.
315	 *
316	 * Provides corrected filename only if filename is provided.
317	 *
318	 * @since 3.5.0
319	 *
320	 * @param string $filename
321	 * @param string $mime_type
322	 * @return array { filename|null, extension, mime-type }
323	 */
324	protected function get_output_format( $filename = null, $mime_type = null ) {
325		$new_ext = null;
326
327		// By default, assume specified type takes priority.
328		if ( $mime_type ) {
329			$new_ext = $this->get_extension( $mime_type );
330		}
331
332		if ( $filename ) {
333			$file_ext  = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
334			$file_mime = $this->get_mime_type( $file_ext );
335		} else {
336			// If no file specified, grab editor's current extension and mime-type.
337			$file_ext  = strtolower( pathinfo( $this->file, PATHINFO_EXTENSION ) );
338			$file_mime = $this->mime_type;
339		}
340
341		// Check to see if specified mime-type is the same as type implied by
342		// file extension. If so, prefer extension from file.
343		if ( ! $mime_type || ( $file_mime == $mime_type ) ) {
344			$mime_type = $file_mime;
345			$new_ext   = $file_ext;
346		}
347
348		/**
349		 * Filters the image editor output format mapping.
350		 *
351		 * Enables filtering the mime type used to save images. By default,
352		 * the mapping array is empty, so the mime type matches the source image.
353		 *
354		 * @see WP_Image_Editor::get_output_format()
355		 *
356		 * @since 5.8.0
357		 *
358		 * @param string[] $output_format {
359		 *     An array of mime type mappings. Maps a source mime type to a new
360		 *     destination mime type. Default empty array.
361		 *
362		 *     @type string ...$0 The new mime type.
363		 * }
364		 * @param string $filename  Path to the image.
365		 * @param string $mime_type The source image mime type.
366		 * }
367		 */
368		$output_format = apply_filters( 'image_editor_output_format', array(), $filename, $mime_type );
369
370		if ( isset( $output_format[ $mime_type ] )
371			&& $this->supports_mime_type( $output_format[ $mime_type ] )
372		) {
373			$mime_type = $output_format[ $mime_type ];
374			$new_ext   = $this->get_extension( $mime_type );
375		}
376
377		// Double-check that the mime-type selected is supported by the editor.
378		// If not, choose a default instead.
379		if ( ! $this->supports_mime_type( $mime_type ) ) {
380			/**
381			 * Filters default mime type prior to getting the file extension.
382			 *
383			 * @see wp_get_mime_types()
384			 *
385			 * @since 3.5.0
386			 *
387			 * @param string $mime_type Mime type string.
388			 */
389			$mime_type = apply_filters( 'image_editor_default_mime_type', $this->default_mime_type );
390			$new_ext   = $this->get_extension( $mime_type );
391		}
392
393		// Ensure both $filename and $new_ext are not empty.
394		// $this->get_extension() returns false on error which would effectively remove the extension
395		// from $filename. That shouldn't happen, files without extensions are not supported.
396		if ( $filename && $new_ext ) {
397			$dir = pathinfo( $filename, PATHINFO_DIRNAME );
398			$ext = pathinfo( $filename, PATHINFO_EXTENSION );
399
400			$filename = trailingslashit( $dir ) . wp_basename( $filename, ".$ext" ) . ".{$new_ext}";
401		}
402
403		if ( $mime_type && ( $mime_type !== $this->mime_type ) ) {
404			// The image will be converted when saving. Set the quality for the new mime-type if not already set.
405			if ( $mime_type !== $this->output_mime_type ) {
406				$this->output_mime_type = $mime_type;
407				$this->set_quality();
408			}
409		} elseif ( ! empty( $this->output_mime_type ) ) {
410			// Reset output_mime_type and quality.
411			$this->output_mime_type = null;
412			$this->set_quality();
413		}
414
415		return array( $filename, $new_ext, $mime_type );
416	}
417
418	/**
419	 * Builds an output filename based on current file, and adding proper suffix
420	 *
421	 * @since 3.5.0
422	 *
423	 * @param string $suffix
424	 * @param string $dest_path
425	 * @param string $extension
426	 * @return string filename
427	 */
428	public function generate_filename( $suffix = null, $dest_path = null, $extension = null ) {
429		// $suffix will be appended to the destination filename, just before the extension.
430		if ( ! $suffix ) {
431			$suffix = $this->get_suffix();
432		}
433
434		$dir = pathinfo( $this->file, PATHINFO_DIRNAME );
435		$ext = pathinfo( $this->file, PATHINFO_EXTENSION );
436
437		$name    = wp_basename( $this->file, ".$ext" );
438		$new_ext = strtolower( $extension ? $extension : $ext );
439
440		if ( ! is_null( $dest_path ) ) {
441			if ( ! wp_is_stream( $dest_path ) ) {
442				$_dest_path = realpath( $dest_path );
443				if ( $_dest_path ) {
444					$dir = $_dest_path;
445				}
446			} else {
447				$dir = $dest_path;
448			}
449		}
450
451		return trailingslashit( $dir ) . "{$name}-{$suffix}.{$new_ext}";
452	}
453
454	/**
455	 * Builds and returns proper suffix for file based on height and width.
456	 *
457	 * @since 3.5.0
458	 *
459	 * @return string|false suffix
460	 */
461	public function get_suffix() {
462		if ( ! $this->get_size() ) {
463			return false;
464		}
465
466		return "{$this->size['width']}x{$this->size['height']}";
467	}
468
469	/**
470	 * Check if a JPEG image has EXIF Orientation tag and rotate it if needed.
471	 *
472	 * @since 5.3.0
473	 *
474	 * @return bool|WP_Error True if the image was rotated. False if not rotated (no EXIF data or the image doesn't need to be rotated).
475	 *                       WP_Error if error while rotating.
476	 */
477	public function maybe_exif_rotate() {
478		$orientation = null;
479
480		if ( is_callable( 'exif_read_data' ) && 'image/jpeg' === $this->mime_type ) {
481			$exif_data = @exif_read_data( $this->file );
482
483			if ( ! empty( $exif_data['Orientation'] ) ) {
484				$orientation = (int) $exif_data['Orientation'];
485			}
486		}
487
488		/**
489		 * Filters the `$orientation` value to correct it before rotating or to prevemnt rotating the image.
490		 *
491		 * @since 5.3.0
492		 *
493		 * @param int    $orientation EXIF Orientation value as retrieved from the image file.
494		 * @param string $file        Path to the image file.
495		 */
496		$orientation = apply_filters( 'wp_image_maybe_exif_rotate', $orientation, $this->file );
497
498		if ( ! $orientation || 1 === $orientation ) {
499			return false;
500		}
501
502		switch ( $orientation ) {
503			case 2:
504				// Flip horizontally.
505				$result = $this->flip( true, false );
506				break;
507			case 3:
508				// Rotate 180 degrees or flip horizontally and vertically.
509				// Flipping seems faster and uses less resources.
510				$result = $this->flip( true, true );
511				break;
512			case 4:
513				// Flip vertically.
514				$result = $this->flip( false, true );
515				break;
516			case 5:
517				// Rotate 90 degrees counter-clockwise and flip vertically.
518				$result = $this->rotate( 90 );
519
520				if ( ! is_wp_error( $result ) ) {
521					$result = $this->flip( false, true );
522				}
523
524				break;
525			case 6:
526				// Rotate 90 degrees clockwise (270 counter-clockwise).
527				$result = $this->rotate( 270 );
528				break;
529			case 7:
530				// Rotate 90 degrees counter-clockwise and flip horizontally.
531				$result = $this->rotate( 90 );
532
533				if ( ! is_wp_error( $result ) ) {
534					$result = $this->flip( true, false );
535				}
536
537				break;
538			case 8:
539				// Rotate 90 degrees counter-clockwise.
540				$result = $this->rotate( 90 );
541				break;
542		}
543
544		return $result;
545	}
546
547	/**
548	 * Either calls editor's save function or handles file as a stream.
549	 *
550	 * @since 3.5.0
551	 *
552	 * @param string|stream $filename
553	 * @param callable      $function
554	 * @param array         $arguments
555	 * @return bool
556	 */
557	protected function make_image( $filename, $function, $arguments ) {
558		$stream = wp_is_stream( $filename );
559		if ( $stream ) {
560			ob_start();
561		} else {
562			// The directory containing the original file may no longer exist when using a replication plugin.
563			wp_mkdir_p( dirname( $filename ) );
564		}
565
566		$result = call_user_func_array( $function, $arguments );
567
568		if ( $result && $stream ) {
569			$contents = ob_get_contents();
570
571			$fp = fopen( $filename, 'w' );
572
573			if ( ! $fp ) {
574				ob_end_clean();
575				return false;
576			}
577
578			fwrite( $fp, $contents );
579			fclose( $fp );
580		}
581
582		if ( $stream ) {
583			ob_end_clean();
584		}
585
586		return $result;
587	}
588
589	/**
590	 * Returns first matched mime-type from extension,
591	 * as mapped from wp_get_mime_types()
592	 *
593	 * @since 3.5.0
594	 *
595	 * @param string $extension
596	 * @return string|false
597	 */
598	protected static function get_mime_type( $extension = null ) {
599		if ( ! $extension ) {
600			return false;
601		}
602
603		$mime_types = wp_get_mime_types();
604		$extensions = array_keys( $mime_types );
605
606		foreach ( $extensions as $_extension ) {
607			if ( preg_match( "/{$extension}/i", $_extension ) ) {
608				return $mime_types[ $_extension ];
609			}
610		}
611
612		return false;
613	}
614
615	/**
616	 * Returns first matched extension from Mime-type,
617	 * as mapped from wp_get_mime_types()
618	 *
619	 * @since 3.5.0
620	 *
621	 * @param string $mime_type
622	 * @return string|false
623	 */
624	protected static function get_extension( $mime_type = null ) {
625		if ( empty( $mime_type ) ) {
626			return false;
627		}
628
629		return wp_get_default_extension_for_mime_type( $mime_type );
630	}
631}
632
633