Wednesday, April 19, 2006

Forget TXTWIDTH - use GdipMeasureString

For years, we've used code like the following to determine the width of a string:
lnWidth = txtwidth(lcText, lcFontName, lnFontSize, ;
lnWidth = lnWidth * fontmetric(6, lcFontName, ;
lnFontSize, lcFontStyle)
This code works OK in many situations, but not in one in particular: when defining how wide to make an object in a report.

The value calculated above is in pixels, so you must convert the value to FRUs (the units used in reports, which are 1/10000th of an inch); you need to multiply by 104.166 (10000 FRUs per inch / 96 pixels per inch). Instead of doing all that work, you could use the GetFRUTextWidth method of the FFC _FRXCursor helper object:
loFRXCursor = newobject('FRXCursor', ;
home() + 'FFC\_FRXCursor.vcx')
lnWidth = loFRXCursor.GetFRUTextWidth(lcText, ;
lcFontName, lnFontSize, lcFontStyle)
The problem is this doesn't actually give you the correct value. The reason is because reports use GDI+ for rendering and GDI+ renders objects a little larger than you'd expect it to.

To see this problem, do the following:
use home() + 'samples\data\customer'
loFRXCursor = newobject('FRXCursor', ;
home() + 'FFC\_FRXCursor.vcx')
select max(loFRXCursor.GetFRUTextWidth(trim(company), ;
'Arial', 10)) from customer into array laWidth
wait window laWidth[1]
I get 22500. Now create a report, add a field, enter "company" as the expression, and make it 2.25 inches wide (22500 FRUs / 10000 FRUs per inch). Preview the report. The telltale ellipsis at the end of some values indicates the field wasn't sized wide enough.

This drove me crazy for years. I figured out an empirical "fudge" factor to add to the calculated width; 19 pixels (1979.154 FRU) seemed to work most of the time, but occasionally I'd find that wasn't enough for some values.

Fortunately, since reports use GDI+, we can use a GDI+ function to accurately calculate the width. GdipMeasureString determines several things about the specified string, including the width. Even better, VFP 9 comes with a GDI+ wrapper object so you don't have to understand the GDI+ API to call GdipMeasureString.

To show an example of using the GDI+ wrapper classes, take a look at this function:
function GetWidth(tcText, tcFontName, tnFontSize)
local loGDI, ;
loFont, ;
lnChars, ;
lnLines, ;
loGDI = newobject('GPGraphics', ;
home() + 'FFC\_GDIPlus.vcx')
loFont = newobject('GPFont', ;
home() + 'FFC\_GDIPlus.vcx', '', tcFontName, ;
tnFontSize, 0, 3)
lnChars = 0
lnLines = 0
loSize = loGDI.MeasureStringA(tcText, loFont, , , ;
@lnChars, @lnLines)
lnWidth = loSize.W
release loGDI, loFont, loSize
return lnWidth
Now try the following:
select max(GetWidth(trim(company), ;
'Arial', 10)) from customer into array laWidth
wait window ceiling(laWidth[1] * 104.166)
This gives 23838. Change the width of the field in the report to 2.384 inches and preview it again. This time the values fit correctly.

The only problem now is that this code can take a long time to execute if there are a lot of records because for each call, a couple of GDI+ wrapper objects are created and some GDI+ setup is done. I created a wrapper class for GdipMeasureString called SFGDIMeasureString that works a lot more efficiently.

Let's look at this class in sections. Here's the start: it defines some constants, the class, and its properties:
* These #DEFINEs are taken from
* home() + 'ffc\gdiplus.h'

#define GDIPLUS_FontStyle_Regular 0
#define GDIPLUS_FontStyle_Bold 1
#define GDIPLUS_FontStyle_Italic 2
#define GDIPLUS_FontStyle_BoldItalic 3
#define GDIPLUS_FontStyle_Underline 4
#define GDIPLUS_FontStyle_Strikeout 8
#define GDIPLUS_Unit_Point 3

define class SFGDIMeasureString as Custom
&& a reference to a GPGraphics object
oFormat = .NULL.
&& a reference to a GPStringFormat object
oFont = .NULL.
&& a reference to a GPFont object
oSize = .NULL.
&& a reference to a GPSize object
nChars = 0
&& the number of characters fitted in the
&& bounding box
nLines = 0
&& the number of lines in the bounding box
nWidth = 0
&& the width of the bounding box
nHeight = 0
&& the height of the bounding box
nStatus = 0
&& the status code from GDI+ functions
The Init method instantiates some helper objects and declares the GdipMeasureString function. Destroy nukes the member objects:
function Init
This.oGDI = newobject('GPGraphics', ;
home() + 'ffc\_gdiplus.vcx')
This.oFormat = newobject('GPStringFormat', ;
home() + 'ffc\_gdiplus.vcx')
This.oFont = newobject('GPFont', ;
home() + 'ffc\_gdiplus.vcx')
This.oSize = newobject('GPSize', ;
home() + 'ffc\_gdiplus.vcx')
declare integer GdipMeasureString ;
in gdiplus.dll ;
integer nGraphics, string cUnicode, ;
integer nLength, integer nFont, ;
string cLayoutRect, integer nStringFormat, ;
string @cRectOut, integer @nChars, ;
integer @nLines

function Destroy
store .NULL. to This.oGDI, This.oFormat, ;
This.oFont, This.oSize
MeasureString determines the dimensions of the bounding box for the specified string:
function MeasureString(tcString, tcFontName, ;
tnFontSize, tcStyle)
local lcStyle, ;
lnStyle, ;
lnChars, ;
lnLines, ;
lcBoundingBox, ;
lnGDIHandle, ;
lnFontHandle, ;
lnFormatHandle, ;
lcRectF, ;
lnStatus, ;
with This

* Ensure the parameters are passed correctly.

do case
case vartype(tcString) <> 'C' or ;
error 11
return .F.
case pcount() > 1 and ;
(vartype(tcFontName) <> 'C' or ;
empty(tcFontName) or ;
vartype(tnFontSize) <> 'N' or ;
not between(tnFontSize, 1, 128))
error 11
return .F.
case pcount() = 4 and ;
(vartype(tcStyle) <> 'C' or ;
error 11
return .F.

* Set up the font object if the font and size
* were specified.

if pcount() > 1
lcStyle = iif(vartype(tcStyle) = 'C', ;
tcStyle, '')
.SetFont(tcFontName, tnFontSize, lcStyle)
endif pcount() > 1

* Initialize output variables used in
* GdipMeasureString.

lnChars = 0
lnLines = 0
lcBoundingBox = replicate(chr(0), 16)

* Get the GDI+ handles we need.

lnGDIHandle = .oGDI.GetHandle()
if lnGDIHandle = 0
lnGDIHandle = .oGDI.GetHandle()
endif lnGDIHandle = 0
lnFontHandle = .oFont.GetHandle()
lnFormatHandle = .oFormat.GetHandle()

* Get the size of the layout box.

lcRectF = replicate(chr(0), 8) + ;

* Call the GdipMeasureString function to get
* the dimensions of the bounding box for the
* specified string.

.nStatus = GdipMeasureString(lnGDIHandle, ;
strconv(tcString, 5), len(tcString), ;
lnFontHandle, lcRectF, lnFormatHandle, ;
@lcBoundingBox, @lnChars, @lnLines)
.nChars = lnChars
.nLines = lnLines
.nWidth = ctobin(substr(lcBoundingBox, ;
9, 4), 'N')
.nHeight = ctobin(substr(lcBoundingBox, ;
13, 4), 'N')
llReturn = .T.
llReturn = .F.
endif .nStatus = GDIPLUS_STATUS_OK
return llReturn
GetWidth is a utility method that returns the width of the specified string:
function GetWidth(tcString, tcFontName, ;
tnFontSize, tcStyle)
local llReturn, ;
with This
do case
case pcount() < 2
llReturn = .MeasureString(tcString)
case pcount() < 4
llReturn = .MeasureString(tcString, ;
tcFontName, tnFontSize)
llReturn = .MeasureString(tcString, ;
tcFontName, tnFontSize, tcStyle)
if llReturn
lnReturn = .nWidth
endif llReturn
return lnReturn
SetSize sets the dimensions of the layout box for the string:
function SetSize(tnWidth, tnHeight)
if vartype(tnWidth) = 'N' and ;
tnWidth >= 0 and ;
vartype(tnHeight) = 'N' and tnHeight >=0
This.oSize.Create(tnWidth, tnHeight)
error 11
endif vartype(tnWidth) = 'N' ...
SetFont sets the font name, size, and style to use:
function SetFont(tcFontName, tnFontSize, tcStyle)
local lcStyle
do case
case pcount() <>= 2 and ;
(vartype(tcFontName) <> 'C' or ;
empty(tcFontName) or ;
vartype(tnFontSize) <> 'N' or ;
not between(tnFontSize, 1, 128))
error 11
return .F.
case pcount() = 3 and ;
vartype(tcStyle) <> 'C'
error 11
return .F.
lcStyle = iif(vartype(tcStyle) = 'C', tcStyle, '')
lnStyle = iif('B' $ lcStyle, ;
GDIPLUS_FontStyle_Bold, 0) + ;
iif('I' $ lcStyle, ;
GDIPLUS_FontStyle_Italic, 0) + ;
iif('U' $ lcStyle, ;
GDIPLUS_FontStyle_Underline, 0) + ;
iif('-' $ lcStyle, ;
GDIPLUS_FontStyle_Strikeout, 0)
This.oFont.Create(tcFontName, tnFontSize, ;
lnStyle, GDIPLUS_Unit_Point)
Let's try the previous example using this class:
loGDI = newobject('SFGDIMeasureString', ;
select max(loGDI.GetWidth(trim(company), 'Arial', 10)) ;
from customer into array laWidth
wait window laWidth[1] * 10000/96
This is a lot faster than the GetWidth function presented earlier. The following would run even faster because the font object doesn't have to be initialized on each call:
loGDI = newobject('SFGDIMeasureString', ;
loGDI.SetFont('Arial', 10)
select max(loGDI.GetWidth(trim(company))) ;
from customer into array laWidth
wait window laWidth[1] * 10000/96
The cool thing about this class is that it can do a lot more than just calculate the width of a string. It can also determine the height or the number of lines a string will take at a certain width (think setting MEMOWIDTH to a certain width and then using MEMLINES(), but faster, more accurate, and supporting fonts).

For example, I have a generic message dialog class I use to display warnings, errors, and other types of messages to the user. I don't use MESSAGEBOX() for this because my class support multiple buttons with custom captions. The problem is that the buttons appear below an editbox used to display the message. So, how much room do I have to allocate for the height of the editbox? If I don't specify enough, the user has to scroll to see the message. If I specify too much, short messages look goofy because there's a lot of blank space before the buttons. Now, I can make the editbox an arbitrary size and use SFGDIMeasureString to determine the necessary height for the editbox for a given message, adjusting the positions of the buttons dynamically. To do so, I call the SetSize method to tell SFGDIMeasureString the width of the editbox (I pass a very large value, like 10000, for the height, so it isn't a factor), then call MeasureString, and use the value of the nHeight property for the height of the editbox.

I'm finding a lot more uses for this class. I hope you find it useful too.

1 comment:

Alex Sosa said...

Doug, congratulations on a wonderful tool, and thanks for sharing it.

I used it to correct a problem that occurs in VFP9 with labels used as column titles that don't align properly with the corresponding fields (at least with monospaced fonts.)

Essentially I move those labels to the left just before using the report unless the comment field contains "LJ" (for Left Justify). I needed to know exactly by how much to move them and your class lets me find out.

Thanks again.