Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Keeley Hammond
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Keeley Hammond@keeleyhammond )

Generating Fallback Avatars Using CFImage And ColdFusion

By on
Tags:

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.

Installing a Font: Roboto Mono

When you draw text to an image in ColdFusion, you can't just use any arbitrary font - you can only use fonts that the JVM (Java Virtual Machine) knows about. Furthermore, the JVM seems to need to know about the font at start-up time. Which means, if you install a new font on the system, you have to restart the ColdFusion process in order for said font to be consumable.

ASIDE: 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).

Drawing Centered Text to the ColdFusion Image

When you draw text to an Image in ColdFusion, you provide the {x,y} coordinate that the text will start at. Which is fine if you just want your text to be right-aligned. In my case, however, I want the text to be centered within the image, both vertically and horizontally. To do this, I need to "measure" the text as it will be rendered to the image; and then, adjust the {x,y} coordinates of the text based on said measurement.

In the past, I've looked at 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 & "/" & 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.

Reader Comments

15,329 Comments

One thing I forgot to include in the post is some code to list out all the fonts that the JVM knows about:

<cfscript>

	fontFamilies = graphicsEnvironment = createObject( "java", "java.awt.GraphicsEnvironment" )
		.getLocalGraphicsEnvironment()
		.getAvailableFontFamilyNames()
	;

	writeDump( fontFamilies );

</cfscript>

When I run this in my Docker container locally, I get:

  • DejaVu Sans
  • DejaVu Sans Mono
  • DejaVu Serif
  • Dialog
  • DialogInput
  • Monospaced
  • Roboto Mono
  • Roboto Mono ExtraLight
  • Roboto Mono Light
  • Roboto Mono Medium
  • Roboto Mono SemiBold
  • Roboto Mono Thin
  • SansSerif
  • Serif

Now, I should point out that this is not necessarily the names of the fonts, I don't think - these are the "font families". So, for example, if I wanted to use SansSerif in the CFImage tag, I can't actually use "SansSerif" - that would lead to a ColdFusion error. Instead, I have to use SansSerif.plain. Also, there is no "Roboto Mono" - I would have to use "Roboto Mono Regular".

There's a lot of trial-and-error with stuff (or, at least as someone how does know the JVM and AWT (Abstract Window Toolkit) very well).

Post A Comment — I'd Love To Hear From You!

Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.