Post Taggati ‘Images’

Costruire un handler per immagini in Asp.Net

4 Commenti »

download esempio

In questo articolo illustreremo come costruire un handler in grado di manipolare un’immagine richiesta dal client.

Il problema

Quando inseriamo un’immagine (tipicamente la miniatura di un’immagine più grande) in una pagina web spesso riduciamo la dimensione dell’immagine utilizzando le proprietà css width e height

<img src="immagine.jpg" alt="bla bla" style="width:200px; height: 200px;" />

il che porta al resize operato dal browser che, più o meno, è una schifezza.
L’alternativa è produrre la miniatura utilizzando programmi come Photoshop e Gimp, che scalano e ricampionano correttamente l’immagine.
Spesso però non è possibile costruirsi a mano la miniatura. Innanzitutto perchè è una rottura, e quantomeno duplica il numero di immagini da gestire nel nostro markup. Spesso non possiamo nemmeno sapere a priori quali saranno le immagini, ad esempio nella visualizzazione di gallerie fotografiche uppate da altri utenti.

La soluzione

Asp.Net ci viene in aiuto con la tecnica degli HttpHandler generici.
Senza dilungarci troppo nella presentazione degli handler diciamo semplicemente che un handler è una parte di codice che si occupa di intercettare una particolare richiesta inviata al server e di costruire una risposta adatta.
Possiamo quindi immaginare di costruire una classe che implementa l’interafccia IHttpHandler, in grado di processare un’immagine richiesta in base ad alcuni parametri e riversare nello stream di output una versione modificata della stessa immagine (ovviamente senza modificare l’immagine originale).

La richiesta potrebbe includere una semplice scalatura dell’immagine, un ritaglio di una particolare sezione, l’arrotondamento degli angoli, ecc…

Veniamo al codice

La costruzione di un handler prevede 2 passi fondamentali:
- Scrivere la classe
- Aggiungere l’handler all’insieme degli handlers del nostro sito web.

Un primo abbozzo della classe (che chiameremo imghandler) potrebbe essere il seguente:

namespace Recoding.Web
{
	using System;
	using System.Web;
	using System.Drawing;
	using System.Drawing.Imaging;
	using System.Drawing.Drawing2D;

	public class imghandler : IHttpHandler
	{
		public void ProcessRequest(HttpContext context)
		{

		}

		public bool IsReusable { get { return false; } }
	}
}

Il metodo ProcessRequest è il cuore dell’handler e conterrà il comportamento da seguire per soddisfare la richiesta.
I namespace System.Drawing, System.Drawing.Imaging e System.Drawing.Drawing2D costituiscono l’accesso alle librerie GDI+ che saranno indispensabili per la manipolazione dell’immagine (per una panoramica generale leggete quì).

Ora dobbiamo definire l’handler nel web.config del nostro sito web, per fare in modo che le richieste specifiche vengano dirottate a questa classe:

<httpHandlers>
    <remove verb="*" path="*.asmx"/>
    <add verb="*" path="imghandler.ashx" type="Recoding.Web.imghandler"/>
</httpHandlers>

In questo modo ogni richiesta alla risorsa imghandler.ashx passerà per il nostro ProcessRequest, e saremo in grado di manipolarla.

Definizione dei parametri

Come prima cosa dobbiamo definire cosa l’handler è in grado di accettare come richiesta.
Prevediamo quindi un set di costanti e attributi:

...
public class imghandler : IHttpHandler
{
	public static string _PARAM_IMAGEPATH = "imagepath";
	public static string _PARAM_WIDTH = "width";
	public static string _PARAM_HEIGHT = "height";
	public static string _PARAM_CROP = "crop";
	public static string _PARAM_VERTICALALIGN = "valign";
	public static string _PARAM_HORIZONTALALIGN = "halign";
	public static string _PARAM_DEFAULTIMAGEPATH = "defaultimagepath";
	public static string _PARAM_ROUNDED = "rounded";
	public static string _PARAM_RADIUS = "radius";

