4

As already mentioned in a related question:

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, the solution that is most commonly used and recommended (and that, from my experiences so far, at least computes the correct bounds for most cases) once more shows a rather odd behavior under certain conditions.

The following is an example that shows what I currently consider as a plain bug:

import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.font.FontRenderContext;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.util.Locale;

public class StringBoundsBugTest
{
    public static void main(String[] args)
    {
        Font font = new Font("Dialog", Font.PLAIN, 10);
        BufferedImage bi = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = bi.createGraphics();
        g.setRenderingHint(
            RenderingHints.KEY_FRACTIONALMETRICS,  
            RenderingHints.VALUE_FRACTIONALMETRICS_ON);

        for (int i=1; i<30; i++)
        {
            double scaling = 1.0 / i;

            AffineTransform oldAt = g.getTransform();
            g.scale(scaling, scaling);
            FontRenderContext fontRenderContext = g.getFontRenderContext();
            Rectangle2D bounds = 
                font.getStringBounds("Test", fontRenderContext);
            g.setTransform(oldAt);

            System.out.printf(Locale.ENGLISH, 
                "Scaling %8.5f, width %8.5f\n",
                scaling, bounds.getWidth());
        }

    }
}

The program creates a Graphics2D instance (where it does not matter whether it comes from a JComponent or a BufferedImage), then applies various scaling factors to this graphics object, and computes the bounds of a string using the FontRenderContext of the graphics object.

