Generating Fallback Avatars Using CFImage And ColdFusion
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:
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
One thing I forgot to include in the post is some code to list out all the fonts that the JVM knows about:
When I run this in my Docker container locally, I get:
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 theCFImage
tag, I can't actually use"SansSerif"
- that would lead to a ColdFusion error. Instead, I have to useSansSerif.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! ❤️
Post a Comment →