Wednesday, January 16, 2008

Measuring image text width and height using ColdFusion 8

Yesterday I saw an interesting question on houseoffusion.com about how to measure the width and height of a text string drawn on an image. Curious, I checked the image functions in the documentation but did not find anything suitable. So I checked the java API's and found that the TextLayout object can be used to obtain the width and height of a graphical text string.

Now originally I had thought of using a method called getStringBounds() in the FontMetrics class. But I decided against it after reading in the API that "the logical bounds does not always enclose all the text... To obtain a visual bounding box, which encloses all the text, use the getBounds method of TextLayout".

So I ran some tests using TextLayout with a few different characters, fonts and styles and it appeared to work correctly. So I suggested the poster try using TextLayout and started to write up an entry about it.

As often happens, you get a neat idea and then find out someone else beat you to it. Case in point, today I saw a post by Ben Nadel mentioning an entry he did a few months ago, based on Barney Boisvert's work. The entry dealt with images and text measurement. I went to read the entry and the first thing that caught my attention was that it uses the getStringBounds() method. That piqued my curiosity. So I decided to test the veracity of the API and run the two methods side by side.

First I created a new image and drew some text. I deliberately selected a few funky characters that were likely to stretch outside the normal bounds. For illustration purposes, I also drew a rectangle to show the text bounds as reported by FontMetrics and TextLayout.

On the left (in red) are the dimensions reported by FontMetrics. If you look closely, you will see the text is slightly wider than the FontMetrics width would lead you to believe. The TextLayout dimensions (in blue) appear to be slightly more accurate.



Note, the positioning of the rectangles is slightly off. Since I am not drawing the text directly onto the image I cannot take advantage of the boundary x,y coordinates.


Another interesting difference is the FontMetric height includes the leading value. Whereas the TextLayout height does not. Roughly translated, the leading value is the distance between lines of text.

So it looks like the API was correct and that TextLayout provides a more accurate measurement of text width and height. Having said that, from everything I have read so far, font measurement is a complex topic. Especially when handling some of the more exotic character sets. So I would not expect this technique to be as useful with those character sets.

If you are interested in learning more about the java classes have a look at the API and/or graphics tutorials on the sun.com site.


Test code - Create Image


<cfscript>
// initialize the text properties
Font = CreateObject("java", "java.awt.Font");
text = { x = 50, y = 100, string = "ÀÇŸÍÊÃ" };
text.prop = { font="Times New Roman", style="bolditalic", javaStyle= BitOr(Font.BOLD, Font.ITALIC), size=25 };

// create a new image and draw the text
img = ImageNew("", 450, 150, "rgb", "lightgray");
ImageSetDrawingColor( img, "black" );
ImageDrawText( img, text.string, text.x, text.y, text.prop );
</cfscript>


Test FontMetrics dimensions

<cfscript>
// get the underlying graphic of the image
graphics = ImageGetBufferedImage( img ).getGraphics();

// recreate the font used to draw the text
currentFont = Font.init( javacast("string", text.prop.font),
javacast("int", text.prop.javaStyle),
javacast("int", text.prop.size)
);

// get text measurements using font metrics
fontMetrics = graphics.getFontMetrics( currentFont );
fontBounds = fontMetrics.getStringBounds( javacast("string", text.string), graphics );

// draw a rectangle indicatating the font bounds
Color = createObject("java", "java.awt.Color");
graphics.setColor(Color.RED);
graphics.drawRect(
javacast("int", text.x),
javacast("int", text.y) - fontMetrics.getAscent(),
fontBounds.getWidth(),
fontBounds.getHeight()
);

// get the dimensions
dimensions.type = "FontMetrics";
dimensions.width = fontBounds.getWidth();
dimensions.height = fontBounds.getHeight();
dimensions.leading = fontMetrics.getLeading();
dimensions.ascent = fontMetrics.getAscent();
dimensions.descent = fontMetrics.getDescent();
graphics.dispose();
</cfscript>

<cfdump var="#dimensions#">
<cfimage action="writeToBrowser" source="#img#">


Test TextLayout dimensions

<cfscript>
// get the text measurements using text layout
graphics = ImageGetBufferedImage( img ).getGraphics();
context = graphics.getFontRenderContext();
TextLayout = createObject("java", "java.awt.font.TextLayout");
layout = TextLayout.init( text.string, currentFont, context );
layoutBounds = layout.getBounds();

