Home > Designing, Others > Responsive Images in WordPress with Cloudinary, Part 2

Responsive Images in WordPress with Cloudinary, Part 2

February 27th, 2017 Leave a comment Go to comments

In Part 1 of this series, I provided some background on responsive images, describing how you can add srcset and sizes attributes to an img element to serve appropriately sized image files to users of a website based on the size and capabilities of their browser and device. I also shared how WordPress used its native image resizing functionality to implement srcset and sizes automatically and how you can use an external image service like Cloudinary to extend the native implementation that WordPress provides.

Article Series:

  1. An Intro to Responsive Images and WordPress
  2. A WordPress Plugin integrating Cloudinary and Responsive Images (you are here!)

In this installment, I go into more detail about how image resizing in WordPress works. I explain how I used built-in WordPress hooks along with Cloudinary’s application programming interface (API)—and its PHP integration library—to create a WordPress plug?in that offloads responsive image resizing and optimization to Cloudinary.

Getting Started

This article assumes that you understand how to create a WordPress plug?in. If you don’t, read the WordPress Codex article Writing a Plugin before continuing. For my example, I used the WP-CLI to create a scaffold for my plug?in from the command line.

Next, follow the instructions in the WordPress PHP Getting Started Guide to download and include Cloudinary’s PHP library in your plug?in. Save the library files to the `/lib/cloudinary_php/` directory within your plug?in, and include them in your main plug?in file with the following code:

// Load dependencies.
require 'lib/cloudinary_php/src/Cloudinary.php';
require 'lib/cloudinary_php/src/Uploader.php';
require 'lib/cloudinary_php/src/Api.php';

Finally, set up Cloudinary using the configuration parameters found in your Cloudinary management console, which you define as constants in your `wp-config.php` file because it’s a bad idea to save account information directly to your plug?in. Here’s what the config setup in my plug?in looks like:

Cloudinary::config( array(
 "cloud_name" => CLD_CLOUD_NAME,
 "api_key"    => CLD_API_KEY,
 "api_secret" => CLD_API_SECRET
) );

Now that you have configured your plug?in to communicate with the Cloudinary API, you can start building the functionality of your plug?in.

Image Resizing in WordPress

In planning my plug?in, I wanted to integrate seamlessly with the default user experience for managing images in WordPress. I also wanted to maintain local copies of my files so that everything would continue working even if I decided to deactivate the plug?in. To fulfill these goals, it’s helpful to understand what WordPress does whenever you upload an image.

By default, wp_generate_attachment_metadata() resizes images and stores metadata about them in the database.

When you upload an image, WordPress executes the media_handle_upload() function, which saves the uploaded file to the server and creates a new post in the database representing the image using the wp_insert_attachment() function. After the post is created, media_handle_upload() calls wp_generate_attachment_metadata() to create additional metadata about the image. It’s during this step that WordPress creates additional sizes of the image and includes information about those sizes in the attachment metadata. Here’s an example of what attachment metadata in WordPress looks like by default:

array(
  'width' => 1500,
  'height' => 1500,
  'file' => '2016/11/image.jpg',
  'sizes' => array(
    'thumbnail' => array(
      'file' => 'image-150x150.jpg',
      'width' => 150,
      'height' => 150,
      'mime-type' => 'image/jpeg',
    ),
    'medium' => array(
      'file' => 'image-300x300.jpg',
      'width' => 300,
      'height' => 300,
      'mime-type' => 'image/jpeg',
    ),
    'large' => array(
      'file' => 'image-1024x1024.jpg',
      'width' => 1024,
      'height' => 1024,
      'mime-type' => 'image/jpeg',
    ),
  ),
)

As you can see, WordPress now knows the file name and dimensions for the original image file, and for any additional sizes created by WordPress after the image was uploaded. WordPress references this metadata to create the HTML markup needed to display the image on a webpage. This is the information we will extend by including information generated by the Cloudinary API.

Integrating WordPress with Cloudinary

For the remainder of this article, I’ll be referencing the Cloudinary_WP_Integration class in the plug?in. Here’s the full source code for this class. In this class, I’ve added a method named register_hooks(), which adds all my custom functionality by taking advantage of WordPress built-in filter hooks. To better understand WordPress hooks, read about the Plugin API in the WordPress Codex.

