Java - How to visually center a specific string (not just a font) in a rectangle
Asked Answered
L

2

16

I am trying to visually center an arbitrary user-supplied string on a JPanel. I have read dozens of other similar questions and answers here on SO but haven't found any that directly address the problem I am having.

In the code sample below, getWidth() and getHeight() refer to the width and height of the JPanel on which I'm placing the text string. I have found that TextLayout.getBounds() does a very good job of telling me the size of a bounding rectangle that encloses the text. So, I figured that it would be relatively simple to center the text rectangle in the JPanel rectangle by calculating the x and y positions on the JPanel of the lower left corner of the text-bounding rectangle:

FontRenderContext context = g2d.getFontRenderContext();
messageTextFont = new Font("Arial", Font.BOLD, fontSize);
TextLayout txt = new TextLayout(messageText, messageTextFont, context);
Rectangle2D bounds = txt.getBounds();
xString = (int)((getWidth() - (int)bounds.getWidth()) / 2 );
yString = (int)((getHeight()/2) + (int)(bounds.getHeight()/2));

g2d.setFont(messageTextFont);
g2d.setColor(rxColor);
g2d.drawString(messageText, xString, yString);

This worked perfectly for strings which were all uppercase. However, when I started testing with strings that contained lowercase letters with descenders (like g, p, y), the text was no longer centered. The descenders on the lower case letters (the parts that extend below the baseline of the font) were being drawn too low on the JPanel to have the text appear to be centered.

That's when I discovered (thanks to SO) that the y parameter passed to drawString() specifies the baseline of the drawn text, not the lower bound. Thus, again with the help of SO, I realized that I needed to adjust the placement of the text by the length of the descenders in my string:

....
    TextLayout txt = new TextLayout(messageText, messageTextFont, context);
    Rectangle2D bounds = txt.getBounds();
    int descent = (int)txt.getDescent();
    xString = (int)((getWidth() - (int)bounds.getWidth()) / 2 );
    yString = (int)((getHeight()/2) + (int)(bounds.getHeight()/2) - descent);
....

I tested this with strings heavy in lowercase letters like g, p, and y and it worked great! WooHoo! But....wait. Ugh. Now when I try with only uppercase letters, the text is way too HIGH on the JPanel to look centered.

That's when I discovered that TextLayout.getDescent() (and all the other getDescent() methods I have found for other classes) returns the maximum descent of the FONT not of the specific string. Thus, my uppercase string was being raised up to account for descenders that didn't even occur in that string.

What am I to do? If I don't adjust the y parameter for drawString() to account for descenders then lowercase strings with descenders are visually too low on the JPanel. If I do adjust the y parameter for drawString() to account for the descenders then strings which do not contain any characters with descenders are visually too high on the JPanel. There doesn't seem to be any way for me to determine where the baseline is in the text-bounding rectangle for a GIVEN string. Thus, I can't figure out exactly what y to pass to drawString().

Thanks for any help or suggestions.

Location answered 19/5, 2014 at 4:57 Comment(2)
If you're only intending to display a single line of text, you could using something like this exampleLamartine
yString = (int)(((getHeight() + (int) bounds.getHeight()) / 2) - descent);Casing
L
30

While I muck about with TextLayout, you could just use the Graphics context's FontMetrics, for example...

Text

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.font.FontRenderContext;
import java.awt.font.TextLayout;
import java.awt.geom.Rectangle2D;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

public class LayoutText {

    public static void main(String[] args) {
        new LayoutText();
    }

    public LayoutText() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                }

                JFrame frame = new JFrame("Testing");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setLayout(new BorderLayout());
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private String text;

        public TestPane() {
            text = "Along time ago, in a galaxy, far, far away";
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(200, 200);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();

            g2d.setColor(Color.RED);
            g2d.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight());
            g2d.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2);

            Font font = new Font("Arial", Font.BOLD, 48);
            g2d.setFont(font);
            FontMetrics fm = g2d.getFontMetrics();
            int x = ((getWidth() - fm.stringWidth(text)) / 2);
            int y = ((getHeight() - fm.getHeight()) / 2) + fm.getAscent();

            g2d.setColor(Color.BLACK);
            g2d.drawString(text, x, y);

            g2d.dispose();
        }
    }

}

Okay, after some fussing about...

Basically, text rendering occurs at the baseline, this makes the y position of the bounds usually appear above this point, making it look like the text is been painted above the y position

To overcome this, we need to add the font's ascent minus the font's descent to the y position...

