Click here to Skip to main content
15,881,882 members
Articles / Programming Languages / Java
Tip/Trick

Drawing Text with Wrapping and Text Alignment

Rate me:
Please Sign up or sign in to vote.
4.33/5 (2 votes)
19 May 2014CPOL6 min read 28.1K   814   2   2
A simple class which provides drawing support for formatted text

Image 1

Introduction

This is a simple class implementation which provides automatic formatting for text, when rendering onto a Java Graphics handle. The Java Graphics drawString method does not natively support the ability to word-wrap text when drawing, nor does it support automatic text alignment. The purpose of the class is to fill these gaps to simplify textual graphic operations.

Implementation

In order to implement this formatting utility, several important classes were used. Some of these classes determine information about the text, such as the dimensions and offsets of the target Font, while others provide the functions necessary for measuring and rendering the text.

The alignment of the text is determined using an enumerator, which contains a single entry for each of the regions within rectangular bounds:

Java
public enum TextAlignment
{
    TOP_LEFT,
    TOP,
    TOP_RIGHT,
    MIDDLE_LEFT,
    MIDDLE,
    MIDDLE_RIGHT,
    BOTTOM_LEFT,
    BOTTOM,
    BOTTOM_RIGHT
};

The alignment enumerations are simple enough to understand. Any enumerations which are not suffixed with a 'left' or 'right' identifier are simply center aligned. These enumerations cover the sections of rectangular bounds when the bounds are divided into a 3x3 grid (similar to the .NET Framework ContentAlignment enumeration.)

Additional formatting options are available through an additional class, which contains several static fields which determine extra formatting solutions which should be applied when rendering the text.

  • TextFormat.NONEIndicates that no additional formatting should be applied.
  • TextFormat.NO_ANTI_ALIASINGIndicates that the text should be rendered without anti-aliasing.
  • TextFormat.FIRST_LINE_VISIBLEIndicates that the first line which is rendered should always be visible. This applies, particularly, to text alignments in the 'middle' or 'bottom' of the bounds.
These additional flags need not be supplied to the rendering method, as the value TextFormat.NONE will be used by default. The flags also function as bit-masks, so or'ing the flags will result in multiple formats being applied, i.e., TextFormat.NO_ANTI_ALIASING | TextFormat.FIRST_LINE_VISIBLE

Using the above classes for formatting options, the rendering method utilizes the main classes and handles the text drawing operation.

Java
public static Rectangle drawString(Graphics g, String text, 
Font font, Color color, Rectangle bounds, TextAlignment align, int format)
{

The above is the main declaration of the method, with all optional parameters supplied. Each parameter is pretty self explanatory, and documentation is supplied with the class.

Java
if (g == null)
    throw new NullPointerException("The graphics handle cannot be null.");
if (text == null)
    throw new NullPointerException("The text cannot be null.");
if (font == null)
    throw new NullPointerException("The font cannot be null.");
if (color == null)
    throw new NullPointerException("The text color cannot be null.");
if (bounds == null)
    throw new NullPointerException("The text bounds cannot be null.");
if (align == null)
    throw new NullPointerException("The text alignment cannot be null."); 

All arguments are checked to ensure that no null-values are referenced while processing the method. This is to prevent future exceptions when handling the arguments.

Java
if (text.length() == 0)
    return new Rectangle(bounds.x, bounds.y, 0, 0); 

Should the text being rendered be empty, the method does not attempt to paint the text. This helps with increasing the rendering speed, where calculating the bounds of the text would be overhead calculations for no result.

Java
Graphics2D g2D = (Graphics2D)g;

AttributedString attributedString = new AttributedString(text);
attributedString.addAttribute(TextAttribute.FOREGROUND, color);
attributedString.addAttribute(TextAttribute.FONT, font); 

The Graphics object is cast into a Graphics2D object for future use with the TextLayout.draw method, which requires such an object.

The text is also bound to an AttributeString object which is used for the LineBreakMeasurer later in the method. The AttributedString class functions as both a data storage utility (for holding the text) and for storing attribute information which can be used to alter the rendering behaviour. As such, both the foreground color and the font of the text are retained as attributes in the class.

Java
AttributedCharacterIterator attributedCharIterator = attributedString.getIterator();

FontRenderContext fontContext = new FontRenderContext(null, !TextFormat.isEnabled(format, TextFormat.NO_ANTI_ALIASING), false);
LineBreakMeasurer lineMeasurer = new LineBreakMeasurer(attributedCharIterator, fontContext); 

The code retrieves an iterator object for the AttributedString textual data, for parsing the content, and constructs a FontRenderContext which provides additional formatting options for the rendering operation. In the constructor, we read the TextFormat.NO_ANTI_ALIASING flag to enable or disable anti-aliasing on the text.

A LineBreakMeasurer class is constructed which functions as the measurer for widths of individual lines. The class constructor accepts the AttributedCharacterIterator, for parsing individual characters in the string when calculating, and the FontRenderContext for the additional formatting options when calculating the bounds (when anti-aliasing is enabled, the bounds of the text may be adjusted.)

Java
Point targetLocation = new Point(bounds.x, bounds.y);
int nextOffset = 0;

if (align.isMiddle() || align.isBottom())
{
    if (align.isMiddle())
        targetLocation.y = bounds.y + (bounds.height / 2);
    if (align.isBottom())
        targetLocation.y = bounds.y + bounds.height;

    while (lineMeasurer.getPosition() < text.length())
    {
        nextOffset = lineMeasurer.nextOffset(bounds.width);
        nextOffset = nextTextIndex(nextOffset, lineMeasurer.getPosition(), text);
        
        TextLayout textLayout = lineMeasurer.nextLayout(bounds.width, nextOffset, false);
        
        if (align.isMiddle())
            targetLocation.y -= (textLayout.getAscent() + textLayout.getLeading() + textLayout.getDescent()) / 2;
        if (align.isBottom())
            targetLocation.y -= (textLayout.getAscent() + textLayout.getLeading() + textLayout.getDescent());
    }

    if (TextFormat.isEnabled(format, TextFormat.FIRST_LINE_VISIBLE))
        targetLocation.y = Math.max(0, targetLocation.y);

    lineMeasurer.setPosition(0);
} 

The next stage calculates the initial position that the text will be rendered. This is applicable to any alignments at the 'middle' or 'bottom' of the bounds. In order to render the text in the correct position, the location must take account of the height of the bounds, and subtract according to the bounds the text will consume. For the locations, the following calculations can be used:

Middle Y = (Bounds.Height / 2) - (TextBounds.Height / 2) 
Bottom Y = (Bounds.Height - TextBounds.Height) 

If the TextFormat.FIRST_LINE_VISIBLE has been assigned as a format flag, the target Y location is updated to ensure that the text never falls below the total bounds. Therefore, if the target bounds have a height of 100, while the bounds of the text consume 120 bounds, and 'middle' or 'bottom' are the alignment factors, the position of the text would begin at Y -20. If the flag is enabled, the text will always begin at Y 0 when below the bounds.

Java
if (align.isRight() || align.isCenter())
    targetLocation.x = bounds.x + bounds.width;
        Rectangle consumedBounds = new Rectangle(targetLocation.x, targetLocation.y, 0, 0); 

The initial consumed bounds are allocated, which involves recording the starting X and Y locations. If the alignment of the text is to the right or center, then the location is set to the right-most bound, so that the left-most location can be calculated later.

Java
while (lineMeasurer.getPosition() < text.length())
{
    nextOffset = lineMeasurer.nextOffset(bounds.width);
    nextOffset = nextTextIndex(nextOffset, lineMeasurer.getPosition(), text);

    TextLayout textLayout = lineMeasurer.nextLayout(bounds.width, nextOffset, false);
    Rectangle2D textBounds = textLayout.getBounds();

    targetLocation.y += textLayout.getAscent();
    consumedBounds.width = Math.max(consumedBounds.width, (int)textBounds.getWidth());

Next, the method begins the rendering operation for the text. The same methodology as the pre-rendering calculation is used to determine the text being rendered. The width of the consumed bounds is updated if the width of the current line is wider than the previous.

Java
switch (align)
{
    case TOP_LEFT:
    case MIDDLE_LEFT:
    case BOTTOM_LEFT:
        textLayout.draw(g2D, targetLocation.x, targetLocation.y);
        break;

    case TOP:
    case MIDDLE:
    case BOTTOM:
        targetLocation.x = bounds.x + (bounds.width / 2) - (int)(textBounds.getWidth() / 2);
        consumedBounds.x = Math.min(consumedBounds.x, targetLocation.x);
        textLayout.draw(g2D, targetLocation.x, targetLocation.y);
        break;

    case TOP_RIGHT:
    case MIDDLE_RIGHT:
    case BOTTOM_RIGHT:
        targetLocation.x = bounds.x + bounds.width - (int)textBounds.getWidth();
        textLayout.draw(g2D, targetLocation.x, targetLocation.y);
        consumedBounds.x = Math.min(consumedBounds.x, targetLocation.x);
        break;
} 

Depending on the alignment of the content, the position of the text is updated accordingly. The horizontal location is calculated using the width of the current line, such that center aligned text is rendered according to half the total bounds width minus half the text bounds width, while right aligned text is simply the total bounds minus the text bounds width.

Additionally, for center and right aligned text, the left-most position of the consumed bounds is checked, to ensure that the consumed bounds begins at the lowest possible left-most position. This is due to different lines consuming different widths depending on the total content of that line.

Java
    targetLocation.y += textLayout.getLeading() + textLayout.getDescent();
} 

The vertical position of the next text block is updated, taking into account the leading height of the font, plus the descent.

Java
    consumedBounds.height = targetLocation.y - consumedBounds.y;

    return consumedBounds;
}

