On-the-fly Image Resizing

Posted by Tom on 2010-02-07 15:19

Designers, man. All web developers hate them, but as soon as you try and set one on fire someone always phones the police. Spoilsports.

Among my biggest gripes is inconsistant image sizes. It may be fine to design something using 8 different image sizes spread across 4 different aspect ratios when you're sitting in front of a copy of Photoshop, but that just ain't feasible once we start dealing with dynamic content. You can resize everything on upload, but as soon as someone tweaks the design slightly you're having to reach for IrfanView and cursing. Wouldn't it be better for all concerned if the images just came out the right size? That would be cool.

Wut?

Thankfully, resizing images on demand isn't all that hard. One HttpHandler is all it takes to leave our crayon-wielding brethren free to sling around whatever image sizes they want. Here's a quick list of the features I'll want:

Implementation

First up we'll start with a custom configuration section.

<ImageHandler
    imagePath="~/Resources"
    missingImageName="missing_image.png"
    cachingEnabled="true"
    cachePath="~/Resources/Cache"
    maxCacheSize="250000"
    backgroundColour="#000000"
/>

Most of the attribute names should be pretty self explanatory. If they're not then I've not done my job correctly, so feel free to leave a comment calling me a dick. The code to read the following looks like this:

public class ImageHandlerConfiguration : ConfigurationSection
{
    [ConfigurationProperty("imagePath", IsRequired = true)]
    public string ImagePath
    {
        get { return this["imagePath"].ToString(); }
        set { this["imagePath"] = value; }
    }
 
    [ConfigurationProperty("missingImageName", IsRequired = false)]
    public string MissingImageName
    {
        get { return this["missingImageName"].ToString(); }
        set { this["missingImageName"] = value; }
    }
 
    [ConfigurationProperty("cachePath", IsRequired = false)]
    public string CachePath
    {
        get { return this["cachePath"].ToString(); }
        set { this["cachePath"] = value; }
    }
 
    [ConfigurationProperty("cachingEnabled", IsRequired = false, DefaultValue = false)]
    public bool CachingEnabled
    {
        get { return Convert.ToBoolean(this["cachingEnabled"]); }
        set { this["cachingEnabled"] = value; }
    }
 
    [ConfigurationProperty("maxCacheSize", IsRequired = false, DefaultValue = -1)]
    public int MaxCacheSize
    {
        get { return Convert.ToInt32(this["maxCacheSize"]); }
        set { this["maxCacheSize"] = value; }
    }
 
    [ConfigurationProperty("backgroundColour", IsRequired = false)]
    public string BackgroundColour
    {
        get { return this["backgroundColour"].ToString(); }
        set { this["backgroundColour"] = value; }
    }
 
    public static ImageHandlerConfiguration GetConfig()
    {
        return (ImageHandlerConfiguration)ConfigurationManager.GetSection("ImageHandler");
    }
}

And now the code of the HttpHandler itself:

public class ImageHandler : IHttpHandler
{
    #region Properties
 
    private static ImageHandlerConfiguration Config
    {
        get;
        set;
    }
 
    private static Dictionary<Guid, string> MimeTypes
    {
        get;
        set;
    }
 
    public bool IsReusable
    {
        get { return false; }
    }
 
    #endregion
 
    #region Methods
 
