Click here to Skip to main content
15,890,825 members
Articles / Web Development / ASP.NET

Rendering headings in non-standard fonts with ASP.NET

Rate me:
Please Sign up or sign in to vote.
4.33/5 (4 votes)
3 Dec 2009CPOL6 min read 20.5K   92   11   2
A user control to render web-page headings in fonts not usually supported on the web.

Image 1

Introduction

Ever felt frustrated by the range of fonts available in HTML/CSS? Though it's a limitation we've all adapted to, there are occasions when it would be nice to have a big header rendered in a font that sits nicely with your design or the font used in a logo. I've seen it done with Flash, but that's like killing a fly with a bazooka, and has the problem of being restricted to Flash-enabled browsers (what about iPhone users, for example?). Then, there's the option of creating the heading in Photoshop and inserting the image, but that is time-consuming, and impossible for a content-managed site.

That was the problem I confronted while working on the site you see a snippet of above. I wanted the text of the header to match that used in the logo and other images, and I needed it to work in with the Content Management System I'd devised to run the site, so the page headers would display in the correct font without the web administrator even being aware that anything unusual was going on. What I wanted, in fact, was a user control where you just plug in some parameters like the text to display, font, size, color etc., and the text gets rendered to the page as an image. That's exactly what this code does.

How it works

Ever wondered how a captcha image works? How do you get some arbitrary piece of text and display it as an image? The basic idea (in .NET) is to use GDI to render the text as a GIF and then embed an <img> tag pointing to it. If you know a little GDI, that's not so hard. But, this particular problem has some unique challenges:

  • A captcha image which gets rendered once on a few forms is one thing, but what about server load if you're rendering headings on every page in your site?
  • What about long strings? We need the control to be able to wrap text.
  • What about the search engine implications of replacing text with images? Headings provide vital information about a web-page that might get lost if we substitute an image.
  • GDI font rendering does not natively support control over kerning and spacing. I needed to be able to control these font display elements in order to get my Rockwell headers to match the Rockwell font used elsewhere in the design.

