javaswingclipboardjtextpanehtmleditorkit

Write contents of JTextPane to clipboard using HTMLEditorKit without extra spaces between different colored elements


I have built a custom syntax highlighter gui for my own programming language in Swing. I am writing a feature to copy a selection of text to the clipboard so I can easily paste my custom syntax highlighted formatted text into a web browser or a word document. However doing so adds a space between each element with a different color.

The following simplified code succinctly demonstrates this issue. First, we have the Gui class, using a JTextPane containing the text "A:b" with the color red set upon the character ':'. The JButton calls a method which uses HTMLEditorKit to write the text pane's StyledDocument contents to a String. This is then put in a custom HTMLTransferable object and passed to the Clipboard.

import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JTextPane;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyleContext;
import javax.swing.text.StyledDocument;
import javax.swing.text.html.HTMLEditorKit;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Container;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

public class Gui {
    private JFrame window;
    private JTextPane textPane;
    private JButton copyButton;

    public Gui() {
        window = new JFrame();
        textPane = new JTextPane();
        textPane.setText("A:b");

        StyleContext styleContext = StyleContext.getDefaultStyleContext();
        AttributeSet attributeSet = styleContext.addAttribute(SimpleAttributeSet.EMPTY, StyleConstants.Foreground, Color.RED);

        StyledDocument styledDocument = textPane.getStyledDocument();
        styledDocument.setCharacterAttributes(1, 1, attributeSet, false);

        copyButton = new JButton("Copy");
        copyButton.addActionListener(event -> copyHtmlTextToClipboard());

        Container contentPane = window.getContentPane();
        contentPane.add(textPane, BorderLayout.CENTER);
        contentPane.add(copyButton, BorderLayout.SOUTH);
    }

    public void copyHtmlTextToClipboard() {
        Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        HTMLEditorKit htmlEditorKit = new HTMLEditorKit();

        int startIndex = textPane.getSelectionStart();
        int endIndex = textPane.getSelectionEnd();
        int length = endIndex - startIndex;

        StyledDocument styledDocument = textPane.getStyledDocument();
        try {
            htmlEditorKit.write(outputStream, styledDocument, startIndex, length);
            outputStream.flush();

            String contents = new String(outputStream.toByteArray());
            HTMLTransferable htmlTransferable = new HTMLTransferable(contents);
            clipboard.setContents(htmlTransferable, null);
        }
        catch (IOException | BadLocationException e) {
            throw new RuntimeException(e);
        }
    }

    public void start() {
        window.pack();
        window.setLocationRelativeTo(null);
        window.setVisible(true);
    }

    public static void main(String[] args) {
        Gui gui = new Gui();
        gui.start();
    }
}

This produces the following UI:

Example code UI

The HTMLTransferable class is barebones. It does work on my system (macOS 12.6) to pass this data to the Pages app using paste.

import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;

public class HTMLTransferable implements Transferable {
    private String hmtlFormattedText;
    private DataFlavor[] dataFlavors;

    public HTMLTransferable(String hmtlFormattedText) {
        this.hmtlFormattedText = hmtlFormattedText;
        this.dataFlavors = new DataFlavor[] {
                DataFlavor.allHtmlFlavor
        };
    }

    @Override
    public DataFlavor[] getTransferDataFlavors() {
        return dataFlavors;
    }