// draw a rectangle to indicating the layout bounds
Color = createObject("java", "java.awt.Color");
graphics.setColor( Color.BLUE );
graphics.drawRect( javacast("int", text.x) - layout.getLeading() - 2,
javacast("int", text.y) - layout.getAscent() + 1,
layoutBounds.getWidth(),
layoutBounds.getHeight()
);

// get the dimensions
dimensions.type = "TextLayout";
dimensions.width = layoutBounds.getWidth();
dimensions.height = layoutBounds.getHeight();
dimensions.leading = layout.getLeading();
dimensions.ascent = layout.getAscent();
dimensions.descent = layout.getDescent();

graphics.dispose();
</cfscript>

<cfdump var="#dimensions#">
<cfimage action="writeToBrowser" source="#img#">

10 comments:

Peter Swanson October 16, 2008 at 10:19 AM  

Nice post.
What does this do:
javaStyle= BitOr(Font.BOLD, Font.ITALIC)

Does it set the font to bold and italic? If so, is there any way I can make this dynamic, i.e. changeable? What I'd like to do is set the font, style on my own, maybe getting rid of the javastyle property altogether.

Thanks,

Peter Swanson
peterswan@earthlink.net

cfSearching October 16, 2008 at 8:55 PM  

@Peter,

Does it set the font to bold and italic?

Yes


If so, is there any way I can make this dynamic, i.e. changeable?


Possibly. What did you have in mind? (ie pass in x and the results are ..y)

Peter Swanson October 17, 2008 at 8:30 AM  

The text on the image that I'm trying to measure is bold at the moment and not italic. My question is whether I can get this piece of code to measure a chunk of text that may or may not be bold or italic. I believe the following line takes care of setting the font family, style, size, etc:
text.prop = { font="Times New Roman", style="bolditalic", javaStyle= BitOr(Font.BOLD, Font.ITALIC), size=25 }

So if the "Style=" takes care of setting the style, why do you also need the javaStyle attribute?

Can I get rid of the javaStyle attribute altogether? I tried to get rid of it but the error message said that it's required.

Anyway, cool post. I'm just hoping that I can also measure some text that may or may not be bold and italic.

Thanks,
Peter

cfSearching October 17, 2008 at 3:06 PM  

@Peter,

My question is whether I can get this piece of code to measure a chunk of text that may or may not be bold or italic.

Oh, sure.



So if the "Style=" takes care of setting the style, why do you also need the javaStyle attribute?


Well, that is the interesting and slightly confusing part. You do not actually need to draw the text onto the image to measure it. Measuring and drawing are separate actions. Obviously the example would not make much sense without seeing the text, so its drawn onto the image just to illustrate the concept. Not because it is needed to perform the measurements.

The reason there are two separate attributes is because I used CF to draw the text and java to measure it. Since CF and java have different ways of representing "style" (plain, italic, etcetera), you cannot just pass style="bolditalic" to the java objects. You have to translate it to the correct java "style".

That said, you really do not need to use separate attributes to achieve this. (I just did that to minimize the size of the code snippet). Instead, just wrap the java code in a function and inside it use a switch/case statement to convert whatever CF "style" was passed in, to its corresponding java value auto-magically.

I did something like that in another entry. Here is a snippet:

<cfscript>
switch (arguments.style) {
case "bold":
Local.prop.style = Local.Font.BOLD;
break;
case "italic":
Local.prop.style = Local.Font.ITALIC;
break;
case "bolditalic":
Local.prop.style = BitOr( Local.Font.BOLD, Local.Font.ITALIC);
break;
case "plain":
Local.prop.style = Local.Font.PLAIN;
break;
default:
Local.prop.style = Local.Font.PLAIN;
break;
}
</cfscript>

cfSearching October 17, 2008 at 3:14 PM  

You do not actually need to draw the text onto the image to measure it.

In case that comment sounds a bit crazy, here is an example to demonstrate. If you run the code, it will display a read rectangle illustrating the size of the text. But the actual characters are never drawn onto the image.

<cfscript>
// create anew image
img = ImageNew("", 450, 150, "rgb", "lightgray");
// initialize the text properties
text = { x = 50, y = 100, string = "ÀÇŸÍÊÃ" };
// initialize the java font style
Font = CreateObject("java", "java.awt.Font");
javaStyle= BitOr(Font.BOLD, Font.ITALIC);