generate_cloudinary_data() mirrors images to Cloudinary and saves additional, Cloudinary-specific data to the database.

Because I want to mirror uploaded files with Cloudinary and use its API to generate a set of image sizes for use in srcset attributes, the first thing I do is hook into the wp_generate_attachment_metadata filter to extend the metadata that WordPress is already creating. To register this functionality, I added the following code to my register_hooks() class:

add_filter( 'wp_generate_attachment_metadata', array( $this, 'generate_cloudinary_data' ) );

This tells WordPress to call the generate_cloudinary_data() method in my class when the wp_generate_attachment_metadata filter is fired. Here’s what that method looks like:

public function generate_cloudinary_data( $metadata ) {
 // Bail early if we don't have a file path to work with.
 if ( ! isset( $metadata['file'] ) ) {
  return $metadata;
 }

 $uploads = wp_get_upload_dir();
 $filepath = trailingslashit( $uploads['basedir'] ) . $metadata['file'];

 // Mirror the image on Cloudinary and build custom metadata from the response.
 if ( $data = $this->handle_upload( $filepath ) ) {
  $metadata['cloudinary_data'] = array(
   'public_id'  => $data['public_id'],
   'width'      => $data['width'],
   'height'     => $data['height'],
   'bytes'      => $data['bytes'],
   'url'        => $data['url'],
   'secure_url' => $data['secure_url'],
  );

  foreach ( $data['responsive_breakpoints'][0]['breakpoints'] as $size ) {
   $metadata['cloudinary_data']['sizes'][$size['width'] ] = $size;
  }
 };

 return $metadata;
}

This code uses wp_get_upload_dir() to build the path to the uploaded image and passes it to a second method, handle_upload(), which uploads the image to Cloudinary and returns data from the API. When handle_upload() is complete, I add the returned data to a cloudinary_data array in the metadata, and then loop through each breakpoint size that Cloudinary returns—which I’ll explain in a moment—and save those to the cloudinary_data['sizes'] key. Let’s look at what happens in the handle_upload() method:

public function handle_upload( $file ) {
  $data = false;
  if ( is_callable( array( 'CloudinaryUploader', 'upload' ) ) ) {
    $api_args = array(
      'responsive_breakpoints' => array(
        array(
          'create_derived' => false,
          'bytes_step'  => 20000,
          'min_width' => 200,
          'max_width' => 1000,
          'max_images' => 20,
        ),
     ),
     'use_filename' => true,
    );
    $response = CloudinaryUploader::upload( $file, $api_args );
    // Check for a valid response before returning Cloudinary data.
    $data = isset( $response['public_id'] ) ? $response : false;
  }
  return $data;
}

This method determines whether it can call the upload() method in the CloudinaryUploader class I imported earlier by using is_callable(). If it can, it builds the arguments I plan to pass to CloudinaryUploader::upload(). First, I use Cloudinary’s responsive image breakpoint functionality to automatically generate the best set of image sizes based on the content of the image itself. I’m passing a few options to the responsive_breakpoints argument here, so let me explain each:

  • create_derived tells Cloudinary whether it should create additional image files as soon as the original is uploaded. Passing false generates data about the image without actually creating the files until they’re requested.
  • bytes_step defines how many bytes should be allowed between images before creating a new size. I’m going with 20,000 (or 20 KB), but you can tweak that number to suit your needs.
  • The min_width and max_width arguments tell Cloudinary what the dimensions of the smallest and largest images, respectively, should be so that you don’t create unnecessary image sizes.
  • max_images sets the maximum total number of images that Cloudinary should create.

With this information, Cloudinary automatically determines the optimal number and size of images to create for use in srcset attributes. Finally, I set use_filename to true, which tells Cloudinary to use file names matching the one I’m uploading—defined as the $file variable—rather than generating random image file names. This helps me identify images in my Cloudinary library but makes no real difference otherwise.

