Monday, September 21, 2015

Accurately measure monospace font widths

I was building a custom control in C# to allow a user to identify the positions of data fields in a flat text file. So one of the things the control needs to know is where a given character position is in a line of text.

Easy peasy you may say – make the font Courier New or GenericMonospace (of course) and use the Graphics.MeasureString() function.

Here is some test code:

private void Dummy2_Load(object sender, EventArgs e) {     Font mFont = new Font(FontFamily.GenericMonospace, (float)12);     this.Font = mFont;     Graphics g = this.CreateGraphics();     label1.Text += Convert.ToString(g.MeasureString("A", mFont));     label2.Text += Convert.ToString(g.MeasureString("AA", mFont));     label3.Text += Convert.ToString(g.MeasureString("AbCdEfGhIj", mFont));     g.Dispose();     mFont.Dispose(); }

Which gives a result like:

So is a character 15.2 pixels wide or 12.5 pixels wide or 10.4 pixels wide or...?

If I grab a screen shot and count the pixels I can see ALL of the characters are drawn with a width of 10 pixels. [I am currently ignoring the supposed character height as I assume that is a notional line height (but w.t.f?).]

So if you or I want to determine the character width on an unknown device with unknown settings we have a bit of a problem.

One might wonder if the function returns a notional “overhead” for any given string. But if we subtract the width returned for the single character from the width of 10 characters and then divide by 9 – we get 9.89 pixels. So any overhead is not consistent.

The reducing “error” returned by MeasureString() as the length of the initial test strings increased might make you wonder if a very long string would give an accurate result. Sadly a string of 100 characters measures at 9.94 pixels per character which at least could “round up” to the correct value but raises new questions about just what is being measured.

Perhaps there is an optimal string length that could be applied?

A bit more code came up with a value of 48 characters for an optimal result on my development system – but I have no idea if that is fully “portable” for the full range of font sizes and screen resolutions.

N.B. Just in case you were wondering, and even though you know the code is using a monospaced font – you get identical results substituting lower case i's into any or all of the test strings.

After posting this I built a little class to measure the true font size. The class creates a bitmap and then draws the characters q and j onto the bitmap and scans the pixels to find the first and last rows of pixels that contain part of one of the characters - this gives the true height. Then the bitmap is cleared and two j's drawn next to each other. The class then measures the number of pixels between the first column containing a pixel for the first character and the first column of pixels used to draw the second. This gives the true width.

The code is more than a bit scrappy so feel free to correct/optimise. Please treat it as a prototype (which it is) and not production quality code.

class MeasureMonoFont : IDisposable {     private Graphics g;     private Bitmap bm;     private SolidBrush br;     StringFormat drawFormat;     private const int defBitmapSize = 100;     public MeasureMonoFont()     {         bm = new Bitmap(defBitmapSize, defBitmapSize);         g = Graphics.FromImage(bm);         br = new SolidBrush(Color.Black);         drawFormat = new StringFormat();     }     public SizeF GetCharSize(Font withFont)     {         SizeF cSize = new SizeF();         g.Clear(Color.White);         PointF cPoint = new PointF(0, 0);         g.DrawString("qj", withFont, br, cPoint, drawFormat);         Color tPixel;         int firstRow = -1;         int lastRow = -1;         for (var yp = 0; yp < defBitmapSize; yp++)         {             for (var xp = 0; xp < defBitmapSize; xp++)             {                 tPixel = bm.GetPixel(xp, yp);                 if (!(tPixel.ToArgb() == Color.White.ToArgb()))                 {                     if(firstRow == -1) { firstRow = yp; }                     if(yp > lastRow) { lastRow = yp; }                 }             }         }         cSize.Height = ++lastRow - firstRow;         g.Clear(Color.White);         g.DrawString("jj", withFont, br, cPoint, drawFormat);         firstRow = -1;         lastRow = -1;         bool secondChar = false;         bool firstChar = false;         bool emptyColumn = false;         for (var xp = 0; xp < defBitmapSize && lastRow == -1; xp++)         {             emptyColumn = true;             for (var yp = 0; yp < defBitmapSize && lastRow == -1; yp++)             {                 tPixel = bm.GetPixel(xp, yp);                 if (!(tPixel.ToArgb() == Color.White.ToArgb()))                 {                     if (!firstChar)                     {                         firstChar = true;                         firstRow = xp;                     }                     if (secondChar) { lastRow = xp; }                     emptyColumn = false;                 }             }             if(firstChar && emptyColumn) { secondChar = true; }         }         cSize.Width = lastRow - firstRow;         return cSize;     }     public void Dispose()     {         g.Dispose();         bm.Dispose();         br.Dispose();         drawFormat.Dispose();     } }

No comments: