Wednesday, September 8, 2010

Truncating a text string in ASP.NET to fit within a given pixel width

Sometimes when developing a web application, you have strings that must fit within a certain pixel width (usually the width of a container, like a div, p, td .. etc). This is esp. more common with links, as you sometimes need every link to fit within one single line and doesn't wrap to the next line (which will happen if the text of the link is too wide to fit within the width of the container). Or to express it visually, you might have a link that looks like this:

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis.

And want to make it look like this:

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor ...

While this should eventually be possible with CSS (using the text-overflow style), it's still not implemented in all browsers. There are some javascript solutions for now that can do the same thing across all browsers, still, I personally prefer a server-side solution as it:

  1. Saves bandwidth (which can also be good for the user experience if the text is too long and takes more time to download, esp. if the user has a slow connection).
  2. Is guaranteed to work in all browsers (including the older ones) regardless of whether they have javascript enabled, or even support it.

OK, now that you're (hopefully!) convinced, let's see how this can be done in ASP.NET. Obviously, first thing we need to do is measure the text width so that if it's longer than the specified width, we truncate it. For this, we'll need to use TextRenderer.MeasureText(), which is actually intended for Windows Forms Applications, but still can be used with ASP.NET.

I wrapped the code required for the truncation into a class, TextTruncator, which you can see its code here (also see the download link at the end of the post):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.Drawing;

namespace aspnet_TextTruncator
{
    public static class TextTruncator
    {
        // Private Properties
        //

        private static Dictionary<string, int[]> _fontWidthDic;
        private static Dictionary<string, int[]> FontWidthDic
        {
            get
            {
                if (_fontWidthDic == null)
                {
                    _fontWidthDic = new Dictionary<string, int[]>();
                }
                return _fontWidthDic;
            }
        }

        //
        // Public Methods
        //

        public static string TruncateText(string text, int textMaxWidth, string fontName, int fontSizeInPixels)
        {
            return TruncateText(text, textMaxWidth, fontName, fontSizeInPixels, false);
        }

        public static string TruncateText(string text, int textMaxWidth, string fontName, int fontSizeInPixels, bool isFontBold)
        {
            if (string.IsNullOrEmpty(text))
                return text;

            // Check
            //
            if (textMaxWidth < 1 ||
                string.IsNullOrEmpty(fontName) ||
                fontSizeInPixels < 1)
            {
                throw new ArgumentException();
            }

            int[] fontWidthArray = GetFontWidthArray(fontName, fontSizeInPixels, isFontBold);
            int ellipsisWidth = fontWidthArray['.'] * 3;
            int totalCharCount = text.Length;
            int textWidth = 0;
            int charIndex = 0;
            for (int i = 0; i < totalCharCount; i++)
            {
                textWidth += fontWidthArray[text[i]];
                if (textWidth > textMaxWidth)
                {
                    return text.Substring(0, charIndex) + "...";
                }
                else if (textWidth + ellipsisWidth <= textMaxWidth)
                {
                    charIndex = i;
                }
            }
            return text;
        }

        //
        // Private Methods
        //

        private static int[] GetFontWidthArray(string fontName, int fontSizeInPixels, bool isFontBold)
        {
            string fontEntryName = fontName.ToLower() + "_" + fontSizeInPixels.ToString() + "px" + (isFontBold ? "_bold" : "");
            int[] fontWidthArray;
            if (!FontWidthDic.TryGetValue(fontEntryName, out fontWidthArray))
            {
                fontWidthArray = CreateFontWidthArray(new Font(fontName, fontSizeInPixels, isFontBold ? FontStyle.Bold : FontStyle.Regular, GraphicsUnit.Pixel));
                FontWidthDic[fontEntryName] = fontWidthArray;
            }

            return fontWidthArray;
        }

        private static int[] CreateFontWidthArray(Font font)
        {
            int[] fontWidthArray = new int[256];
            for (int i = 32; i < 256; i++)
            {
                char c = (char)i;
                fontWidthArray[i] = IsIllegalCharacter(c, false) ? 0 : GetCharWidth(c, font);
            }
            return fontWidthArray;
        }

