Wednesday, October 30, 2013

Rotating text with GDI Graphics within given bounds

An unlikely blog post from me, about graphics; and not any kind of graphics, but GDI graphics. It involves something that may seem simple at first: rotating a text in a rectangle container so that it is readable when you turn the page to the left. It is useful to write text in containers that have a height that is bigger than the width. This is not about writing vertically, that's another issue entirely.
So, the bird's eye view of the problem: I had to create a PDF that contains some generated images, a sort of chart with many colored rectangles that contain text. The issue is that some of them are a lot higher than they are wide, which means it is better to write text that is rotated, in this case to -90 degrees, or 270 degrees, if you like it more. To the left, as Beyoncé would put it.

I created the image, using the Bitmap class, then got a Graphics instance from it, then starting drawing things up. It's trivial to draw a line, fill a rectangle, or draw an arc. Just as easy it is to write some text, using the DrawString method of the Graphics object. I half expected there to be a parameter that would allow me to write rotated, but there wasn't. How hard could it be?

Let's start with the basics. You want to draw a colored rectangle and write some text into it. This is achieved by:
var rectangle=new Rectangle(x,y,width,height); // reuse the dimensions
g.FillRectangle(new SolidBrush(Color.Blue),rectangle); // fill the rectangle with the blue color
g.DrawRectangle(new Pen(Color.Black),rectangle); // draw a black border
g.DrawString("This is my text",new Font("Verdana",12,GraphicsUnit.Pixel),new SolidBrush(Color.Black),rectangle, new StringFormat {
    LineAlignment=StringAlignment.Center,
    Alignment=StringAlignment.Center,
    Trimming = StringTrimming.None
}); // this draws a string in the middle of the rectangle, wrapping it up as needed

All very neat. However, you might already notice some problems. One of them is that there is no way to "overflow" the container. If you worked with HTML you know what I mean. If you use the method that uses a rectangle as a parameter, then the resulting text will NOT go over the edges of that rectangle. This is usually a good thing, but not all the time. Another issue that might have jumped in your eyes is that there is no way to control the way the text is wrapped. In fact, it will wrap the text in the middle of words or clip the text in order to keep the text inside the container. If you don't use the container function, there is no wrapping around. In other words, if you want custom wrapping you're going to have to go another way.
Enter TextRenderer, a class that is part of the Windows.Forms library. If you decide that linking to that library is acceptable, even if you are using this in a web or console application, you will see that the parameters given to the TextRenderer.DrawText method contain information about wrapping. I did that in my web application and indeed it worked. However, besides drawing the text really thick and ugly, you will see that it completely ignores text rotation, even if it has a specific option to not ignore translation tranforms (PreserveGraphicsTranslateTransform).

But let's not get into that at this moment. Let's assume we like the DrawString wrapping of text or we don't need it. What do we need to do in order to write at a 270 degrees angle? Basically you need to use two transformations, one translates and one rotates. I know it sounds like a bad cop joke, but it's not that complicated. The difficulty comes in understanding what to rotate and how.
Let's try the naive implementation, what everyone probably tried before going to almighty Google to find how it's really done:
// assume we already defined the rectangle and drew it
g.RotateTransform(-270);
g.DrawString("This is my text",new Font("Verdana",12,GraphicsUnit.Pixel),new SolidBrush(Color.Black),rectangle, new StringFormat {
    LineAlignment=StringAlignment.Center,
    Alignment=StringAlignment.Center,
    Trimming = StringTrimming.None
}); // and cross fingers
g.ResetTranformation();
Of course it doesn't work. For once, the rotation transformation applies to the Graphics object and, in theory, the primitive drawing the text doesn't know what to rotate. Besides, how do you rotate it? On a corner, on the center, the center of the text or the container?
The trick with the rotation transformation is that it rotates on the origin, always. Therefore we need the translate transformation to help us out. Here is where it gets tricky.

g.TranslateTransform(rectangle.X+rectangle.Width/2,rectangle.Y+rectangle.Height/2); // we define the center of rotation in the center of the container rectangle
g.RotateTransform(-270); // this doesn't change
var newRectangle=new Rectangle(-rectangle.Height/2,-rectangle.Width/2,rectangle.Height,rectangle.Width);  // notice that width is switched with height
g.DrawString("This is my text",new Font("Verdana",12,GraphicsUnit.Pixel),new SolidBrush(Color.Black),newRectangle, new StringFormat {
    LineAlignment=StringAlignment.Center,
    Alignment=StringAlignment.Center,
    Trimming = StringTrimming.None
});
g.ResetTranformation();
So what's the deal? First of all we changed the origin of the entire graphics object and that means we have to draw anything relative to it. So if we would not have rotated the text, the new rectangle would have had the same width and height, but the origin in 0,0.
But we want to rotate it, and therefore we need to think of the original bounding rectangle relative to the new origin and rotated 270 degrees. That's what newRectangle is, a rotated original rectangle in which to limit the drawn string.