	public string imagepath { get; set; }
	public string defaultimagepath { get; set; }
	public string physical_imagepath { get; set; }
	private string imgExtension { get; set; }
	public int width { get; set; }
	public int height { get; set; }
	public bool crop { get; set; }
	public string valign { get; set; }
	public string halign { get; set; }
	public bool rounded { get; set; }
	public int radius { get; set; }

	public void ProcessRequest(HttpContext context) { ... }
...

Con questi parametri una possibile richiesta al nostro handler potrebbe essere costruita nella forma:

<img alt="" src="imghandler.ashx?imagepath=immagine1.jpg&width=200&height=200" />

ad indicare che vogliamo ottenere l’immagine1.jpg scalata (o croppata) a 200 pixel per 200.

Ora possiamo procedere alla scrittura del corpo del metodo ProcessRequest, che come prima cosa dovrà ricavare i parametri passati in querystring, dopodichè procederà all’elaborazione vera e propria, che demanderemo ad altri 2 metodi:

public void ProcessRequest(HttpContext context)
{
	string sImg = context.Request.QueryString[_PARAM_IMAGEPATH];
	physical_imagepath = context.Server.MapPath(sImg.StartsWith("~/") ? sImg : "~/" + sImg);

	width = GenericConverter.TryGetValue<int>(context.Request.QueryString[_PARAM_WIDTH], 0);
	height = GenericConverter.TryGetValue<int>(context.Request.QueryString[_PARAM_HEIGHT], 0);
	crop = GenericConverter.TryGetValue<bool>(context.Request.QueryString[_PARAM_CROP], false);
	rounded = GenericConverter.TryGetValue<bool>(context.Request.QueryString[_PARAM_ROUNDED], false);
	radius = GenericConverter.TryGetValue<int>(context.Request.QueryString[_PARAM_RADIUS], 8);

	defaultimagepath = "";
	if (context.Request.QueryString[_PARAM_DEFAULTIMAGEPATH] != null)
		defaultimagepath = context.Request.QueryString[_PARAM_DEFAULTIMAGEPATH].ToString();

	try
	{
		FileInfo fileImage = new FileInfo(physical_imagepath);
		if (!fileImage.Exists)
		{
			sImg = defaultimagepath;
			physical_imagepath = context.Server.MapPath(sImg.StartsWith("~/") ? sImg : "~/" + sImg);
		}
	}
	catch
	{
		sImg = defaultimagepath;
		physical_imagepath = context.Server.MapPath(sImg.StartsWith("~/") ? sImg : "~/" + sImg);
	}

	// Forzo l'estenzione a png nel caso sia richiesta la stondatura dell'immagine
	// ciò mi consente di avere lo sfondo trasparente nei punti non coperti
	imgExtension = rounded ? ".PNG" : Path.GetExtension(sImg);

	ProcessImage(context);
}

Come si può vedere, la prima operazione è costruire il percorso fisico dell’immagine nel server, se l’immagine non viene trovata si utilizzerà un’immagine di default rappresentata dal parametro _PARAM_DEFAULTIMAGEPATH, che potrebbe ad esempio essere il logo del sito.
Al termine della raccolta parametri viene invocato il metodo ProcessImage, che si occuperà dell’effettiva elaborazione.

NOTA: la classe GenericConverter è un helper contenente dei metodi in grado di tentare una conversione di un dato in un certo tipo, se la conversione riesce viene restituito il valore, altrimenti viene restituito un valore di default passato come parametro. Ho sviluppato la classe all’interno del namespace Recoding.Convert, che occorre aggiungere tra gli using della classe. Il codice dell’helper è il seguente, è piuttosto banale e non mi dilungherò in spiegazioni:

namespace Recoding.Convert
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Globalization;

