bobbylight / RSyntaxTextArea

A syntax highlighting, code folding text editor for Java Swing applications.
BSD 3-Clause "New" or "Revised" License
1.11k stars 258 forks source link

NPE when painting RSyntaxTextArea to an off-screen image #335

Open vlsi opened 4 years ago

vlsi commented 4 years ago

Test case (macOS, Java 13):

    private static void layoutComponent(Component component) {
        synchronized (component.getTreeLock()) {
            component.doLayout();
            if (component instanceof Container) {
                for (Component child : ((Container) component).getComponents()) {
                    layoutComponent(child);
                }
            }
        }
    }

    @Test
    public void paintRSyntaxArea() {
        RSyntaxTextArea area = new RSyntaxTextArea();
        area.setText("Hello, world!");
        area.setSize(new Dimension(320, 240));
        layoutComponent(area);

        BufferedImage image = new BufferedImage(area.getWidth(), area.getHeight(), BufferedImage.TYPE_4BYTE_ABGR);
        Graphics2D g2 = image.createGraphics();
        g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        area.paint(g2);
        g2.dispose();
    }
java.lang.NullPointerException
    at org.fife.ui.rsyntaxtextarea.RSyntaxTextArea.getGraphics2D(RSyntaxTextArea.java:1263)
    at org.fife.ui.rsyntaxtextarea.RSyntaxTextArea.paintComponent(RSyntaxTextArea.java:2141)
    at java.desktop/javax.swing.JComponent.paint(JComponent.java:1074)
    at ...Test.paintRSyntaxArea(...Test.java:151)
bobbylight commented 4 years ago

Is this only for tests? If so I suggest doing the following, which is what I do in the RSTA unit tests, even though it leaves a bad taste in your mouth:

https://github.com/bobbylight/RSyntaxTextArea/blob/master/RSyntaxTextArea/src/test/java/org/fife/ui/rsyntaxtextarea/AbstractRSyntaxTextAreaTest.java#L67

The issue is that the component isn't displayable. You might be able to come up with an alternative fix that dupes the control into thinking it's displayable, though IIRC I couldn't find one in a headless environment.

vlsi commented 4 years ago

Is this only for tests?

It is not only for tests. The case discovered in https://github.com/apache/jmeter/pull/574 where I want to implement automatic screenshot generation for Apache JMeter. The screens that use RSTA fail in headless mode (I guess it is the only component that fails in headless).

Apparently I don't really want to augment production code with createTestGraphics()

OK. Can you please clarify why RSyntaxTextArea.paintComponent(RSyntaxTextArea.java:2141) does not use the Graphics object that is passed to the method? Why does it query getGraphics()?

vlsi commented 4 years ago

What if https://github.com/bobbylight/RSyntaxTextArea/blob/cf1da1f03e380aaa7d85ab25bc1cbb3bec11a014/RSyntaxTextArea/src/main/java/org/fife/ui/rsyntaxtextarea/RSyntaxTextArea.java#L2141 is replaced with refreshFontMetrics(getGraphics2D(g))?

bobbylight commented 4 years ago

It's not getGraphics2D() that queries getGraphics(), but rather paintComponent():

    /**
     * The <code>paintComponent</code> method is overridden so we
     * apply any necessary rendering hints to the Graphics object.
     */
    @Override
    protected void paintComponent(Graphics g) {

        // A call to refreshFontMetrics() used to be in addNotify(), but
        // unfortunately we cannot always get the graphics context there.  If
        // the parent frame/dialog is LAF-decorated, there is a chance that the
        // window's width and/or height is still == 0 at addNotify() (e.g.
        // WebLaF).  So unfortunately it's safest to do this here, with a flag
        // to only allow it to happen once.
        if (metricsNeverRefreshed) {
            refreshFontMetrics(getGraphics2D(getGraphics()));
            metricsNeverRefreshed = false;
        }

        super.paintComponent(getGraphics2D(g));
    }