        private static int GetCharWidth(char c, Font font)
        {
            // Note1: For typography related reasons, TextRenderer.MeasureText() doesn't return the correct
            // width of the character in pixels, hence the need to use this hack (with the '<' & '>'
            // characters and the subtraction). Note that <' and '>' were chosen randomly, other characters 
            // can be used.
            //

            // Note2: As the TextRenderer class is intended to be used with Windows Forms Applications, it has a 
            // special use for the ampersand character (used for Mnemonics). Therefore, we need to check for the 
            // ampersand character and replace it with '&&' to escape it (TextRenderer.MeasureText() will treat 
            // it as one ampersand character)
            //

            return
                TextRenderer.MeasureText("<" + (c == '&' ? "&&" : c.ToString()) + ">", font).Width -
                TextRenderer.MeasureText("<>", font).Width;
        }

        private static bool ContainsIllegalCharacters(string text, bool excludeLineBreaks)
        {
            if (!string.IsNullOrEmpty(text))
            {
                foreach (char c in text)
                {
                    if (IsIllegalCharacter(c, excludeLineBreaks))
                        return true;
                }
            }

            return false;
        }

        private static bool IsIllegalCharacter(char c, bool excludeLineBreaks)
        {
            // See the Windows-1252 encoding (we use ISO-8859-1, but all browsers, or at least
            // IE, FF, Opera, Chrome and Safari, interpret ISO-8859-1 as Windows-1252).
            // For more information, see http://en.wikipedia.org/wiki/ISO/IEC_8859-1#ISO-8859-1_and_Windows-1252_confusion
            //

            return
                (c < 32 && (!excludeLineBreaks || c != '\n')) ||
                c > 255 ||
                c == 127 ||
                c == 129 ||
                c == 141 ||
                c == 143 ||
                c == 144 ||
                c == 157;
        }
    }
}

Code Description:

The class has only one public method, TruncateText(), which you should call to truncate the text. For performance reasons, I cache the width of the letters so that I don't need to call TextRenderer.MeasureText() again. This was done for good reasons as TextRenderer.MeasureText() can be really slow (in my testing, this change cut the truncation time for a million strings from 8 minutes to 2 seconds!).

Because I chose to cache the width of the letters, I had to limit the character set to ISO-8859-1 (for Latin based languages) which works well for my own purposes. If you need this code to work with Unicode, you'll need to remove the letter width caching mechanism and call TextRenderer.MeasureText() every time you want to measure the text width (which will slow things down a bit, but shouldn't be noticeable unless you plan to use this on a very high traffic website and on not-fast-enough machines, you'll have to do your own testing to be sure). You could also change the letter width caching mechanism to use a dictionary instead of an array and only add the letters to the dictionary when they are actually used.

You may notice that in TruncateText(), instead of passing a font object, I pass the font name, size in pixels and whether it's bold. I do this for convenience reasons, but this should be fairly easy to change if you want to pass a Font object (probably, to also use other font styles like italic, but you'll also need to change the caching mechanism of the letter width).

Note: You'll need to add references to the System.Windows.Forms and System.Drawing assemblies in your project in order for this code to work.

Example:

In your .aspx page
<%= TextTruncator.TruncateText("Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut", 300, "Verdana", 12) %>
Of course in real use, the text will most likely come from your database, an xml file or another source.


A Note About Web Browsers

In many discussions on the web, you'll read that you shouldn't depend on the text width in pixels as there's no guarantee that different browsers will display the text in the same way (i.e. the width of the text may differ from one browser to another). While it's true that there's no guarantee, in my real life testing in all popular browsers (and less popular ones), I found that in all browsers the width of the text as displayed in the browser never exceeded the maximum width I specified for the text in TruncateText(), sometimes it was slightly smaller (by only a few pixels - that was in the older versions of Safari on Windows), but again it was never larger. If you are paranoid, like me, always use a 'safety margin'. For example, if the width of the container of the text (div, p, td or whatever element) is 500px, make the maximum width (that's passed to TruncateText()) less by 10px or 20px, that's 490 or 480.


Download code and demo project

No comments:

Post a Comment