net.sf.jasperreports.engine.fill.SimpleTextLineWrapper.java Source code

Java tutorial

Introduction

Here is the source code for net.sf.jasperreports.engine.fill.SimpleTextLineWrapper.java

Source

/*
 * JasperReports - Free Java Reporting Library.
 * Copyright (C) 2001 - 2019 TIBCO Software Inc. All rights reserved.
 * http://www.jaspersoft.com
 *
 * Unless you have purchased a commercial license agreement from Jaspersoft,
 * the following license terms apply:
 *
 * This program is part of JasperReports.
 *
 * JasperReports is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * JasperReports is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with JasperReports. If not, see <http://www.gnu.org/licenses/>.
 */
package net.sf.jasperreports.engine.fill;

import java.awt.Font;
import java.awt.font.LineBreakMeasurer;
import java.awt.font.LineMetrics;
import java.awt.font.TextAttribute;
import java.awt.geom.Rectangle2D;
import java.lang.Character.UnicodeBlock;
import java.text.AttributedCharacterIterator.Attribute;
import java.text.AttributedString;
import java.text.Bidi;
import java.text.BreakIterator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.UUID;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import net.sf.jasperreports.annotations.properties.Property;
import net.sf.jasperreports.annotations.properties.PropertyScope;
import net.sf.jasperreports.engine.JRPropertiesUtil;
import net.sf.jasperreports.engine.fonts.AwtFontAttribute;
import net.sf.jasperreports.engine.fonts.FontUtil;
import net.sf.jasperreports.engine.util.JRStyledText;
import net.sf.jasperreports.engine.util.JRStyledText.Run;
import net.sf.jasperreports.engine.util.Pair;
import net.sf.jasperreports.properties.PropertyConstants;

/**
 * @author Lucian Chirita (lucianc@users.sourceforge.net)
 */
public class SimpleTextLineWrapper implements TextLineWrapper {

    @Property(category = PropertyConstants.CATEGORY_FILL, scopes = {
            PropertyScope.CONTEXT }, sinceVersion = PropertyConstants.VERSION_4_7_1)
    public static final String PROPERTY_MEASURE_EXACT = JRPropertiesUtil.PROPERTY_PREFIX
            + "measure.simple.text.exact";

    @Property(category = PropertyConstants.CATEGORY_FILL, defaultValue = "2000", scopes = {
            PropertyScope.CONTEXT }, sinceVersion = PropertyConstants.VERSION_4_7_1, valueType = Integer.class)
    public static final String PROPERTY_ELEMENT_CACHE_SIZE = JRPropertiesUtil.PROPERTY_PREFIX
            + "measure.simple.text.element.cache.size";

    public static final String MEASURE_EXACT_ALWAYS = "always";
    public static final String MEASURE_EXACT_MULTILINE = "multiline";

    private static final Log log = LogFactory.getLog(SimpleTextLineWrapper.class);

    protected static final int FONT_MIN_COUNT = 10;
    protected static final double FONT_SIZE_MIN_FACTOR = 0.1;
    protected static final double FONT_WIDTH_CHECK_FACTOR = 1.2;

    protected static final int NEXT_BREAK_INDEX_THRESHOLD = 3;
    protected static final int COMPEX_LAYOUT_START_CHAR = 0x0300;// got this from sun.font.FontUtilities
    protected static final int COMPEX_LAYOUT_END_CHAR = 0x206F;// got this from sun.font.FontUtilities

    protected static final String FILL_CACHE_KEY_ELEMENT_FONT_INFOS = SimpleTextLineWrapper.class.getName()
            + "#elementFontInfos";

    protected static final String FILL_CACHE_KEY_GENERAL_FONT_INFOS = SimpleTextLineWrapper.class.getName()
            + "#generalFontInfos";