Now that I have a way to automatically upload images to Cloudinary and save the returned data to the attachment metadata for my image, I can use these data to serve images from the Cloudinary content delivery network (CDN) rather than my local server. To do this, I first want to filter all attachment URLs so that the Cloudinary URL is used instead of local URLs. For this, I’ve added a filter named get_attachment_url() to the wp_get_attachment_url hook in my register_hooks() method here:

add_filter( 'wp_get_attachment_url', array( $this, 'get_attachment_url' ), 10, 2 );

This line returns the URL and attachment ID of an image to be passed to my get_attachment_url() method, which looks like this:

public function get_attachment_url( $url, $attachment_id ) {
  $metadata = wp_get_attachment_metadata( $attachment_id );

  if ( isset( $metadata['cloudinary_data']['secure_url'] ) ) {
    $url = $metadata['cloudinary_data']['secure_url'];
  }

  return $url;
}

This method looks up the metadata associated with my image and determines whether a URL from the cloudinary_data I saved in my last step exists. If it does, it returns the URL from Cloudinary. Otherwise, it returns the local URL.

This takes care of the URL for the full-sized image, but replacing URLs for any of the sizes WordPress creates (i.e., intermediate sizes) can be a bit trickier. To accomplish this, I need to hook into image_downsize(), which is the function WordPress uses to get information about intermediate sizes associated with an image. Here, I use Cloudinary instead of local files.

The following code registers my filter followed by the method that replaces the data from WordPress with data from Cloudinary:

add_filter( 'image_downsize', array( $this, 'image_downsize' ), 10, 3 );
public function image_downsize( $downsize, $attachment_id, $size ) {
  $metadata = wp_get_attachment_metadata( $attachment_id );

  if ( isset( $metadata['cloudinary_data']['secure_url'] ) ) {
    $sizes = $this->get_wordpress_image_size_data( $size );

    // If we found size data, let's figure out our own downsize attributes.
    if ( is_string( $size ) && isset( $sizes[ $size ] ) &&
       ( $sizes[ $size ]['width'] <= $metadata['cloudinary_data']['width'] ) &&
       ( $sizes[ $size ]['height'] <= $metadata['cloudinary_data']['height'] ) ) {

      $width = $sizes[ $size ]['width'];
      $height = $sizes[ $size ]['height'];

      $dims = image_resize_dimensions( $metadata['width'], $metadata['height'], $sizes[ $size ]['width'], $sizes[ $size ]['height'], $sizes[ $size ]['crop'] );

      if ( $dims ) {
        $width = $dims[4];
        $height = $dims[5];
      }

      $crop = ( $sizes[ $size ]['crop'] ) ? 'c_lfill' : 'c_limit';

      $url_params = "w_$width,h_$height,$crop";

      $downsize = array(
        str_replace( '/image/upload', '/image/upload/' . $url_params, $metadata['cloudinary_data']['secure_url'] ),
        $width,
        $height,
        true,
      );

    } elseif ( is_array( $size ) ) {
      $downsize = array(
        str_replace( '/image/upload', "/image/upload/w_$size[0],h_$size[1],c_limit", $metadata['cloudinary_data']['secure_url'] ),
        $size[0],
        $size[1],
        true,
      );
    }
  }

  return $downsize;
}

This is a long block of code, so let me walk through it. Again, I start by getting the attachment metadata and checking for the $metadata['cloudinary_data'] information. I then use a helper function called get_wordpress_image_size_data() to get the image sizes that are registered with WordPress, which I then pass to image_resize_dimensions() to calculate the expected dimensions if I’m using a named size (e.g., thumbnail, medium). If the $size parameter is already an array of dimensions, which happens occasionally, I pass those dimensions directly to Cloudinary for processing.

I should note here that I could have used the Cloudinary API to replicate all the alternate sizes WordPress creates. Instead, I chose to take advantage of Cloudinary’s dynamic URL image generation functionality to generate the additional sizes I need by replacing the URL to the full-sized image with dynamic parameters like this:

str_replace( 
  '/image/upload',
  "/image/upload/w_$size[0],h_$size[1],c_limit",
  $metadata['cloudinary_data']['secure_url'] );

If the image dimensions should be an exact crop, I’ll use Cloudinary’s c_lfill cropping algorithm. Otherwise, c_limit make images that fit within my target dimensions while maintaining the original file’s aspect ratio.

