Thursday, January 17, 2008

Text Wrapping with Images and ColdFusion 8

One question that has plagued me since I started using ColdFusion 8's image functions, is how to wrap text on an image. Today I discovered a real jewel in the java API that demonstrates how to wrap text. I ported it ColdFusion and so far it seems to work equally well in CF. Who knew documentation could be so useful? ;)

Now the function may seem complex at first, but when you break it down it is surprisingly simply. I will just highlight the important parts of the code. Otherwise this entry will be the size of War and Peace. (Okay, it might be anyway so consider yourself warned .. and consider refreshing your coffee or beer now).

Where do they get these names?

The heart of the work is performed by two classes: LineBreakMeasurer and AttributedCharacterIterator. As the name implies, the LineBreakMeasurer class is used to break text into lines. Despite the intimidating name, AttributedCharacterIterator just provides a method for looping through a string of text. The "Attributed" part comes from the fact that the class stores more than just text. It also stores related attributes like font information. With that in mind, time to jump into the code.

War and Peace - The abbreviated version

Now the first thing the function does is extracts the underlying graphics object from our ColdFusion image. This allows us to grab what is called the FontRendererContext which is used for measuring graphical text.

<cfscript>
Local.buffered = ImageGetBufferedImage(arguments.image);
Local.graphics = Local.buffered.getGraphics();
Local.context = Local.graphics.getFontRenderContext();
</cfscript>

Next we create a Font object with the desired style, font, and size for our text. This is important because they all contribute to how much space the graphical text occupies. Once we have the basic information, we create what is called an AttributedString. That just means a string of text with some associated properties, like our font. Then we use the AttributedString to get our iterator. (We are almost there)

<cfscript>
// create a font object that will be used to draw the text
Local.textFont = createJavaFont( argumentCollection = arguments.fontAttributes );

Local.attrString = createObject("java", "java.text.AttributedString").init( arguments.text );
Local.TextAttribute = createObject("java", "java.awt.font.TextAttribute");
Local.attrString.addAttribute( Local.TextAttribute.FONT, Local.textFont );
Local.attrIterator = Local.attrString.getIterator();
</cfscript>

Finally, we create the last and most important object: our LineBreakMeasurer. We pass in both the AttributedString and the FontRendererContext so the object can accurately measure our text.

<cfscript>
Local.measurer = createObject("java", "java.awt.font.LineBreakMeasurer").init(
Local.attrIterator,
Local.context
);
</cfscript>

Then we use the measurer to loop through the text and split it into lines. The measurer does all of the hard work. We only need to tell it the width of the wrapping area and it auto-magically calculates how much of the text can fit on the current line. The measurer passes back a very useful object called a TextLayout. It contains lots of good information about the dimensions of the current line of text. Our function code uses both the layout and measurer to extract and store the dimensions and number of characters in each line. I will leave you to explore the rest of the function code on your own.

<cfscript>
...
// while there are still characters to read
while ( Local.measurer.getPosition() LT Local.attrIterator.getEndIndex() ) {

// get the layout (bounds) of the current line of text
Local.layout = Local.measurer.nextLayout( Local.wrapWidth );
...

// get the y coordinate
Local.posY = Local.posY + Local.layout.getAscent();

// get the x coordinate
if ( Local.layout.isLeftToRight()) {
Local.posX = arguments.x;
}
else {
Local.posX = arguments.x + Local.maxWidth - Local.layout.getAdvance();
}

...
// calculate the final y position
Local.posY = Local.posY + Local.layout.getDescent() + Local.layout.getLeading();
}
</cfscript>



Are we there yet?


Now for the good part. Here is an example of how you could use the attached functions to draw two equal sized columns of wrapped text on an image.


<!--- create the image --->
<cfset img = ImageNew("", 600, 200, "rgb", "lightgray")>
<cfset textProp = { font="Arial", size="14" }>
<cfset text = "Most modern calendars mar the sweet simplicity of our lives by">
<cfset text = text &" reminding us that each day that passes is the anniversary of">
<cfset text = text &" some perfectly uninteresting event.">

<!--- this will create a margin of 15 on both sides of the column --->
<cfset x = 15>
<cfset y = 15>
<cfset margin = 15>
<cfset columnWidth = (ImageGetWidth(img)/2) - (margin*2)>

<!--- draw the wrapped text in the left column --->
<cfset ImageSetDrawingColor( img, "black")>
<cfset dimen = ImageDrawWrappedText( img, text, x, y, columnWidth, textProp )>
<cfset ImageSetDrawingColor(img, "blue")>
<cfset ImageDrawRect(img, x, y, dimen.width, dimen.height)>

<!--- draw it one more time on in the right column --->
<!--- adjust the x coordinate to take into account the column we just drew --->
<cfset x = (ImageGetWidth(img)/2) + margin>
<cfset ImageSetDrawingColor( img, "black")>
<cfset dimen = ImageDrawWrappedText( img, text, x, y, columnWidth, textProp )>
<cfset ImageSetDrawingColor(img, "red")>
<cfset ImageDrawRect(img, x, y, dimen.width, dimen.height)>

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