    public void ProcessRequest(HttpContext context)
    {
        // Load list of image encoders for MIME type lookup
        lock (this.GetType())
        {
            if (MimeTypes == null)
                Init();
        }
 
        HttpRequest request = context.Request;
        HttpResponse response = context.Response;
 
        int width = 0;
        int height = 0;
        bool clamp = true;
        ImageFormat imageFormat = null;
 
        // Grab request details from the querystring
        string filename = request.QueryString["i"];
        string filePath = context.Server.MapPath(Config.ImagePath + "/" + filename);
        Int32.TryParse(request.QueryString["w"], out width);
        Int32.TryParse(request.QueryString["h"], out height);
        clamp = String.IsNullOrEmpty(request.QueryString["c"]) 
                || Convert.ToInt32(request.QueryString["c"]) != 0;
 
        // Put together a path for the cached file
        string fileExtension = filename.Substring(filename.LastIndexOf('.'));
        string cacheFilename = filename.Replace(fileExtension, 
                String.Format("_{0}-{1}-{2}{3}", width, height, clamp ? "1" : "0", fileExtension));
        string cacheFilePath = context.Server.MapPath(Config.CachePath + "/" + cacheFilename);
 
        Image resizedImage = null;
        Image originalImage = null;
 
        try
        {
            if (File.Exists(cacheFilePath)) // A cached version exists, so use that
            {
                resizedImage = Image.FromFile(cacheFilePath);
                imageFormat = resizedImage.RawFormat;
            }
            else
            {
                if (File.Exists(filePath))
                    originalImage = Image.FromFile(filePath);
                else if (!String.IsNullOrEmpty(Config.MissingImageName))
                    originalImage = Image.FromFile(context.Server.MapPath(
                                    Config.ImagePath + "/" + Config.MissingImageName));
                else
                    throw new FileNotFoundException(filePath);
 
                imageFormat = originalImage.RawFormat;
                if (width == 0 && height == 0)
                {
                    resizedImage = (Image)originalImage.Clone();
                }
                else
                {
                    Size size = GetImageDimensions(originalImage, width, height);
 
                    resizedImage = ResizeImage(originalImage, size, clamp);
                    if (Config.CachingEnabled && (Config.MaxCacheSize < 0 
                            || GetDirectorySize(context.Server.MapPath(Config.CachePath)) < Config.MaxCacheSize))
                        resizedImage.Save(cacheFilePath, imageFormat);
                }
            }
 
            response.ContentType = MimeTypes[imageFormat.Guid];
            resizedImage.Save(response.OutputStream, imageFormat);
        }
        finally
        {
            if (originalImage != null)
                originalImage.Dispose();
            if (resizedImage != null)
                resizedImage.Dispose();
        }
    }
 
    private Image ResizeImage(Image source, Size targetSize, bool clamp)
    {
        Image resizedImage = new Bitmap(targetSize.Width, targetSize.Height, source.PixelFormat);
 
        using (Graphics g = Graphics.FromImage(resizedImage))
        {
            g.CompositingQuality = CompositingQuality.HighQuality;
            g.InterpolationMode = InterpolationMode.HighQualityBicubic;
            g.SmoothingMode = SmoothingMode.HighQuality;
 
            Rectangle rec = Rectangle.Empty;
            if (clamp)
                rec = new Rectangle(0, 0, targetSize.Width, targetSize.Height);
            else
                rec = new Rectangle((targetSize.Width - source.Width) / 2, 
                        (targetSize.Height - source.Height) / 2, source.Width, source.Height);
 
            if (!String.IsNullOrEmpty(Config.BackgroundColour))
                g.Clear(ColorTranslator.FromHtml(Config.BackgroundColour));
 
            g.DrawImage(source, rec);
        }
 
        return resizedImage;
    }
 
    private Size GetImageDimensions(Image sourceImage, int width, int height)
    {
        Size result = new Size(width, height);
        if (width == 0) // Constrain to height
        {
            float scale = (float)height / sourceImage.Height;
            result.Width = Convert.ToInt32(sourceImage.Width * scale);
        }
        else if (height == 0) // Constrain to width
        {
            float scale = (float)width / sourceImage.Width;
            result.Height = Convert.ToInt32(sourceImage.Height * scale);
        }
        return result;
    }
 
    private int GetDirectorySize(string path)
    {
        int result = 0;
        DirectoryInfo dir = new DirectoryInfo(path);
        foreach (FileInfo file in dir.GetFiles())
            result += Convert.ToInt32(file.Length);
        return result;
    }
 
    private static void Init()
    {
        Config = ImageHandlerConfiguration.GetConfig();
 
        MimeTypes = new Dictionary<Guid, string>();
        foreach (ImageCodecInfo info in ImageCodecInfo.GetImageEncoders())
            MimeTypes.Add(info.FormatID, info.MimeType);
    }
 
    #endregion
}

Usage

To start you need to set up your web.config. You'll want this line in your <configSections> config section:

<section name="ImageHandler" type="Colourblind.Web.ImageHandlerConfiguration, Colourblind.Web"/>

Next you'll need to hook up your image handler. Where this gets stuffed varies between different versions of IIS. Your best bet here is to get your Google on. Finally, to get a resized image you pass in the following parameters to your handler via the querystring.

i - image filename (required - must exist within the path specified in the config section)
w - width (leave blank if you want to constrain by height)
h - height (leave blank if you want to constrain by width)
c - clamp (set to zero if you wish to keep the image the original size and add borders)

Ultimately, your URL will look something like this:

/Img.aspx?i=pretty_picture.jpg&w=400&h=600&c=1

Examples

First the original, unmolested image.

And here are some examples of the image once molestation has taken place.


  1. Width set to 200
  2. Height set to 200
  3. Width and height both set to 400

And I'm spent

I think that just about covers it. I'll probably do a follow-up post in the future to revisit this stuff as there are already things in there that are making my fingers itch. But it'll do for now . . .