For example...

FontRenderContext context = g2d.getFontRenderContext();
Font font = new Font("Arial", Font.BOLD, 48);
TextLayout txt = new TextLayout(text, font, context);

Rectangle2D bounds = txt.getBounds();
int x = (int) ((getWidth() - (int) bounds.getWidth()) / 2);
int y = (int) ((getHeight() - (bounds.getHeight() - txt.getDescent())) / 2);
y += txt.getAscent() - txt.getDescent();

... This is why I love rendering text by hand ...

Runnable example...

Layout

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.font.FontRenderContext;
import java.awt.font.TextLayout;
import java.awt.geom.Rectangle2D;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

public class LayoutText {

    public static void main(String[] args) {
        new LayoutText();
    }

    public LayoutText() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                }

                JFrame frame = new JFrame("Testing");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setLayout(new BorderLayout());
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private String text;

        public TestPane() {
            text = "Along time ago, in a galaxy, far, far away";
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(200, 200);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();

            g2d.setColor(Color.RED);
            g2d.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight());
            g2d.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2);

            FontRenderContext context = g2d.getFontRenderContext();
            Font font = new Font("Arial", Font.BOLD, 48);
            TextLayout txt = new TextLayout(text, font, context);

            Rectangle2D bounds = txt.getBounds();
            int x = (int) ((getWidth() - (int) bounds.getWidth()) / 2);
            int y = (int) ((getHeight() - (bounds.getHeight() - txt.getDescent())) / 2);
            y += txt.getAscent() - txt.getDescent();

            g2d.setFont(font);
            g2d.setColor(Color.BLACK);
            g2d.drawString(text, x, y);

            g2d.setColor(Color.BLUE);
            g2d.translate(x, y);
            g2d.draw(bounds);

            g2d.dispose();
        }
    }

}

Take a look at Working with Text APIs for more information...

Updated

As has already been suggested, you could use a GlyphVector...

Each word (Cat and Dog) is calculated separatly to demonstrate the differences

CatDog

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.geom.Rectangle2D;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.UIManager;
import javax.swing.UnsupportedLookAndFeelException;

public class LayoutText {

    public static void main(String[] args) {
        new LayoutText();
    }

    public LayoutText() {
        EventQueue.invokeLater(new Runnable() {
            @Override
            public void run() {
                try {
                    UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
                } catch (ClassNotFoundException | InstantiationException | IllegalAccessException | UnsupportedLookAndFeelException ex) {
                }

                JFrame frame = new JFrame("Testing");
                frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                frame.setLayout(new BorderLayout());
                frame.add(new TestPane());
                frame.pack();
                frame.setLocationRelativeTo(null);
                frame.setVisible(true);
            }
        });
    }

    public class TestPane extends JPanel {

        private String text;

        public TestPane() {
            text = "A long time ago, in a galaxy, far, far away";
        }

        @Override
        public Dimension getPreferredSize() {
            return new Dimension(200, 200);
        }

        @Override
        protected void paintComponent(Graphics g) {
            super.paintComponent(g);
            Graphics2D g2d = (Graphics2D) g.create();

            g2d.setColor(Color.RED);
            g2d.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight());
            g2d.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2);

            Font font = new Font("Arial", Font.BOLD, 48);
            g2d.setFont(font);

            FontRenderContext frc = g2d.getFontRenderContext();
            GlyphVector gv = font.createGlyphVector(frc, "Cat");
            Rectangle2D box = gv.getVisualBounds();

            int x = 0;
            int y = (int)(((getHeight() - box.getHeight()) / 2d) + (-box.getY()));
            g2d.drawString("Cat", x, y);

            x += box.getWidth();

            gv = font.createGlyphVector(frc, "Dog");
            box = gv.getVisualBounds();

            y = (int)(((getHeight() - box.getHeight()) / 2d) + (-box.getY()));
            g2d.drawString("Dog", x, y);