    public class GenericConverter
    {
        public static T TryGetValue<T>(object p, object defaultValue) where T : IConvertible
        {
            return TryGetValue<T>(p, defaultValue, CultureInfo.CurrentCulture);
        }

        public static T TryGetValue<T>(object p, object defaultValue, IFormatProvider fp) where T : IConvertible
        {
            try
            {
                return (T)Convert.ChangeType(p, typeof(T), fp);
            }
            catch
            {
                return (T)defaultValue;
            }
        }
    }
}

ProcessImage

Uno stub iniziale del metodo prevederà la creazione di una Bitmap dal percorso fisico del file richiesto (indicato nell’attributo physical_imagepath) e si dividerà in due tronconi: la scalatura dell’immagine e il suo ritaglio. La strada da intraprendere viene indicata dal valore dell’attributo crop.

...
public void ProcessImage(HttpContext context)
{
	System.Drawing.Image img = System.Drawing.Image.FromFile(physical_imagepath);

	// Bitmap di partenza
	Bitmap original_image = (Bitmap)img;

	// Seleziono il metodo di elaborazione
	if (crop)
	{
		#region -- crop --
                // TODO
		#endregion
	}
	else
	{
		#region -- scalatura --
                // TODO
		#endregion
	}
}
...

Una piccola nota sul processo di crop.
Solitamente il crop porta ad una selezione di un’area all’interno dell’immagine che può anche essere separata da tutti e 4 i bordi.

crop1Immagini gentilmente concesse da Riccardo Vandoni

Il crop che ho deciso di implementare, invece, ridimensiona l’immagine per far si che almeno una coppia di bordi tocchi sempre l’area selezionata.
In sostanza se abbiamo un’immagine di w500 e h200 e decidiamo di visualizzare una miniatura croppata di w100 e h100 l’handler scalerà proporzionalmente l’immagine per ottenere una miniatura alta 100 e larga 250, taglierà fuori i 150 pixel in eccesso, posizionando il viewport orizzontalmente sulla base del parametro halign.

crop2

Ho preferito questo approccio perchè l’handler è stato concepito per la visualizzazione di miniature di foto, e non avrebbe senso selezionare un’area all’interno della foto senza eseguire una scalatura, col rischio quindi di inquadrare un’area priva di significato.
Non escludo comunque la possibilità di implementare in futuro l’altra possibilità.

La scalatura

Ora vediamo come scalare l’immagine, la cosa più semplice.
Partiamo dal presupposto che la scalatura può essere effettuata in 2 modi: modificando una delle due dimensioni dell’immagine e ricalcolando la restante, lasciando invariato il rapporto base/altezza, oppure ridefinendo entrambe le dimensioni, e ciò può portare ad uno stretch dell’immagine.

...
#region -- scalatura --

if (width > 0 || height > 0)
{
	//se solo height=0, l'altezza viene ridotta proporzionalmente alla larghezza
	if (height == 0)
		height = Convert.ToInt32(((float)(width) / (float)(original_image.Width)) * original_image.Height);

	//se solo width=0, l'altezza viene ridotta proporzionalmente alla larghezza
	if (width == 0)
		width = Convert.ToInt32(((float)(height) / (float)(original_image.Height)) * original_image.Width);

	//Ora viene creata la thumbnail e salvata in un output di stream
	using (Bitmap oThumb = new Bitmap(original_image, width, height))
	{
		if (rounded)
		{
			TextureBrush texture = new TextureBrush(oThumb);

			Graphics graphic = Graphics.FromImage(oThumb);
			graphic.Clear(Color.Transparent);
			graphic.CompositingQuality = CompositingQuality.HighQuality;
			graphic.SmoothingMode = SmoothingMode.HighQuality;
			graphic.InterpolationMode = InterpolationMode.HighQualityBicubic;
			graphic.FillRoundedRectangle(texture, 0, 0, width, height, radius);
			graphic.Save();
		}
		else
		{
			using (Graphics oGraphic = Graphics.FromImage(oThumb))
			{
				oGraphic.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
				oGraphic.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
				oGraphic.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;

				Rectangle oRectangle = new Rectangle(0, 0, width, height);

				oGraphic.DrawImage(original_image, oRectangle);
			}
		}

		//salvo in uno stream di output
		StreamOut(oThumb, context);
	}
}
else
{
	// Se width e height sono entrambi uguali a 0, l’immagine viene salvata nello stream cosi com’è
	StreamOut(original_image, context);
}

