Search code examples
c#winformsfontstextrenderer

C# Textrenderer - Measuring smaller fontsize results in larger size


I am trying to measure the size of a string given a certain font using the TextRenderer class. Despite the fact that i tried measuring it with 3 different approaches (Graphics.MeasureCharacterRanges, Graphics.MeasureString, TextRenderer.MeasureText) and they all give me different results without being accurate, i've stumbled across something else.
Measuring the same string START with the same font using a fontsize of 7 and 8, the fontsize 7 measurement turns out to be wider than the the fontsize 8 measurement.

Here's the code i use:

Font f1 = new Font("Arial", 7, FontStyle.Regular);
Font f2 = new Font("Arial", 8, FontStyle.Regular);
Size s1 = TextRenderer.MeasureText("START", f1);
Size s2 = TextRenderer.MeasureText("START", f2);

The result is s1 having a width of 41 and a height of 13 while s2 having a width of 40 and a height of 14.

Why would a smaller font result in a larger width?


Solution

  • To address specifically why it would be possible for a larger font to produce a smaller width, I put together this sample console app. It's worth noting that I adjust the 7 & 8 font sizes to 7.5 & 8.25, respectively, as this is what size TextRenderer evaluates them as internally.

    using System;
    using System.Drawing;
    using System.Linq;
    using System.Runtime.InteropServices;
    
    namespace FontSizeDifference
    {
        static class Program
        {
            [StructLayout(LayoutKind.Sequential)]
            struct ABCFLOAT
            {
                public float abcfA;
                public float abcfB;
                public float abcfC;
            }
    
            [DllImport("gdi32.dll")]
            static extern bool GetCharABCWidthsFloat(IntPtr hdc, int iFirstChar, int iLastChar, [Out] ABCFLOAT[] lpABCF);
    
            [DllImport("gdi32.dll", CharSet = CharSet.Auto, EntryPoint = "SelectObject", SetLastError = true)]
            static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
    
            [DllImport("gdi32.dll", EntryPoint = "DeleteObject")]
            static extern bool DeleteObject([In] IntPtr hObject);
    
            [StructLayout(LayoutKind.Sequential)]
            struct KERNINGPAIR
            {
                public ushort wFirst;
                public ushort wSecond;
                public int iKernAmount;
            }
    
            [DllImport("gdi32.dll")]
            static extern int GetKerningPairs(IntPtr hdc, int nNumPairs, [Out] KERNINGPAIR[] lpkrnpair);
    
            [STAThread]
            static void Main()
            {
                var fonts = new[] {
                    new Font("Arial", 7.5f, FontStyle.Regular),
                    new Font("Arial", 8.25f, FontStyle.Regular)
                };
                string textToMeasure = "START";
    
                using (Graphics g = Graphics.FromHwnd(IntPtr.Zero))
                {
                    IntPtr hDC = g.GetHdc();
    
                    foreach (Font font in fonts)
                    {
                        float totalWidth = 0F;
                        IntPtr hFont = font.ToHfont();
    
                        // Apply the font to dc
                        SelectObject(hDC, hFont);
    
                        int pairCount = GetKerningPairs(hDC, short.MaxValue, null);
                        var lpkrnpair = new KERNINGPAIR[pairCount];
                        GetKerningPairs(hDC, pairCount, lpkrnpair);
    
                        Console.WriteLine("\r\n" + font.ToString());
    
                        for (int ubound = textToMeasure.Length - 1, i = 0; i <= ubound; ++i)
                        {
                            char c = textToMeasure[i];
                            ABCFLOAT characterWidths = GetCharacterWidths(hDC, c);
                            float charWidth = (characterWidths.abcfA + characterWidths.abcfB + characterWidths.abcfC);
                            totalWidth += charWidth;
    
                            int kerning = 0;
                            if (i < ubound)
                            {
                                kerning = GetKerningBetweenCharacters(lpkrnpair, c, textToMeasure[i + 1]).iKernAmount;
                                totalWidth += kerning;
                            }
    
                            Console.WriteLine(c + ": " + (charWidth + kerning) + " (" + charWidth + " + " + kerning + ")");
                        }
    
                        Console.WriteLine("Total width: " + totalWidth);
    
                        DeleteObject(hFont);
                    }
    
                    g.ReleaseHdc(hDC);
                }
            }
    
            static KERNINGPAIR GetKerningBetweenCharacters(KERNINGPAIR[] lpkrnpair, char first, char second)
            {
                return lpkrnpair.Where(x => (x.wFirst == first) && (x.wSecond == second)).FirstOrDefault();
            }
    
            static ABCFLOAT GetCharacterWidths(IntPtr hDC, char character)
            {
                ABCFLOAT[] values = new ABCFLOAT[1];
                GetCharABCWidthsFloat(hDC, character, character, values);
                return values[0];
            }
        }
    }
    

    For each font size, it outputs the width of each character, including kerning. At 96 DPI, for me, this results in:

    [Font: Name=Arial, Size=7.5, Units=3, GdiCharSet=1, GdiVerticalFont=False]
    S: 7 (7 + 0)
    T: 6 (7 + -1)
    A: 7 (7 + 0)
    R: 7 (7 + 0)
    T: 7 (7 + 0)
    Total width: 34

    [Font: Name=Arial, Size=8.25, Units=3, GdiCharSet=1, GdiVerticalFont=False]
    S: 7 (7 + 0)
    T: 5 (6 + -1)
    A: 8 (8 + 0)
    R: 7 (7 + 0)
    T: 6 (6 + 0)
    Total width: 33

    Though I've obviously not captured the exact formula for measurements made by TextRenderer, it does illustrate the same width-discrepancy. At font size 7, all characters are 7 in width. However, at font size 8, the character widths begin to vary, some larger, some smaller, ultimately adding up to a smaller width.