4

There are many (many) questions about computing the size (width or height) of a string that should be painted into a Swing component. And there are many proposed solutions. However, I noticed that most of these solutions do not work properly for small fonts.

The following is an MCVE that shows some of the approaches:

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.GridLayout;
import java.awt.Shape;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.font.TextLayout;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.util.function.BiFunction;

import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;

public class TextBoundsTest
{
    public static void main(String[] args)
    {
        SwingUtilities.invokeLater(new Runnable()
        {
            @Override
            public void run()
            {
                createAndShowGUI();
            }
        });
    }

    private static void createAndShowGUI()
    {
        JFrame f = new JFrame();
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

        Font baseFont = new Font("Sans Serif", Font.PLAIN, 10);
        Font smallFont0 = baseFont.deriveFont(0.5f);
        Font smallFont1 = baseFont.deriveFont(0.4f);

        f.getContentPane().setLayout(new GridLayout(5,2));

        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont0, 
                TextBoundsTest::computeBoundsWithFontMetrics, 
                "FontMetrics"));
        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont1, 
                TextBoundsTest::computeBoundsWithFontMetrics, 
                "FontMetrics"));

        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont0, 
                TextBoundsTest::computeBoundsWithFontAndFontRenderContext, 
                "Font+FontRenderContext"));
        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont1, 
                TextBoundsTest::computeBoundsWithFontAndFontRenderContext, 
                "Font+FontRenderContext"));

        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont0, 
                TextBoundsTest::computeBoundsWithGlyphVectorLogicalBounds, 
                "GlyphVectorLogicalBounds"));
        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont1, 
                TextBoundsTest::computeBoundsWithGlyphVectorLogicalBounds, 
                "GlyphVectorLogicalBounds"));

        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont0, 
                TextBoundsTest::computeBoundsWithGlyphVectorVisualBounds, 
                "GlyphVectorVisualBounds"));
        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont1, 
                TextBoundsTest::computeBoundsWithGlyphVectorVisualBounds, 
                "GlyphVectorVisualBounds"));

        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont0, 
                TextBoundsTest::computeBoundsWithTextLayout, 
                "TextLayout"));
        f.getContentPane().add(
            new TextBoundsTestPanel(smallFont1, 
                TextBoundsTest::computeBoundsWithTextLayout, 
                "TextLayout"));


        f.setSize(600,800);
        f.setLocationRelativeTo(null);
        f.setVisible(true);
    }

    private static Rectangle2D computeBoundsWithFontMetrics(
        String string, Graphics2D g)
    {
        FontMetrics fontMetrics = g.getFontMetrics();
        Rectangle2D bounds = fontMetrics.getStringBounds(string, g);
        return bounds;
    }

    private static Rectangle2D computeBoundsWithFontAndFontRenderContext(
        String string, Graphics2D g)
    {
        FontRenderContext fontRenderContext =
            new FontRenderContext(g.getTransform(),true, true);
        Font font = g.getFont();
        Rectangle2D bounds = font.getStringBounds(string, fontRenderContext);
        return bounds;
    }

    private static Rectangle2D computeBoundsWithGlyphVectorLogicalBounds(
        String string, Graphics2D g)
    {
        FontRenderContext fontRenderContext = g.getFontRenderContext();
        Font font = g.getFont();
        GlyphVector glyphVector = font.createGlyphVector(
            fontRenderContext, string);
        return glyphVector.getLogicalBounds();
    }

    private static Rectangle2D computeBoundsWithGlyphVectorVisualBounds(
        String string, Graphics2D g)
    {
        FontRenderContext fontRenderContext = g.getFontRenderContext();
        Font font = g.getFont();
        GlyphVector glyphVector = font.createGlyphVector(
            fontRenderContext, string);
        return glyphVector.getVisualBounds();
    }

    private static Rectangle2D computeBoundsWithTextLayout(
        String string, Graphics2D g)
    {
        FontRenderContext fontRenderContext = g.getFontRenderContext();
        Font font = g.getFont();
        TextLayout textLayout = new TextLayout(string, font, fontRenderContext);
        return textLayout.getBounds();        
    }


}


class TextBoundsTestPanel extends JPanel
{
    private final Font textFont;
    private final BiFunction<String, Graphics2D, Rectangle2D> boundsComputer;
    private final String boundsComputerName;

    TextBoundsTestPanel(Font textFont, 
        BiFunction<String, Graphics2D, Rectangle2D> boundsComputer,
        String boundsComputerName)
    {
        this.textFont = textFont;
        this.boundsComputer = boundsComputer;
        this.boundsComputerName = boundsComputerName;
    }

    @Override
    protected void paintComponent(Graphics gr) 
    {
        super.paintComponent(gr);
        Graphics2D g = (Graphics2D)gr;
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, getWidth(), getHeight());
        g.setColor(Color.BLACK);

        g.drawString("Font size: "+textFont.getSize2D(), 10, 20);
        g.drawString("Bounds   : "+boundsComputerName, 10, 40);

        AffineTransform oldAt = g.getTransform();
        AffineTransform at = AffineTransform.getScaleInstance(50, 50);
        g.transform(at);
        g.translate(1, 2);

        g.setFont(textFont);

        String string = "Test";
        g.drawString(string, 0, 0);

        Rectangle2D bounds = boundsComputer.apply(string, g);
        Shape boundsShape = at.createTransformedShape(bounds);

        g.setTransform(oldAt);

        g.setColor(Color.RED);
        g.translate(50, 100);
        g.draw(boundsShape);
    }
}

