<?php
/*
	Plugin Name: ImageMagick Engine
	Plugin URI: https://wordpress.org/plugins/imagemagick-engine/
	Description: Improve the quality of re-sized images by replacing standard GD library with ImageMagick
	Author: Orangelab
	Author URI: https://orangelab.com/
	Version: 2.0.0
	Requires at least: 6.4
	Requires PHP: 7.4
	Text Domain: imagemagick-engine
	License: GPLv2 or later

	Copyright @ 2026 Orangelab AB

	Licenced under the GNU GPL:

	This program is free software; you can redistribute it and/or modify
	it under the terms of the GNU General Public License as published by
	the Free Software Foundation; either version 2 of the License, or
	(at your option) any later version.

	This program is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.

	You should have received a copy of the GNU General Public License
	along with this program; if not, write to the Free Software
	Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
*/

if ( ! defined( 'ABSPATH' ) ) {
    exit();
}

/*
 * Constants
 */
define( 'IME_OPTION_VERSION', 2 );
define( 'IME_VERSION', '2.0.0' );
define( 'IME_REGEN_OPTION', 'ime_regen_queue' );
define( 'IME_REGEN_TTL', 12 * HOUR_IN_SECONDS );
define( 'IME_REGEN_BATCH_START', 5 );
define( 'IME_REGEN_BATCH_MIN', 1 );
define( 'IME_REGEN_BATCH_MAX', 25 );
define( 'IME_REGEN_FAILED_CAP', 100 );

require_once __DIR__ . '/includes/admin-page.php';
require_once __DIR__ . '/includes/ajax.php';

/*
 * Global variables
 */

// Plugin options default values -- change on plugin admin page
global $ime_options_default;
$ime_options_default = [
    'enabled'      => false,
    'mode'         => null,
    'cli_path'     => null,
    'gm_path'      => null,
    'handle_sizes' => [
        'thumbnail'    => 'size',
        'medium'       => 'quality',
        'medium_large' => 'quality',
        'large'        => 'quality',
    ],
    'quality'      => [
        'quality' => -1,
        'size'    => 70,
    ],
    'interlace'    => false,
    'keep_exif'    => false,
    // Turn off the browser-based image processing WordPress 7.1 added, so all
    // sub-sizes are generated by ImageMagick instead of a mix of both.
    'disable_client_side_processing' => true,
    'version'      => constant( 'IME_OPTION_VERSION' ),
];

// Available quality modes
$ime_available_quality_modes = [ 'quality', 'size', 'skip' ];

// Current options
$ime_options = null;

// Keep track of attachment file & sizes between different filters
$ime_image_sizes  = null;
$ime_image_file   = null;
$ime_failed_sizes = [];

/*
 * Functions
 */
add_action( 'plugins_loaded', 'ime_init_early' );
add_action( 'init', 'ime_init' );
register_uninstall_hook( __FILE__, 'ime_uninstall' );

/* Plugin setup (early) */
function ime_init_early() {
    load_plugin_textdomain( 'imagemagick-engine', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );

    if ( ime_active() ) {
        add_filter( 'intermediate_image_sizes_advanced', 'ime_filter_image_sizes', 99, 1 );
        add_filter( 'wp_read_image_metadata', 'ime_filter_read_image_metadata', 10, 3 );

        /*
         * Priority 5, deliberately ahead of the default 10.
         *
         * On this filter we are a *producer* of $metadata['sizes'], not a
         * consumer: we are finishing work core would already have done before
         * any filter ran, had ime_filter_image_sizes() not stripped those sizes
         * out of intermediate_image_sizes_advanced. So we have to run before
         * everything that reads $metadata['sizes'], and the default priority of
         * 10 is where those consumers live.
         *
         * The concrete case that motivated this is the Modern Image Formats
         * plugin (webp-uploads), which also filters at 10 and bails early when
         * $metadata['sizes'] is still empty -- leaving our sub-sizes without
         * WebP/AVIF variants. See issue #41.
         */
        add_filter( 'wp_generate_attachment_metadata', 'ime_filter_attachment_metadata', 5, 2 );

        if ( ime_disable_client_side_processing() ) {
            add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
        }
    }
}