From my understanding, the scaling factor of the graphics object should not affect the bounds (one might expect something different here, but that's what it seems to do anyhow).

Nevertheless, the output of the above program for me (with JDK 1.8.0_31) is

Scaling  1.00000, width 19.44824
Scaling  0.50000, width 19.44824
Scaling  0.33333, width 19.32669
Scaling  0.25000, width 19.44824
Scaling  0.20000, width 19.44824
Scaling  0.16667, width 19.32669
Scaling  0.14286, width 19.14436
Scaling  0.12500, width 19.44824
Scaling  0.11111, width 19.14436
Scaling  0.10000, width 19.44824
Scaling  0.09091, width 19.38747
Scaling  0.08333, width 18.96204
Scaling  0.07692, width 18.96204
Scaling  0.07143, width 18.71893
Scaling  0.06667, width 19.14436
Scaling  0.06250, width 19.44824
Scaling  0.05882, width 18.59738
Scaling  0.05556, width 18.59738
Scaling  0.05263, width 18.47583
Scaling  0.05000, width 19.44824
Scaling  0.04762, width  0.00000
Scaling  0.04545, width  0.00000
Scaling  0.04348, width  0.00000
Scaling  0.04167, width  0.00000
Scaling  0.04000, width  0.00000
Scaling  0.03846, width  0.00000
Scaling  0.03704, width  0.00000
Scaling  0.03571, width  0.00000
Scaling  0.03448, width  0.00000 

One can see the computed size oddly wiggling about ~18-19. This indicates that the size should indeed be "fixed", regardless of the scaling that is applied to the graphics, and I would not mind the small errors that may come from rounding issues and the ridiculous complexity of font-related computations in general.

But what is not acceptable is that, for a certain scaling factor, the computed size plainly drops to zero. The scaling factor for which this happens depends on the font size, but even for larger fonts, it happens with smaller scaling factors, respectively.

Of course, there is an obvious, high level explanation: Somewhere, deep inside the font-related Swing classes like FontRenderContext etc, some computation is performed, scaling some value with the scaling factor of the graphics and then ... casting it to int. (The same was likely the issue in the question linked above).

And an obvious workaround could be to create a single, fixed FontRenderContext and use this for font-related computations everywhere. But this defeats the purpose of the font-related computations usually being bound to a Graphics: Doing the computations with a different FontRenderContext than the painting may introduce deviations between the computed sizes and the actual, painted sizes.

Does anybody have a clean, reliable solution for computing the bounds of strings in Swing, regardless of the font size, and regardless of the scaling factors that are applied to the graphics?

Community
  • 1
  • 1
Marco13
  • 53,703
  • 9
  • 80
  • 159
  • 1
    I tend to convert the `String` representation to a `Shape` then apply all transforms (e.g. scaling) to the `Shape`. – Andrew Thompson Oct 21 '15 at 20:19
  • @AndrewThompson This might work, but e.g. creating a `GlyphVector` and fetching its "logical bounds" (as also done in the linked question) is rather expensive - nothing that one would like to do for hundreds or thousands of labels. I think it's odd that such a comparatively (!) simple task like obtaining the bounds is so difficult (and seems to involve so many subtle bugs). – Marco13 Oct 21 '15 at 21:49
  • *"..nothing that one would like to do for hundreds or thousands of labels."* Speak for yourself. Me, I'd try it before going into 'premature optimization'. *"I think it's odd that such a comparatively (!) simple task"* Compared to what? Creating world peace? It's a lot more complex than describing the task in words. – Andrew Thompson Oct 21 '15 at 21:54
  • @Andrew Thompson Sure, I'll run some benchmarks, but think that creating `GlyphVector`s was near the top of the "hot methods" list in some of my previous benchmark runs (even for "few" strings). One could consider the task of computing the bounding box as "simple" compared to computing *the actual shape*. I mean, the code in classes like `FontMetrics` is rather simple and efficient, but unfortunately, only operates on *integer* values. (And I still assume that some casts to `int` cause these odd rounding errors in other bounds computation approaches) – Marco13 Oct 21 '15 at 22:01
  • @Marco13: I usually use `TextLayout` or down-sample a high-resolution `BufferedImage`, for [example](http://stackoverflow.com/a/16014525/230513). If _hundreds or thousands of labels_ proves troublesome, consider _flyweight rendering_, e.g. `JTable`. Does it matter that `scale()` _concatenates_ transformations? – trashgod Oct 21 '15 at 22:43
  • @trashgod A `TextLayout` turned out to be orders of magnitudes slower than the `Font#getStringBounds` approach (based on a benchmark that was extended from the one in my answer). I'm not sure in how far the Flyweight/Renderer concept could be applicable here, as I'm only interested in the *bounds* of the text. BTW: The transform of the `Graphics2D` was reset after each call to `scale` in the test, so the concatenation should not apply here. – Marco13 Oct 22 '15 at 18:18
  • @trashgod BTW, the `TextLayout` only seems to be capable of computing the *visual* bounds, and not the *logical* bounds. – Marco13 Oct 22 '15 at 18:25
  • @Marco: Thanks for responding; as I use it for rotated text, I never noticed the performance hit for `TextLayout`; I don't see `g.scale()` being reset; sorry if I'm misreading. – trashgod Oct 22 '15 at 18:35
  • @trashgod The old transform is backuped as `oldAt`, and restored with `g.setTransform(oldAt);` after the computation is done. This is the usual way, AFAIK, particularly for restoring the transform after a sequence of transforming calls on the `Graphics` (also see the docs for `setTransform`). (Side note: I considered asking (or examining myself) about the advantages/disadvantages of this compared to "cloning" the graphics object with `g.createGraphics()` and applying the transforms only to the clone - but that's unrelated to this question for now) – Marco13 Oct 22 '15 at 18:58

2 Answers2

3

There may be a solution for this issue (and, by the way, also for the issue in the other question). It looks a bit hacky at the first glance, but I considered the advantages and disadvantages of the alternative solutions:

Computing the bounds with Font#getStringBounds and the FontRenderContext of the Graphics2D gave plainly wrong results for certain scaling factors, as described in this question.

Computing the bounds using a "default" (untransformed) FontRenderContext (as suggested by StanislavL in his answer) may be an option (with slight adjustments), but still suffered from the issue described in the other question - namely that the results are wrong for small fonts (with a size smaller than 0.5).

So I extended the solution approach from the other question, killing two birds bugs with one stone hack: Instead of using a normalized font with a size of 1.0, I'm using a ridiculously large font, compute the size of the bounds with a FontMetrics object, and scale these bounds down based on the original size of the font.

This is summarized in this helper class:

import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;

public class StringBoundsUtils
{
    private static final Graphics2D DEFAULT_GRAPHICS;
    static
    {
        BufferedImage bi = new BufferedImage(1,1,BufferedImage.TYPE_INT_ARGB);
        DEFAULT_GRAPHICS = bi.createGraphics();
        DEFAULT_GRAPHICS.setRenderingHint(
            RenderingHints.KEY_FRACTIONALMETRICS,
            RenderingHints.VALUE_FRACTIONALMETRICS_ON);
    }

    public static Rectangle2D computeStringBounds(String string, Font font)
    {
        return computeStringBounds(string, font, new Rectangle2D.Double());
    }

    public static Rectangle2D computeStringBounds(
        String string, Font font, Rectangle2D result)
    {
        final float helperFontSize = 1000.0f;
        final float fontSize = font.getSize2D();
        final float scaling = fontSize / helperFontSize;
        Font helperFont = font.deriveFont(helperFontSize);
        FontMetrics fontMetrics = DEFAULT_GRAPHICS.getFontMetrics(helperFont);
        double stringWidth = fontMetrics.stringWidth(string) * scaling;
        double stringHeight = fontMetrics.getHeight() * scaling;
        if (result == null)
        {
            result = new Rectangle2D.Double();
        }
        result.setRect(
            0, -fontMetrics.getAscent() * scaling,
            stringWidth, stringHeight);
        return result;

    }
}

(This could be extended / adjusted to use a given Graphics2D object, although one should then verify that the scaling does not affect the FontMetrics as well...)

I'm pretty sure that there are cases where this does not work: Right-To-Left text, chinese characters, or all cases where the internal workings of the FontMetrics are simply not sufficient for measuring the text size appropriately. But it works for all cases that are relevant for me (and probably, for many others), and it does not suffer from the aforementioned bugs, and ... it's pretty fast.

Here is a very simple performance comparison (not a real benchmark, but should give a rough measure) :

import java.awt.Font;
import java.awt.font.FontRenderContext;
import java.awt.geom.Rectangle2D;
import java.util.Locale;

public class StringBoundsUtilsPerformance
{
    public static void main(String[] args)
    {
        String strings[] = {
            "a", "AbcXyz", "AbCdEfGhIjKlMnOpQrStUvWxYz",
            "AbCdEfGhIjKlMnOpQrStUvWxYz" +
            "AbCdEfGhIjKlMnOpQrStUvWxYz" +
            "AbCdEfGhIjKlMnOpQrStUvWxYz" +
            "AbCdEfGhIjKlMnOpQrStUvWxYz" +
            "AbCdEfGhIjKlMnOpQrStUvWxYz" };
        float fontSizes[] = { 1.0f, 10.0f, 100.0f };
        int runs = 1000000;

        long before = 0;
        long after = 0;
        double resultA = 0;
        double resultB = 0;

        for (float fontSize : fontSizes)
        {
            Font font = new Font("Dialog", Font.PLAIN, 10).deriveFont(fontSize);
            for (String string : strings)
            {
                before = System.nanoTime();
                for (int i=0; i<runs; i++)
                {
                    Rectangle2D r = computeStringBoundsDefault(string, font);
                    resultA += r.getWidth();
                }
                after  = System.nanoTime();
                resultA /= runs;
                System.out.printf(Locale.ENGLISH,
                    "A: time %14.4f result %14.4f, fontSize %3.1f, length %d\n",
                    (after-before)/1e6, resultA, fontSize, string.length());

                before = System.nanoTime();
                for (int i=0; i<runs; i++)
                {
                    Rectangle2D r =
                        StringBoundsUtils.computeStringBounds(string, font);
                    resultB += r.getWidth();
                }
                after  = System.nanoTime();
                resultB /= runs;
                System.out.printf(Locale.ENGLISH,
                    "B: time %14.4f result %14.4f, fontSize %3.1f, length %d\n",
                    (after-before)/1e6, resultB, fontSize, string.length());
            }
        }
    }

    private static final FontRenderContext DEFAULT_FONT_RENDER_CONTEXT =
        new FontRenderContext(null, true, true);
    public static Rectangle2D computeStringBoundsDefault(
        String string, Font font)
    {
        return font.getStringBounds(string, DEFAULT_FONT_RENDER_CONTEXT);
    }
}

It computes the bounds of strings with different lengths and font sizes, and the timing results are along the lines of the following:

A: time      1100.4441 result        14.7813, fontSize 1.0, length 26
B: time       218.6409 result        14.7810, fontSize 1.0, length 26
...
A: time      1167.1569 result       147.8125, fontSize 10.0, length 26
B: time       200.6532 result       147.8100, fontSize 10.0, length 26
...
A: time      1179.7873 result      1478.1253, fontSize 100.0, length 26
B: time       208.9414 result      1478.1003, fontSize 100.0, length 26

So the StringBoundsUtils are faster than the Font#getStringBounds approach by a factor of 5 (even more for longer strings).

The result column in the output above already indicates that the difference between the widths of the bounds computed with Font#getStringBounds and the width of the bounds computed with these StringBoundsUtils is negligible.

However, I wanted to make sure that this is not only true for the widhts, but for the whole bounds. So I created a small test:

StringBoundsUtilsTest01

In this example, one can see that the bounds are "practically equal" for both approaches, regardless of the scaling and font size - and, of course, that the StringBoundsUtils compute the proper bounds even for font sizes smaller than 0.5.

The source code of this test, for completeness: (It uses a small Viewer library, the Viewer JAR is in Maven Central)

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.util.Locale;

import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSpinner;
import javax.swing.SpinnerNumberModel;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;

import de.javagl.viewer.Painter;
import de.javagl.viewer.Viewer;

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

    private static final Font DEFAULT_FONT =
        new Font("Dialog", Font.PLAIN, 10);
    private static Font font = DEFAULT_FONT.deriveFont(10f);

    private static void createAndShowGUI()
    {
        JFrame f = new JFrame("Viewer");
        f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        f.getContentPane().setLayout(new BorderLayout());

        Viewer viewer = new Viewer();

        String string = "AbcXyz";
        viewer.addPainter(new Painter()
        {
            @Override
            public void paint(Graphics2D g, AffineTransform worldToScreen,
                double w, double h)
            {
                AffineTransform at = g.getTransform();
                g.setColor(Color.BLACK);
                g.setRenderingHint(
                    RenderingHints.KEY_FRACTIONALMETRICS,
                    RenderingHints.VALUE_FRACTIONALMETRICS_ON);
                g.setRenderingHint(
                    RenderingHints.KEY_ANTIALIASING,
                    RenderingHints.VALUE_ANTIALIAS_ON);

                Rectangle2D boundsA =
                    StringBoundsUtilsPerformance.computeStringBoundsDefault(
                        string, font);
                Rectangle2D boundsB =
                    StringBoundsUtils.computeStringBounds(string, font);

                g.setFont(new Font("Monospaced", Font.BOLD, 12));
                g.setColor(Color.GREEN);
                g.drawString(createString(boundsA), 10, 20);
                g.setColor(Color.RED);
                g.drawString(createString(boundsB), 10, 40);

                g.setFont(font);
                g.transform(worldToScreen);
                g.drawString(string, 0, 0);
                g.setTransform(at);

                g.setColor(Color.GREEN);
                g.draw(worldToScreen.createTransformedShape(boundsA));
                g.setColor(Color.RED);
                g.draw(worldToScreen.createTransformedShape(boundsB));
            }
        });
        f.getContentPane().add(viewer, BorderLayout.CENTER);

        f.getContentPane().add(
            new JLabel("Mouse wheel: Zoom, "
                + "Right mouse drags: Move, "
                + "Left mouse drags: Rotate"),
            BorderLayout.NORTH);

        JSpinner fontSizeSpinner =
            new JSpinner(new SpinnerNumberModel(10.0, 0.1, 100.0, 0.1));
        fontSizeSpinner.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(ChangeEvent e)
            {
                Object object = fontSizeSpinner.getValue();
                Number number = (Number)object;
                float fontSize = number.floatValue();
                font = DEFAULT_FONT.deriveFont(fontSize);
                viewer.repaint();
            }
        });
        JPanel p = new JPanel();
        p.add(new JLabel("Font size"), BorderLayout.WEST);
        p.add(fontSizeSpinner, BorderLayout.CENTER);
        f.getContentPane().add(p, BorderLayout.SOUTH);


        viewer.setPreferredSize(new Dimension(1000,500));
        viewer.setDisplayedWorldArea(-15,-15,30,30);
        f.pack();
        viewer.setPreferredSize(null);
        f.setLocationRelativeTo(null);
        f.setVisible(true);
    }

    private static String createString(Rectangle2D r)
    {
        return String.format(Locale.ENGLISH,
            "x=%12.4f y=%12.4f w=%12.4f h=%12.4f",
            r.getX(), r.getY(), r.getWidth(), r.getHeight());
    }

}
Community
  • 1
  • 1