    protected static final Set<Character.UnicodeBlock> simpleLayoutBlocks;
    static {
        // white list of Unicode blocks that have simple text layout
        simpleLayoutBlocks = new HashSet<Character.UnicodeBlock>();
        // got these from sun.font.FontUtilities, but the list is not exhaustive
        simpleLayoutBlocks.add(Character.UnicodeBlock.GREEK);
        simpleLayoutBlocks.add(Character.UnicodeBlock.CYRILLIC);
        simpleLayoutBlocks.add(Character.UnicodeBlock.CYRILLIC_SUPPLEMENTARY);
        simpleLayoutBlocks.add(Character.UnicodeBlock.ARMENIAN);
        simpleLayoutBlocks.add(Character.UnicodeBlock.SYRIAC);
        simpleLayoutBlocks.add(Character.UnicodeBlock.THAANA);
        simpleLayoutBlocks.add(Character.UnicodeBlock.MYANMAR);
        simpleLayoutBlocks.add(Character.UnicodeBlock.GEORGIAN);
        simpleLayoutBlocks.add(Character.UnicodeBlock.ETHIOPIC);
        simpleLayoutBlocks.add(Character.UnicodeBlock.TAGALOG);
        simpleLayoutBlocks.add(Character.UnicodeBlock.MONGOLIAN);
        simpleLayoutBlocks.add(Character.UnicodeBlock.LATIN_EXTENDED_ADDITIONAL);
        simpleLayoutBlocks.add(Character.UnicodeBlock.GREEK_EXTENDED);
    }

    // storing per instance to avoid too many calls (and to allow runtime level changes)
    private final boolean logTrace = log.isTraceEnabled();

    private TextMeasureContext context;
    private boolean measureSimpleTexts;
    private boolean measureExact;
    private boolean measureExactMultiline;
    private Map<FontKey, ElementFontInfo> fontInfos;

    private String wholeText;
    private FontKey fontKey;
    private ElementFontInfo fontInfo;

    private String paragraphText;
    private boolean paragraphTruncateAtChar;
    private boolean paragraphLeftToRight;
    private boolean paragraphMeasureExact;
    private int paragraphOffset;
    private int paragraphPosition;
    private BreakIterator paragraphBreakIterator;

    public SimpleTextLineWrapper() {
    }

    public SimpleTextLineWrapper(SimpleTextLineWrapper parent) {
        this.context = parent.context;
        this.measureSimpleTexts = parent.measureSimpleTexts;
        this.measureExact = parent.measureExact;
        this.measureExactMultiline = parent.measureExactMultiline;
        this.fontInfos = parent.fontInfos;

        this.wholeText = parent.wholeText;
        this.fontKey = parent.fontKey;
        this.fontInfo = parent.fontInfo;
    }

    @Override
    public void init(TextMeasureContext context) {
        this.context = context;

        JRPropertiesUtil properties = JRPropertiesUtil.getInstance(context.getJasperReportsContext());
        measureSimpleTexts = properties.getBooleanProperty(context.getPropertiesHolder(),
                TextMeasurer.PROPERTY_MEASURE_SIMPLE_TEXTS, true);
        if (measureSimpleTexts) {
            String exactProp = properties.getProperty(context.getPropertiesHolder(), PROPERTY_MEASURE_EXACT);
            if (exactProp != null) {
                if (MEASURE_EXACT_ALWAYS.equals(exactProp)) {
                    measureExact = true;
                } else if (MEASURE_EXACT_MULTILINE.equals(exactProp)) {
                    measureExactMultiline = true;
                }
            }

            fontInfos = new HashMap<FontKey, ElementFontInfo>();
        }
    }

    @Override
    public boolean start(JRStyledText styledText) {
        if (!measureSimpleTexts) {
            return false;
        }

        List<Run> runs = styledText.getRuns();
        if (runs.size() != 1) {
            // multiple styles
            return false;
        }

        wholeText = styledText.getText();
        if (wholeText.indexOf('\t') >= 0) {
            // supporting tabs is more difficult because we'd need
            // measureParagraphFragment to include the white space advance.
            return false;
        }

        Run run = styledText.getRuns().get(0);
        if (run.attributes.get(TextAttribute.SUPERSCRIPT) != null) {
            // not handling this case, see JRStyledText.getAwtAttributedString
            return false;
        }

        AwtFontAttribute fontAttribute = AwtFontAttribute.fromAttributes(run.attributes);
        Number size = (Number) run.attributes.get(TextAttribute.SIZE);
        if (!fontAttribute.hasAttribute() || size == null) {
            // this should not happen, but still checking
            return false;
        }

        int style = 0;
        Number posture = (Number) run.attributes.get(TextAttribute.POSTURE);
        if (posture != null && !TextAttribute.POSTURE_REGULAR.equals(posture)) {
            if (TextAttribute.POSTURE_OBLIQUE.equals(posture)) {
                style |= Font.ITALIC;
            } else {
                // non standard posture
                return false;
            }
        }

        Number weight = (Number) run.attributes.get(TextAttribute.WEIGHT);
        if (weight != null && !TextAttribute.WEIGHT_REGULAR.equals(weight)) {
            if (TextAttribute.WEIGHT_BOLD.equals(weight)) {
                style |= Font.BOLD;
            } else {
                // non standard weight
                return false;
            }
        }

        fontKey = new FontKey(fontAttribute, size.floatValue(), style, styledText.getLocale());
        createFontInfo(run.attributes);

        return true;
    }