/* Plugin setup */
function ime_init() {
    if ( is_admin() ) {
        add_action( 'admin_menu', 'ime_admin_menu' );
        add_filter( 'plugin_action_links', 'ime_filter_plugin_actions', 10, 2 );
        add_filter( 'media_meta', 'ime_filter_media_meta', 10, 2 );

        add_action( 'wp_ajax_ime_test_im_path', 'ime_ajax_test_im_path' );
        add_action( 'wp_ajax_ime_process_image', 'ime_ajax_process_image' );
        add_action( 'wp_ajax_ime_regen_start', 'ime_ajax_regen_start' );
        add_action( 'wp_ajax_ime_regen_batch', 'ime_ajax_regen_batch' );
        add_action( 'wp_ajax_ime_regen_cancel', 'ime_ajax_regen_cancel' );
        add_action( 'wp_ajax_ime_regen_state', 'ime_ajax_regen_state' );

        wp_register_script( 'ime-alpinejs', plugins_url( '/js/alpine.csp.min.js', __FILE__ ), [ 'ime-admin' ], '3.15.9', true );
        wp_register_script( 'ime-admin', plugins_url( '/js/ime-admin.js', __FILE__ ), [], constant( 'IME_VERSION' ), true );
    }
}

/* Remove all plugin data on uninstall */
function ime_uninstall() {
    delete_option( 'ime_options' );
    delete_option( IME_REGEN_OPTION );
    delete_transient( 'ime_cli_valid' );
    delete_transient( 'ime_gm_valid' );
}

/* Are we enabled with valid mode? */
function ime_active() {
    return ime_get_option( 'enabled' ) && ime_mode_valid();
}

/* Check if mode is valid */
function ime_mode_valid( $mode = null ) {
    if ( empty( $mode ) ) {
        $mode = ime_get_option( 'mode' );
    }
    $fn = 'ime_im_' . $mode . '_valid';
    return ( ! empty( $mode ) && function_exists( $fn ) && call_user_func( $fn ) );
}

// Get array of available image sizes
function ime_available_image_sizes() {
    $sizes = [
        'thumbnail'    => __( 'Thumbnail' ),
        'medium'       => __( 'Medium' ),
        'medium_large' => __( 'Medium Large' ),
        'large'        => __( 'Large' ),
    ]; // Standard sizes
    foreach ( wp_get_additional_image_sizes() as $name => $spec ) {
        $sizes[ $name ] = $name;
    }

    return $sizes;
}



/*
 * Plugin option handling
 */

// Setup plugin options
function ime_setup_options() {
    global $ime_options;

    // Already setup?
    if ( is_array( $ime_options ) ) {
        return;
    }

    $ime_options = get_option( 'ime_options' );

    // No stored options yet?
    if ( ! is_array( $ime_options ) ) {
        global $ime_options_default;
        $ime_options = $ime_options_default ?? array();
    }

    // Do we need to upgrade options?
    if ( ! array_key_exists( 'version', $ime_options )
        || $ime_options['version'] < constant( 'IME_OPTION_VERSION' ) ) {

        /*
         * Future compatability code goes here!
         */

        // Option version 2: added client-side media processing switch (WP 7.1).
        if ( ! array_key_exists( 'disable_client_side_processing', $ime_options ) ) {
            $ime_options['disable_client_side_processing'] = true;
        }

        $ime_options['version'] = constant( 'IME_OPTION_VERSION' );
        ime_store_options();
    }
}

// Store plugin options
function ime_store_options() {
    global $ime_options;

    ime_setup_options();

    $stored_options = get_option( 'ime_options' );

    if ( $stored_options === false ) {
        add_option( 'ime_options', $ime_options, null, false );
    } else {
        update_option( 'ime_options', $ime_options );
    }
}

