Graphics.MeasureCharacterRanges giving wrong size calculations
Asked Answered
S

5

8

I'm trying to render some text into a specific part of an image in a Web Forms app. The text will be user entered, so I want to vary the font size to make sure it fits within the bounding box.

I have code that was doing this fine on my proof-of-concept implementation, but I'm now trying it against the assets from the designer, which are larger, and I'm getting some odd results.

I'm running the size calculation as follows:

StringFormat fmt = new StringFormat();
fmt.Alignment = StringAlignment.Center;
fmt.LineAlignment = StringAlignment.Near;
fmt.FormatFlags = StringFormatFlags.NoClip;
fmt.Trimming = StringTrimming.None;

int size = __startingSize;
Font font = __fonts.GetFontBySize(size);

while (GetStringBounds(text, font, fmt).IsLargerThan(__textBoundingBox))
{
    context.Trace.Write("MyHandler.ProcessRequest",
        "Decrementing font size to " + size + ", as size is "
        + GetStringBounds(text, font, fmt).Size()
        + " and limit is " + __textBoundingBox.Size());

    size--;

    if (size < __minimumSize)
    {
        break;
    }

    font = __fonts.GetFontBySize(size);
}

context.Trace.Write("MyHandler.ProcessRequest", "Writing " + text + " in "
    + font.FontFamily.Name + " at " + font.SizeInPoints + "pt, size is "
    + GetStringBounds(text, font, fmt).Size()
    + " and limit is " + __textBoundingBox.Size());

I then use the following line to render the text onto an image I'm pulling from the filesystem:

g.DrawString(text, font, __brush, __textBoundingBox, fmt);

where:

  • __fonts is a PrivateFontCollection,
  • PrivateFontCollection.GetFontBySize is an extension method that returns a FontFamily
  • RectangleF __textBoundingBox = new RectangleF(150, 110, 212, 64);
  • int __minimumSize = 8;
  • int __startingSize = 48;
  • Brush __brush = Brushes.White;
  • int size starts out at 48 and decrements within that loop
  • Graphics g has SmoothingMode.AntiAlias and TextRenderingHint.AntiAlias set
  • context is a System.Web.HttpContext (this is an excerpt from the ProcessRequest method of an IHttpHandler)

The other methods are:

private static RectangleF GetStringBounds(string text, Font font,
    StringFormat fmt)  
{  
    CharacterRange[] range = { new CharacterRange(0, text.Length) };  
    StringFormat myFormat = fmt.Clone() as StringFormat;  
    myFormat.SetMeasurableCharacterRanges(range);  

    using (Graphics g = Graphics.FromImage(new Bitmap(
       (int) __textBoundingBox.Width - 1,
       (int) __textBoundingBox.Height - 1)))
    {
        g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
        g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
        
        Region[] regions = g.MeasureCharacterRanges(text, font,
            __textBoundingBox, myFormat);
        return regions[0].GetBounds(g);
    }  
}

public static string Size(this RectangleF rect)
{
    return rect.Width + "×" + rect.Height;
}

public static bool IsLargerThan(this RectangleF a, RectangleF b)
{
    return (a.Width > b.Width) || (a.Height > b.Height);
}

Now I have two problems.

Firstly, the text sometimes insists on wrapping by inserting a line-break within a word, when it should just fail to fit and cause the while loop to decrement again. I can't see why it is that Graphics.MeasureCharacterRanges thinks that this fits within the box when it shouldn't be word-wrapping within a word. This behaviour is exhibited irrespective of the character set used (I get it in Latin alphabet words, as well as other parts of the Unicode range, like Cyrillic, Greek, Georgian and Armenian). Is there some setting I should be using to force Graphics.MeasureCharacterRanges only to be word-wrapping at whitespace characters (or hyphens)? This first problem is the same as post 2499067.

Secondly, in scaling up to the new image and font size, Graphics.MeasureCharacterRanges is giving me heights that are wildly off. The RectangleF I am drawing within corresponds to a visually apparent area of the image, so I can easily see when the text is being decremented more than is necessary. Yet when I pass it some text, the GetBounds call is giving me a height that is almost double what it's actually taking.

Using trial and error to set the __minimumSize to force an exit from the while loop, I can see that 24pt text fits within the bounding box, yet Graphics.MeasureCharacterRanges is reporting that the height of that text, once rendered to the image, is 122px (when the bounding box is 64px tall and it fits within that box). Indeed, without forcing the matter, the while loop iterates to 18pt, at which point Graphics.MeasureCharacterRanges returns a value that fits.

The trace log excerpt is as follows:

Decrementing font size to 24, as size is 193×122 and limit is 212×64
Decrementing font size to 23, as size is 191×117 and limit is 212×64
Decrementing font size to 22, as size is 200×75 and limit is 212×64
Decrementing font size to 21, as size is 192×71 and limit is 212×64
Decrementing font size to 20, as size is 198×68 and limit is 212×64
Decrementing font size to 19, as size is 185×65 and limit is 212×64
Writing VENNEGOOR of HESSELINK in DIN-Black at 18pt, size is 178×61 and limit is 212×64

