[Estimated Reading Time: 5 minutes]

In putting some finishing polish on the GUI console of my Smoketest framework (see, I am working on it!) I ran into something that I remember I once knew and – seemingly – had forgotten, about mixing API level access to a GDI device context with the high-level TCanvas access that is conveniently provided for us.

For a lot of basic drawing work, TCanvas is plenty good enough, but there are certain things that you cannot do with it, at least not directly.

One of these is – or rather, used to be :

  • Calculating the width of some text when rendered into some
    rectangle constrained to a maximum width, using ellipsis to fit if necessary.

And other more complex text measurements other than just “how wide” or “how high” is this line of text.

I say “used to be“, because you can now achieve this using TCanvas, as of at least Delphi 2010 (possibly much earlier – I haven’t checked), courtesy of additional overloads of the TextRect() method. But previously to perform this calculation required that a call be made to the Windows GDI DrawTextEx() function. Since Smoketest aims to remain compatible – at the core framework implementation level – with Delphi 7, this rules out the use of the new overload and demands the use of the Windows API.

In any case, the point of this post is about the general problem of mixing certain GDI and TCanvas operations, not about solving that particular, specific problem. I only use it as an example.

So, first of all, let’s see the symptoms of the problem.

The issue arose in my case with a simple calculation of the width of a label which was then used to determine the left edge position of a following piece of text. This is most clearly seen in the “Inspections” output of the self-test suite:

The problem is the inconsistent spacing between the labels and the following value. This spacing should be consistent and indeed, in the code, it is a simple absolute pixel adjustment applied to the TRect subsequently to be used for text value:

    aCanvas.Font.Style := [fsBold];
    DrawTextEx(dc, PChar(LabelText), Length(LabelText), LabelRect,
               DT_CALCRECT or DT_WORD_ELLIPSIS or DT_NOPREFIX, NIL);
    TextRect.Left := LabelRect.Right + 4;

Do you see the problem ? Don’t worry, it isn’t immediately obvious.

The dc parameter passed to DrawTextEx() is the HDC (device context handle) of the canvas we are performing the calculations for. Since there are a large number of calculations being performed in this canvas, this handle is obtained and stored in a local variable for re-use, to avoid having to repeatedly use the aCanvas.Handle accessor.

  var
    dc: HDC;
  begin
    :
    dc := aCanvas.Handle;
    :
  end;

This however is the key to the problem. Or at least a partner in the crime. The accomplice is the fact that the label text we are calculating the width of is rendered in bold, requiring a change in the font properties.

If we look at the implementation of TCanvas we find that this installed it’s own listener (handler if you prefer) on the OnChanged event of it’s Font object. Changing the properties of the Font results in the following:

procedure TCanvas.FontChanged(AFont: TObject);
begin
  if csFontValid in State then
  begin
    Exclude(State, csFontValid);
    SelectObject(FHandle, StockFont);
  end;
end;

Because the Font has changed, the canvas removes the csFontValid flag from it’s internal state. But it also selects the StockFont into the device context. i.e. having changed the properties of the Font, the device context is now using a completely different Font (unless the new font properties happen by coincidence to reflect those of StockFont itself of course)!

If we were only using TCanvas methods, this would not be a problem.

When we next access the TCanvas.Handle property, the canvas checks the internal state to make sure that everything is good to go. If not, it takes whatever steps are necessary to rectify that.

In the case of the Font properties having changed, the lack of csFontValid in the state will cause the canvas to create the required GDI font object and select it into the device context for us.

But because I am specifically avoiding using that accessor, the device context is left with StockFont selected, which is absolutely not the font I expected, and in this case has quite different characteristics. Which mean that when I then start measuring text, I get results that are not correct for the font I will subsequently use to actually render the text (the actual drawing code is able to use TCanvas, without having to mix-in GDI calls, so the problem only arises in calculations for the layout which that drawing code then relies on).

The solution therefore is simply to ensure that after changing the Font properties, that I “touch” the TCanvas Handle property in order to ensure that the internal state of the device context is brought up-to-update:

    aCanvas.Font.Style := [fsBold];
    dc := aCanvas.Handle;  // Cause the changed font to be selected into the canvas
     :
    // Proceed to use dc ...

With all of the relevant areas of my layout code addressed, I now get the result I was aiming for. Nice, consistent spacing:

It’s worth noting that the problem isn’t that our cached HDC is no longer “valid”. The value of Handle (dc) itself doesn’t actually change since the device context doesn’t need to be re-created, we simply need the side-effects that come from simply accessing the handle.

Also worth noting is that this behaviour is also seen with the other GDI objects exposed through wrapper objects on the canvas – the Pen and the Brush.

The final thing to mention is that the tempting looking TCanvas.Refresh() method is a bit of a red-herring, since it does the exact opposite of what you might expect – it DE-selects all the current Pen, Brush and Font objects and selects the GDI StockPen, StockBrush and StockFont, marking all these objects as invalid so that they ALL get reselected when Handle is accessed.

Refresh() should perhaps really be called RefreshWhenINextAccessHandle().

Footnote

There is a mechanism in TCanvas to enable you to explicitly request that a particular graphic object be selected into the device context – RequiredState(). This is used all over the place in TCanvas itself to ensure that the objects required for any particular operation are up-to-date in the device context.

Unfortunately this mechanism is a protected implementation detail, so if a well-behaved consumer of the class needs to trigger this mechanism, all they can do is rely on side-effects. (When I say well-behaved, I mean if you don’t want to have to fiddle around with “cracker” classes or helpers etc to gain access to the protected members through a side-door)

8 thoughts on “Text Metrics, Font Styles, TCanvas and Device Contexts”

  1. Removing the local variable and always using Canvas.Handle is the way to go. The rule you need to follow when working with Canvas is never cache the DC.

    1. “Never cache the DC” is the rule when you don’t know any better, just as “Never cross the road on your own” is the way to avoid getting run over before you have learned how to cross the road safely. 🙂

      The real rule is to learn when it is safe to cache the DC. The trick with this rule, as I remembered this evening, is to then remember what you’ve learned after [mumble] years have passed. 😉

      1. The VCL is already caching the DC for you. Why do you want to create another cache? What are you gaining by doing so? If you follow my rule, you can never get caught out in the way described in your post. What is the downside of always using Canvas.Handle when you need the DC?

        1. The cached DC in TCanvas is behind a “pay-wall” of checking to see whether you may have done something that requires that things need to be re-selected into the DC for you. Since you yourself can know whether you have done those things, there is no point asking TCanvas to make those checks for you. As long as you remember yourself.

          In the same way that a responsible adult is able to manage the business of crossing the road by themselves, so that we are able to allow our parents to get on with other things and not have to hold our hands for our entire lives (and leave us stranded on one side of the road when they shuffle off this mortal coil).

          Not begin entirely serious there, obviously. 😀

          But there is a slightly serious point which is that if we always avoid anything that carries even a small risk, nothing would ever get done. Most people would never even get out of bed (although they might if they found out how many people die in their beds each year!) LOL

          But back to the pay-wall. Sure, the “cost” of that pay-wall is small, but when you have a potentially large number of operations to be performed using it (as in this case) it quickly adds up. Besides which, “dc” vs “aCanvas.Handle” saves a fair amount of typing when you have lots of calls making us of that (I add that reason in there because these days that’s usually a stated “good reason” for doing something that gets flung my way). 😉

          Bottom line, this post is about explaining why you might choose your rule. But it does so by equipping the reader with the knowledge that allows them to make their own choice in an informed way, rather than just being spoon-fed.

          I don’t set out to advocate one way or the way in the article. I chose my way in this case for my reasons. Feel free to choose your way in your own code, but since this isn’t your code, it doesn’t have to follow your rules. 😉

            1. Interesting. Being open to ideas often involves making contortions. Dogmatically sticking to rules is pretty much a defining feature of a closed mind.

    1. Yep, you believe that by sticking to your rule you will avoid all problems in this area. But don’t lose sight of the fact that the only person prescribing or recommending any particular approach here is you (and that when reasons are offered – light heartedly – for perhaps choosing something other than your prescription, that person is engaging in “contortions”).

      I on the other hand merely present the evidence of the existence of a problem and then leave it up to others to decide how they wish to go about dealing with that, whilst describing how I chose to go about it in this particular case in my code. In other code I might, and would choose differently.

      It seems to me that you have 1 rule above all else, which is that anything that I say has to be challenged. Go ahead, enjoy yourself.

Comments are closed.