// Get plugin option
function ime_get_option( $option_name, $default = null ) {
    ime_setup_options();

    global $ime_options, $ime_options_default;

    if ( is_array( $ime_options ) && array_key_exists( $option_name, $ime_options ) ) {
        return $ime_options[ $option_name ];
    }

    if ( ! is_null( $default ) ) {
        return $default;
    }

    if ( is_array( $ime_options_default ) && array_key_exists( $option_name, $ime_options_default ) ) {
        return $ime_options_default[ $option_name ];
    }

    return null;
}

// Set plugin option
function ime_set_option( $option_name, $option_value, $store = false ) {
    ime_setup_options();

    global $ime_options;

    $ime_options[ $option_name ] = $option_value;

    if ( $store ) {
        ime_store_options();
    }
}

// Should images be converted with interlace or not
function ime_interlace() {
    return ime_get_option( 'interlace' );
}

// Should Exif data (including GPS) be preserved when stripping metadata
function ime_keep_exif() {
    return ime_get_option( 'keep_exif' );
}

/*
 * Should WordPress' client-side media processing be turned off?
 *
 * WordPress 7.1 generates sub-sizes in the browser (WebAssembly) when the admin
 * runs in a secure context. Those sizes never reach our filters, so an upload
 * would end up with some sizes made by ImageMagick and some by the browser.
 */
function ime_disable_client_side_processing() {
    return ime_client_side_processing_available() && ime_get_option( 'disable_client_side_processing' );
}

// Does this WordPress version have client-side media processing? (7.1+)
function ime_client_side_processing_available() {
    return function_exists( 'wp_is_client_side_media_processing_enabled' );
}

// Get image quality setting for type
function ime_get_quality( $resize_mode = 'quality' ) {
    $quality = ime_get_option( 'quality', '-1' );
    if ( ! $quality ) {
        return -1;
    }
    if ( ! is_array( $quality ) ) {
        return $quality;
    }
    if ( isset( $quality[ $resize_mode ] ) ) {
        return $quality[ $resize_mode ];
    }

    return -1;
}

// Get resize mode for size
function ime_get_resize_mode( $size ) {
    $handle_sizes = ime_get_option( 'handle_sizes' );
    if ( isset( $handle_sizes[ $size ] ) && is_string( $handle_sizes[ $size ] ) ) {
        return $handle_sizes[ $size ];
    } else {
        return 'quality'; // default to quality
    }
}


/*
 * WP integration & image handling functions
 */

/*
 * Filter image sizes (in wp_generate_attachment_metadata()).
 *
 * We store the sizes we are interested in, and remove those sizes from the
 * list so that WP doesn't handle them -- we will take care of them later.
 *
 * The reason we do things this way is so we do not resize image twize (once
 * by WordPress using GD, and then again by us).
 */
function ime_filter_image_sizes( $sizes ) {
    global $ime_image_sizes;

    $handle_sizes = ime_get_option( 'handle_sizes' );
    foreach ( $handle_sizes as $s => $handle ) {
        if ( ! $handle || $handle == 'skip' || ! array_key_exists( $s, $sizes ) ) {
            continue;
        }
        $ime_image_sizes[ $s ] = $sizes[ $s ];
        unset( $sizes[ $s ] );
    }
    return $sizes;
}

/*
 * Filter to get target file name.
 *
 * Function wp_generate_attachment_metadata calls wp_read_image_metadata which
 * gives us a hook to get the target filename.
 */
function ime_filter_read_image_metadata( $metadata, $file, $ignore ) {
    global $ime_image_file;

    $ime_image_file = $file;

    return $metadata;
}

/*
 * Filter new attachment metadata
 *
 * Resize image for the sizes we are interested in.
 *
 * Parts of function copied from wp-includes/media.php:image_resize()
 */