The results should look like this.



As they say "That's all she wrote". Comments/questions/suggestions are welcome.


Updated 14-10-2008: Updated to included alignment option (left,center,right)


ImageDrawWrappedText Function

<cffunction name="ImageDrawWrappedText" returntype="struct" access="public" output="false">
<cfargument name="image" type="any" required="true" hint="ColdFusion image object">
<cfargument name="text" type="string" required="true" hint="The text to draw on the image. Minimum of 1 character.">
<cfargument name="x" type="numeric" required="true" hint="The starting x coordinate. Minimum value is 0.">
<cfargument name="y" type="numeric" required="true" hint="The starting y coordinate. Minimum value is 0.">
<cfargument name="wrapWidth" type="numeric" required="true" hint="Wrap the text within this width. Minimum value is 1.">
<cfargument name="fontAttributes" type="struct" required="false" default="#structNew()#" hint="Text font, style and or size">
<cfargument name="align" type="string" required="false" default="left" hint="Text alignment (Left, Right or Center).">

<cfset var Local = structNew()>

<!--- get the dimensions of the wrapped text --->
<cfset Local.dimen = GetTextWrapInfo( argumentCollection = arguments )>

<!--- loop through the array of lines, and draw each line of text --->
<cfloop from="1" to="#arrayLen(Local.dimen.lines)#" index="Local.x">
<cfset Local.line = Local.dimen.lines[ Local.x ]>
<cfset Local.text = Mid( arguments.text, Local.line.startAt, Local.line.endAt )>
<cfset ImageDrawText( arguments.image, Local.text, Local.line.x, Local.line.y, arguments.fontAttributes) >
</cfloop>

<cfreturn Local.dimen>
</cffunction>


GetTextWrapInfo Function

<cffunction name="GetTextWrapInfo" returntype="struct" access="public" output="true"
hint="Calculates the amount of text to draw on each line. Returns the overall text dimensions and an array of lines. ">

<cfargument name="image" type="any" required="true" hint="ColdFusion image object">
<cfargument name="text" type="string" required="true" hint="The text to draw on the image. Must be at least 1 character.">
<cfargument name="x" type="numeric" required="true" hint="The starting x coordinate. Minimum value is 0.">
<cfargument name="y" type="numeric" required="true" hint="The starting y coordinate. Minimum value is 0.">
<cfargument name="wrapWidth" type="numeric" required="true" hint="Wrap the text within this width. Minimum value is 1.">
<cfargument name="fontAttributes" type="struct" required="false" default="#structNew()#" hint="Text font, style and or size">
<cfargument name="align" type="string" required="false" default="left" hint="Text alignment (Left, Right or Center).">

<cfset var Local = structNew()>

<!--- verify the supplied arguments --->
<cfif NOT IsImage(arguments.image) >
<cfthrow message="Argument.Image must be a ColdFusion image object">
</cfif>
<cfif NOT len(arguments.text) >
<cfthrow message="Argument.text must contain at least 1 character">
</cfif>
<cfif arguments.x LT 0 OR arguments.y LT 0>
<cfthrow message="The x and y coordinates cannot be less than 0.">
</cfif>
<cfif arguments.wrapWidth LT 1>
<cfthrow message="The minimum wrapWidth value allowed is 1.">
</cfif>
<cfif not listFindNoCase("left,right,center", trim(arguments.align))>
<cfthrow message="Valid align values are: left,right,center.">
</cfif>

<cfscript>
// create an array for storing the results
Local.lines = [];

// get the underlying graphics object and the renderer context for measuring the text
Local.buffered = ImageGetBufferedImage(arguments.image);
Local.graphics = Local.buffered.getGraphics();
Local.context = Local.graphics.getFontRenderContext();

// create a font object that will be used to draw the text
Local.textFont = createJavaFont( argumentCollection = arguments.fontAttributes );

// the AttributedString holds information about the image text. namely the
// characters to be drawn and the font used to determine the size of the text
Local.attrString = createObject("java", "java.text.AttributedString").init( arguments.text );
Local.TextAttribute = createObject("java", "java.awt.font.TextAttribute");
Local.attrString.addAttribute( Local.TextAttribute.FONT, Local.textFont );
// the iterator allows us to loop over the characters in our text string below
Local.attrIterator = Local.attrString.getIterator();

// create a LineBreakMeasurer for breaking our text into individual lines
Local.measurer = createObject("java", "java.awt.font.LineBreakMeasurer").init(
Local.attrIterator,
Local.context
);

Local.LEFT_ALIGN = "left";
Local.RIGHT_ALIGN = "right";
Local.CENTER_ALIGN = "center";
Local.leftMargin = arguments.x;
Local.rightMargin = arguments.wrapWidth + arguments.x;
Local.alignment = trim( arguments.align );