How did I solve these problems? Firstly, I managed the server load issue by ensuring that each header only gets rendered once. When the user control receives a request, it generates a unique file name based on the parameters passed to it. Every time it receives a request, it checks if a file by this name already exists before it renders the text, thus making sure that it only does the rendering work once. (In retrospect, I think this feature may be overkill. The likely load of using GDI is probably very small, even in a high-traffic scenario, but it does at least solve the issue of how we handle getting our images to the page. If we'd opted to return the image directly in the way captcha type programs work, it would mean passing our parameters through another implementation layer.)

Line wrapping was the biggest complication, especially since my need to control kerning meant that I had to render each character individually. Most of the code complications arise from having to measure the size required to render each string and then wrap at the appropriate point.

To solve the search engine problem, in addition to displaying the image of the text, the code outputs the header text in standard HTML tags (you provide the type of tag as a parameter), with a CSS class that you can set up in your style sheet to display:none. Thus, bots get to see the text, but your visitors won't.

Using the code

Using the user control could hardly be easier. Just add the control to your web project and reference it as per the example page (Example.aspx). The parameters you can use are as follows:

  • Text: The text of the header you want to render.
  • FontName: The name of the font you want to use. Obviously, it has to be installed on the server, and it has to be supported by GDI (i.e., no OpenType, sadly).
  • FontStyle: A FontStyle value (bold, italic etc.).
  • TextColor: A color value in the format "#rrggbb". Note, some valid CSS codes will fail: the string must have six digits.
  • BgColor: The background color.
  • MaxWidth: The maximum width in pixels allowed for the text before it wraps.
  • CssClass: A CSS class to attach to the image tag in the HTML - this allows you to set parameters like margins.
  • LineHeightAdjustment: A pixel value (float) to add to the default space between lines. Negative values are possible.
  • CharWidthAdjustment: A pixel value (float) to add to the default space between characters (kerning).
  • SpaceAdjustment: A pixel value (float) to add to the default space between words.
  • OutputHeaderTags: Set to true if you want to output the text in HTML tags as well as as an image.
  • TagType: The type of tag you want to wrap your text in (h1, h2 etc.). Don't add the angled braces.
  • TextCssClass: The CSS class to add to the header tag. This should be set to display:none in your style sheet.

Gotchas

There are only a couple of traps. Firstly, you need to define a class in your stylesheet with display:none and set that as the CssClass parameter when you use the user control. Yes, it could simply have output "style="display:none"", but I figured this might annoy bots on the lookout for dodgy practices. I don't know if my method is actually any better though... A little JavaScript doovery to hide the text might be the best bet if you're really concerned about it.

The other thing is I have used an AppSetting called "ImagePath" to set the path to where the rendered images should be saved. So, you'll need to create that, or else modify the code appropriately.

The code

C#
using System;
using System.Collections;
using System.Configuration;
using System.Data;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.HtmlControls;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Text;
using System.Text.RegularExpressions;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Drawing.Text;
using System.Collections.Generic;
using System.IO;

public partial class RenderHeader : System.Web.UI.UserControl
{
    private string _text = "";
    private int _spaceAdjustment = 5;

    /// <summary>
    /// Pixel width to add to each space character. Can be a fractional value 
    /// </summary>
    public int SpaceAdjustment
    {
        get { return _spaceAdjustment; }
        set { _spaceAdjustment = value; }
    }

    private int _lineHeightAdjustment = 0;

    /// <summary>
    /// Pixel height to add to each line. Can be a fractional value 
    /// </summary>
    public int LineHeightAdjustment
    {
        get { return _lineHeightAdjustment; }
        set { _lineHeightAdjustment = value; }
    }

    private float _charWidthAdjustment = 0;

    /// <summary>
    /// Pixel width to add to each character. Can be a fractional value 
    /// </summary>
    public float CharWidthAdjustment
    {
        get { return _charWidthAdjustment; }
        set { _charWidthAdjustment = value; }
    }

    /// <summary>
    /// Text to render
    /// </summary>
    public string Text
    {
        get { return _text; }
        set { _text = value; }
    }

    private string _fontName = "Arial";

    public string FontName
    {
        get { return _fontName; }
        set { _fontName = value; }
    }

    private FontStyle _fontStyle = FontStyle.Regular;

    public FontStyle FontStyle
    {
        get { return _fontStyle; }
        set { _fontStyle = value; }
    }

    private float _fontSize = 12.0F;

    public float FontSize
    {
        get { return _fontSize; }
        set { _fontSize = value; }
    }
    private string _textColor = "#000000";

    public string TextColor
    {
        get { return _textColor; }
        set { _textColor = value; }
    }
    private string _bgColor = "#ffffff";

    /// <summary>
    /// Background color
    /// </summary>
    public string BgColor
    {
        get { return _bgColor; }
        set { _bgColor = value; }
    }

    private int _maxWidth = 3000; //set an arbitrary maximum width as a default

    public int MaxWidth
    {
        get { return _maxWidth; }
        set { _maxWidth = value; }
    }

    private string _cssclass = "";

    /// <summary>
    /// CSS class to add to image tag
    /// </summary>
    public string CssClass
    {
        get { return _cssclass; }
        set { _cssclass = value; }
    }

    private bool _outputHeaderTags = true;

    /// <summary>
    /// Set to true in order to output the Text field
    /// in html tags as well as as an image
    /// This is for search engine purposes
    /// (set TextCssClass to some class in your stylesheet with display:none)
    /// </summary>
    public bool OutputHeaderTags
    {
        get { return _outputHeaderTags; }
        set { _outputHeaderTags = value; }
    }

    private string _tagType ="h1";     

    /// <summary>
    /// The type of tag (h1,h2 etc) to output
    /// if OutputHeaderTags=true. Do not include angled braces
    /// Default is h1.
    /// </summary>
    public string TagType
    {
      get { return _tagType; }
      set { _tagType = value; }
    }

    /// <summary>
    /// A class defined in your stylesheet set to display:none
    /// </summary>
    private string _textCssClass = "";

    public string TextCssClass
    {
        get { return _textCssClass; }
        set { _textCssClass = value; }
    }

    private string _fileName
    {
        get
        {
            //Make a unique name from the parameters passed in to render the header.
            //Later, if we're asked to render the same thing, we won't need to do it again,
            //we'll just return a link to the unique file name
            string s = _text + _fontSize + _fontName + _bgColor.Substring(1) + 
                _textColor.Substring(1) + _charWidthAdjustment.ToString() + 
                _fontStyle.ToString() + _maxWidth.ToString() + 
                _lineHeightAdjustment.ToString() + _spaceAdjustment.ToString() + ".gif";
            //need to change any problematic characters into acceptable underscores
            //so we don't get any errors when we try to save...
            s = MakeValidFileName(s);
            return s;
        }
    }
    /// <summary>
    /// the path to save rendered text images in.
    /// Uses an AppSetting to avoid hard coding the location.
    /// Create the AppSetting "ImagePath" in your
    /// web.config and set it to the appropriate location 
    /// </summary>
    private string _filePath
    {
        get
        {
            return Server.MapPath(
              ConfigurationManager.AppSettings["ImagePath"]) + @"\" + _fileName;
        }
    }
    public RenderHeader()
    {
        
    }

    protected void Page_Load(object sender, EventArgs e)
    {
        //check if we already rendered this image. If not, generate it...
        if (!File.Exists(_filePath))
        {
            GenerateImage();
        }
        //create a new image tag and add it to the page
        System.Web.UI.WebControls.Image HeaderImage = new System.Web.UI.WebControls.Image();
        HeaderImage.ImageUrl=
          ConfigurationManager.AppSettings["ImagePath"] + "/" + _fileName;
        HeaderImage.AlternateText = _text;
        HeaderImage.CssClass = _cssclass;
        this.Controls.Add((Control)HeaderImage);

        //if we've been asked to render the text as an HTML tag
        //as well, create the tag and add it to the page
        if (_outputHeaderTags)
        {
            System.Web.UI.LiteralControl header = new LiteralControl();
            header.Text = "<" + _tagType + " class='" + 
              _textCssClass + "'>" + _text + 
              "</" + _tagType + ">";
            this.Controls.Add(header);
        }
    }

    private void GenerateImage()
    {
        //in order to use our graphics object, we have to create a token 1 pixel bitmap.
        //Later, when we know how large the bitmap is that we're going to need for the image
        //we'll create a new bitmap at the correct size
        Bitmap bitmap = new Bitmap(1, 1, PixelFormat.Format32bppArgb);
        Graphics g = Graphics.FromImage(bitmap);
        StringFormat format = StringFormat.GenericTypographic;
        // Set up the text font.
        Font font = new Font(_fontName, _fontSize, _fontStyle);

        //Now we need to work out how large our bitmap is going to have to be before we
        //actually render anything. This is the complicated bit. 
        //We have to do everything in a two-part process. First measure 
        //the size required, then render the text into the bitmap line by line.
        //The bitmap can't be wider than the maxwidth, 
        //so we may have to wrap over multiple lines. First we'll measure the height 
        //of a single line without the line height adjustment.
        SizeF size = g.MeasureString(_text, font);
        float lineHeight = size.Height;

        //'lines' will be the string broken up into wrapped lines
        List<string> lines = new List<string>();
        // the widest line will be the one we use to set the bitmap width
        float widestLine = 0F; 

        //first split the text into words. We can't break over a word boundary
        string[] words = _text.Split(' ');
        StringBuilder sbLine = new StringBuilder();//this is the 
        int numSpaces = 0; //we need to count the spaces to add in the extra pixels 
        //added to each space character (SpaceAdjustment)

        //Iterate through the words, each time measuring if it fits this line, 
        //or of we have to wrap and start a new line 
        float lineWidth = 0F;
        for(int i=0; i<words.Length; i++)
        {
            string w = words[i];
            //this is the current line before we test whether the next word fits.
            string line = sbLine.ToString();
            if (line.Length > 0)
            //if not the first word in the line add a space before the word
            {
                sbLine.Append(" ");
                numSpaces += 1;
            }
            sbLine.Append(w);
            string testLine = sbLine.ToString();

            //we have to measure each character individually 
            //and then see if we've reached the end of the line (maxwidth)
            //lineWidth is increased cumulatively for each character in the string...
            for (int i2 = line.Length; i2 < testLine.Length; i2++)
            {
                SizeF sf = g.MeasureString(testLine[i2].ToString(), 
                           font, new PointF(lineWidth, 0F), format);
                if (testLine[i2] == ' ')
                {
                    lineWidth += (float)(sf.Width) + _spaceAdjustment;
                }
                else
                {
                    lineWidth += (float)(sf.Width + _charWidthAdjustment);
                }
            }
            
            //... then if we hit the end of the line we wrap to the next...
            if (lineWidth > (float)_maxWidth)
            {
                lines.Add(line);
                size.Height += lineHeight +_lineHeightAdjustment;
                //reset our variables for the next line
                lineWidth = 0;
                sbLine.Length = 0;
                numSpaces = 0;
                sbLine.Append(w);
                //we need to add the word that didn't fit to the next line
            }
            else
            {
                //widestLine tracks the widest line so far, 
                //which will end up being our bitmap width
                if (lineWidth > widestLine)
                {
                    widestLine = lineWidth;
                }
            }
            // if we're at the end of the text,
            // add the line to the list of lines to be rendered
            if (i == words.Length-1) 
            {
                lines.Add(sbLine.ToString());
            }
        }

        //allow room for some anti-aliasing blur...
        int width = (int)widestLine +2;
        int height= (int)size.Height +1;
        bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
        g = Graphics.FromImage(bitmap); 
        g.SmoothingMode = SmoothingMode.HighQuality;
        g.TextRenderingHint= TextRenderingHint.ClearTypeGridFit;

        //fill the bitmap with the background color
        SolidBrush sb = new SolidBrush(ColorTranslator.FromHtml(BgColor));
        Rectangle rect = new Rectangle(0, 0, width, height);
        g.FillRectangle(sb, rect);

        //write the text to the bitmap
        sb = new SolidBrush(ColorTranslator.FromHtml(TextColor));
        float ypos = 0F;
        foreach (string l in lines)
        {
            float xpos = 0F;
            for (int i = 0; i < l.Length; i++)
            {
                g.DrawString(l.Substring(i, 1), font, sb, 
                             new PointF(xpos, ypos),format); 
                //all this individual measuring is necessary 
                //because we want the facility to adjust kerning and
                //space width in our font. This means we have
                //to render one character at a time.
                SizeF sf = g.MeasureString(l.Substring(i, 1), 
                           font, new PointF(xpos, ypos), format);
                if (l.Substring(i,1) == " ")
                {
                   xpos += (float)(sf.Width) + _spaceAdjustment;
                }
                else
                {
                   xpos += (float)(sf.Width + _charWidthAdjustment);
                }
             }
            ypos += (lineHeight + _lineHeightAdjustment);
        }
        //save the bitmap as a gif and dispose of our resources cleanly...
        bitmap.Save(_filePath, ImageFormat.Gif);
        bitmap.Dispose();
        font.Dispose();
        g.Dispose();
        sb.Dispose();
    }

    private static string MakeValidFileName(string name)
    {
        string invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars()));
        string invalidReStr = string.Format(@"[{0}]", invalidChars);
        return Regex.Replace(name, invalidReStr, "_");
    }
}