function ime_filter_attachment_metadata( $metadata, $attachment_id ) {
    global $ime_image_sizes, $ime_image_file, $ime_failed_sizes;

    // Reset before the loop below so a previous attachment's failures cannot
    // leak into this one.
    $ime_failed_sizes = [];

    // Any sizes we are interested in?
    if ( empty( $ime_image_sizes ) ) {
        return $metadata;
    }

    $attachment = get_post( $attachment_id );

    // We can only process attachments.
    if ( 'attachment' !== get_post_type( $attachment ) ) {
        return $metadata;
    }

    // Make sure file exists on server
    if ( ! $ime_image_file || ! file_exists( $ime_image_file ) ) {
        return $metadata;
    }

    $editor = wp_get_image_editor( $ime_image_file );
    if ( is_wp_error( $editor ) ) {
        // Display a more helpful error message.
        if ( 'image_no_editor' === $editor->get_error_code() ) {
            $editor = new WP_Error( 'image_no_editor', __( 'The current image editor cannot process this file type.', 'imagemagick-engine' ) );
        }

        $editor->add_data( array(
            'attachment' => $attachment,
            'status'     => 415,
        ) );

        return $editor;
    }

    // Get size & image type of original image
    $old_stats = wp_getimagesize( $ime_image_file );
    if ( ! $old_stats || is_wp_error( $old_stats ) ) {
        return $metadata;
    }

    list($orig_w, $orig_h, $orig_type) = $old_stats;

    /*
     * Exif orientation 5-8 means the image is displayed rotated a quarter turn.
     * All engines apply that rotation to the pixels before resizing, so the
     * dimension math below has to use the rotated width/height. Without the
     * swap the target size is computed for the wrong aspect ratio, and both the
     * resized file and the stored metadata come out wrong.
     */
    if ( in_array( ime_image_exif_orientation( $ime_image_file, $orig_type ), [ 5, 6, 7, 8 ], true ) ) {
        list($orig_w, $orig_h) = [ $orig_h, $orig_w ];
    }

    /*
     * Sort out the filename, extension (and image type) of resized images
     */
    $info     = pathinfo( $ime_image_file );
    $dir      = $info['dirname'];
    $ext      = $info['extension'];

    /*
     * Do the actual resize
     */
    foreach ( $ime_image_sizes as $size => $size_data ) {
        $width  = $size_data['width'];
        $height = $size_data['height'];

        // ignore sizes equal to or larger than original size
        if ( $orig_w <= $width && $orig_h <= $height ) {
            continue;
        }

        $crop = $size_data['crop'];

        $dims = image_resize_dimensions( $orig_w, $orig_h, $width, $height, $crop );
        if ( ! $dims ) {
            continue;
        }
        list($dst_x, $dst_y, $src_x, $src_y, $dst_w, $dst_h, $src_w, $src_h) = $dims;

        $suffix       = "{$dst_w}x{$dst_h}";
        $new_filename = $editor->generate_filename( $suffix, null, $ext );

        $resized = ime_im_resize( $ime_image_file, $new_filename, $dst_w, $dst_h, $crop, ime_get_resize_mode( $size ) );
        if ( ! $resized ) {
            $ime_failed_sizes[] = $size;
            continue;
        }

        /*
         * Keep the same keys, in the same order, that core's image editor
         * writes for a sub-size: file, width, height, mime-type, filesize.
         * Consumers rely on them -- webp-uploads reads filesize to decide
         * whether a generated WebP/AVIF is actually smaller than what we made.
         */
        $size_meta = [
            'file'   => wp_basename( $new_filename ),
            'width'  => $dst_w,
            'height' => $dst_h,
        ];

        // The engines take their output format from the extension, which is the
        // source extension, so the written file is what we ask wp_check_filetype
        // about. Omit the key entirely rather than store a falsy value.
        $filetype = wp_check_filetype( $new_filename );
        if ( ! empty( $filetype['type'] ) ) {
            $size_meta['mime-type'] = $filetype['type'];
        }

        $size_meta['filesize'] = wp_filesize( $new_filename );

        $metadata['sizes'][ $size ] = $size_meta;

        if ( ! isset( $metadata['image-converter'] ) || ! is_array( $metadata['image-converter'] ) ) {
            $metadata['image-converter'] = [];
        }

        $metadata['image-converter'][ $size ] = 'IME';

        // Set correct file permissions
        $stat  = stat( dirname( $new_filename ) );
        $perms = $stat['mode'] & 0000666; //same permissions as parent folder, strip off the executable bits
        @ chmod( $new_filename, $perms );
    }

    $ime_image_sizes = null;
    return $metadata;
}