    protected void createFontInfo(Map<Attribute, Object> textAttributes) {
        fontInfo = fontInfos.get(fontKey);
        if (fontInfo != null) {
            // found in local cache
            return;
        }

        Map<Pair<UUID, FontKey>, ElementFontInfo> elementFontInfos = null;
        Pair<UUID, FontKey> elementFontKey = null;

        // look in the fill cache
        if (context.getElement() instanceof JRFillElement) {
            JRFillElement fillElement = (JRFillElement) context.getElement();
            JRFillContext fillContext = fillElement.getFiller().getFillContext();
            elementFontKey = new Pair<UUID, FontKey>(fillElement.getUUID(), fontKey);

            elementFontInfos = (Map<Pair<UUID, FontKey>, ElementFontInfo>) fillContext
                    .getFillCache(FILL_CACHE_KEY_ELEMENT_FONT_INFOS);
            if (elementFontInfos == null) {
                elementFontInfos = createElementFontInfosFillCache();
                fillContext.setFillCache(FILL_CACHE_KEY_ELEMENT_FONT_INFOS, elementFontInfos);
            }

            fontInfo = elementFontInfos.get(elementFontKey);
        }

        if (fontInfo == null) {
            // did not find in the general cache, create the font info
            // we first need the general font info
            FontInfo generalFontInfo = getGeneralFontInfo(textAttributes);

            if (logTrace) {
                log.trace("creating element font info for " + fontKey
                        + (elementFontKey == null ? "" : (" and element " + elementFontKey.first())));
            }

            fontInfo = new ElementFontInfo(generalFontInfo);
            fontInfos.put(fontKey, fontInfo);

            if (elementFontInfos != null && elementFontKey.first() != null)//UUID should not be null but check to be sure
            {
                elementFontInfos.put(elementFontKey, fontInfo);
            }
        }
    }

