Articles Projects Tips Downloads Contacts About

Hyphenation in the JEditorPane/JTextPane
By Stanislav Lapitsky

The hyphenation feature is useful to save line space and reduce amount of lines in a paragraph. The lines are broken not only by space chars but also by words’ syllables with insertion hyphen sign ‘-‘ to show that the broken word continues on the next line. This article describes how the hyphenation feature can be implemented in JEditorPane component.

Hyphens offsets calculation.

For each paragraph we create array of possible breaks offsets. The array contains relative offsets (from the beginning of the paragraph) of chars where line can be broken. When a paragraph is changed (e.g. user types new text) the break offsets array is recalculated. All the rest paragraphs still keep existing arrays. No need to update the arrays because paragraph start offset is changed but relative offset remains intact. The approach is used to minimize recalculation during user typing and improve recalculation performance.

We define a static method to get hyphens offsets of a word.

    public static int[] getHyphens(String word) {
        int[] res=new int[word.length()/3];
        for (int i = 0; i < res.length; i++) {
            res[i]=i*3;
        }
        return res;
    }

In this example we break suppose that each third character can break a word. In real applications the method should use grammar rules to get actual hyphens offsets. The method is used in calculation of break offsets.

        protected void calculateBreakOffsets() {
            int start = getStartOffset();
            int end = getEndOffset();
            int len=end-start;
            size = 0;
            breakOffsets = new int[len/5];
            try {
                String text = getDocument().getText(start, end - start);

                int index = text.indexOf(' ');
                int lastIndex = 0;
                while (index > -1) {
                    String word = text.substring(lastIndex, index);
                    addHyphenOffsets( word, lastIndex);
                    addBreakOffset(index + 1, true);
                    lastIndex = index + 1;
                    index = text.indexOf(' ', lastIndex);
                }
                if (lastIndex >= 0) {
                    String word = text.substring(lastIndex);
                    addHyphenOffsets( word, lastIndex);
                }
            }
            catch (BadLocationException ex) {
                //do nothing
            }
        }
        protected void addHyphenOffsets(String word, int startOffset) {
            if (word.length() == 0) {
                return;
            }
            int len = word.length();
            StringBuffer subWord = new StringBuffer();
            int start = 0;
            while (isBreakChar(word.charAt(start))) {
                start++;
                if (start >= len) {
                    return;
                }
            }
            char c;
            int startWord = start;
            for (int j = start; j < len; j++) {
                c = word.charAt(j);
                if (isBreakChar(word.charAt(j))) {
                    int offs[] = getHyphens(subWord.toString());
                    int cnt = offs.length;
                    for (int i = 0; i < cnt; i++) {
                        addBreakOffset(startOffset + startWord + offs[i],false);
                    }
                    subWord.delete(0, subWord.length());
                    startWord = j + 1;
                } else {
                    subWord.append(c);
                }
            }
            if (subWord.length() != 0) {
                int offs[] = getHyphens(subWord.toString());
                int cnt = offs.length;
                for (int i = 0; i < cnt; i++) {
                    addBreakOffset(startOffset + startWord + offs[i],false);
                }
            }
        }

        private void addBreakOffset(int offs, boolean isSpace) {
            if (breakOffsets.length == size) {
                int[] tmpOffset = breakOffsets;
                breakOffsets = new int[Math.max(1,size * 2)];
                System.arraycopy(tmpOffset, 0, breakOffsets, 0, tmpOffset.length);
            }
            breakOffsets[size] = offs;
            size++;
        }

When paragraph is changed we call the method to actualize offsets. For this we override

        public void preferenceChanged(View child, boolean width, boolean height) {
            super.preferenceChanged(child, width, height);
            calculateBreakOffsets();
        }

Line breaking algorithm.

LabelView class represents separate fragment of text with the same attributes. ParagraphView layout LabelViews in rows breaking labels if necessary. The rows creation algorithm is very important to understand what we have to do to provide hyphenation.

First step of layout is creation of single very long line (pool of LabelViews) which will be continuously broken to fit available paragraph space.

Paragraph creates a row and adds to the row the first LabelView from the pool and measure current row. If the width of row after adding the label is less than available paragraph width the next label is added till the row width exceeds the available width. As soon as the width is exceeded paragraph starts to search where the row can be broken. It means paragraph need an offset where we can break view. All the labels in the row are asked for break weight. A quote from javadoc: “break weight - Determines how attractive a break opportunity in this view is. This can be used for determining which view is the most attractive to call breakView method on in the process of formatting. The higher the weight, the more attractive the break. A value equal to or lower than View.BadBreakWeight should not be considered for a break. A value greater than or equal to View.ForcedBreakWeight should be broken.”

There are four base break weight the LabelView can return:

      View.BadBreakWeight – means no break possible.
      View.GoodBreakWeight – can break if there is no better opportunity.
      View.ExcellentBreakWeight – it’s preferred to break the view.
      View.ForcedBreakWeight – the view must be broken.