            g2d.dispose();
        }
    }

}
Lamartine answered 19/5, 2014 at 5:13 Comment(16)
I always tend to go direct to a Shape obtained from GlyphVector. That way, we can not only easily get a double quality value of the size of the shape, but can use it as a graphics clip or fill/draw it, as seen in this example. In Java, there seem to be a dozen ways to skin this cat.Stringboard
@AndrewThompson Not argument here, the TextLayout is really "weird" in my opinion, seems to counterintuitive when compared to other approachesLamartine
Thank you, MadProgrammer for the quick ready-to-test response. The problem I have had is that, as you say, getAscent() and getDescent() provide the FONT's ascent and descent, regardless of whether any of the characters in the particular string actually extend to the full ascent or descent. Thus, if you have a string that covers the full range, it looks close to centered: link BUT, if you have a string that has basically no descent or basically no ascent, it can go horribly wrong: link. Thoughts?Location
@user3651208 That's the best you'll get. If you look at Measuring Text, you see that the font height is the ascent + descent. You could "try" and walk through each character and calculate some kind of average, but frankly, that's a lot of work.Lamartine
@user3651208 I did a test with GlyphVector, have a look at the updateLamartine
"I did a test with GlyphVector.." I was waiting for that. ;)Stringboard
@AndrewThompson It's a very simple test, still not convinced, need more testing ;)Lamartine
"A long time ago, in a galaxy, far, far away" Speaking of which May The Peace Be With You. ;) That one was a bit of a saga solved using the GlyphVector of the Unicode symbol for peace amongst a Star Warsesque font. The font had to be turned into an Area before I could properly do the outline of the joined letters. I used the outline of the peace symbol as a Clip for the 'star field' image. It was fun (on a bun)!Stringboard
@AndrewThompson Yeah, I've done similar effects this way, not convinced about using it to center text though, but that's just my personal preference ;)Lamartine
Yes, got to admit that centering text is usually only the first of many things I'll do when rendering text. I mean, if you are doing custom rendering anyway, you might as well add a little flare. :)Stringboard
Thank you, MadProgrammer for extending your answer to include GlyphVector. It is EXACTLY what I needed! And thank you to @Andrew Thompson for his contributions in that direction too. Perhaps GlyphVector is not the solution for all text centering situations, but it solves my problem. As opposed to the way the "AAAA" and "yyyy" examples linked in my comment above didn't center, these other two differeing examples centered perfectly using your GlyphVector code: link and link.Location
there's a mistake in the code - "Along" should be written with a spaceGahan
@Gahan I wouldn't consider that a "mistake in the code", as the code still runs and demonstrates the pointLamartine
yeah, just kidding, example is perfect, tnx :)Gahan
Missing box.getX call for x? I used vector code for single text and it had a bug (text had x offset). So after adding float x = (float) (((... + (-box.getX())); -- it fixed a weird offset.Perreira
@Perreira Not really the intention of the demonstrationLamartine
F
6

I think this answer is the correct way to do it however I have had problems in the past with custom fonts and getting their bounds. In one project I had to resort to actually getting the outline of the font and using those bounds. This method is likely more memory intensive however it seems to be a surefire way for getting font bounds.

@Override
protected void paintComponent(Graphics g) {
    super.paintComponent(g);
    Font font = new Font("Arial", Font.BOLD, 48);
    String text = "Along time ago, in a galaxy, far, far away";

    Shape outline = font.createGlyphVector(g.getFontMetrics().getFontRenderContext(), text).getOutline();
    // the shape returned is located at the left side of the baseline, this means we need to re-align it to the top left corner. We also want to set it the the center of the screen while we are there
    AffineTransform transform = AffineTransform.getTranslateInstance(
                -outline.getBounds().getX() + getWidth()/2 - outline.getBounds().width / 2, 
                -outline.getBounds().getY() + getHeight()/2 - outline.getBounds().height / 2);
    outline = transform.createTransformedShape(outline);
    g2d.fill(outline);
}

Like I said before try to use the font metrics but if all else fails try this method out.

Flexed answered 19/5, 2014 at 5:23 Comment(3)
"..In one project I had to resort to actually getting the outline of the font and using those bounds." Oh right, just as I was only moments ago commenting! I've just finished about 20+ examples which all use the Shape of the glyphs. You can see some of the results on my FaceBook page.Stringboard
Ug_, I was heading toward trying out the GlyphVector approach when I decided to come to SO and see if there was something I was missing about using FontMetrics or TextLayout. When I saw your answer I started to think it was going to be the right way for me. Thankfully, @Lamartine added GlyphVector to his sample code and that proved it. Thank you for being the first to mention it for this question, although ultimately I accepted MadProgrammer's answer. I did mark your answer as useful because I think you and Andrew Thompson helped push toward trying this solution.Location
@user3651208 Thanks for the response I appreciate it. I agree that the mad programmer definitively has a much better answer with alot of great detail into the matter and deserves the accept :)Flexed

© 2022 - 2024 — McMap. All rights reserved.