    @Override
    public boolean isDataFlavorSupported(DataFlavor flavor) {
        for (DataFlavor supportedFlavor : dataFlavors) {
            if (supportedFlavor.equals(flavor)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public Object getTransferData(DataFlavor flavor) {
        if (flavor == DataFlavor.allHtmlFlavor) {
            return hmtlFormattedText;
        }
        return null;
    }
}

And finally, the raw output of the HTMLEditorKit writing the contents of the JTextPane's StyledDocument. Formatting is applied to text using span elements. However, the contents of each span are listed on their own separate line.

<html>
  <head>
    <style>
      <!--
        p.default {
          family:Lucida Grande;
          size:4;
          bold:normal;
          italic:;
        }
      -->
    </style>
  </head>
  <body>
    <p class=default>
      <span style="font-size: 13pt; font-family: Lucida Grande">
        A
      </span>
      <span style="color: #ff0000; font-size: 13pt; font-family: Lucida Grande">
        :
      </span>
      <span style="font-size: 13pt; font-family: Lucida Grande">
        b
      </span>
    </p>
  </body>
</html>

This adds a space between each element contained in a unique span when pasted in Pages, or when viewed in a web browser such as Chrome:

Rendered text in Pages app

For my syntax-highlighted code, this adds a lot of unnecessary spaces. So I'm looking for the best way to remove these spaces. I really don't want to have to parse the output HTML myself. Ideally there is some setting on HTMLEditorKit or something similar I am simply overlooking. Plus it gets trickier when the formatted text contains spaces that need to be preserved in the final result.

Formatted Code in HTML Addendum:

After verifying the solution provided by aterai solves the added spaces between spans issue, I found 2 other issues with copying code from a JTextPane to the clipboard in an HTML format:

  1. My code's leading indentation was lost when displayed.
  2. Each <p> tag added additional line spacing.

This was fixed by using string replacement to edit the output HTML:

  1. Wrap the body in a <pre> tag.
  2. Remove <p> tags, and replace <p/> tags with <br/>.
String contents = outputStream.toString();

// fix preservation of whitespace
contents = contents.replaceAll(Pattern.quote("<body>"), "<body><pre>");
contents = contents.replaceAll(Pattern.quote("</body>"), "</pre></body>");

// fix line spacing
contents = contents.replaceAll(Pattern.quote("<p class=default>"), "");
contents = contents.replaceAll(Pattern.quote("</p>"), "<br/>");

HTMLTransferable htmlTransferable = new HTMLTransferable(contents);

Solution

  • It seems difficult to disable line breaks and indents only for span tags, so how about outputting html using MinimalHTMLWriter with all line breaks and indents disabled?

    public void copyHtmlTextToClipboard() {
        Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
        // HTMLEditorKit htmlEditorKit = new HTMLEditorKit();
        int startIndex = textPane.getSelectionStart();
        int endIndex = textPane.getSelectionEnd();
        int length = endIndex - startIndex;
        StyledDocument styledDocument = textPane.getStyledDocument();
        try (OutputStream outputStream = new ByteArrayOutputStream();
             OutputStreamWriter osw = new OutputStreamWriter(outputStream)) {
            // htmlEditorKit.write(outputStream, styledDocument, startIndex, length);
            MinimalHTMLWriter w = new MinimalHTMLWriter(
              osw, styledDocument, startIndex, length) {
                @Override
                public String getLineSeparator() {
                    return "";
                }
    
                @Override
                protected int getIndentSpace() {
                    return 0;
                }
            };
            w.write();
            osw.flush();
            String contents = outputStream.toString();
            System.out.println(contents);
            HTMLTransferable htmlTransferable = new HTMLTransferable(contents);
            clipboard.setContents(htmlTransferable, null);
        } catch (IOException | BadLocationException e) {
            throw new RuntimeException(e);
        }
    }
    

    Gui2.java

    import java.awt.*;
    import java.awt.datatransfer.Clipboard;
    import java.awt.datatransfer.DataFlavor;
    import java.awt.datatransfer.Transferable;
    import java.io.ByteArrayOutputStream;
    import java.io.IOException;
    import java.io.OutputStream;
    import java.io.OutputStreamWriter;
    import javax.swing.*;
    import javax.swing.text.AttributeSet;
    import javax.swing.text.BadLocationException;
    import javax.swing.text.SimpleAttributeSet;
    import javax.swing.text.StyleConstants;
    import javax.swing.text.StyleContext;
    import javax.swing.text.StyledDocument;
    import javax.swing.text.html.HTMLEditorKit;
    import javax.swing.text.html.MinimalHTMLWriter;
    
    public class Gui2 {
        private JFrame window;
        private JTextPane textPane;
        private JButton copyButton;
    
        public Gui2() {
            window = new JFrame();
            window.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
            textPane = new JTextPane();
            textPane.setText("A:b");
    
            StyleContext styleContext = StyleContext.getDefaultStyleContext();
            AttributeSet attributeSet = styleContext.addAttribute(
              SimpleAttributeSet.EMPTY, StyleConstants.Foreground, Color.RED);
    
            StyledDocument styledDocument = textPane.getStyledDocument();
            styledDocument.setCharacterAttributes(1, 1, attributeSet, false);
    
            copyButton = new JButton("Copy");
            copyButton.addActionListener(event -> copyHtmlTextToClipboard());
    
            Container contentPane = window.getContentPane();
            contentPane.add(textPane, BorderLayout.CENTER);
            contentPane.add(copyButton, BorderLayout.SOUTH);
        }
    
        public void copyHtmlTextToClipboard() {
            Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
            // HTMLEditorKit htmlEditorKit = new HTMLEditorKit();
            int startIndex = textPane.getSelectionStart();
            int endIndex = textPane.getSelectionEnd();
            int length = endIndex - startIndex;
            StyledDocument styledDocument = textPane.getStyledDocument();
            try (OutputStream outputStream = new ByteArrayOutputStream();
                 OutputStreamWriter osw = new OutputStreamWriter(outputStream)) {
                // htmlEditorKit.write(outputStream, styledDocument, startIndex, length);
                MinimalHTMLWriter w = new MinimalHTMLWriter(
                  osw, styledDocument, startIndex, length) {
                    @Override
                    public String getLineSeparator() {
                        return "";
                    }
    
                    @Override
                    protected int getIndentSpace() {
                        return 0;
                    }
                };
                w.write();
                osw.flush();
                String contents = outputStream.toString();
                System.out.println(contents);
                HTMLTransferable htmlTransferable = new HTMLTransferable(contents);
                clipboard.setContents(htmlTransferable, null);
            } catch (IOException | BadLocationException e) {
                throw new RuntimeException(e);
            }
        }
    
        public void start() {
            window.pack();
            window.setLocationRelativeTo(null);
            window.setVisible(true);
        }
    
        public static void main(String[] args) {
            Gui2 gui = new Gui2();
            gui.start();
        }
    }
    
    class HTMLTransferable implements Transferable {
        private String hmtlFormattedText;
        private DataFlavor[] dataFlavors;
    
        public HTMLTransferable(String hmtlFormattedText) {
            this.hmtlFormattedText = hmtlFormattedText;
            this.dataFlavors = new DataFlavor[] {
                DataFlavor.allHtmlFlavor
            };
        }
    
        @Override
        public DataFlavor[] getTransferDataFlavors() {
            return dataFlavors;
        }
    
        @Override
        public boolean isDataFlavorSupported(DataFlavor flavor) {
            for (DataFlavor supportedFlavor : dataFlavors) {
                if (supportedFlavor.equals(flavor)) {
                    return true;
                }
            }
            return false;
        }
    
        @Override
        public Object getTransferData(DataFlavor flavor) {
            if (flavor == DataFlavor.allHtmlFlavor) {
                return hmtlFormattedText;
            }
            return null;
        }
    }