graphics = ImageGetBufferedImage( img ).getGraphics();
// create a font object that will be used to measure the text size
// font=Arial, style=bolditalic, size=25
currentFont = Font.init( javacast("string", "Times New Roman"),
javacast("int", javaStyle),
javacast("int", 25)
);

// get text measurements using font metrics
fontMetrics = graphics.getFontMetrics( currentFont );
fontBounds = fontMetrics.getStringBounds( javacast("string", text.string), graphics );

// draw a rectangle indicatating the font bounds
Color = createObject("java", "java.awt.Color");
graphics.setColor(Color.RED);
graphics.drawRect(
javacast("int", text.x),
javacast("int", text.y) - fontMetrics.getAscent(),
fontBounds.getWidth(),
fontBounds.getHeight()
);

// get the dimensions
dimensions.type = "FontMetrics";
dimensions.width = fontBounds.getWidth();
dimensions.height = fontBounds.getHeight();
dimensions.leading = fontMetrics.getLeading();
dimensions.ascent = fontMetrics.getAscent();
dimensions.descent = fontMetrics.getDescent();
graphics.dispose();
</cfscript>

<cfdump var="#dimensions#" label="dimensions">
<cfimage action="writeToBrowser" source="#img#">

Peter Swanson October 20, 2008 at 10:29 AM  

Hi cfsearching. Thanks for the message. I noticed you also responded to an earlier post on the Adobe site where I had asked about how to do curved text. You mentioned it might be cool to measure each piece of text and throw them onto the image one by one, increasing the angle size based on the size of each character. Anyway, I combined some geometry code with the text measurement stuff you have listed here, and here's what I came up with. I'm not sure if I made correct use of the JavaStyle attribute you explained here but I gave it my best shot.

This code allows for the programmer to choose a radius, font size, image size, and space between fonts.

One major problem, though, is that in many cases the character does not land exactly on the curved arc. I believe this is due to the fact that the geometry code in many cases cannot find an exact match for a coordinate that falls on the the arc, and then throws in a coordinate that is as close as possible, but not exact. The result is that sometimes the lettering appears jagged. Not that bad for testing and goofing around, but for an official seal that has the user name written across the top, I don't feel this will be adequate. You can see this problem more clearly with smaller fonts. They have a tendency to "miss" the arc at times.

This ended up as a cool experiment, but won't work for the corporate seal I need it for. If you have any ideas about how the jagged text can be avoided, please let me know. Or maybe there's a third party tool that does arcs.

Thanks again. I'll post the code in the next post as this entry has gotten a bit long.

Peter Swanson

Peter Swanson October 20, 2008 at 10:34 AM  

The entry form wouldn't allow for my code, I guess server-side languages aren't allowed. I posted it just now onto the Adobe site, where I had asked about curved text last week.

Peter

cfSearching October 21, 2008 at 7:36 AM  

@Peter,

You can post code, but you have to change the < and > symbols to &lt; and &gt;

As far as the "javaStyle" attribute, I was thinking to eliminate it entirely by placing all of the text measurement code inside a function, instead of using the code inline. Then you could just pass in the image, text and CF font properties as arguments. The function code would take care of the rest.

<cfset dimen = getTextSize(
image = img,
text = textToDraw,
fontProp = yourCFFontProperties
)>

The suggestion about drawing the characters one by one, was not mine. I have not done much work with arcs. So I mentioned possibly using svg to create a template for the curve, and make the text dynamic. Though that might not work for your needs, as it may not be as flexible as your code.

Are you sure the positioning math is correct? I have seen a few java examples that _appear_ to produce a smoother rendering of the text along the arc. Though the code is more involved.

Peter Swanson October 21, 2008 at 8:24 AM  

Here it is:

<cfset img = ImageNew("", 400, 400)>

<cfset Text2Write = "FRED SMITH FOR PRESIDENT">

<!--- 1 for upper curve, 0 for lower curve --->
<cfset isUpper = 1>

<cfset textFont = "arial">
<cfset textStyle = "bold">
<cfset textSize = "20">
<cfset ImageSetDrawingColor(img,"WHITE")>
<cfset ImageSetAntialiasing(img,"on")>

<cfset attr = { font="#textFont#", style="#textStyle#", size="#textSize#" }>

<!--- get the width of the string --->
<cfscript>
Font = CreateObject("java", "java.awt.Font");
text = {string = "#Text2Write#" };
text.prop = { font="#textFont#", style="#textStyle#", javaStyle= BitOr(Font.BOLD, Font.ITALIC), size="#textSize#" };