So this works, but how do you determine if the text needs to be rotated and its size?
Here we have to use MeasureString, but it's not easy. It basically does the same thing as DrawString, only it returns a size rather than drawing things. This means you cannot measure the actual text size, you will always get either the size of the text or the size of the container rectangle, if the text is bigger. I created a method that attempts to get the maximum font size for normal text and rotated text and then returns it. I do that by using a slightly larger bounding rectangle and then going a size down when I find the result. But it wasn't nice.

We have a real problem in the way Graphics wraps the text. A simple, but incomplete solution is to use TextRenderer to measure and Graphics.DrawString to draw. But it's not exactly what we need. The complete solution would determine its own wrapping, work with multiple strings and draw (and rotate) them individually. One interesting question is what happens if we try to draw a string containing new lines. And the answer is that it does render text line by line. We can use this to create our own wrapping and not work with individual strings.

So here is the final solution, a helper class that adds a new DrawString method to Graphics that takes the string, the font name, the text color and the bounding rectangle and writes the text as large as possible, with the orientation most befitting.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace GraphicsTextRotation
{
    public static class GraphicsExtensions
    {
        public static void DrawString(this Graphics g, string text, string fontName, Rectangle rect, Color textColor, int minTextSize=1)
        {
            var textInfo = getTextInfo(g, text, fontName, rect.Width, rect.Height); // get the largest possible font size and the necessary rotation and text wrapping
            if (textInfo.Size < minTextSize) return;
            g.TranslateTransform(rect.X + rect.Width / 2, rect.Y + rect.Height / 2); // translate for any rotation
            Rectangle newRect;
            if (textInfo.Rotation != 0) // a bit hackish, because we know the rotation is either 0 or -90
            {
                g.RotateTransform(textInfo.Rotation);
                newRect = new Rectangle(-rect.Height / 2, -rect.Width / 2, rect.Height, rect.Width); //switch height with width
            }
            else
            {
                newRect = new Rectangle(-rect.Width / 2, -rect.Height / 2, rect.Width, rect.Height);
            }
            g.DrawString(textInfo.Text, new Font(fontName, textInfo.Size, GraphicsUnit.Pixel), new SolidBrush(textColor), newRect, new StringFormat
            {
                Alignment = StringAlignment.Center,
                LineAlignment = StringAlignment.Center,
                Trimming = StringTrimming.None
            });
            g.ResetTransform();
        }

        private static TextInfo getTextInfo(Graphics g, string text, string fontName, int width, int height)
        {
            var arr = getStringWraps(text); // get all the symmetrical ways to split this string
            var result = new TextInfo();
            foreach (string s in arr) //for each of them find the largest size that fits in the provided dimensions
            {
                var nsize = 0;
                Font font;
                SizeF size;
                do
                {
                    nsize++;
                    font = new Font(fontName, nsize, GraphicsUnit.Pixel);
                    size = g.MeasureString(s, font);
                } while (size.Width <= width && size.Height <= height);
                nsize--;
                var rsize = 0;
                do
                {
                    rsize++;
                    font = new Font(fontName, rsize, GraphicsUnit.Pixel);
                    size = g.MeasureString(text, font);
                } while (size.Width <= height && size.Height <= width);
                rsize--;
                if (nsize > result.Size)
                {
                    result.Size = nsize;
                    result.Rotation = 0;
                    result.Text = s;
                }
                if (rsize > result.Size)
                {
                    result.Size = rsize;
                    result.Rotation = -90;
                    result.Text = s;
                }
            }
            return result;
        }

        private static List<string> getStringWraps(string text)
        {
            var result = new List<string>();
            result.Add(text); // add the original text
            var indexes = new List<int>();
            var match = Regex.Match(text, @"\b"); // find all word breaks
            while (match.Success)
            {
                indexes.Add(match.Index);
                match = match.NextMatch();
            }
            for (var i = 1; i < indexes.Count; i++)
            {
                var pos = 0;
                string segment;
                var list = new List<string>();
                for (var n = 1; n <= i; n++) // for all possible splits 1 to indexes.Count+1
                {
                    var limit = text.Length / (i + 1) * n;
                    var index = closest(indexes, limit); // find the most symmetrical split
                    segment = index <= pos
                                ? ""
                                : text.Substring(pos, index - pos);
                    if (!string.IsNullOrWhiteSpace(segment))
                    {
                        list.Add(segment);
                    }
                    pos = index;
                }
                segment = text.Substring(pos);
                if (!string.IsNullOrWhiteSpace(segment))
                {
                    list.Add(segment);
                }
                result.Add(string.Join("\r\n", list)); // add the split by new lines to the list of possibilities
            }
            return result;
        }

        private static int closest(List<int> indexes, int limit)
        {
            return indexes.OrderBy(i => Math.Abs(limit - i)).First();
        }

        private class TextInfo
        {
            public int Rotation { get; set; }
            public float Size { get; set; }
            public string Text { get; set; }
        }
    }
}

I hope you like it.

2 comments:

Andrei Rinea said...

Why no WPF love? :P

Siderite said...

Actually, it was a web application that needed to generate a PDF containing images. Didn't think to use WPF to generate images, but then again, it's just as annoying as having to reference Windows.Forms.