Per the comment, it appears there were instances where our metrics cache wasn't populated yet. We're not rendering the component to its own getGraphics() graphics object, we're fetching it to populate our font metrics cache, in which case we need the text area's own graphics context.

I'm not clear on whether we can simply rely on any Graphics2D instance or if we should use the one from the component when generating our font metrics cache. Using any arbitrary one just seems wrong - perhaps it has different rendering hints for example. Not sure if there is a real-world impact of doing so or not or if the difference is academic.

vlsi commented 4 years ago

Using any arbitrary one just seems wrong - perhaps it has different rendering hints for example

I guess the component should paint itself with the exact rendering hints that are specified in paintComponent(Graphics g)

For example: when painting to a screen device, it could use cleartype (e.g. LCD_RGB) antialiasing, however, when printing to a png image, it could make sense to use just gray antialiasing (for better compatibility with different screens where PNG will be viewed).

vlsi commented 4 years ago

Per the comment, it appears

I have seen the comment, however, it does not clarify why it uses getGraphics() rather than input Graphics g which is surprising :-/ That is why I'm asking.

bobbylight commented 4 years ago

I hear you, my only concern is because font metrics are cached, if paintComponent() is called with some Graphics context other than RSTA's screen-based context initially, we'll cache metrics info based on the PNG's (for example) rendering hints and use those forevermore (or until some method is called that causes us to recompute our cache).

Again, I'm not sure this is an actual problem for a couple of reasons:

Judging from the JMeter defect you reference, your real-world code will not experience the NPE, is that correct? It will simply use the FontMetrics that the realized RSTA instance used for rendering on-screen, rather than those based off of the BufferedImage (for example)'s Graphics2D. However, unit tests will fail without some sort of workaround like I suggested earlier. Can you confirm whether these assumptions are correct?

vlsi commented 4 years ago

I do face NPE when rendering the documentation.

vlsi commented 4 years ago

It will simply use the FontMetrics that the realized RSTA instance used for rendering on-screen

The documentation screenshots will be rendered off-screen.

I do not see why RSTA requires on-screen so much. There's Graphics provided. What else does it need?

bobbylight commented 4 years ago

Can you provide an SSCCE that demonstrates an NPE when rendering a screenshot of an RSyntaxTextArea instance that's already rendered on-screen? It should always work - font metrics will always be cached in that case. For example:

import org.fife.ui.rtextarea.RTextScrollPane;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;

public class ScreenshotTest {

    private static void runScreenshotTest() {

        JFrame frame = new JFrame();
        frame.setLayout(new BorderLayout());

        RSyntaxTextArea textArea = new RSyntaxTextArea(25, 80);
        textArea.setText("public static void main(String[] args);");
        textArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVA);
        frame.add(new RTextScrollPane(textArea));

        JToolBar toolBar = new JToolBar();
        JButton button = new JButton("Take Screenshot");
        button.addActionListener((e) -> {

            BufferedImage image = new BufferedImage(frame.getWidth(), frame.getHeight(),
                BufferedImage.TYPE_INT_ARGB);
            Graphics2D g2d = (Graphics2D)image.getGraphics();

            frame.getContentPane().paint(g2d);

            File outputFile = new File("C:/temp/test.png");
            try {
                ImageIO.write(image, "PNG", outputFile);
                Desktop.getDesktop().open(outputFile);
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        });
        toolBar.add(button);
        frame.add(toolBar, BorderLayout.NORTH);

        frame.pack();
        frame.setLocationByPlatform(true);
        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        frame.setVisible(true);
    }

