Generating Fallback Avatars Using CFImage And ColdFusion

Cyberdime
Published: November 11, 2022

Earlier this week, I talked about proxying Gravatar images in order to serve more aggressive Cache-Control headers in ColdFusion. Another benefit of proxying Gravatar is that I can exert more control over what happens when the given user doesn’t have a Gravatar image. Meaning, instead of using the current, default Arnold Schwarzenegger avatar, I might be able to generate a per-user custom avatar. As a first step in this exploration, I wanted to see if I could use the CFImage tag / image functions in Adobe ColdFusion 2021 to generate name-based images.

Paul Klinkenberg has a post on dynamically registering fonts at runtime so that you don’t have to actually restart the ColdFusion process. Unfortunately, this seem to be a Lucee-only behavior. For reasons that are beyond me, his approach works fine in the latest Lucee CFML, but throws an error in the latest Adobe ColdFusion.

I decided to use Roboto Mono from Google Fonts. Locally, in my Dockerized development environment, I downloaded the font files and added this line to my Dockerfile:

ADD ./fonts /usr/share/fonts/truetype

This copies my ./fonts/roboto_mono folder into my Adobe ColdFusion 2021 CommandBox image.

In production, which is both Windows and not Dockerized, I simply copied the .ttf font files into my c:\Windows\Fonts folder (and restarted the ColdFusion Application Server service).

measuring image text dimensions in order to render wrapped text in a ColdFusion image. However, in this case, I’m taking a cue from Ray Camden, who used the java.awt.font.TextLayout class to find the bounding box of a given text value.

In the following ColdFusion component, AvatarGenerator.cfc, I’ve encapsulated this measurement logic in a private method called, measureText(). This takes the image, the text I want to render, and the font properties I intend to use (ie, the Font name and the size). This method returns the bounding box measurements, which I then use in the image.drawText() call:

component
	accessors = true
	output = false
	hint = "I generate simple, initials-based avatars."
	{
	// Define properties for dependency-injection.
	property scratchDisk;
	// ---
	// PUBLIC METHODS.
	// ---
	/**
	* I generate an initials-based avatar and return the image binary. The image is always
	* generated as a JPG in order to match what Gravatar uses.
	*/
	public binary function generateAvatar(
		required string initials,
		required numeric size
		) {
		var imageData = withTempDirectory(
			( tempDirectory ) => {
				var image = imageNew( "", size, size, "rgb", "212121" );
				image.setDrawingColor( "ffffff" );
				image.setAntialiasing( true );
				var fontProperties = {
					font: "Roboto Mono Regular",
					size: ( fix( size / 3 ) - 2 )
				};
				// Since we don't know what text is going to be passed into the function,
				// we need to "measure" the text that will be rendered to the image when
				// using the given text value and font properties. We can then use this
				// to center the text within the canvas size.
				var bounds = measureText( image, initials, fontProperties );
				// Center text horizontally.
				var x = ( ( size / 2 ) - ( bounds.width / 2 ) - bounds.xOffset );
				// Center text vertically.
				var y = ( ( size / 2 ) + ( bounds.height / 2 ) );
				image.drawText( initials, x, y, fontProperties );
				// Save the image object to disk (Virtual File System in this case, for
				// fast file I/O operations).
				var imagePath = ( tempDirectory & "/avatar.jpg" );
				image.write( imagePath, 0.80 );
				return( fileReadBinary( imagePath ) );
			}
		);
		return( imageData );
	}
	// ---
	// PRIVATE METHODS.
	// ---
	/**
	* I get the bounding box dimensions of the given text as it would appear written to
	* the given image.
	*/
	private struct function measureText(
		required any image,
		required string text,
		required struct fontProperties
		) {
		var awtContext = image.getBufferedImage()
			.getGraphics()
			.getFontRenderContext()
		;
		// CAUTION: When decoding a font definition, you can use either a space (" ") or
		// a dash ("-") delimiter. But, you cannot mix-and-match the two characters. As
		// such, if you have a Font name which has spaces in it (ex, "Roboto Mono"), you
		// MUST USE the dash delimiter in order to prevent Java from parsing the font name
		// as a multi-item list. In this case, note that I am using the "-" because I know
		// my font name doesn't contain a dash.
		var awtFont = createObject( "java", "java.awt.Font" )
			.decode( "#fontProperties.font#-#fontProperties.size#" )
		;
		var bounds = createObject( "java", "java.awt.font.TextLayout" )
			.init( text, awtFont, awtContext )
			.getBounds()
		;
		return({
			width: bounds.width,
			height: bounds.height,
			xOffset: bounds.x,
			yOffset: bounds.y
		});
	}

	/**
	* I execute the given callback, passing in a temporary directory that can be used for
	* transient file IO. The temporary directory is deleted after the callback has been
	* executed.
	*/
	private any function withTempDirectory( required function callback ) {
		var folderPath = ( scratchDisk & "https://www.bennadel.com/" & createUuid() );
		try {
			directoryCreate( folderPath );
			return( callback( folderPath ) );
		} finally {
			directoryDelete( folderPath, true );
		}
	}
}

This ColdFusion component has a single public method, generateAvatar(), which takes the user’s initials, such as “BN” for “Ben Nadel”, and an image dimension and then renders the image and returns the image binary. Which means, I can pipe the binary response directly to the CFContent tag’s variable attribute:

<cfscript>
	// NOTE: I'm using RAM disk (Virtual File System) as the scratch disk for the image
	// operations so that I get fast I/O performance.
	generator = new AvatarGenerator()
		.setScratchDisk( "ram://" )
	;
	cfcontent(
		type = "image/jpeg",
		variable = generator.generateAvatar( "BN", 120 )
	);
</cfscript>

Now, if I run this ColdFusion code, we can see the “BN” avatar being generated instantly and getting streamed to the browser:

Avatar with the initials, BN.

Of course, you don’t want to be generating images on-the-fly all the time. So, one thought might be to render these to disk first and then serve them up; or, I might just rely on the CDN (Content Delivery Caching) to cache them and prevent unnecessary processing. But, that’s a thought for a different post.

Want to use code from this post?
Check out the license.

Source: www.bennadel.com