#endregion
...

Come si può vedere la prima cosa che facciamo è controllare il valore dei due attributi width e height e ricalcolare se necessario l’attributo mancante.
Se nessuno dei due è valorizzato il processo viene bypassato e verrà restituita in output l’immagine originale.
Se sono presenti dei valori l’immagine viene alterata (proporzionalmente o no), viene quindi generato un oggetto Bitmap che rappresenta la nuova miniatura.

Viene fatto un ulteriore controllo: se l’attributo rounded è uguale a true si procede alla costruzione degli angoli stondati.
Per fare ciò mi sono avvalso del fantastico namespace Plasmoid, sviluppato da Arun Reginald, e che trovate pubblicato su CodeProject a questo indirizzo.
Il namespace contiene dei metodi di estenzione del namespace System.Drawing, uno dei quali è appunto FillRoundedRectangle.

Non mi dilungherò sull’utilizzo delle librerie GDI+, una completa documentazione la trovate all’indirizzo indicato precedentemente.

Il crop

Per quel che riguarda il ritaglio dell’immagine le cose si complicano un pò.
Se ad esempio la miniatura che vogliamo generare ha un rapporto base/altezza diverso da quello dell’immagine originale avremo una delle due dimensioni “esuberante” rispetto alla superficie visualizzata, dovremo operare quindi un riallineamento del viewport.
Potremmo ad esempio voler sempre visualizzare la parte centrale, la sinistra, la destra, e così via, escludendo ciò che eccede il nostro nuovo formato.

Per definire questi allineamenti ci avvarremo degli enumeratori VerticalAlign e HorizontalAlign, comuni ai WebControl, i cui valori saranno intercettati dai parametri _PARAM_VERTICALALIGN e _PARAM_HORIZONTALALIGN.

Il codice della nostra sezione crop sarà il seguente:

...
#region -- crop --
// Se una delle due dimensioni è mancante le rendo uguali
if (width == 0 || height == 0) { width = height = Math.Max(width, height); }

