Articles Projects Tips Downloads Contacts About

Scaling (zoom) feature in the JEditorPane/JTextPane component.
By Stanislav Lapitsky

Many text editors support a zooming feature. If we want to write our own editor based on javax.swing, we may need scaling, too. This article will explain how to add the feature to our text editor.

JEditorPane creates, edits, and represents all content via EditorKit, so first of all we should define our own EditorKit. SUN provides several EditorKits for different types of content. There are StyledEditorKit, RTFEditorKit, and HTMLEditorKit integrated in swings. For our example, we'll extend the simplest - StyledEditorKit.

Setting the Zoom Factor

For a start, we should set a JEditorPane's zoom factor variable to let our component easily access it. The simplest way is to put the zoom value into the properties of the document:

        scaledTextPane.getDocument().putProperty("ZOOM_FACTOR", new Double(2.5));
 

Thus, our JEditorPane's zoom will be 250%.

EditorKit represents data using views that are generated by ViewFactory. We should replace the default ViewFactory so the only method we override in our EditorKit is getViewFactory().

class ScaledEditorKit extends StyledEditorKit {
    public ViewFactory getViewFactory() {
        return new StyledViewFactory();
    }

    class StyledViewFactory implements ViewFactory {

        public View create(Element elem) {
            String kind = elem.getName();
            if (kind != null) {
                if (kind.equals(AbstractDocument.ContentElementName)) {
                    return new LabelView(elem);
                }
                else if (kind.equals(AbstractDocument.ParagraphElementName)) {
                    return new ParagraphView(elem);
                }
                else if (kind.equals(AbstractDocument.SectionElementName)) {
                    return new ScaledView(elem, View.Y_AXIS);
                }
                else if (kind.equals(StyleConstants.ComponentElementName)) {
                    return new ComponentView(elem);
                }
                else if (kind.equals(StyleConstants.IconElementName)) {
                    return new IconView(elem);
                }
            }

            // default to text display
            return new LabelView(elem);
        }

    }
}
 

ViewFactory provides views for different kinds of elements, but we should replace only the root view, the view for the document's root element. Each view draws content via the paint (Graphics g, Shape allocation) method that paints itself and invokes paint() methods of children. Thus, to provide correct drawing, we just need to set the scale factor to Graphics instance passed from container (zoomed JEditorPane).

    public double getZoomFactor() {
        Double scale = (Double) getDocument().getProperty("ZOOM_FACTOR");
        if (scale != null) {
            return scale.doubleValue();
        }

        return 1;
    }

    public void paint(Graphics g, Shape allocation) {
        Graphics2D g2d = (Graphics2D) g;
        double zoomFactor = getZoomFactor();
        AffineTransform old = g2d.getTransform();
        g2d.scale(zoomFactor, zoomFactor);
        super.paint(g2d, allocation);
        g2d.setTransform(old);
    }

The Fractional metrics' rendered hint is used to provide more precise drawing of the editor pane's content. We should restore the previous AffineTransform after scaling because the same instance of Graphics is used to paint other components in the parent frame.

Changing the Layout Method

Now, our content becomes bigger but a part of it is invisible because view's size requirements correspond to the normal, not scaled, content. To change the size requirements, we override the getXXXSpan() methods. View lays out its content due to size requirements, so we have to change the layout method accordingly to preserve the default layout algorithm.

    public float getMinimumSpan(int axis) {
        float f = super.getMinimumSpan(axis);
        f *= getZoomFactor();
        return f;
    }

    public float getMaximumSpan(int axis) {
        float f = super.getMaximumSpan(axis);
        f *= getZoomFactor();
        return f;
    }

    public float getPreferredSpan(int axis) {
        float f = super.getPreferredSpan(axis);
        f *= getZoomFactor();
        return f;
    }

    protected void layout(int width, int height) {
        super.layout(new Double(width / getZoomFactor()).intValue(),
                     new Double(height * getZoomFactor()).intValue());
    }

If we needed the read-only content, it would be enough, but of course our editor allows making changes so we have to adapt it to provide the correct editing process. We should override two more methods: viewToModel() and modelToView(). viewToModel() defines which offset in the document's content corresponds to the specified point. We restore the method's parameters as though they were without scaling and invoke the super method of ancestor. modelToView(), in turn, provides Shape where the specified offset is placed in the view. All we need is to get super Shape and change it according to our zoom factor.

    public Shape modelToView(int pos, Shape a, Position.Bias b) throws BadLocationException {
        double zoomFactor = getZoomFactor();
        Rectangle alloc;
        alloc = a.getBounds();
        Shape s = super.modelToView(pos, alloc, b);
        alloc = s.getBounds();
        alloc.x *= zoomFactor;
        alloc.y *= zoomFactor;
        alloc.width *= zoomFactor;
        alloc.height *= zoomFactor;

        return alloc;
    }

    public int viewToModel(float x, float y, Shape a,
                           Position.Bias[] bias) {
        double zoomFactor = getZoomFactor();
        Rectangle alloc = a.getBounds();
        x /= zoomFactor;
        y /= zoomFactor;
        alloc.x /= zoomFactor;
        alloc.y /= zoomFactor;
        alloc.width /= zoomFactor;
        alloc.height /= zoomFactor;

        return super.viewToModel(x, y, alloc, bias);
    }

If we do not use an integer scale factor, we get some distortions during caret navigation. It happens because of rounding errors in a default glyph painter. For example:

Suppose we have a "123" text string. Each character takes 9 pixels and 1 pixel between the characters. The string width, then, is 9+1+9+1+9=29. If we set the scale factor to 0.5, the width of each character will be 5 pixels and still 1 pixel between characters. Thus, the scaled string width is 5+1+5+1+5=17, but the caret position is 29*0.5=15.

The GlyphPainter1 class is based on integer calculations and generates rounding errors because of our float zoom factor. The class has private access and we can't change it. But, there is one trick that allows us to use a float-based painter. Such a painter, GlyphPainter2, is used when the text component should reflect bidirectional text. We set an appropriate property of our document to enforce GlyphPainter2 using the following:

        scaledTextPane.getDocument().putProperty("i18n", Boolean.TRUE);

There is one more problem. When we select text in our zoomed JEditorPane with the mouse, the highlighter uses original coordinates instead of the scaled ones. As a result, some content might remain unselected. It's only a visual effect, in fact. If we move a window on top of our component, the selection is restored. To provide correct behavior in all cases, we override the repaint() method of our zoomed JEditorPane.

    public void repaint(int x, int y, int width, int height) {
        super.repaint(0, 0, getWidth(), getHeight());
    }

After these changes, our text editor supports zooming. If we change the zoom factor property of the document and generate a DocumentChange event, our editor will show us new scaled content.

The last step is setting our ScaledEditorKit to the JEditorPane.

Appendix

Here is the full source code of the zooming in JEditorPane example.

Back to Table of Content