The result of this program is shown in this screenshot:

TextBounds

As one can see, the simple methods work nicely for a font with size 0.5, but suddenly bail out and return bounds with a height of 0.0 for the font with size 0.4.

(Side note: I wonder whether this is simply a bug - although it might be caused by some internal round-off-errors, as it happens exactly between font sizes of 0.5 and 0.49 ...)

The only solutions that work for these smaller fonts are the computation using a GlyphVector, or the TextLayout. But both of these approaches are tremendously expensive, as they require the creation of the Shape of the string and lots of auxiliary objects. Furthermore, they only return the visual bounds (that is, the bounds of the actual shape), and not the logical bounds of the text.

Is there any efficient solution for computing the logical bounds of strings in small fonts?

Community
  • 1
  • 1
Marco13
  • 53,703
  • 9
  • 80
  • 159
  • Curious, I can't reproduce that at all. I get similar results for both font sizes in all the cases. (openjdk on linux, tested 3 different drawing backends). Maybe the default rendering hints differ, for some transforms `RenderingHints.VALUE_STROKE_PURE` matters. – kiheru Jul 18 '15 at 21:53
  • @kiheru You mean that the bounding boxes are correct for you in all cases when you run the example program? (That would be odd, and I'd have to do some more tests with other JREs...) – Marco13 Jul 18 '15 at 23:52
  • The results differ for me for the different methods (3 first look the same, the 2 last are different, but look the same as each other), but the font size has no effect, and the bounds are never smaller than the text. – kiheru Jul 19 '15 at 08:06

1 Answers1

4

You can normalize the font first. Measure that then scale the dimensions of the rectangle by the true size2D of the font.

private static Rectangle2D computeBoundsUsingNormalizedFont(
        String string, Graphics2D g) {
    Font normalizedFont = g.getFont().deriveFont(1f);
    Rectangle2D bounds = normalizedFont.getStringBounds(string, g.getFontRenderContext());

    float scale = g.getFont().getSize2D();
    return new Rectangle2D.Double(bounds.getX() * scale,
            bounds.getY() * scale,
            bounds.getWidth() * scale,
            bounds.getHeight() * scale);
}

Proof

Then obviously you can cache the normalized font and hide this work around inside a calculator class, something like this:

TextBoundsCalculator textBoundsCalculator = TextBoundsCalculator.forFont(smallFontX);

Rectangle2D bounds = textBoundsCalculator.boundsFor(string, g);

Where TextBoundsCalculator:

import java.awt.*;
import java.awt.geom.Rectangle2D;

public final class TextBoundsCalculator {
    private interface MeasureStrategy {
        Rectangle2D boundsFor(String string, Graphics2D g);
    }

    private MeasureStrategy measureStrategy;

    private TextBoundsCalculator(MeasureStrategy measureStrategy) {
        this.measureStrategy = measureStrategy;
    }

    public static TextBoundsCalculator forFont(Font font) {
        if (font.getSize() == 0)
            return new TextBoundsCalculator(new ScaleMeasureStrategy(font));

        // The bug appears to be only when font.getSize()==0.
        // So there's no need to normalize, measure and scale with fonts
        // where this is not the case
        return new TextBoundsCalculator(new NormalMeasureStrategy(font));
    }

    public Rectangle2D boundsFor(String string, Graphics2D g) {
        return measureStrategy.boundsFor(string, g);
    }

    private static class ScaleMeasureStrategy implements MeasureStrategy {
        private final float scale;
        private final Font normalizedFont;

        public ScaleMeasureStrategy(Font font) {
            scale = font.getSize2D();
            normalizedFont = font.deriveFont(1f);
        }

        public Rectangle2D boundsFor(String string, Graphics2D g) {
            Rectangle2D bounds = NormalMeasureStrategy.boundsForFont(normalizedFont, string, g);
            return scaleRectangle2D(bounds, scale);
        }
    }

    private static class NormalMeasureStrategy implements MeasureStrategy {
        private final Font font;

        public NormalMeasureStrategy(Font font) {
            this.font = font;
        }

        public Rectangle2D boundsFor(String string, Graphics2D g) {
            return boundsForFont(font, string, g);
        }

        private static Rectangle2D boundsForFont(Font font, String string, Graphics2D g) {
            return font.getStringBounds(string, g.getFontRenderContext());
        }
    }

    private static Rectangle2D scaleRectangle2D(Rectangle2D rectangle2D, float scale) {
        return new Rectangle2D.Double(
                rectangle2D.getX() * scale,
                rectangle2D.getY() * scale,
                rectangle2D.getWidth() * scale,
                rectangle2D.getHeight() * scale);
    }
}
weston
  • 54,145
  • 21
  • 145
  • 203
  • Thanks so far. This was helpful in so far that the hint that `Font#getSize` returns 0 for a size <0.5 might explain the observed behavior. About the solution ... I'm not sure whether it is always the case that scaling the rectangle by the font size will yield the rectangle of the scaled font. (Fonts are tricky, to say the least). But I will test and consider this, maybe it is an option as the last resort... – Marco13 Jul 18 '15 at 23:49
  • Although I hoped that there might be a more efficient solution (and I still wonder whether the fact that the bounds computation does not work for font sizes smaller than 0.5 is a bug), I'll accept this one, as the font normalization seems to be the best (or only?) workaround so far. From my observations, it seems like scaling the bounding box indeed gives the bounds of the smaller font, although I only tested this for "simple" fonts (and ASCII characters) so far... – Marco13 Jul 27 '15 at 17:36