So why is Graphics.MeasureCharacterRanges giving me a wrong result? I could understand it being, say, the line height of the font if the loop stopped around 21pt (which would visually fit, if I screenshot the results and measure it in Paint.Net), but it's going far further than it should be doing because, frankly, it's returning the wrong damn results.

Shoffner answered 21/4, 2010 at 16:53 Comment(1)
+1 for one of the most documented questions I've seen in a while!Flown
S
1

I have a similar problem. I want to know how big the text I'm drawing is going to be, and where it's going to appear, EXACTLY. I haven't had the line-break problem, so I don't think I can help you there. I had the same problems you had with all the various measuring techniques available, including ending up with MeasureCharacterRanges, which worked okay for the left and right, but not at all for the height and top. (Playing with the baseline can work well for some rare applications though.)

I've ended up with a very inelegant, inefficient, but working solution, at least for my use case. I draw the text on a bitmap, check the bits to see where they ended up, and that's my range. Since I'm mostly drawing small fonts and short strings, it's been fast enough for me (especially with the memoization I added). Maybe this won't be exactly what you need, but maybe it can lead you down the right track anyway.

Note it requires compiling the project to allow unsafe code at the moment, as I'm trying to squeeze out every bit of efficiency from it, but that constraint could be removed if you wanted to. Also, it's not as thread safe as it could be right now, you could easily add that if you needed it.

Dictionary<Tuple<string, Font, Brush>, Rectangle> cachedTextBounds = new Dictionary<Tuple<string, Font, Brush>, Rectangle>();
/// <summary>
/// Determines bounds of some text by actually drawing the text to a bitmap and
/// reading the bits to see where it ended up.  Bounds assume you draw at 0, 0.  If
/// drawing elsewhere, you can easily offset the resulting rectangle appropriately.
/// </summary>
/// <param name="text">The text to be drawn</param>
/// <param name="font">The font to use when drawing the text</param>
/// <param name="brush">The brush to be used when drawing the text</param>
/// <returns>The bounding rectangle of the rendered text</returns>
private unsafe Rectangle RenderedTextBounds(string text, Font font, Brush brush) {

  // First check memoization
  Tuple<string, Font, Brush> t = new Tuple<string, Font, Brush>(text, font, brush);
  try {
    return cachedTextBounds[t];
  }
  catch(KeyNotFoundException) {
    // not cached
  }

  // Draw the string on a bitmap
  Rectangle bounds = new Rectangle();
  Size approxSize = TextRenderer.MeasureText(text, font);
  using(Bitmap bitmap = new Bitmap((int)(approxSize.Width*1.5), (int)(approxSize.Height*1.5))) {
    using(Graphics g = Graphics.FromImage(bitmap))
      g.DrawString(text, font, brush, 0, 0);
    // Unsafe LockBits code takes a bit over 10% of time compared to safe GetPixel code
    BitmapData bd = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
    byte* row = (byte*)bd.Scan0;
    // Find left, looking for first bit that has a non-zero alpha channel, so it's not clear
    for(int x = 0; x < bitmap.Width; x++)
      for(int y = 0; y < bitmap.Height; y++)
        if(((byte*)bd.Scan0)[y*bd.Stride + 4*x + 3] != 0) {
          bounds.X = x;
          goto foundX;
        }
  foundX:
    // Right
    for(int x = bitmap.Width - 1; x >= 0; x--)
      for(int y = 0; y < bitmap.Height; y++)
        if(((byte*)bd.Scan0)[y*bd.Stride + 4*x + 3] != 0) {
          bounds.Width = x - bounds.X + 1;
          goto foundWidth;
        }
  foundWidth:
    // Top
    for(int y = 0; y < bitmap.Height; y++)
      for(int x = 0; x < bitmap.Width; x++)
        if(((byte*)bd.Scan0)[y*bd.Stride + 4*x + 3] != 0) {
          bounds.Y = y;
          goto foundY;
        }
  foundY:
    // Bottom
    for(int y = bitmap.Height - 1; y >= 0; y--)
      for(int x = 0; x < bitmap.Width; x++)
        if(((byte*)bd.Scan0)[y*bd.Stride + 4*x + 3] != 0) {
          bounds.Height = y - bounds.Y + 1;
          goto foundHeight;
        }
  foundHeight:
    bitmap.UnlockBits(bd);
  }
  cachedTextBounds[t] = bounds;
  return bounds;
}
Seedtime answered 15/8, 2011 at 15:45 Comment(4)
Nice work! I'll have to give that a try and see if that solves my problem. Though I'm somewhat loathe to have to rely on unsafe code. How quick do you find that code? The circumstance I'm using it in checks whether the text is too big for a fixed-size bounding box and decreases the font size within a while loop (checking while too big or size gt hard-floor-limit), so I don't want to perform an expensive operation in that while loop…Shoffner
Speed depends heavily on font size. It allocates bitmap, renders it, then searches for boundaries. Smaller fonts are a lot faster, and you lose with larger fonts something like O(size^2) I think. May want to guess small size and work up to a size that's too big instead of the other way around. You could use MeasureCharacterRanges as first guess for lower bound, since it always seems to end up too small a guess. There's a lot of room for cleverness searching for the right size, binary search with clever guessing etc. I don't need any of that for my purpose, so I haven't played with it.Seedtime
As to unsafe, you can do it without that but speed takes a hit. As commented this is almost 90% faster than using GetPixel, but you can still do an in between method using LockBits without unsafe code. I didn't try that as I was ok with unsafe code and wanted to squeeze out a bit of easily obtained performance. There are lots of ways to try to squeeze out performance depending on how exactly you're using this stuff, but they're all complicated. This whole thing is kind of nasty I fully realize. I would think there would be a better way, but my search online was as unproductive as yours.Seedtime
That's really helpful, user12861; thank you. Hopefully, I'll get a chance to give this a try sometime soon.Shoffner
K
1