    protected HashMap<Pair<UUID, FontKey>, ElementFontInfo> createElementFontInfosFillCache() {
        final int cacheSize = JRPropertiesUtil.getInstance(context.getJasperReportsContext())
                .getIntegerProperty(PROPERTY_ELEMENT_CACHE_SIZE, 2000);//hardcoded default
        if (log.isDebugEnabled()) {
            log.debug("creating element font infos cache of size " + cacheSize);
        }

        // creating a LRU map
        return new LinkedHashMap<Pair<UUID, FontKey>, SimpleTextLineWrapper.ElementFontInfo>(64, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Entry<Pair<UUID, FontKey>, ElementFontInfo> eldest) {
                return size() > cacheSize;
            }
        };
    }

    protected FontInfo getGeneralFontInfo(Map<Attribute, Object> textAttributes) {
        Map<FontKey, FontInfo> generalFontInfos = null;
        FontInfo generalFontInfo = null;
        // look in the fill cache
        if (context.getElement() instanceof JRFillElement) {
            JRFillElement fillElement = (JRFillElement) context.getElement();
            JRFillContext fillContext = fillElement.getFiller().getFillContext();

            generalFontInfos = (Map<FontKey, FontInfo>) fillContext.getFillCache(FILL_CACHE_KEY_GENERAL_FONT_INFOS);
            if (generalFontInfos == null) {
                generalFontInfos = new HashMap<FontKey, FontInfo>();
                fillContext.setFillCache(FILL_CACHE_KEY_GENERAL_FONT_INFOS, generalFontInfos);
            }

            generalFontInfo = generalFontInfos.get(fontKey);
        }

        if (generalFontInfo == null) {
            Font font = loadFont(textAttributes);
            boolean complexLayout = determineComplexLayout(font);
            // computing the leading a single time, assuming that it doesn't change with text
            //FIXME verify if computing leading for each line is needed
            float leading = determineLeading(font);
            if (logTrace) {
                log.trace("font " + font + " has complex layout " + complexLayout + ", leading " + leading);
            }

            generalFontInfo = new FontInfo(font, complexLayout, leading);

            if (generalFontInfos != null) {
                generalFontInfos.put(fontKey, generalFontInfo);
            }
        }

        return generalFontInfo;
    }

    protected Font loadFont(Map<Attribute, Object> textAttributes) {
        // check bundled fonts
        FontUtil fontUtil = FontUtil.getInstance(context.getJasperReportsContext());
        Font font = fontUtil.getAwtFontFromBundles(fontKey.fontAttribute, fontKey.style, fontKey.size,
                fontKey.locale, false);
        if (font == null) {
            // checking AWT font
            fontUtil.checkAwtFont(fontKey.fontAttribute.getFamily(), context.isIgnoreMissingFont());
            // creating AWT font
            // FIXME using the current text attributes might be slightly dangerous as we are sharing font metrics
            font = Font.getFont(textAttributes);
        }
        return font;
    }

    protected boolean determineComplexLayout(Font font) {
        // this tries to emulate the tests in Font.getStringBounds()
        //FIXME use font.hasLayoutAttributes() instead of this?
        Map<TextAttribute, ?> fontAttributes = font.getAttributes();
        Object kerning = fontAttributes.get(TextAttribute.KERNING);
        Object ligatures = fontAttributes.get(TextAttribute.LIGATURES);
        return (kerning != null && TextAttribute.KERNING_ON.equals(kerning))
                || (ligatures != null && TextAttribute.LIGATURES_ON.equals(ligatures)) || font.isTransformed();
    }

    protected float determineLeading(Font font) {
        LineMetrics lineMetrics = font.getLineMetrics(" ", context.getFontRenderContext());
        return lineMetrics.getLeading();
    }

    @Override
    public void startParagraph(int paragraphStart, int paragraphEnd, boolean truncateAtChar) {
        String text = wholeText.substring(paragraphStart, paragraphEnd);
        startParagraph(text, paragraphStart, truncateAtChar);
    }

    @Override
    public void startEmptyParagraph(int paragraphStart) {
        startParagraph(" ", paragraphStart, false);
    }

    protected void startParagraph(String text, int start, boolean truncateAtChar) {
        paragraphText = text;
        paragraphTruncateAtChar = truncateAtChar;

        char[] textChars = text.toCharArray();
        // direction is per paragraph
        paragraphLeftToRight = isLeftToRight(textChars);
        paragraphMeasureExact = isParagraphMeasureExact(textChars);

        if (logTrace) {
            log.trace("paragraph start at " + start + ", truncate at char " + truncateAtChar + ", LTR "
                    + paragraphLeftToRight + ", exact measure " + paragraphMeasureExact);
        }

        paragraphOffset = start;
        paragraphPosition = 0;

        paragraphBreakIterator = truncateAtChar ? BreakIterator.getCharacterInstance()
                : BreakIterator.getLineInstance();
        paragraphBreakIterator.setText(paragraphText);
    }

    protected boolean isLeftToRight(char[] chars) {
        boolean leftToRight = true;
        if (Bidi.requiresBidi(chars, 0, chars.length)) {
            // determining the text direction
            // using default LTR as there's no way to have other default in the text
            Bidi bidi = new Bidi(chars, 0, null, 0, chars.length, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT);
            leftToRight = bidi.baseIsLeftToRight();
        }
        return leftToRight;
    }

    protected boolean isParagraphMeasureExact(char[] chars) {
        // when we have complex text layout or truncating at char,
        // perform exact break measurement as estimating/guessing could be slower
        if (measureExact || fontInfo.fontInfo.complexLayout || paragraphTruncateAtChar) {
            return true;
        }

        return hasComplexLayout(chars);
    }

    protected boolean hasComplexLayout(char[] chars) {
        UnicodeBlock prevBlock = null;
        for (int i = 0; i < chars.length; i++) {
            char ch = chars[i];
            if (ch >= COMPEX_LAYOUT_START_CHAR && ch <= COMPEX_LAYOUT_END_CHAR) {
                //FIXME use icu4j or CharPredicateCache
                UnicodeBlock chBlock = Character.UnicodeBlock.of(ch);
                if (chBlock == null) {
                    // being conservative
                    return true;
                }

                // if the same block as the previous block, avoid going to the hash set
                // this could offer some speed improvement
                if (prevBlock != chBlock) {
                    prevBlock = chBlock;

                    if (!simpleLayoutBlocks.contains(chBlock)) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    @Override
    public int paragraphPosition() {
        return paragraphPosition;
    }

    @Override
    public int paragraphEnd() {
        return paragraphText.length();
    }

    @Override
    public TextLine nextLine(float width, int endLimit, boolean requireWord) {
        if (logTrace) {
            log.trace("simple line measurement at " + (paragraphOffset + paragraphPosition) + " to "
                    + (paragraphOffset + endLimit) + " in width " + width + " with font " + fontInfo);
        }

        // the result
        TextLine textLine;
        if (useExactLineMeasurement()) {
            textLine = measureExactLine(width, endLimit, requireWord);
        } else {
            textLine = measureLine(width, requireWord, endLimit);
        }
        return textLine;
    }

    protected boolean useExactLineMeasurement() {
        // when missing a character width estimate perform one exact measurement
        return paragraphMeasureExact || !fontInfo.hasCharWidthEstimate();
    }

    protected TextLine measureExactLine(float width, int endLimit, boolean requireWord) {
        int breakIndex = measureExactLineBreakIndex(width, endLimit, requireWord);
        if (breakIndex <= paragraphPosition) {
            // nothing fit
            return null;
        }

        Rectangle2D lineBounds = measureParagraphFragment(breakIndex);
        return toTextLine(breakIndex, lineBounds);
    }

    protected int measureExactLineBreakIndex(float width, int endLimit, boolean requireWord) {
        //FIXME would it be faster to create and cache a LineBreakMeasurer for the whole paragraph?
        Map<Attribute, Object> attributes = new HashMap<Attribute, Object>();
        // we only need the font as it includes the size and style
        attributes.put(TextAttribute.FONT, fontInfo.fontInfo.font);

        String textLine = paragraphText.substring(paragraphPosition, endLimit);
        AttributedString attributedLine = new AttributedString(textLine, attributes);

        // we need a fresh iterator for the line
        BreakIterator breakIterator = paragraphTruncateAtChar ? BreakIterator.getCharacterInstance()
                : BreakIterator.getLineInstance();
        LineBreakMeasurer breakMeasurer = new LineBreakMeasurer(attributedLine.getIterator(), breakIterator,
                context.getFontRenderContext());
        int breakIndex = breakMeasurer.nextOffset(width, endLimit - paragraphPosition, requireWord)
                + paragraphPosition;
        if (logTrace) {
            log.trace("exact line break index measured at " + (paragraphOffset + breakIndex));
        }

        return breakIndex;
    }

    protected TextLine measureLine(float width, boolean requireWord, int endLimit) {
        // try to guess how much of the text would fit based on the average char width
        int measureIndex = estimateBreakIndex(width, endLimit);

        // if estimating that there's more than a line, check measureExactMultiline
        if (measureIndex < endLimit && measureExactMultiline) {
            return measureExactLine(width, endLimit, requireWord);
        }

        // measure the text
        Rectangle2D bounds = measureParagraphFragment(measureIndex);
        //FIXME fast exit when the height is exceeded

        Rectangle2D measuredBounds = bounds;
        if (bounds.getWidth() <= width) {
            // see if there's more that could fit
            boolean done = false;
            do {
                int nextBreakIndex = measureIndex < endLimit ? paragraphBreakIterator.following(measureIndex)
                        : BreakIterator.DONE;
                if (nextBreakIndex == BreakIterator.DONE || nextBreakIndex > endLimit) {
                    // the next break is after the limit, we're done
                    done = true;
                } else {
                    // measure to the next break
                    Rectangle2D nextBounds = measureParagraphFragment(nextBreakIndex);
                    if (nextBounds.getWidth() <= width) {
                        measuredBounds = nextBounds;
                        measureIndex = nextBreakIndex;
                        // loop
                    } else {
                        done = true;
                    }
                }
            } while (!done);
        } else {
            // didn't fit, try shorter texts
            boolean done = false;
            do {
                int previousBreakIndex = measureIndex > paragraphPosition
                        ? paragraphBreakIterator.preceding(measureIndex)
                        : BreakIterator.DONE;
                if (previousBreakIndex == BreakIterator.DONE || previousBreakIndex <= paragraphPosition) {
                    if (requireWord) {
                        // no full word fits, returning empty
                        measureIndex = paragraphPosition;
                    } else {
                        // we need to break inside the word.
                        // measuring the exact break index as estimating/guessing might be slower.
                        measureIndex = measureExactLineBreakIndex(width, endLimit, requireWord);
                        measuredBounds = measureParagraphFragment(measureIndex);
                    }
                    done = true;
                } else {
                    measureIndex = previousBreakIndex;
                    Rectangle2D prevBounds = measureParagraphFragment(measureIndex);
                    if (prevBounds.getWidth() <= width) {
                        // fitted, we're done
                        measuredBounds = prevBounds;
                        done = true;
                    }
                }
            } while (!done);
        }

        if (measureIndex <= paragraphPosition) {
            // nothing fit
            return null;
        }
        return toTextLine(measureIndex, measuredBounds);
    }

    protected int estimateBreakIndex(float width, int endLimit) {
        double avgCharWidth = fontInfo.charWidthEstimate();
        if ((endLimit - paragraphPosition) * avgCharWidth <= width * FONT_WIDTH_CHECK_FACTOR) {
            // there are chances that the entire text would fit, let's be optimistic
            return endLimit;
        }

        // estimate how many characters would fit
        int charCountEstimate = (int) Math.ceil(width / avgCharWidth);
        int estimateFitPosition = paragraphPosition + charCountEstimate;
        if (estimateFitPosition > endLimit) {
            // estimated that everything would fit
            return endLimit;
        }

        // find the break after the estimate
        int breakAfterEstimatePosition = paragraphBreakIterator.following(estimateFitPosition);
        if (breakAfterEstimatePosition == BreakIterator.DONE || breakAfterEstimatePosition > endLimit) {
            breakAfterEstimatePosition = endLimit;
        }

        int estimateIndex = breakAfterEstimatePosition;
        // if the after break is too far way from the estimate, see if the break before is closer
        if (breakAfterEstimatePosition > estimateFitPosition + NEXT_BREAK_INDEX_THRESHOLD) {
            int breakBeforeEstimatePosition = paragraphBreakIterator.previous();
            // if the break before is closer than the break after, use the break before
            if (breakBeforeEstimatePosition == BreakIterator.DONE && breakBeforeEstimatePosition > paragraphPosition
                    && estimateFitPosition - breakBeforeEstimatePosition < breakAfterEstimatePosition
                            - estimateFitPosition) {
                estimateIndex = breakBeforeEstimatePosition;
            }
        }
        return estimateIndex;
    }

    protected Rectangle2D measureParagraphFragment(int measureIndex) {
        int endIndex = measureIndex;
        if (endIndex > paragraphPosition + 1) {
            char lastMeasureChar = paragraphText.charAt(endIndex - 1);
            if (Character.isWhitespace(lastMeasureChar)) {
                // exclude trailing white space from the text to measure.
                // use the previous break as limit, but always keep at least one character to measure.
                int preceding = paragraphBreakIterator.preceding(endIndex);
                if (preceding == BreakIterator.DONE || preceding <= paragraphPosition) {
                    preceding = paragraphPosition + 1;
                }

                do {
                    --endIndex;
                    lastMeasureChar = paragraphText.charAt(endIndex - 1);
                } while (endIndex > preceding && Character.isWhitespace(lastMeasureChar));
            }
        }

        // note that trailing white space will not be included in the advance
        Rectangle2D bounds = fontInfo.fontInfo.font.getStringBounds(paragraphText, paragraphPosition, endIndex,
                context.getFontRenderContext());

        // adding the measurement to the font info statistics
        fontInfo.recordMeasurement(bounds.getWidth() / (endIndex - paragraphPosition));

        if (logTrace) {
            log.trace("measured to index " + (endIndex + paragraphOffset) + " at width " + bounds.getWidth());
        }

        return bounds;
    }

    protected TextLine toTextLine(int measureIndex, Rectangle2D measuredBounds) {
        SimpleTextLine textLine = new SimpleTextLine();
        textLine.setAscent((float) -measuredBounds.getY());
        textLine.setDescent((float) (measuredBounds.getMaxY() - fontInfo.fontInfo.leading));
        textLine.setLeading(fontInfo.fontInfo.leading);
        textLine.setCharacterCount(measureIndex - paragraphPosition);
        textLine.setAdvance((float) measuredBounds.getWidth());
        textLine.setLeftToRight(paragraphLeftToRight);

        // update the paragraph position
        paragraphPosition = measureIndex;

        return textLine;
    }

    @Override
    public TextLine baseTextLine(int index) {
        // this should only be called when the text is tabbed, which is not supported 
        throw new UnsupportedOperationException();
    }

    @Override
    public float maxFontsize(int start, int end) {
        return fontKey.size;
    }

    @Override
    public String getLineText(int start, int end) {
        int newLineIdx = wholeText.indexOf('\n', start);
        int endIdx = (newLineIdx >= 0 && newLineIdx < end) ? newLineIdx : end;
        return wholeText.substring(start, endIdx);
    }

    @Override
    public char charAt(int index) {
        return wholeText.charAt(index);
    }

    @Override
    public TextLineWrapper lastLineWrapper(String lineText, int start, int textLength, boolean truncateAtChar) {
        if (logTrace) {
            log.trace("last line wrapper at " + start + ", textLength " + textLength);
        }

        SimpleTextLineWrapper lastLineWrapper = new SimpleTextLineWrapper(this);
        lastLineWrapper.startParagraph(lineText, start, truncateAtChar);
        return lastLineWrapper;
    }

    protected static class FontKey {
        AwtFontAttribute fontAttribute;
        float size;
        int style;
        Locale locale;

        public FontKey(AwtFontAttribute fontAttribute, float size, int style, Locale locale) {
            super();
            this.fontAttribute = fontAttribute;
            this.size = size;
            this.style = style;
            this.locale = locale;
        }

        @Override
        public int hashCode() {
            int hash = 43;
            hash = hash * 29 + fontAttribute.hashCode();
            hash = hash * 29 + Float.floatToIntBits(size);
            hash = hash * 29 + style;
            hash = hash * 29 + (locale == null ? 0 : locale.hashCode());
            return hash;
        }

        @Override
        public boolean equals(Object obj) {
            FontKey info = (FontKey) obj;
            return fontAttribute.equals(info.fontAttribute) && size == info.size && style == info.style
                    && ((locale == null) ? (info.locale == null)
                            : (info.locale != null && locale.equals(info.locale)));
        }

        @Override
        public String toString() {
            return "{font: " + fontAttribute + ", size: " + size + ", style: " + style + "}";
        }
    }

    protected static class FontInfo {
        final Font font;
        final boolean complexLayout;
        final float leading;
        final FontStatistics fontStatistics;

        public FontInfo(Font font, boolean complexLayout, float leading) {
            this.font = font;
            this.complexLayout = complexLayout;
            this.leading = leading;
            this.fontStatistics = new FontStatistics();
        }

        @Override
        public String toString() {
            return font.toString();
        }
    }

    protected static class FontStatistics {
        int measurementsCount;
        double characterWidthSum;

        public void recordMeasurement(double avgWidth) {
            ++measurementsCount;
            characterWidthSum += avgWidth;
        }
    }

    protected static class ElementFontInfo {
        final FontInfo fontInfo;
        final FontStatistics fontStatistics;

        public ElementFontInfo(FontInfo fontInfo) {
            this.fontInfo = fontInfo;
            this.fontStatistics = new FontStatistics();
        }

        public boolean hasCharWidthEstimate() {
            return fontStatistics.measurementsCount > 0 || fontInfo.fontStatistics.measurementsCount > 0;
        }

        public double charWidthEstimate() {
            double avgCharWidth;
            if (fontStatistics.measurementsCount > 0) {
                avgCharWidth = fontStatistics.characterWidthSum / fontStatistics.measurementsCount;
            } else if (fontInfo.fontStatistics.measurementsCount > 0) {
                avgCharWidth = fontInfo.fontStatistics.characterWidthSum
                        / fontInfo.fontStatistics.measurementsCount;
            } else {
                throw new IllegalStateException("No measurement available for char width estimate");
            }

            return avgCharWidth;
        }

        public void recordMeasurement(double avgWidth) {
            fontStatistics.recordMeasurement(avgWidth);
            fontInfo.fontStatistics.recordMeasurement(avgWidth);
        }

        @Override
        public String toString() {
            return fontInfo.font.toString();
        }
    }

}