/*
 * Read the Exif orientation (1-8) of an image file, 1 when unknown.
 *
 * $image_type is an IMAGETYPE_* constant as returned by wp_getimagesize().
 */
function ime_image_exif_orientation( $file, $image_type = null ) {
    if ( ! is_callable( 'exif_read_data' ) ) {
        return 1;
    }

    // exif_read_data() only handles JPEG and TIFF
    $exif_types = [ IMAGETYPE_JPEG, IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM ];
    if ( ! is_null( $image_type ) && ! in_array( $image_type, $exif_types, true ) ) {
        return 1;
    }

    $exif = @exif_read_data( $file );
    if ( empty( $exif['Orientation'] ) ) {
        return 1;
    }

    $orientation = (int) $exif['Orientation'];

    return ( $orientation >= 1 && $orientation <= 8 ) ? $orientation : 1;
}

// Resize file by calling mode specific resize function
function ime_im_resize( $old_file, $new_file, $width, $height, $crop, $resize_mode = 'quality' ) {
    $mode = ime_get_option( 'mode' );
    $fn   = 'ime_im_' . $mode . '_valid';
    if ( empty( $mode ) || ! function_exists( $fn ) || ! call_user_func( $fn ) ) {
        return false;
    }

    $fn      = 'ime_im_' . $mode . '_resize';
    $success = ( function_exists( $fn ) && call_user_func( $fn, $old_file, $new_file, $width, $height, $crop, $resize_mode ) );
    do_action( 'ime_after_resize', $success, $old_file, $new_file, $width, $height, $crop, $resize_mode );
    return $success;
}

// Is this the filename of a jpeg?
function ime_im_filename_is_jpg( $filename ) {
    $info = pathinfo( $filename );
    $ext  = $info['extension'];
    return ( strcasecmp( $ext, 'jpg' ) == 0 ) || ( strcasecmp( $ext, 'jpeg' ) == 0 );
}

// Get file extenstion
function ime_im_get_filetype( $filename ) {
    $info = pathinfo( $filename );
    return strtolower( $info['extension'] );
}

/*
 * PHP ImageMagick ("Imagick") class handling
 */

// Does class exist?
function ime_im_php_valid() {
    return class_exists( 'Imagick' );
}

// Resize file using PHP Imagick class
function ime_im_php_resize( $old_file, $new_file, $width, $height, $crop, $resize_mode = 'quality' ) {
    try {
        $im = new Imagick( $old_file );
        if ( ! $im->valid() ) {
            return false;
        }

        $im->setImageFormat( ime_im_get_filetype( $old_file ) );

        // Apply Exif orientation to actual pixels before any dimension calculations.
        // Without this, getImageGeometry() returns pre-rotation dimensions and
        // resized images end up with the wrong orientation.
        $im->autoOrient();

        $quality = ime_get_quality( $resize_mode );
        if ( is_numeric( $quality ) && $quality >= 0 && $quality <= 100 && ime_im_filename_is_jpg( $new_file ) ) {
            $im->setImageCompression( Imagick::COMPRESSION_JPEG );
            $im->setImageCompressionQuality( $quality );
        }

        if ( ime_interlace() ) {
            $im->setInterlaceScheme( Imagick::INTERLACE_PLANE );
        }

        if ( $resize_mode == 'size' ) {
            if ( ime_keep_exif() ) {
                // Strip everything except Exif (preserves GPS and other Exif data)
                foreach ( [ 'iptc', '8bim', 'xmp', 'APP13' ] as $profile ) {
                    @$im->removeImageProfile( $profile );
                }
            } else {
                $im->stripImage();
            }
        }

        if ( $crop ) {
            /*
             * Unfortunately we cannot use the PHP module
             * cropThumbnailImage() function as it strips profile data.
             *
             * Crop an area proportional to target $width and $height and
             * fall through to scaleImage() below.
             */

            $geo         = $im->getImageGeometry();
            $orig_width  = $geo['width'];
            $orig_height = $geo['height'];

            if ( ( $orig_width / $width ) < ( $orig_height / $height ) ) {
                $crop_width  = $orig_width;
                $crop_height = ceil( ( $height * $orig_width ) / $width );
                $off_x       = 0;
                $off_y       = ceil( ( $orig_height - $crop_height ) / 2 );
            } else {
                $crop_width  = ceil( ( $width * $orig_height ) / $height );
                $crop_height = $orig_height;
                $off_x       = ceil( ( $orig_width - $crop_width ) / 2 );
                $off_y       = 0;
            }
            $im->cropImage( $crop_width, $crop_height, $off_x, $off_y );
        }

        $im->scaleImage( $width, $height, true );

        $im->setImagePage( $width, $height, 0, 0 ); // to make sure canvas is correct
        $im->writeImage( $new_file );

        return file_exists( $new_file );
    } catch ( ImagickException $ie ) {
        return false;
    }
}