Finally, the total height consumed by the text is calculated based on the final vertical position minus the starting vertical position. After which, the consumed bounds are returned from the method.

Java
private static int nextTextIndex(int nextOffset, int measurerPosition, String text)
{
    for (int i = measurerPosition + 1; i < nextOffset; ++i)
    {
        if (text.charAt(i) == '\n')
            return i;
    }

    return nextOffset;
}

The purpose of the nextTextIndex method is to calculate the index, within the text line, where the text layout will render to. By default, if the text "hello world" fits within a line, then the lineMeasurer.nextOffset method will return 0 to 11. However, if the text "hello\nworld" is provided (where the \n character is a line-break), then the method would return 0 to 5, resulting in the word "hello" only being printed on a single line, and the word "world" being printed on the next.

Using the Code

Using the code is exceptionally simple. The class TextRenderer contains static methods for rendering text, which need a few standard parameters for rendering.

Java
public void paint(Graphics g)
{
    Rectangle bounds = new Rectangle(0, 0, 100, 100);
    TextRenderer.drawString(
        g,
        "Hello world",
        getFont(),
        getForeground(),
        bounds,
        TextAlignment.TOP_LEFT
    );
}

The above method would render the text in the top-left corner of the graphics handle, with maximum bounds of width 100, height 100.

For a JPanel component, to render text in the center of the component always, while ensuring that the first line of the text is always visible, the following method would suffice:

Java
public void paint(Graphics g)
{
    Rectangle bounds = new Rectangle(0, 0, getWidth(), getHeight());

    TextRenderer.drawString(
        g,
        "This is some long content which would be displayed.\n" +
        "This line here would be rendered on the next line.",
        getFont(),
        getForeground(),
        bounds,
        TextAlignment.MIDDLE,
        TextFormat.FIRST_LINE_VISIBLE
    );
}

The use of the code is fairly simple. Playing with the code will provide different results, so feel free to try different positioning.

Included in the archive attached are the three main classes responsible for the rendering. Additionally, there is a test class which was generated for testing the text renderer (see the primary image.)

License

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


Written By
Chief Technology Officer
United Kingdom United Kingdom
Software developer for 18 years, established in both Java and C# development. Ambitious, rambunctious, perhaps a little bit impracticable. Spaghetti toast.

Comments and Discussions

 
SuggestionChange targetLocation to be of type Point2D.Double Pin
Martin Gladigau13-Jan-22 1:52
Martin Gladigau13-Jan-22 1:52 
GeneralMy vote of 5 Pin
Member 88114238-Nov-19 6:56
Member 88114238-Nov-19 6:56 

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.