Local.posY = arguments.y;
Local.posX = arguments.x;
Local.wrapWidth = javacast("float", arguments.wrapWidth);
Local.maxWidth = 0;
Local.maxHeight = 0;

// while there are still characters to read
while ( Local.measurer.getPosition() LT Local.attrIterator.getEndIndex() ) {
// get the starting character position
Local.startAt = Local.measurer.getPosition();

// get the layout of the current line of text
Local.layout = Local.measurer.nextLayout( Local.wrapWidth );
Local.posY = Local.posY + Local.layout.getAscent();

/* 2008-10-14: replace with alignment attribute
if ( Local.layout.isLeftToRight()) {
Local.posX = arguments.x;
}
else {
Local.posX = arguments.x + Local.maxWidth - Local.layout.getAdvance();
}
*/
/////////////////////////////////////////////////////////////////////
// Calculate the x coordinate of this line based on alignment
/////////////////////////////////////////////////////////////////////
if ( Local.layout.isLeftToRight()) {

if ( Local.alignment == Local.RIGHT_ALIGN ) {
Local.posX = Local.rightMargin - Local.layout.getVisibleAdvance();
}
else if ( Local.alignment == Local.CENTER_ALIGN ) {
Local.posX = ( Local.leftMargin + Local.rightMargin - Local.layout.getVisibleAdvance()) / 2;
}
else {
// Otherwise, use left alignment
Local.posX = arguments.x;
}

}
else {

if ( Local.alignment == Local.LEFT_ALIGN ) {
Local.posX = arguments.x + Local.maxWidth - Local.layout.getAdvance();
}
else if ( Local.alignment == Local.CENTER_ALIGN ) {
Local.posX = ( Local.leftMargin + Local.rightMargin + Local.layout.getAdvance()) / 2 - Local.layout.getAdvance();
}
else {
// Otherwise, use right alignment
Local.posX = Local.leftMargin + ( Local.layout.getVisibleAdvance() - layout.getAdvance() );
}

}
// save the current line information. note, we add 1 to the character
// positions because CF string functions are 1-based and java's are 0-based
Local.currentLine = { x = Local.posX,
y = Local.posY,
startAt = Local.startAt + 1,
endAt = Local.measurer.getPosition() - Local.startAt
};
arrayAppend( Local.lines, Local.currentLine );

// save the maximum line width
Local.currentWidth = Local.layout.getBounds().getWidth();
if ( Local.maxWidth LT Local.currentWidth ) {
Local.maxWidth = Local.currentWidth;
}

// calculate the final y position
Local.posY = Local.posY + Local.layout.getDescent() + Local.layout.getLeading();

}

Local.graphics.dispose();
Local.results = { x = arguments.x,
y = arguments.y,
width = Local.maxWidth,
height = Local.posY - arguments.y,
lines = Local.lines
};
</cfscript>

<cfreturn Local.results>
</cffunction>


CreateJavaFont Function

<cffunction name="CreateJavaFont" returntype="any" access="public" output="true"
hint="Creates a java.awt.Font object using the supplied text attributes. All attributes are optional.">

<cfargument name="font" type="string" required="false" hint="Font name (example: Arial)">
<cfargument name="style" type="string" required="false" default="plain" hint="Font style (Plain, Italic, Bold, BoldItalic)">
<cfargument name="size" type="numeric" required="false" hint="Font size in points">
<cfset var Local = structNew()>

<cfscript>
Local.Font = createObject("java", "java.awt.Font");

// get the default font properties
Local.DefaultFont = Local.Font.init( javacast("null", "") );
Local.prop = { font = Local.DefaultFont.getName(),
style = Local.DefaultFont.getStyle(),
size = Local.DefaultFont.getSize()
};

// extract the supplied font name
if ( structKeyExists(arguments, "font") ) {
Local.prop.font = arguments.font;
}
// extract the supplied font size
if ( structKeyExists(arguments, "size") ) {
Local.prop.size = arguments.size;
}
// extract and convert the supplied font style
if ( structKeyExists(arguments, "style") ) {
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;
}
}

// create a java font using the current properties
Local.javaFont = Local.Font.init( javacast("string", Local.prop.font),
javacast("int", Local.prop.style),
javacast("int", Local.prop.size)
);
</cfscript>

<cfreturn Local.javaFont>
</cffunction>

4 comments:

Unknown January 18, 2008 at 1:54 AM  

the discription about coldfusion is good.

Anonymous,  February 3, 2008 at 11:54 PM  

Impressive ;)

Anonymous,  February 27, 2008 at 6:39 AM  

Does it support center alignment of the text?

Unknown February 27, 2008 at 7:15 AM  

To center align the text I added the code below under the "if ( Local.layout.isLeftToRight()" block.



if (Local.alignText EQ "Center" AND Local.maxWidth GT Local.layout.getAdvance())
{
Local.posX = arguments.x + ((Local.maxWidth - Local.layout.getAdvance())/2) ;
}

  © Blogger templates The Professional Template by Ourblogtemplates.com 2008

Header image adapted from atomicjeep