// Does Gmagick class exist?
function ime_im_gmagick_valid() {
    return class_exists( 'Gmagick' );
}

// Resize file using PHP Gmagick class
function ime_im_gmagick_resize( $old_file, $new_file, $width, $height, $crop, $resize_mode = 'quality' ) {
    try {
        $im = new Gmagick( $old_file );

        $im->setimageformat( ime_im_get_filetype( $old_file ) );

        // Apply Exif orientation correction manually (Gmagick has no autoOrient())
        $orientation = $im->getimageorientation();
        switch ( $orientation ) {
            case Gmagick::ORIENTATION_BOTTOMRIGHT: // 3 — rotated 180
                $im->rotateimage( '#000000', 180 );
                break;
            case Gmagick::ORIENTATION_RIGHTTOP: // 6 — rotated 90 CW
                $im->rotateimage( '#000000', 90 );
                break;
            case Gmagick::ORIENTATION_LEFTBOTTOM: // 8 — rotated 270 CW
                $im->rotateimage( '#000000', 270 );
                break;
            case Gmagick::ORIENTATION_TOPRIGHT: // 2 — flipped horizontal
                $im->flopimage();
                break;
            case Gmagick::ORIENTATION_BOTTOMLEFT: // 4 — flipped vertical
                $im->flipimage();
                break;
            case Gmagick::ORIENTATION_LEFTTOP: // 5 — transpose
                $im->flopimage();
                $im->rotateimage( '#000000', 90 );
                break;
            case Gmagick::ORIENTATION_RIGHTBOTTOM: // 7 — transverse
                $im->flopimage();
                $im->rotateimage( '#000000', 270 );
                break;
        }
        if ( $orientation > 1 ) {
            $im->setimageorientation( Gmagick::ORIENTATION_TOPLEFT );
        }

        $quality = ime_get_quality( $resize_mode );
        if ( is_numeric( $quality ) && $quality >= 0 && $quality <= 100 && ime_im_filename_is_jpg( $new_file ) ) {
            $im->setimagecompression( Gmagick::COMPRESSION_JPEG );
            $im->setimagecompressionquality( intval( $quality ) );
        }

        if ( ime_interlace() && defined( 'Gmagick::INTERLACE_PLANE' ) ) {
            $im->setinterlacescheme( Gmagick::INTERLACE_PLANE );
        }

        if ( $resize_mode == 'size' ) {
            if ( ime_keep_exif() ) {
                foreach ( [ 'iptc', '8bim', 'xmp', 'APP13' ] as $profile ) {
                    try {
                        $im->removeimageprofile( $profile );
                    } catch ( GmagickException $e ) {
                        // Profile may not exist — not an error
                    }
                }
            } else {
                $im->stripimage();
            }
        }

        $orig_width  = $im->getimagewidth();
        $orig_height = $im->getimageheight();

        if ( $crop ) {
            if ( ( $orig_width / $width ) < ( $orig_height / $height ) ) {
                $crop_width  = $orig_width;
                $crop_height = ceil( ( $height * $orig_width ) / $width );
                $off_x       = 0;
                $off_y       = ceil( ( $orig_height - $crop_height ) / 2 );
            } else {
                $crop_width  = ceil( ( $width * $orig_height ) / $height );
                $crop_height = $orig_height;
                $off_x       = ceil( ( $orig_width - $crop_width ) / 2 );
                $off_y       = 0;
            }
            $im->cropimage( intval( $crop_width ), intval( $crop_height ), intval( $off_x ), intval( $off_y ) );
        }

        $im->scaleimage( $width, $height, true );
        $im->writeimage( $new_file );

        return file_exists( $new_file );
    } catch ( GmagickException $ge ) {
        return false;
    }
}