Once I’ve completed these steps, Cloudinary should serve any image newly uploaded to WordPress. The last task is to generate srcset and sizes attributes by using the metadata that I previously got back from Cloudinary’s responsive image breakpoint functionality.

Automatically Generating srcset and sizes

The payoff.

To understand the details of WordPress’s responsive images implementation, you may want to read Responsive Images in WordPress 4.4. To summarize, I’ll review the two occasions when WordPress dynamically adds srcset and sizes to images.

Responsive Markup for Dynamically Generated Images

First, WordPress automatically attempts to add these attributes to any image dynamically generated in a template using wp_get_attachment_image() or similar functions. You can add srcset and sizes attributes to these images by filtering the image attributes before the markup is assembled by using the wp_get_attachment_image_attributes filter:

add_filter( 'wp_get_attachment_image_attributes', array( $this, 'wp_get_attachment_image_attributes' ), 10, 3 );
public function wp_get_attachment_image_attributes( $attr, $attachment, $size ) {
  $metadata = wp_get_attachment_metadata( $attachment->ID );

  if ( is_string( $size ) ) {
    if ( 'full' === $size ) {
      $width = $attachment['width'];
      $height = $attachment['height'];
    } elseif ( $data = $this->get_wordpress_image_size_data( $size ) ) {
      // Bail early if this is a cropped image size.
      if ( $data[$size]['crop'] ) {
        return $attr;
      }

      $width = $data[$size]['width'];
      $height = $data[$size]['height'];
    }
  } elseif ( is_array( $size ) ) {
    list( $width, $height ) = $size;
  }

  if ( isset( $metadata['cloudinary_data']['sizes'] ) ) {
    $srcset = '';

    foreach( $metadata['cloudinary_data']['sizes'] as $s ) {
      $srcset .= $s['secure_url'] . ' ' . $s['width'] . 'w, ';
    }

    if ( ! empty( $srcset ) ) {
      $attr['srcset'] = rtrim( $srcset, ', ' );
      $sizes = sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $width );

      // Convert named size to dimension array for the filter.
      $size = array($width, $height);
      $attr['sizes'] = apply_filters( 'wp_calculate_image_sizes', $sizes, $size, $attr['src'], $metadata, $attachment->ID );
    }
  }

  return $attr;
}

In the wp_get_attachment_image_attributes() method, you calculate the dimensions of the image based on the $size parameter. For now, I’m only adding srcset and sizes to those images with an aspect ratio matching the original file so that I can take advantage of the breakpoint sizes Cloudinary provided when I uploaded my image. If I determine that the $size is a different aspect ratio (e.g., a hard crop), I return the $attr value unchanged.

Once you have the dimensions of your image, you loop through all the breakpoint sizes from the $metadata['cloudinary_data']['sizes'] array to build the srcset attribute. Afterward, you create a sizes attribute based on the width of the image. Finally, you pass your sizes attribute value to the wp_calculate_image_sizes() filter so that themes and plug?ins can modify the sizes attribute based on their specific layout needs.

Responsive Markup for Images in Post Content

WordPress also automatically adds srcset and sizes attributes to images embedded in post content. Instead of saving these attributes in the post content in the database, WordPress generates them dynamically when the page is generated. That way, as new methods for serving responsive images become available, WordPress can easily adopt them.

You want your Cloudinary integration to be just as future friendly as the native implementation. So, replace the content filter that WordPress uses — wp_make_content_images_responsive() — with your own filter, named make_content_images_responsive(). Here’s the code that accomplishes both tasks:

// Replace the default WordPress content filter with our own.
remove_filter( 'the_content', 'wp_make_content_images_responsive' );
add_filter( 'the_content', array( $this, 'make_content_images_responsive',  ) );
public function make_content_images_responsive( $content ) {
  if ( ! preg_match_all( '/<img [^>]+>/', $content, $matches ) ) {
    return $content;
  }

  $selected_images = $attachment_ids = array();

  foreach( $matches[0] as $image ) {
    if ( false === strpos( $image, ' srcset=' ) && preg_match( '/wp-image-([0-9]+)/i', $image, $class_id ) &&
      ( $attachment_id = absint( $class_id[1] ) ) ) {

      /*
       * If exactly the same image tag is used more than once, overwrite it.
       * All identical tags will be replaced later with 'str_replace()'.
       */
      $selected_images[ $image ] = $attachment_id;
      // Overwrite the ID when the same image is included more than once.
      $attachment_ids[ $attachment_id ] = true;
    }
  }

  if ( count( $attachment_ids ) > 1 ) {
    /*
     * Warm object cache for use with 'get_post_meta()'.
     *
     * To avoid making a database call for each image, a single query
     * warms the object cache with the meta information for all images.
     */
    update_meta_cache( 'post', array_keys( $attachment_ids ) );
  }

  foreach ( $selected_images as $image => $attachment_id ) {
    $image_meta = wp_get_attachment_metadata( $attachment_id );
    $content = str_replace( $image, $this->add_srcset_and_sizes( $image, $image_meta, $attachment_id ), $content );
  }

  return $content;
}

The make_content_images_responsive() method is essentially a copy of the wp_make_content_images_responsive() function from WordPress, which searches the content for all elements — handling some edge cases and including some performance optimizations in the process—and passes them to a second function that handles adding the srcset and sizes attributes. I created a custom callback method in my class named add_srcset_and_sizes() for this purpose:

public function add_srcset_and_sizes( $image, $image_meta, $attachment_id ) {
  if ( isset( $image_meta['cloudinary_data']['sizes'] ) ) {
    // See if our filename is in the URL string.
    if ( false !== strpos( $image, wp_basename( $image_meta['cloudinary_data']['url'] ) ) && false === strpos( $image, 'c_lfill') ) {
      $src = preg_match( '/src="([^"]+)"/', $image, $match_src ) ? $match_src[1] : '';
      $width  = preg_match( '/ width="([0-9]+)"/',  $image, $match_width  ) ? (int) $match_width[1]  : 0;
      $height = preg_match( '/ height="([0-9]+)"/', $image, $match_height ) ? (int) $match_height[1] : 0;

      $srcset = '';

      foreach( $image_meta['cloudinary_data']['sizes'] as $s ) {
        $srcset .= $s['secure_url'] . ' ' . $s['width'] .  'w, ';
      }

      if ( ! empty( $srcset ) ) {
        $srcset = rtrim( $srcset, ', ' );
        $sizes = sprintf( '(max-width: %1$dpx) 100vw, %1$dpx', $width );

        // Convert named size to dimension array.
        $size = array($width, $height);
        $sizes = apply_filters( 'wp_calculate_image_sizes', $sizes, $size, $src, $image_meta, $attachment_id );
      }

      $image = preg_replace( '/src="([^"]+)"/', 'src="$1" srcset="' . $srcset . '" sizes="' . $sizes .'"', $image );
    }
  }

  return $image;
}

Here, I again make sure that my attachment metadata includes size data from Cloudinary. Then, I make sure that the image markup includes the same file name as the image I uploaded to Cloudinary, just in case the image markup hasn’t been edited after it was inserted into the content. Finally, I include false === strpos( $image, 'c_lfill') to determine whether the URL indicates that Cloudinary is hard-cropping the image, similar to how I checked for hard cropping in wp_get_attachment_image_attributes(). If all checks pass, I can loop through the breakpoint sizes that were created when I originally uploaded the image to Cloudinary and use those data to build out my srcset and sizes attributes.

With this functionality, you can now successfully offload all your responsive image processing to Cloudinary and serve optimized images from the Cloudinary CDN instead of your local web server.

Wrap?Up

I hope this gives you a better understanding of how WordPress handles resizing images and shows how you can extend WordPress to take advantage of Cloudinary to dynamically generate and serve images that are optimized for different device types and sizes. To try this code out on your site, download the plug?in from GitHub, and be sure to leave feedback about anything that you think could be improved.


This post (and the plugin!) was written by Joe McGill.


Responsive Images in WordPress with Cloudinary, Part 2 is a post from CSS-Tricks

Categories: Designing, Others Tags:
  1. No comments yet.
  1. No trackbacks yet.
You must be logged in to post a comment.