PluginProbe
HEIC Support / trunk
HEIC Support vtrunk
2.3.0 2.2.1 trunk 1.0.0 1.0.1 2.0.0 2.1.0 2.1.1 2.1.3 2.1.4 2.2.0
heic-support / heic-support.php

heic-support.php in HEIC Support trunk, at heic-support.php

616 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: HEIC Support
4 * Description: Allows .heic uploads to the Media Library. Creates .webp, .avif, or .jpg copies of .heic images when they are uploaded.
5 * Plugin URI: https://breakfastco.xyz/heic-support/
6 * Author: Breakfast
7 * Author URI: https://breakfastco.xyz/
8 * Version: 2.3.0
9 * Text-domain: heic-support
10 * License: GPLv2
11 * GitHub Plugin URI: https://github.com/csalzano/heic-support
12 * Primary Branch: main
13 *
14 * @author Corey Salzano <csalzano@duck.com>
15 * @package HEIC_Support
16 */
17
18 defined( 'ABSPATH' ) || exit;
19
20 // Cloud conversion service endpoints.
21 defined( 'HEIC_SUPPORT_STORE_URL' ) || define( 'HEIC_SUPPORT_STORE_URL', 'https://breakfastco.xyz' );
22 defined( 'HEIC_SUPPORT_REMOTE_API_URL' ) || define( 'HEIC_SUPPORT_REMOTE_API_URL', 'https://heic.breakfastco.xyz/v1' );
23
24 if ( ! class_exists( 'Heic_Support_Plugin' ) ) {
25 /**
26 * Heic_Support_Plugin
27 */
28 class Heic_Support_Plugin {
29
30 const OPTION_TEST_IMAGE = 'heic_support_test_image_paths';
31
32 /**
33 * True or false, the test image conversion worked.
34 *
35 * @var bool $test_success
36 */
37 protected $test_success;
38
39 /**
40 * Escaped HTML displayed in the Test setting at Settings → Media.
41 *
42 * @var string $test_result_html
43 */
44 protected $test_result_html;
45
46 /**
47 * Adds filter and action hooks that power this plugin.
48 *
49 * @return void
50 */
51 public function add_hooks() {
52 // Allow .heic files to be uploaded into the Media Library.
53 add_filter( 'upload_mimes', array( $this, 'add_mimes' ) );
54
55 // Creates a copy of .heic images uploaded to the Media Library.
56 add_action( 'add_attachment', array( $this, 'create_copy' ), 12, 1 );
57
58 // Replace heic uploads without preserving the heic.
59 add_filter( 'wp_handle_upload_prefilter', array( $this, 'replace' ) );
60
61 // Populates width, height, and other attributes in meta key _wp_attachment_metadata.
62 add_filter( 'wp_generate_attachment_metadata', array( $this, 'populate_meta' ), 10, 2 );
63
64 // Run our conversion test when users visit wp-admin/options-media.php.
65 add_action( 'admin_init', array( $this, 'test_run' ), 9 );
66
67 // Adds settings to the dashboard at Settings → Media.
68 add_action( 'admin_init', array( $this, 'add_settings' ) );
69
70 // Adds a link to the plugins list that helps users find Settings → Media.
71 add_filter( 'plugin_action_links_heic-support/heic-support.php', array( $this, 'add_settings_link' ) );
72
73 // Deletes the test image when the plugin is uninstalled.
74 register_uninstall_hook( __FILE__, array( __CLASS__, 'uninstall' ) );
75 }
76
77 /**
78 * Allow .heic files to be uploaded into the Media Library.
79 *
80 * @param array $mimes Array of allowed mime types.
81 * @return array
82 */
83 public function add_mimes( $mimes ) {
84 if ( empty( $mimes['heic'] ) ) {
85 $mimes['heic'] = 'image/heic';
86 }
87 return $mimes;
88 }
89
90 /**
91 * Adds settings to the dashboard at Settings → Media.
92 *
93 * @return void
94 */
95 public function add_settings() {
96
97 $section = 'heic_support_section';
98 add_settings_section(
99 $section,
100 __( 'HEIC Support', 'heic-support' ),
101 array( $this, 'callback_section' ),
102 'media'
103 );
104
105 // Format setting registration.
106 register_setting(
107 'media',
108 'heic_support_format',
109 array(
110 'type' => 'string',
111 'description' => __( 'Convert .heic images to this format.', 'heic-support' ),
112 'sanitize_callback' => 'sanitize_text_field',
113 'show_in_rest' => true,
114 )
115 );
116
117 // Replace setting registration.
118 register_setting(
119 'media',
120 'heic_support_replace',
121 array(
122 'type' => 'boolean',
123 'description' => __( 'Replace .heic images uploaded to the Media Library instead of creating copies.', 'heic-support' ),
124 'sanitize_callback' => 'rest_sanitize_boolean',
125 'show_in_rest' => true,
126 )
127 );
128
129 /**
130 * Format setting output. Checks for ImageMagick and shows a "sorry"
131 * message if the free conversion is not going to work.
132 */
133 add_settings_field(
134 'format',
135 __( 'Convert To', 'heic-support' ),
136 array( $this, 'callback_format_setting' ),
137 'media',
138 $section
139 );
140
141 // Is the plugin's primary feature going to work?
142 if ( ! class_exists( 'Imagick' ) ) {
143 // No. Do not output any of the options.
144 return;
145 }
146
147 if ( $this->test_success ) {
148 // Replace setting output.
149 add_settings_field(
150 'replace',
151 __( 'Replace', 'heic-support' ),
152 array( $this, 'callback_replace_setting' ),
153 'media',
154 $section
155 );
156
157 // ImageMagick setting.
158 add_settings_field(
159 'imagemagick',
160 __( 'ImageMagick', 'heic-support' ),
161 array( $this, 'callback_imagemagick_setting' ),
162 'media',
163 $section
164 );
165 }
166
167 // Test setting.
168 add_settings_field(
169 'test',
170 __( 'Test', 'heic-support' ),
171 array( $this, 'callback_test_setting' ),
172 'media',
173 $section
174 );
175 }
176
177 /**
178 * Adds a "Settings" link to this plugin's entry at wp-admin/plugins.php.
179 *
180 * @param array $links An array of plugin action links. By default this can include 'activate', 'deactivate', and 'delete'. With Multisite active this can also include 'network_active' and 'network_only' items.
181 * @return array
182 */
183 public function add_settings_link( $links ) {
184 $links[] = '<a href="' . admin_url( 'options-media.php' ) . '">' . __( 'Settings', 'heic-support' ) . '</a>';
185 return $links;
186 }
187
188 /**
189 * Returns the file extension to which .heic images are converted.
190 * Either "jpg", "webp", or "avif".
191 *
192 * @return string
193 */
194 protected static function get_extension() {
195 $format = self::get_format();
196 if ( 'jpeg' === $format ) {
197 return apply_filters( 'heic_support_extension', 'jpg' );
198 }
199 return $format;
200 }
201
202 /**
203 * Retrieves the file format to which .heic images are converted from
204 * the option where it is stored. Either "jpeg", "webp", or "avif".
205 *
206 * @return string
207 */
208 protected static function get_format() {
209 $value = get_option( 'heic_support_format' );
210 if ( ! in_array( $value, array( 'webp', 'jpeg', 'avif' ), true ) ) {
211 $value = 'webp';
212 }
213 return apply_filters( 'heic_support_format', $value );
214 }
215
216 /**
217 * Outputs HTML that renders the ImageMagick setting content at Settings
218 * → Media → HEIC Support. This is the version of ImageMagick running
219 * on the server.
220 *
221 * @return void
222 */
223 public function callback_imagemagick_setting() {
224 echo esc_html( $this->imagemagick_version() );
225 }
226
227 /**
228 * Outputs HTML that renders the Format setting radio buttons.
229 *
230 * @return void
231 */
232 public function callback_format_setting() {
233 // Is the plugin's primary feature going to work?
234 if ( ! class_exists( 'Imagick' ) ) {
235 // No. Frame the cloud option around the benefit, with a clear next step.
236 printf(
237 /* translators: 1. Anchor element opening tag. 2. Anchor element closing tag. */
238 '<p>%1$s</p><p>%2$s</p>',
239 esc_html__( 'Your web host can\'t convert .heic images on its own. They will upload, but won\'t display in most browsers.', 'heic-support' ),
240 sprintf(
241 /* translators: 1. Anchor element opening tag. 2. Anchor element closing tag. */
242 esc_html__( 'HEIC Support can convert them for you automatically in the cloud, on any host, without installing more software. One credit converts one image, and packs start at 3 conversions for $5.99. %1$sGet conversion credits%2$s, then add your license key below to switch it on.', 'heic-support' ),
243 '<a href="https://breakfastco.xyz/heic-support/" target="_blank" rel="noopener"><strong>',
244 '</strong></a>'
245 )
246 );
247 return;
248 }
249 $value = self::get_format();
250 printf(
251 '<fieldset><label for="heic_support_webp"><input type="radio" id="heic_support_webp" name="heic_support_format" value="webp" %1$s/> %2$s</label><br />'
252 . '<label for="heic_support_avif"><input type="radio" id="heic_support_avif" name="heic_support_format" value="avif" %3$s/> %4$s</label><br />'
253 . '<label for="heic_support_jpeg"><input type="radio" id="heic_support_jpeg" name="heic_support_format" value="jpeg" %5$s/> %6$s</label></fieldset>',
254 checked( $value, 'webp', false ),
255 esc_html__( '.webp', 'heic-support' ),
256 checked( $value, 'avif', false ),
257 esc_html__( '.avif', 'heic-support' ),
258 checked( $value, 'jpeg', false ),
259 esc_html__( '.jpg', 'heic-support' )
260 );
261 }
262
263 /**
264 * Outputs HTML that renders the Replace ID settings checkbox.
265 *
266 * @return void
267 */
268 public function callback_replace_setting() {
269 $value = get_option( 'heic_support_replace' );
270 printf(
271 '<input type="checkbox" id="heic_support_replace" name="heic_support_replace" %s/> <label for="heic_support_replace">%s</label><p class="description">%s</p>',
272 checked( $value, '1', false ),
273 esc_html__( 'Replace .heic images uploaded to the Media Library instead of creating copies.', 'heic-support' ),
274 esc_html__( 'Does not preserve the original .heic files.', 'heic-support' )
275 );
276 }
277
278 /**
279 * Outputs the Test setting content at Settings → Media → HEIC Support.
280 *
281 * @return void
282 */
283 public function callback_test_setting() {
284 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
285 echo $this->test_result_html ?? '';
286 }
287
288 /**
289 * Outputs the HEIC Support section content at Settings → Media.
290 *
291 * @return void
292 */
293 public function callback_section() {
294 esc_html_e( 'Control how .heic images are handled during uploads.', 'heic-support' );
295 }
296
297 /**
298 * Filter callback on add_attachment. Creates a copy of .heic images
299 * uploaded to the Media Library.
300 *
301 * @param int $post_id The ID of a new attachment.
302 * @return void
303 */
304 public function create_copy( $post_id ) {
305 // Is the Replace feature enabled? If so, abort the copy.
306 $replace = filter_var( get_option( 'heic_support_replace' ), FILTER_VALIDATE_BOOLEAN );
307 if ( $replace ) {
308 // Yes. Replace is enabled. Abort.
309 return;
310 }
311
312 // Is ImageMagick running?
313 if ( ! class_exists( 'Imagick' ) ) {
314 // No.
315 return;
316 }
317 $file_path = get_attached_file( $post_id );
318 if ( false === $file_path ) {
319 return;
320 }
321 // Is the attachment an heic?
322 if ( 'heic' !== pathinfo( $file_path, PATHINFO_EXTENSION ) ) {
323 // No.
324 return;
325 }
326 $imagick = new Imagick();
327 try {
328 if ( $imagick->readImage( $file_path ) ) {
329 $imagick->setImageFormat( self::get_format() );
330 // Create a path to a copy of the image.
331 $name = basename( $file_path, '.heic' ) . '.' . self::get_extension();
332 $upload_dir = wp_upload_dir();
333 $imagick->writeImage( $upload_dir['path'] . DIRECTORY_SEPARATOR . $name );
334
335 /**
336 * The Media Library loads these files, but not the Editor.
337 *
338 * @link https://developer.wordpress.org/reference/functions/media_sideload_image/#more-information
339 */
340 if ( ! function_exists( 'media_sideload_image' ) ) {
341 require_once ABSPATH . 'wp-admin/includes/media.php';
342 require_once ABSPATH . 'wp-admin/includes/file.php';
343 require_once ABSPATH . 'wp-admin/includes/image.php';
344 }
345 $copy_post_id = media_sideload_image( $upload_dir['url'] . '/' . $name, 0 /* post_parent */, get_the_title( $post_id ), 'id' );
346 if ( ! is_wp_error( $copy_post_id ) ) {
347 update_post_meta( $copy_post_id, '_heic_support_copy_of', $post_id );
348 update_post_meta( $post_id, '_heic_support_copy_of', $copy_post_id );
349 }
350 }
351 } catch ( ImagickException $ie ) {
352 // "Fatal error: Uncaught ImagickException: no decode delegate for this image format `HEIC'".
353 // The version of Imagick does not support heic
354 return;
355 }
356 }
357
358 /**
359 * Returns the ImageMagick version string.
360 *
361 * @return string
362 */
363 protected function imagemagick_version() {
364 if ( ! class_exists( 'Imagick' ) ) {
365 return '';
366 }
367 return Imagick::getVersion()['versionString'];
368 }
369
370 /**
371 * When .heic files are added to the Media Library, populate their width,
372 * height, and other attributes that live in meta key _wp_attachment_metadata.
373 *
374 * @param array $metadata An array of attachment meta data.
375 * @param int $attachment_id Current attachment ID.
376 * @return array
377 */
378 public function populate_meta( $metadata, $attachment_id ) {
379 // Is ImageMagick running?
380 if ( ! class_exists( 'Imagick' ) ) {
381 // No.
382 return $metadata;
383 }
384 $file_path = get_attached_file( $attachment_id );
385 if ( false === $file_path ) {
386 return $metadata;
387 }
388 // Is the attachment an heic?
389 if ( 'heic' !== pathinfo( $file_path, PATHINFO_EXTENSION ) ) {
390 // No.
391 return $metadata;
392 }
393 // Are the width and height missing?
394 if ( false === $metadata ) {
395 $metadata = array();
396 }
397 if ( ! empty( $metadata['width'] ) && ! empty( $metadata['height'] ) ) {
398 // No.
399 return $metadata;
400 }
401 $imagick = new Imagick();
402 try {
403 if ( $imagick->readImage( $file_path ) ) {
404 $new_values = $imagick->getImageGeometry();
405 $new_values['sizes'] = array();
406 $new_values['file'] = get_post_meta( $attachment_id, '_wp_attached_file', true );
407 $metadata = wp_parse_args( $metadata, $new_values );
408 return $metadata;
409 }
410 } catch ( ImagickException $ie ) {
411 // "Fatal error: Uncaught ImagickException: no decode delegate for this image format `HEIC'".
412 // The version of Imagick does not support heic
413 return $metadata;
414 }
415 }
416
417 /**
418 * Replaces uploaded .heic files with equivalents during uploads.
419 *
420 * @param array $file An array of data for a single file.
421 * @return array
422 */
423 public function replace( $file ) {
424 // Does $file look like an uploaded file?
425 if ( empty( $file['tmp_name'] ) || empty( $file['name'] ) ) {
426 return $file;
427 }
428
429 // Is this image even an heic?
430 $wp_filetype = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'] );
431 if ( empty( $wp_filetype['type'] ) || 'image/heic' !== $wp_filetype['type'] ) {
432 // No.
433 return $file;
434 }
435
436 // Is ImageMagick available?
437 if ( ! class_exists( 'Imagick' ) ) {
438 // No.
439 return $file;
440 }
441
442 // Is this replace feature enabled?
443 $replace = filter_var( get_option( 'heic_support_replace' ), FILTER_VALIDATE_BOOLEAN );
444 if ( ! $replace ) {
445 // No. The feature is not enabled.
446 return $file;
447 }
448
449 $imagick = new Imagick();
450 try {
451 if ( $imagick->readImage( $file['tmp_name'] ) ) {
452 $format = self::get_format();
453 $imagick->setImageFormat( $format );
454 $file['type'] = apply_filters( 'heic_support_mime', 'image/' . $format );
455 $file['name'] = basename( $file['name'], '.heic' ) . '.' . self::get_extension();
456 $imagick->writeImage( $file['tmp_name'] );
457 $file['size'] = wp_filesize( $file['tmp_name'] );
458 }
459 } catch ( ImagickException $ie ) {
460 // "Fatal error: Uncaught ImagickException: no decode delegate for this image format `HEIC'".
461 // The version of Imagick does not support heic
462 return $file;
463 }
464
465 return $file;
466 }
467
468 /**
469 * Tries to convert an .heic image that ships with this plugin. Stashes
470 * a message describing what happened in $this->test_result_html so it
471 * can be retrieved.
472 *
473 * @return void
474 */
475 public function test_run() {
476 global $pagenow;
477 // Is this page wp-admin/options-media.php?
478 if ( 'options-media.php' !== $pagenow ) {
479 // No.
480 return;
481 }
482
483 // Try our test image conversion & preserve data about the result.
484 if ( ! class_exists( 'Imagick' ) ) {
485 // Can't even try.
486 $this->test_success = false;
487 Heic_Support_Cloud::cache_local_heic_supported( false );
488 $this->test_result_html = esc_html__( 'ImageMagick is not available on this server, so .heic images cannot be converted locally. You can use our cloud servers to convert your uploads on any host.', 'heic-support' );
489 return;
490 }
491
492 $imagick = new Imagick();
493 try {
494 if ( $imagick->readImage( __DIR__ . DIRECTORY_SEPARATOR . 'image4.heic' ) ) {
495 $imagick->setImageFormat( self::get_format() );
496
497 // Create a copy of the image.
498 $path = self::test_file_path();
499 $this->test_save_image_path( $path );
500 $imagick->writeImage( $path );
501 $upload_dir = wp_upload_dir();
502 $name = basename( $path );
503 // It worked!
504 $this->test_success = true;
505 Heic_Support_Cloud::cache_local_heic_supported( true );
506 $this->test_result_html = sprintf(
507 '<figure><img src="%s" width="%d" /><figcaption>%s .%s.</figcaption></figure>',
508 esc_attr( $upload_dir['url'] . '/' . $name ),
509 esc_attr( get_option( 'medium_size_w' ) ),
510 esc_html__( 'This plugin can convert .heic images. If you do not see an image, your browser may not support', 'heic-support' ),
511 esc_html( self::get_extension() )
512 );
513 }
514 } catch ( ImagickException $ie ) {
515 // "Fatal error: Uncaught ImagickException: no decode delegate for this image format `HEIC'".
516 $msg = 'no decode delegate for this image format `HEIC\'';
517 if ( false !== strpos( $ie->getMessage(), $msg ) ) {
518 $this->test_success = false;
519 Heic_Support_Cloud::cache_local_heic_supported( false );
520 $this->test_result_html = sprintf(
521 /* translators: 1. An opening bold text tag <b>. 2. A closing bold text tag </b>. 3. An ImageMagick version string. */
522 esc_html__( '%1$sFailed%2$s. ImageMagick is installed, but does not support HEIC, so .heic uploads will not be converted locally (the version may be too old, or libheif is missing). Installed version is %3$s. You can convert your uploads on any host using our cloud conversion servers below.', 'heic-support' ),
523 '<b>',
524 '</b>',
525 esc_html( $this->imagemagick_version() )
526 );
527 }
528 }
529 }
530
531 /**
532 * Saves the path to a test image after deleting the file that belongs
533 * to the current value of the option where the path is stored.
534 *
535 * @param string $path The path to a test image we want to save.
536 * @return void
537 */
538 protected function test_save_image_path( $path ) {
539 $old_path = get_option( self::OPTION_TEST_IMAGE );
540 if ( ! empty( $old_path ) && file_exists( $old_path ) ) {
541 wp_delete_file( $old_path );
542 }
543 update_option( self::OPTION_TEST_IMAGE, $path );
544 }
545
546 /**
547 * Returns a unique file path where we can save a test image.
548 *
549 * @return string
550 */
551 protected static function test_file_path() {
552 $upload_dir = wp_upload_dir();
553 return $upload_dir['path'] . DIRECTORY_SEPARATOR
554 . wp_unique_filename(
555 $upload_dir['path'],
556 'heic-support-image4.' . self::get_extension()
557 );
558 }
559
560 /**
561 * Removes plugin data and test images from the current site.
562 *
563 * @return void
564 */
565 protected static function uninstall_guts() {
566 $path = get_option( self::OPTION_TEST_IMAGE );
567 if ( ! empty( $path ) && file_exists( $path ) ) {
568 wp_delete_file( $path );
569 }
570
571 delete_option( 'heic_support_format' );
572 delete_option( 'heic_support_replace' );
573 delete_option( self::OPTION_TEST_IMAGE );
574
575 // Cloud conversion options + cached state.
576 delete_option( 'heic_support_license_key' );
577 delete_option( 'heic_support_cloud_enabled' );
578 delete_option( 'heic_support_cloud_force' );
579 delete_transient( Heic_Support_Cloud::LOCAL_WORKS_TRANSIENT );
580 delete_option( Heic_Support_Cloud::LOCAL_WORKS_TRANSIENT );
581 delete_transient( 'heic_support_credits_status' );
582 delete_transient( 'heic_support_credits_remaining' );
583 delete_transient( 'heic_support_notice' );
584 }
585
586 /**
587 * Deletes plugin data and test images when the plugin is uninstalled.
588 *
589 * @return void
590 */
591 public static function uninstall() {
592 if ( ! is_multisite() ) {
593 self::uninstall_guts();
594 } else {
595 $sites = get_sites(
596 array(
597 'network' => 1,
598 'limit' => 1000,
599 )
600 );
601 foreach ( $sites as $site ) {
602 switch_to_blog( $site->blog_id );
603 self::uninstall_guts();
604 restore_current_blog();
605 }
606 }
607 }
608 }
609 }
610 $heic_support_plugin = new Heic_Support_Plugin();
611 $heic_support_plugin->add_hooks();
612
613 // Cloud conversion client. Only shown if the server does not support free conversion.
614 require_once __DIR__ . '/includes/class-heic-support-cloud.php';
615 ( new Heic_Support_Cloud() )->add_hooks();
616