/*
 * ImageMagick executable handling
 */

// Check if path is executable depending on OS
function ime_is_executable($fullpath) {
    if ( ! function_exists('proc_open') ) {
        return @is_executable($fullpath);
    }
    $whereIsCommand = (PHP_OS == 'WINNT') ? 'where' : 'which';
    $process = proc_open(
        [ $whereIsCommand, $fullpath ],
        [ 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ] ],
        $pipes
    );
    if ( ! is_resource($process) ) {
        return false;
    }
    $output = trim( stream_get_contents( $pipes[1] ) );
    fclose( $pipes[1] );
    fclose( $pipes[2] );
    proc_close( $process );
    return ! empty( $output );
}

// Do we have a valid CLI executable set? Pass $is_gm = true for GraphicsMagick.
function ime_im_cli_valid( $is_gm = false ) {
    $transient = $is_gm ? 'ime_gm_valid' : 'ime_cli_valid';
    if ( WP_DEBUG || false === ( $valid = get_transient( $transient ) ) ) {
        $cmd   = ime_im_cli_command( $is_gm );
        $valid = ( ! empty( $cmd ) && ime_is_executable( $cmd ) ) ? 'yes' : 'no';
        set_transient( $transient, $valid, DAY_IN_SECONDS );
    }
    return $valid === 'yes';
}

// Test if executable is a working IM or GM binary.
function ime_im_cli_check_executable( $fullpath, $is_gm = false ) {
    if ( ! @is_executable( $fullpath ) || ! function_exists( 'proc_open' ) ) {
        return false;
    }

    $args    = $is_gm ? [ $fullpath, 'version' ] : [ $fullpath, '--version' ];
    $process = proc_open(
        $args,
        [ 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ] ],
        $pipes
    );
    if ( ! is_resource( $process ) ) {
        return false;
    }
    $output = stream_get_contents( $pipes[1] );
    fclose( $pipes[1] );
    fclose( $pipes[2] );
    proc_close( $process );

    if ( $is_gm ) {
        preg_match( '/GraphicsMagick ([0-9]+\.[0-9]+(?:\.[0-9]+)?)/', $output, $version );
        if ( isset( $version[1] ) ) {
            ime_set_option( 'graphicsmagick_version', $version[1], true );
            return true;
        }
    } else {
        preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $output, $version );
        if ( isset( $version[1] ) ) {
            ime_set_option( 'imagemagick_version', $version[1], true );
            return true;
        }
    }

    return false;
}

/*
 * Try to get realpath of path
 *
 * This won't work if there is open_basename restrictions.
 */
function ime_try_realpath( $path ) {
    $realpath = @realpath( $path );
    if ( $realpath ) {
        return $realpath;
    } else {
        return $path;
    }
}

// Check if a directory contains a working IM or GM executable.
function ime_im_cli_check_command( $path, $is_gm = false ) {
    $path        = ime_try_realpath( $path );
    $executables = $is_gm ? [ 'gm' ] : [ 'magick', 'convert' ];

    foreach ( $executables as $executable ) {
        $full_path = $path . DIRECTORY_SEPARATOR . $executable;
        if ( ime_im_cli_check_executable( $full_path, $is_gm ) ) {
            return $full_path;
        }
        $full_path_exe = $full_path . '.exe';
        if ( ime_im_cli_check_executable( $full_path_exe, $is_gm ) ) {
            return $full_path_exe;
        }
    }

    return null;
}