using (Bitmap output_image = new Bitmap(width, height))
{
	// Ricavo dalla request gli allineamenti richiesti.
	// Ovviamente ha senso solo per le immagini croppate.
	valign = context.Request.Params[_PARAM_VERTICALALIGN] ?? VerticalAlign.NotSet.ToString();
	halign = context.Request.Params[_PARAM_HORIZONTALALIGN] ?? HorizontalAlign.NotSet.ToString();

	int new_width, new_height;

	// Calcolo il ratio dell'immagine originale e quello dell'immagine di destinazione
	// Mi occorrono per capire in che asse l'immagine originale risulta esuberante rispetto alla nuova proporsione
	float target_ratio = (float)width / (float)height;
	float image_ratio = (float)original_image.Width / (float)original_image.Height;

	if (target_ratio < image_ratio)
	{
		new_height = height;
		new_width = (int)Math.Floor(image_ratio * (float)height);
	}
	else
	{
		new_height = (int)Math.Floor((float)width / image_ratio);
		new_width = width;
	}

	new_width = new_width > width ? new_width : width;
	new_height = new_height > height ? new_height : height;

	#region -- Offset degli assi, assegno i valori di default (NotSet e Middle/Center) --
	int paste_x = (width - new_width) / 2;
	int paste_y = (height - new_height) / 2;

	// Applico gli allineamenti verticali
	switch ((VerticalAlign)Enum.Parse(typeof(VerticalAlign), valign))
	{
		case VerticalAlign.NotSet:
		case VerticalAlign.Middle:
			// bypass
			break;
		case VerticalAlign.Top:
			paste_y = 0;
			break;
		case VerticalAlign.Bottom:
			paste_y = height - new_height;
			break;
	}

	// Applico gli allineamenti orizzontali
	switch ((HorizontalAlign)Enum.Parse(typeof(HorizontalAlign), halign))
	{
		case HorizontalAlign.NotSet:
		case HorizontalAlign.Center:
			// bypass
			break;
		case HorizontalAlign.Left:
			paste_x = 0;
			break;
		case HorizontalAlign.Right:
			paste_x = width - new_width;
			break;
	}
	#endregion

	if (rounded)
	{
		Bitmap thumb = new Bitmap(width, height);

		Graphics gthumb = Graphics.FromImage(thumb);
		gthumb.CompositingQuality = CompositingQuality.HighQuality;
		gthumb.SmoothingMode = SmoothingMode.HighQuality;
		gthumb.InterpolationMode = InterpolationMode.HighQualityBicubic;
		gthumb.DrawImage(original_image, paste_x, paste_y, new_width, new_height);
		gthumb.Save();

		TextureBrush texture = new TextureBrush(thumb);

		Graphics graphic = Graphics.FromImage(output_image);
		graphic.CompositingQuality = CompositingQuality.HighQuality;
		graphic.SmoothingMode = SmoothingMode.HighQuality;
		graphic.InterpolationMode = InterpolationMode.HighQualityBicubic;
		graphic.FillRoundedRectangle(texture, 0, 0, width, height, radius);
		graphic.Save();
	}
	else
	{
		Graphics graphic = Graphics.FromImage(output_image);
		graphic.CompositingQuality = CompositingQuality.HighQuality;
		graphic.SmoothingMode = SmoothingMode.HighQuality;
		graphic.InterpolationMode = InterpolationMode.HighQualityBicubic;
		graphic.DrawImage(original_image, paste_x, paste_y, new_width, new_height);
		graphic.Save();
	}

	// Stream out dell'immagine
	StreamOut(output_image, context);
}
#endregion
...

StreamOut

Rimane quindi da definire il metodo StreamOut, che si occuperà di restituire al client l’immagine elaborata, concordamente al suo formato:

public void StreamOut(Bitmap bitmap, HttpContext context)
{
	switch (imgExtension.ToUpper())
	{
		case ".JPEG":
		case ".JPG":
			bitmap.Save(context.Response.OutputStream, ImageFormat.Jpeg);
			break;
		case ".GIF":
			bitmap.Save(context.Response.OutputStream, ImageFormat.Gif);
			break;
		case ".PNG":
			using (MemoryStream stream = new MemoryStream())
			{
				bitmap.Save(stream, ImageFormat.Png);
				stream.WriteTo(context.Response.OutputStream);
			}
			break;
	}
}

Poichè la png richiede uno stream bidirezionale è necessario eseguire il suo rendering in un MemoryStream, che verrà poi riversato nel Response.OutputStream.

A questo punto abbiamo un handler che può intercettare richieste di vario genere.
Se ad esempio volessimo un’immagine ritagliata a 300 pixel per 300, con angoli stondati di 10 pixel di raggio avremmo la richiesta:

<img alt="" src="imghandler.ashx?imagepath=image.jpg&width=300&height=300&crop=true&rounded=True&radius=10" />

Se volessimo ottenere un’immagine scalata all’altezza di 200 pixel e ricalcolata proporzionalmente scriveremo:

<img alt="" src="imghandler.ashx?imagepath=image.jpg&height=200&crop=false&rounded=false" />

Ulteriori esempi sono presenti in un demo funzionante scaricabile quì.
In un futuro articolo illustrerò come incapsulare quest’handler in un custom control, rendendo più semplice ed elegante il suo utilizzo.