In most cases LabelViews returns View.ExcellentBreakWeight if there is a space chars ‘ ‘, ‘\t’ etc. (Word wrap supposes to break views by spaces) and View.GoodBreakWeight if there is no space but only letters. To explain this imagine the following. You type a very long string of characters and ask for break weight. If there is a space we would prefer to break by space and start a new word on a new row. If there is no space we can break in any possible place to fit our row in available paragraph width.

LabelView with break by hyphens.

To provide hyphenation we must return View.ExcellentBreakWeight not only for spaces but also for offsets where word can be split by a hyphen.

We extend LabelView class to and override some methods to provide correct brekage by hyphens. We define collateral method getBreakSpot(int p0, int p1) to return nearest break offset between two offsets p0 and p1.

        protected int getBreakSpot(int p0, int p1) {
            HyphenatedParagraphView pView = null;
            if (getParent() !=null && getParent().getParent() !=null
                && getParent().getParent() instanceof HyphenatedParagraphView) {
                pView = (HyphenatedParagraphView) getParent().getParent();
            }
            if (pView != null) {
                int[] breakOffsets = pView.breakOffsets;
                int size = pView.size;
                int start=pView.getStartOffset();
                for (int i = size - 1; i >= 0; i--) {
                    if (start+breakOffsets[i] <= p1 && start+breakOffsets[i] > p0) {

                        return start+breakOffsets[i];
                    }
                }
            }
            return -1;
        }

Then the getBreakWeight will be the following.

        public int getBreakWeight(int axis, float pos, float len) {
            int start = getStartOffset();
            int length=getEndOffset()-start;
            length = getGlyphPainter().getBoundedPosition(this, start, pos, len) - start;

            if (getBreakSpot(start, start + length) != -1) {
                return ExcellentBreakWeight;
            }
            int res= super.getBreakWeight(axis, pos, Math.max(1.0F, len - getHyphenWidth()));
            if (res==BadBreakWeight) {
                res=GoodBreakWeight;
            }
            return res;
        }

When optimal break weight is calculated paragraph breaks LabelView (creates fragments in fact). So we override breakView() method.

        public View breakView(int axis, int p0, float pos, float len) {
            if (axis == View.X_AXIS) {
                checkPainter();
                float l = len - getHyphenWidth();
                if (l < 0) {
                    l = 0.0F;
                }
                int p1 = getGlyphPainter().getBoundedPosition(this, p0, pos, l);
                int breakSpot = getBreakSpot(p0, p1);

                if (breakSpot != -1) {
                    p1 = breakSpot;
                }
                // else, no break in the region, return a fragment of the
                // bounded region.
                if (p0 == getStartOffset() && p1 == getEndOffset()) {
                    return this;
                }
                return createFragment(p0, p1);
            }
            return this;
        }

When view is broken we should change preferred size obtaining a little bit to reserve some space for hyphen char painting. The char isn’t actually inserted in text but just painted when needed. So we override the preferred span calculation and hyphen char painting.

        public float getHyphenWidth() {
            if (fm == null) {
                fm = Toolkit.getDefaultToolkit().getFontMetrics(getFont());
            }
            return fm.stringWidth("-");
        }
        protected boolean isShowHyphen() {
            int end=getEndOffset();
            try {
                if (getBreakSpot(end - 1, end) == end && !" ".equals(getDocument().getText(end - 1, 1))) {
                    return true;
                }
            }
            catch (BadLocationException ex) {
            }
            return false;
        }

        public float getPreferredSpan(int axis) {
            float span=super.getPreferredSpan(axis);
            if (axis==View.X_AXIS && isShowHyphen()) {
                span+=getHyphenWidth();
            }
            return span;
        }
        public void paint(Graphics g, Shape a) {
            super.paint(g,a);
            if (isShowHyphen()) {
                Rectangle alloc=a instanceof Rectangle?(Rectangle)a:a.getBounds();
                int last = (int) (getPreferredSpan(View.X_AXIS) - getHyphenWidth());
                Rectangle clip = new Rectangle(alloc.x + last, alloc.y, (int) getHyphenWidth(), alloc.height);
                Shape oldClip = g.getClip();
                g.setClip(clip);
                g.setFont(getFont());
                int charHeight=0;
                if (getContainer()!=null) {
                    charHeight=getContainer().getFontMetrics(getFont()).getHeight();
                    g.drawString("-", alloc.x + last, alloc.y + fm.getMaxAscent()+Math.round((alloc.height - charHeight)/2));
                }
                else {
                    g.drawString("-", alloc.x + last, alloc.y + alloc.height - fm.getMaxDescent());
                }
                g.setClip(oldClip);
            }
        }

After paragraph and label views are implemented we just replace original ones in ViewFactory create() method.

        public View create(Element elem) {
            String kind = elem.getName();
            if (kind != null) {
                if (kind.equals(AbstractDocument.ContentElementName)) {
                    return new HyphenatedLabelView(elem);
                }
                else if (kind.equals(AbstractDocument.ParagraphElementName)) {
                    return new HyphenatedParagraphView(elem);
                }
                else if (kind.equals(AbstractDocument.SectionElementName)) {
                    return new BoxView(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);
        }

Then the kit assigned to a JEditorPane allows hyphenating long words.

Appendix

Here is the full source code of the hyphenation example.

Back to Table of Content