// Try to auto-discover an IM or GM executable in common paths.
function ime_im_cli_find_command( $is_gm = false ) {
    $possible_paths = [ '/usr/bin', '/usr/local/bin', '/opt/homebrew/bin' ];

    foreach ( $possible_paths as $path ) {
        if ( ime_im_cli_check_command( $path, $is_gm ) ) {
            return $path;
        }
    }

    return null;
}

// Get the full path to the IM or GM executable.
function ime_im_cli_command( $is_gm = false ) {
    $path_option = $is_gm ? 'gm_path' : 'cli_path';
    $path        = ime_get_option( $path_option );

    if ( ! empty( $path ) ) {
        return ime_im_cli_check_command( $path, $is_gm );
    }

    $path = ime_im_cli_find_command( $is_gm );
    if ( empty( $path ) ) {
        return null;
    }
    ime_set_option( $path_option, $path, true );
    return ime_im_cli_check_command( $path, $is_gm );
}

// Thin wrappers so the mode dispatch system finds ime_im_graphicsmagick_valid().
function ime_im_graphicsmagick_valid() {
    return ime_im_cli_valid( true );
}

// Shared resize implementation for both ImageMagick and GraphicsMagick CLI.
// GraphicsMagick requires 'convert' as a subcommand; ImageMagick does not.
function ime_im_cli_do_resize( $cmd_path, $is_gm, $old_file, $new_file, $width, $height, $crop, $resize_mode ) {
    $geometry = intval( $width ) . 'x' . intval( $height );
    $prefix   = $is_gm ? [ $cmd_path, 'convert' ] : [ $cmd_path ];

    // Build command args array — passed directly to proc_open, no shell interpretation
    $cmd_args = array_merge( $prefix, [
        $old_file,
        '-auto-orient',        // apply Exif rotation to pixels before resizing
        '-limit', 'memory', '157286400',
        '-limit', 'map', '134217728',
        '-resize', $geometry . ( $crop ? '^' : '!' ),
    ] );

    if ( $crop ) {
        $cmd_args = array_merge( $cmd_args, [ '-gravity', 'center', '-extent', $geometry ] );
    }

    $quality = ime_get_quality( $resize_mode );
    if ( is_numeric( $quality ) && $quality >= 0 && $quality <= 100 && ime_im_filename_is_jpg( $new_file ) ) {
        $cmd_args = array_merge( $cmd_args, [ '-quality', (string) intval( $quality ) ] );
    }

    if ( ime_interlace() ) {
        $cmd_args = array_merge( $cmd_args, [ '-interlace', 'Plane' ] );
    }

    if ( $resize_mode == 'size' ) {
        if ( ime_keep_exif() ) {
            // Remove bulky non-Exif profiles; preserve Exif (contains GPS)
            $cmd_args = array_merge( $cmd_args, [ '+profile', '8bim', '+profile', 'iptc', '+profile', 'xmp' ] );
        } else {
            $cmd_args[] = '-strip';
        }
    }

    $cmd_args[] = $new_file;

    $process = proc_open(
        $cmd_args,
        [ 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ] ],
        $pipes
    );
    if ( is_resource( $process ) ) {
        fclose( $pipes[1] );
        fclose( $pipes[2] );
        proc_close( $process );
    }

    return file_exists( $new_file );
}

function ime_im_cli_resize( $old_file, $new_file, $width, $height, $crop, $resize_mode = 'quality' ) {
    $cmd_path = ime_im_cli_command();
    if ( empty( $cmd_path ) ) {
        return false;
    }
    return ime_im_cli_do_resize( $cmd_path, false, $old_file, $new_file, $width, $height, $crop, $resize_mode );
}

function ime_im_graphicsmagick_resize( $old_file, $new_file, $width, $height, $crop, $resize_mode = 'quality' ) {
    $cmd_path = ime_im_cli_command( true );
    if ( empty( $cmd_path ) ) {
        return false;
    }
    return ime_im_cli_do_resize( $cmd_path, true, $old_file, $new_file, $width, $height, $crop, $resize_mode );
}