Marco13
  • 53,703
  • 9
  • 80
  • 159
  • One note to add. If real Graphics changes TEXT_ANTIALIASING rendering hint ON/OFF the measurement could be wrong. – StanislavL Oct 23 '15 at 05:50
2

Actually FontRenderContext has 4 fields

public class FontRenderContext {
    private transient AffineTransform tx;
    private transient Object aaHintValue;
    private transient Object fmHintValue;
    private transient boolean defaulting;

So transform is part of context. If your scale 1/3 of course there is some rounding.

So you can either set AffineTransform to normal (e.g. no translate and no scale) for the Graphics just before obtaining FontRenderContext.

Or you can just create own and reuse it everywhere

FontRenderContext frc=new FontRenderContext(g.getTransform(), //of just replace with new AffineTransform(),
            g.getRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING),
            g.getRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS));

About GlyphVector creation. It also could be an option.

Check Font.getStringBounds() source

public Rectangle2D getStringBounds( String str, FontRenderContext frc) {
    char[] array = str.toCharArray();
    return getStringBounds(array, 0, array.length, frc);
}

public Rectangle2D getStringBounds(char [] chars,
                                int beginIndex, int limit,
                                   FontRenderContext frc) {
//some checks skipped

    boolean simple = values == null ||
        (values.getKerning() == 0 && values.getLigatures() == 0 &&
          values.getBaselineTransform() == null);
    if (simple) {
        simple = ! FontUtilities.isComplexText(chars, beginIndex, limit);
    }

    if (simple) {
        GlyphVector gv = new StandardGlyphVector(this, chars, beginIndex,
                                                 limit - beginIndex, frc);
        return gv.getLogicalBounds();

So as you can see StandardGlyphVector is created for simple case (when text has no e.g. RTL content). In opposite case TextLayout is used.

The result could be like this

private static Rectangle2D getBounds(Graphics2D g, String text) {
    FontRenderContext frc=new FontRenderContext(new AffineTransform(),
            g.getRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING),
            g.getRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS));
    GlyphVector gv = new StandardGlyphVector(g.getFont(), text.toCharArray(), 0,
            text.length(), frc);
    return gv.getLogicalBounds();
}
StanislavL
  • 56,971
  • 9
  • 68
  • 98
  • Thanks for these hints. It basically boils down to using a `FontRenderContext` with a fixed (identity) `AffineTransform`, which is one of the workarounds that I already mentioned. However, I didn't have on the radar that `Font#getStringBounds` internally creates a `GlyphVector` anyhow - so there is no performance gain possible through my attempts to avoid this. The `StandardGlyphVector` is not public, by the way (and having a look at its source code revealed all the oddities of font-related computations that lead to the bugs of this and the other question..), but one can obtain a ... – Marco13 Oct 22 '15 at 18:09
  • ... `GlyphVector` from a `Font`, which in fact **is** a `StandardGlyphVector`. I wrote another answer, by the way, but am not sure whether either answer is "acceptable" (also for others), because there may still be caveats for both approaches. – Marco13 Oct 22 '15 at 18:11
  • Your answer is fine if it suits your needs. Mine was just an idea – StanislavL Oct 23 '15 at 05:46