    public static void main(String[] args) {

        SwingUtilities.invokeLater(() -> {
            try {
                runScreenshotTest();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}
vlsi commented 4 years ago

Here you go:

import java.awt.Component;
import java.awt.Container;
import java.awt.Desktop;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;

import javax.imageio.ImageIO;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;

import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea;
import org.fife.ui.rsyntaxtextarea.SyntaxConstants;

public class ScreenshotTest {

    private static void runScreenshotTest() {
        RSyntaxTextArea textArea = new RSyntaxTextArea(25, 80);
        textArea.setText("public static void main(String[] args);");
        textArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVA);
        textArea.setSize(new Dimension(320, 240));
//        JTextArea textArea = new JTextArea();
//        textArea.setText("public static void main(String[] args);");
//        textArea.setSize(new Dimension(320, 240));
        layoutComponent(textArea);

        BufferedImage image = new BufferedImage(textArea.getWidth(), textArea.getHeight(),
                BufferedImage.TYPE_INT_ARGB);

        Graphics2D g2d = (Graphics2D) image.getGraphics();
        try {
            textArea.paint(g2d);
        } finally {
            g2d.dispose();
        }

        File outputFile = new File("test.png");
        try {
            ImageIO.write(image, "PNG", outputFile);
            Desktop.getDesktop().open(outputFile);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void layoutComponent(Component component) {
        synchronized (component.getTreeLock()) {
            component.doLayout();
            if (component instanceof Container) {
                for (Component child : ((Container) component).getComponents()) {
                    layoutComponent(child);
                }
            }
        }
    }

    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            try {
                runScreenshotTest();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }
}
> Task :src:core:ScreenshotTest.main()
java.lang.NullPointerException
    at org.fife.ui.rsyntaxtextarea.RSyntaxTextArea.getGraphics2D(RSyntaxTextArea.java:1263)
    at org.fife.ui.rsyntaxtextarea.RSyntaxTextArea.paintComponent(RSyntaxTextArea.java:2141)
    at java.desktop/javax.swing.JComponent.paint(JComponent.java:1074)
    at ScreenshotTest.runScreenshotTest(ScreenshotTest.java:51)
    at ScreenshotTest.lambda$main$0(ScreenshotTest.java:79)
    at java.desktop/java.awt.event.InvocationEvent.dispatch(InvocationEvent.java:313)
    at java.desktop/java.awt.EventQueue.dispatchEventImpl(EventQueue.java:770)
    at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:721)
    at java.desktop/java.awt.EventQueue$4.run(EventQueue.java:715)
    at java.base/java.security.AccessController.doPrivileged(AccessController.java:391)
    at java.base/java.security.ProtectionDomain$JavaSecurityAccessImpl.doIntersectionPrivilege(ProtectionDomain.java:85)
    at java.desktop/java.awt.EventQueue.dispatchEvent(EventQueue.java:740)
    at java.desktop/java.awt.EventDispatchThread.pumpOneEventForFilters(EventDispatchThread.java:203)
    at java.desktop/java.awt.EventDispatchThread.pumpEventsForFilter(EventDispatchThread.java:124)
    at java.desktop/java.awt.EventDispatchThread.pumpEventsForHierarchy(EventDispatchThread.java:113)
    at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:109)
    at java.desktop/java.awt.EventDispatchThread.pumpEvents(EventDispatchThread.java:101)
    at java.desktop/java.awt.EventDispatchThread.run(EventDispatchThread.java:90)
bobbylight commented 4 years ago

But that example never renders the text component in screen. You're not screenshotting something a user is seeing in a running app instance, which is what I thought the original jmeter issue was discussing. It looks like you are setting it up not to create screenshots with a user interactively using the app, is that right? As that is certainly possible.

If RSTA is changed to always use the passed in graphics, note that you'll be responsible for setting rendering hints such as anti aliasing hints to make font rendering look good

vlsi commented 4 years ago

But that example never renders the text component in screen

That is true. I need to generate screenshots without user interaction.

Here's a sample page: https://jmeter.apache.org/usermanual/component_reference.html#If_Controller

There are screenshots (e.g. If Controller using Variable), and I want to make the capture automatic, so it does not require user action to take the appropriate screenshots.

If RSTA is changed to always use the passed in graphics, note that you'll be responsible for setting rendering hints

Of course, it is expected that RSTA honours the graphic settings that are passed to RSTA.