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:
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.NONE
Indicates that no additional formatting should be applied.TextFormat.NO_ANTI_ALIASING
Indicates that the text should be rendered without anti-aliasing.TextFormat.FIRST_LINE_VISIBLE
Indicates 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.
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.
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.
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.
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.
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.)
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.
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.
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.
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.
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.
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.
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.
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:
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.)