Validating image aspect ratios in Episerver

There are already some good resources out there with regards to persisting and validating image dimensions in Episerver (see this blog post or this forum question). However, there is nothing pertaining to validating aspect ratios, so I thought I'd share my solution (although I realize it's probably a bit of a niche requirement)!

I'm also going to quickly run over the necessary setup here, though you can find examples of this elsewhere.

Persisting the height and width

First things first, you need to store the image height and width when it's saved, thus avoiding us having to continually recalculate the dimensions. Here I'm working with the Alloy Demo Kit solution, so I extended the ImageFile media type to have width and height properties:

[ContentType(GUID = "0A89E464-56D4-449F-AEA8-2BF774AB8730")]
[MediaDescriptor(ExtensionString = "jpg,jpeg,jpe,ico,gif,bmp,png")]
public class ImageFile : ImageData
{
    [Editable(false)]
    [Display(Name = "Width",  Order = 10)]
    public virtual int? Width { get; set; }

    [Editable(false)]
    [Display(Name = "Height", Order = 20)]
    public virtual int? Height { get; set; }
}

From here, it's just a simple case of setting those properties whenever we're saving an image:

[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ImageDimensionsEventsInitialization : IInitializableModule
{
    private static readonly ILogger Logger = LogManager.GetLogger();

    public void Initialize(InitializationEngine context)
    {
        var contentEvents = context.Locate.Advanced.GetInstance<IContentEvents>();

        contentEvents.SavingContent += SavingImage;
    }

    public void Uninitialize(InitializationEngine context)
    {
        var contentEvents = context.Locate.Advanced.GetInstance<IContentEvents>();

        contentEvents.SavingContent -= SavingImage;
    }

    private static void SavingImage(object sender, ContentEventArgs e)
    {
        if (!(e.Content is ImageFile imageFile))
        {
            return;
        }

        if (string.Equals(imageFile.MimeType, "image/svg+xml", StringComparison.InvariantCultureIgnoreCase))
        {
            return;
        }

        try
        {
            using (var image = Image.FromStream(imageFile.BinaryData.OpenRead()))
            {
                imageFile.Width = image.Width;
                imageFile.Height = image.Height;
            }
        }
        catch (Exception ex)
        {
            Logger.Error($"Failed to load image with content reference {imageFile.ContentLink.ID}.", ex);
        }
    }
}

That's really all the setup you need. I purposely wrapped the whole thing in a try-catch statement just to ensure we don't prevent the image from being saved under any circumstances and excluded SVG images (although they aren't a concern in Alloy).

If you introduce this into the solution after editors have already started uploading images you'll have to run a migration on existing images, otherwise the width and height will be null.

Validating the aspect ratio

The next bit is just as easy, all we now need to do is create a validation attribute that we can use to decorate our properties with. Something exactly like this:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public class ImageAspectRatioAttribute : ValidationAttribute
{
    public double[] AllowedRatios { get; set; }

    public ImageAspectRatioAttribute(params double[] allowedRatios)
    {
        AllowedRatios = allowedRatios;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var contentLink = value as ContentReference;

        // Allow null
        if (contentLink == null)
        {
            return ValidationResult.Success;
        }

        // No allowed aspect ratios defined
        if (!AllowedRatios.Any())
        {
            return ValidationResult.Success;
        }

        var contentLoader = ServiceLocator.Current.GetInstance<IContentLoader>();

        // Could not get the content, an image is required.
        if (!contentLoader.TryGet(contentLink, out ImageFile image))
        {
            return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
        }

        var aspectRatio = CalculateAspectRatio(image);
        
        // We can't calculate the aspect ration, give the editor the benefit of the doubt
        if (!aspectRatio.HasValue)
        {
            return ValidationResult.Success;
        }

        // The image is within acceptable tolerances of an allowed aspect ratio
        if (AllowedRatios.Any(x => Math.Abs(x - aspectRatio.Value) < 0.01))
        {
            return ValidationResult.Success;
        }

        return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
    }

    private static double? CalculateAspectRatio(ImageFile image)
    {
        if (!image.Width.HasValue || image.Width == 0 || !image.Height.HasValue || image.Height == 0)
        {
            return null;
        }

        return (double)image.Width.Value / image.Height.Value;
    }

    public override string FormatErrorMessage(string name)
    {
        if (string.IsNullOrEmpty(ErrorMessage))
        {
            ErrorMessage = "{0} must be an image and meet aspect ratio criteria.";
        }

        return string.Format(CultureInfo.InvariantCulture, ErrorMessageString, name);
    }
}

I think the code here is fairly self-evident, it essentially allows the editor to pass in various aspect ratios which it validates the image against.

Usage

Finally, using the aspect ratio validation attribute is as easy as decorating a property with it and specifying an aspect ratio:

[Display(Name = "Image (4:3)")]
[UIHint(UIHint.Image)]
[ImageAspectRatio((double)4/3, ErrorMessage = "{0} have an aspect ratio of 4:3.")]
public virtual ContentReference Image { get; set; }

Or, optionally, multiple aspect ratios:

[Display(Name = "Image (4:3 or 3:2)")]
[UIHint(UIHint.Image)]
[ImageAspectRatio((double)4/3, (double)3/2, ErrorMessage = "{0} must have an aspect ratio of 4:3 or 3:2.")]
public virtual ContentReference Image { get; set; }

Comments

Nice work! I did something similar in this add-on (https://www.youtube.com/watch?v=pVifzJIImz8), but also included the ratio constraints to the client through property settings to allow client side validation as soon as an image is dropped.

Ted Nyberg

This looks great! Did you ever release it as a package?

Jake

I like ServiceLocator :)

Valdis

Haha, so do I—clearly! 🤦🏻‍♂️ I've taken the liberty of removing the worst offenses.

Jake