Comments and reflections

I've rather thoroughly commented this code in the hope you'll follow the fairly elaborate logic. Everything would be a lot simpler if we didn't need the kerning adjustment. The process of writing the code was an interesting insight for me into the slightly lower-level ins and outs of text rendering. After all, precisely these same types of algorithms must be employed all the time in browsers, word processors - in fact, any app that has to display and wrap text.

There's a fair bit of scope to add more features to this control. You could add fancy stuff like drop shadows if you felt like getting your hands dirty with GDI. I nearly did, but then I remembered I was supposed to be working... :) Another nice addition would be support for centered text.

You could potentially also render larger slabs of text with this control, but I wouldn't recommend it. Users can't select text in images, and of course, it's impossible to resize the window and have the text readjust - once it's rendered, it's rendered.

Well, that's about it! I hope you enjoy using this little gizmo in your web apps to add some pretty headings to your web pages. To see it in action, take a look at www.menslineaus.org.au.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Australia Australia
Pierz works as web development manager for a not-for-profit organization in Melbourne, Australia. In his spare time, he writes fiction.

Comments and Discussions

 
Question[My vote of 2] Is this the best solution? Pin
Neetflash4-Dec-09 1:49
Neetflash4-Dec-09 1:49 
AnswerRe: [My vote of 2] Is this the best solution? Pin
Pierz Newton-John4-Dec-09 11:51
Pierz Newton-John4-Dec-09 11:51 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.