Ok so 4 years late but this question EXACTLY matched my symptoms and I've actually worked out the cause.

There is most certainly a bug in MeasureString AND MeasureCharacterRanges.

The simple answer is: Make sure you divide your width restriction (int width in MeasureString or the Size.Width property of the boundingRect in MeasureCharacterRanges) by 0.72. When you get your results back multiply each dimension by 0.72 to get the REAL result

int measureWidth = Convert.ToInt32((float)width/0.72);
SizeF measureSize = gfx.MeasureString(text, font, measureWidth, format);
float actualHeight = measureSize.Height * (float)0.72;

or

float measureWidth = width/0.72;
Region[] regions = gfx.MeasureCharacterRanges(text, font, new RectangleF(0,0,measureWidth, format);
float actualHeight = 0;
if(regions.Length>0)
{
    actualHeight = regions[0].GetBounds(gfx).Size.Height * (float)0.72;
}

The explanation (that I can figure out) is that something to do with the context is triggering a conversion in the Measure methods (that doesn't trigger in the DrawString method) for inch->point (*72/100). When you pass in the ACTUAL width limitation it is adjusting this value so the MEASURED width limitation is, in effect, shorter than it should be. Your text then wraps earlier than it is supposed to and so you get a longer height result than expected. Unfortunately the conversion applies to the actual height result as well so it's a good idea to 'unconvert' that value too.

Kidwell answered 16/11, 2014 at 1:6 Comment(1)
On a REALLY annoying side note, I have absolutely NO idea why MeasureString takes an int for width. If you set your graphics context unit of measurement to inches then (due to the int) you can only set your width limitation to 1 inch, or 2 inches, etc. Completely ridiculous!Kidwell
F
0

Could you try removing the following line?

fmt.FormatFlags = StringFormatFlags.NoClip;

Overhanging parts of glyphs, and unwrapped text reaching outside the formatting rectangle are allowed to show. By default all text and glyph parts reaching outside the formatting rectangle are clipped.

That's the best I can come up with for this :(

Flocky answered 29/4, 2010 at 8:22 Comment(1)
Thanks for posting an answer. I did look at this, having thought it might be the problem, but the reported height is almost double the actual height, so I don'think it can be that. It seems that the difference StringFormatFlags.NoClip makes is that if the bowl of a letter P (for example) just pokes outside the bounding box then it's allowed to render, rather than being clipped. That doesn't seem to be the problem I'm experiencing. But thanks :o)Shoffner
S
0

I also had some problems with the MeasureCharacterRanges method. It was giving me inconsistent sizes for the same string and even the same Graphics object. Then I discovered that it depends on the value of the layoutRect parametr - I can't see why, in my opinion it's a bug in the .NET code.

For example if layoutRect was completely empty (all values set to zero), I got correct values for the string "a" - the size was {Width=8.898438, Height=18.10938} using 12pt Ms Sans Serif font.

However, when I set the value of the 'X' property of the rectangle to a non-integer number (like 1.2), it gave me {Width=9, Height=19}.

So I really think there is a bug when you use a layout rectangle with non-integer X coordinate.

Sukkah answered 9/8, 2010 at 9:19 Comment(1)
Interesting — looks like it's always rounding your size up. Unfortunately, my layoutRect is always integral — it's defined as private static readonly RectangleF __textBoundingBox = new RectangleF(150, 110, 212, 64); and its value never changes (obviously, as it's marked readonly). It's definitely a bug in the .Net code, but it doesn't look like your bug and my bug are the same one.Shoffner
I
0

To convert from points to dpi as in screen resolution you need to divide by 72 and multiply by DPI, for example: graphics.DpiY * text.Width / 72

Red Nightengale was really close, because graphics.DpiY is usually 96 for screen resolutions.

Intended answered 10/11, 2018 at 22:38 Comment(1)
That's not an answer to the question as a whole, though. Could you expand this into an answer — including any of Red Nightingale's answer that you need to duplicate? Alternatively, post this as a comment on Red Nightingale's answer? Thanks!Shoffner

© 2022 - 2024 — McMap. All rights reserved.