arguments.style = "#textStyle#";
switch (arguments.style) {
case "bold":
text.prop.style = font.BOLD;
break;
case "italic":
text.prop.style = font.ITALIC;
break;
case "bolditalic":
text.prop.style = BitOr( Font.BOLD, Font.ITALIC);
break;
case "plain":
text.prop.style = Font.PLAIN;
break;
default:
text.prop.style = Font.PLAIN;
break;
}

graphics = ImageGetBufferedImage( img ).getGraphics();
currentFont = Font.init( javacast("string", text.prop.font), javacast("int", text.prop.javaStyle), javacast("int", text.prop.size));
fontMetrics = graphics.getFontMetrics( currentFont );
fontBounds = fontMetrics.getStringBounds( javacast("string", text.string), graphics );
textString.width = fontBounds.getWidth();
graphics.dispose();
</cfscript>

<cfset radius = 110>
<cfset centerX = 200>
<cfset centerY = 200>

<!--- for some reason 270 is the top of the circle --->
<cfif isUpper is 1>
<cfset angle = 270>
<cfelse>
<cfset angle = 90>
</cfif>


<!--- character spacing --->
<cfset angleIncrement = .54>

<cfif isUpper is 1>
<cfset angle = angle - ((textString.width / 2) * angleIncrement)>
<cfelse>
<cfset angle = angle + ((textString.width / 2) * angleIncrement)>
</cfif>

<cfloop from="1" to="#len(Text2Write)#" index="idx">

<cfset singleChar = mid(Text2Write, idx, 1)>

<!--- get the width of the char --->
<cfscript>
Font = CreateObject("java", "java.awt.Font");
text = { x = 50, y = 100, string = "#singleChar#" };
text.prop = { font="#textFont#", style="#textStyle#", javaStyle= BitOr(Font.BOLD, Font.ITALIC), size="#textSize#" };
arguments.style = "#textStyle#";
switch (arguments.style) {
case "bold":
text.prop.style = font.BOLD;
break;
case "italic":
text.prop.style = font.ITALIC;
break;
case "bolditalic":
text.prop.style = BitOr( Font.BOLD, Font.ITALIC);
break;
case "plain":
text.prop.style = Font.PLAIN;
break;
default:
text.prop.style = Font.PLAIN;
break;
}

graphics = ImageGetBufferedImage( img ).getGraphics();
currentFont = Font.init( javacast("string", text.prop.font), javacast("int", text.prop.javaStyle), javacast("int", text.prop.size));
fontMetrics = graphics.getFontMetrics( currentFont );
fontBounds = fontMetrics.getStringBounds( javacast("string", text.string), graphics );
charItem.width = fontBounds.getWidth();
graphics.dispose();
</cfscript>


<!--- get coordinates for the char --->
<cfset theta = pi() * (angle / 180.0)>

<cfset circleX = round(centerX + radius * cos(theta))>
<cfset circleY = round(centerY + radius * sin(theta))>

<cfif isUpper is 1>
<cfset rotateAngle = angle + 90>
<cfelse>
<cfset rotateAngle = angle - 90>
</cfif>

<!--- rotate the char --->
<cfset ImageRotateDrawingAxis(img, rotateAngle, circlex, circley)>

<cfset ImageDrawText(img, "#singleChar#", circlex, circley, attr)>

<!--- reset rotation --->
<cfset ImageRotateDrawingAxis(img, -(rotateAngle), circlex, circley)>

<cfif isUpper is 1>
<cfset angle = angle + (angleIncrement * charItem.width)>
<cfelse>
<cfset angle = angle - (angleIncrement * charItem.width)>
</cfif>


</cfloop>

<cfimage action="writeToBrowser" source="#img#">

cfSearching October 22, 2008 at 10:37 AM  

@Peter,

That is a tough one. Like I said, I have not done much with drawing along on an arc. So I do not know if the issue is the algorithm or something else. BTW, how solid is the algorithm? Is it home grown or adapted from another source?

I have seen a few java examples that at least appear to yield better results. Though I do not know well any method degrades with very small font sizes, when variations would be most apparent. I am still curious if you could do something with svg (which supports paths). But you may be better off looking for something commercial.

  © Blogger templates The Professional Template by Ourblogtemplates.com 2008

Header image adapted from atomicjeep