/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2015, Open Source Geospatial Foundation (OSGeo)
*
* This library 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;
* version 2.1 of the License.
*
* This library 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.
*/
package org.geotools.renderer.label;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.font.LineBreakMeasurer;
import java.awt.font.TextAttribute;
import java.awt.font.TextLayout;
import java.text.AttributedCharacterIterator;
import java.text.AttributedString;
import java.text.Bidi;
import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.geotools.renderer.label.LineInfo.LineComponent;
/**
* Helper class splitting a LabelCacheItem text over multiple lines (if necessary due to newlines or
* autowrap) and each line into horizonal components, {@link LineComponent}, that can be rendered
* with a single font (each component might require a different font due to different scripts being
* used in the labels)
*
* @author Andrea Aime - GeoSolutions
*
*/
class LabelSplitter {
private static final String NOT_EMPTY_STRING = " ";
public List<LineInfo> layout(LabelCacheItem labelItem, Graphics2D graphics) {
String text = labelItem.getLabel();
Font[] fonts = labelItem.getTextStyle().getFonts();
// split the label into lines
int textLength = text.length();
boolean singleFont = fonts.length == 1
|| textLength == fonts[0].canDisplayUpTo(text.toCharArray(), 0, textLength);
if (!(text.contains("\n") || labelItem.getAutoWrap() > 0) && singleFont) {
FontRenderContext frc = graphics.getFontRenderContext();
TextLayout layout = new TextLayout(text, fonts[0], frc);
LineComponent component = new LineComponent(text,
layoutSentence(text, labelItem, graphics, fonts[0]), layout);
LineInfo line = new LineInfo(component);
return Collections.singletonList(line);
}
// first split along the newlines
String[] splitted = text.split("\\n");
List<LineInfo> lines = new ArrayList<LineInfo>();
if (labelItem.getAutoWrap() <= 0) {
// no need for auto-wrapping, we already have the proper split
for (String line : splitted) {
line = checkForEmptyLine(line);
LineInfo lineInfo = new LineInfo();
List<FontRange> ranges = buildFontRanges(line, fonts);
for (FontRange range : ranges) {
graphics.setFont(range.font);
FontRenderContext frc = graphics.getFontRenderContext();
TextLayout layout = new TextLayout(range.text, range.font, frc);
LineComponent component = new LineComponent(range.text,
layoutSentence(range.text, labelItem, graphics, range.font), layout);
lineInfo.add(component);
}
lines.add(lineInfo);
}
} else {
// Perform an auto-wrap using the java2d facilities. This
// is done using a LineBreakMeasurer, but first we need to create
// some extra objects
// setup the attributes
Map<TextAttribute, Object> map = new HashMap<TextAttribute, Object>();
map.put(TextAttribute.FONT, fonts[0]);
// accumulate the lines
for (int i = 0; i < splitted.length; i++) {
String lineText = checkForEmptyLine(splitted[i]);
// build the line break iterator that will split lines at word
// boundaries when the wrapping length is exceeded
List<FontRange> ranges = buildFontRanges(lineText, fonts);
AttributedString attributed = buildAttributedLine(lineText, ranges);
AttributedCharacterIterator iter = attributed.getIterator();
LineBreakMeasurer lineMeasurer = new LineBreakMeasurer(iter,
BreakIterator.getLineInstance(), graphics.getFontRenderContext());
BreakIterator breaks = BreakIterator.getLineInstance();
breaks.setText(lineText);
// setup iteration and start splitting at word boundaries
int prevPosition = 0;
while (lineMeasurer.getPosition() < iter.getEndIndex()) {
// grab the next portion of text within the wrapping limits
TextLayout layout = lineMeasurer.nextLayout(labelItem.getAutoWrap(),
lineText.length(), true);
int newPosition = prevPosition;
if (layout != null) {
newPosition = lineMeasurer.getPosition();
} else {
int nextBoundary = breaks.following(prevPosition);
if (nextBoundary == BreakIterator.DONE) {
newPosition = lineText.length();
} else {
newPosition = nextBoundary;
}
AttributedCharacterIterator subIter = attributed.getIterator(null,
prevPosition, newPosition);
layout = new TextLayout(subIter, graphics.getFontRenderContext());
lineMeasurer.setPosition(newPosition);
}
// extract the text, and trim it since leading and trailing spaces
// can affect label alignment in an unpleasant way (improper left
// or right alignment, or bad centering)
List<FontRange> lineRanges = getLineRanges(ranges, prevPosition, newPosition);
LineInfo lineInfo = new LineInfo();
int lastLineRange = lineRanges.size() - 1;
int currentLineRange = 0;
for (FontRange range : lineRanges) {
String extracted = lineText.substring(Math.max(prevPosition, range.startChar),
Math.min(newPosition, range.endChar));
if (currentLineRange == 0 && currentLineRange == lastLineRange) {
// single string, remote trailing and leading
extracted = extracted.trim();
} else if (currentLineRange == 0) {
// trim leading whitespace
extracted = extracted.replaceAll("^\\s+", "");
} else if (currentLineRange == lastLineRange) {
// trim training whitespace
extracted = extracted.replaceAll("\\s+$", "");
}
currentLineRange++;
LineComponent component = new LineComponent(extracted,
layoutSentence(extracted, labelItem, graphics, range.font), layout);
lineInfo.add(component);
}
lines.add(lineInfo);
prevPosition = newPosition;
}
}
}
return lines;
}
private List<FontRange> getLineRanges(List<FontRange> ranges, int prevPosition,
int newPosition) {
int start = -1;
int end = ranges.size();
for (int i = 0; i < ranges.size(); i++) {
FontRange range = ranges.get(i);
if (start == -1) {
if (range.endChar > prevPosition) {
start = i;
} else {
continue;
}
}
if (range.startChar > newPosition) {
end = i;
break;
}
}
return ranges.subList(start, end);
}
private AttributedString buildAttributedLine(String line, List<FontRange> ranges) {
if (ranges.size() == 1) {
// create a uniform attribute AttributedString
Map<TextAttribute, Object> map = new HashMap<TextAttribute, Object>();
map.put(TextAttribute.FONT, ranges.get(0).font);
AttributedString as = new AttributedString(line, map);
return as;
}
// build a multifont attributed string
AttributedString as = new AttributedString(line);
for (FontRange range : ranges) {
as.addAttribute(TextAttribute.FONT, range.font, range.startChar, range.endChar);
}
return as;
}
/**
* Fix for GEOT-4789: a label line cannot be empty, to avoid exceptions in layout and measuring.
*
* @param line
* @return
*/
private String checkForEmptyLine(String line) {
if (line == null || line.equals("")) {
return NOT_EMPTY_STRING;
}
return line;
}
/**
* Turns a string into the corresponding {@link GlyphVector}
*
* @param label
* @param item
* @return
*/
GlyphVector layoutSentence(String label, LabelCacheItem item, Graphics2D graphics,
Font font) {
final char[] chars = label.toCharArray();
final int length = label.length();
if (Bidi.requiresBidi(chars, 0, length)) {
Bidi bidi = new Bidi(label, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT);
if (bidi.isRightToLeft()) {
return font.layoutGlyphVector(graphics.getFontRenderContext(), chars, 0, length,
Font.LAYOUT_RIGHT_TO_LEFT);
} else if (bidi.isMixed()) {
String r = "";
for (int i = 0; i < bidi.getRunCount(); i++) {
String s1 = label.substring(bidi.getRunStart(i), bidi.getRunLimit(i));
if (bidi.getRunLevel(i) % 2 == 0) {
s1 = new StringBuffer(s1).reverse().toString();
}
r = r + s1;
}
char[] chars2 = r.toCharArray();
return font.layoutGlyphVector(graphics.getFontRenderContext(), chars2, 0, length,
Font.LAYOUT_RIGHT_TO_LEFT);
}
}
return font.layoutGlyphVector(graphics.getFontRenderContext(), chars, 0, chars.length, 0);
}
List<FontRange> buildFontRanges(String text, Font[] fonts) {
if (fonts.length == 1) {
return Arrays.asList(new FontRange(text, 0, text.length(), fonts[0]));
}
List<FontRange> result = new ArrayList<>();
int start = 0;
int lastSupportedChar = 0;
char[] chars = text.toCharArray();
while (start < chars.length) {
for (int i = 0; i < fonts.length;) {
Font font = fonts[i];
int newPosition = font.canDisplayUpTo(chars, start, chars.length);
if (newPosition == -1) {
result.add(new FontRange(text, start, chars.length, font));
start = chars.length;
lastSupportedChar = start;
break;
} else if (newPosition > start) {
result.add(new FontRange(text, start, newPosition, font));
start = newPosition;
lastSupportedChar = start;
;
// restart the scan, a previous font might be able to
// work off the next text segment
if (i > 0) {
// start from scratch
i = 0;
}
} else {
i++;
}
}
if (start < chars.length) {
// it seems we have some chars that cannot be rendered by any font
int base = start;
start++;
boolean foundFont = false;
while (start < chars.length && !foundFont) {
char curr = chars[start];
for (int i = 0; i < fonts.length; i++) {
Font font = fonts[i];
if (font.canDisplay(curr)) {
foundFont = true;
result.add(new FontRange(text, base, start, fonts[0]));
break;
} else {
start++;
}
}
}
}
}
if (lastSupportedChar < chars.length) {
result.add(new FontRange(text, lastSupportedChar, chars.length, fonts[0]));
}
return result;
}
/**
* A range of characters that can be rendered with a single font
*
* @author Andrea Aime - GeoSolutions
*
*/
private static class FontRange {
int startChar;
int endChar;
Font font;
String text;
public FontRange(String fullText, int startChar, int endChar, Font font) {
super();
this.text = fullText.substring(startChar, endChar);
this.startChar = startChar;
this.endChar = endChar;
this.font = font;
}
/*
* (non-Javadoc)
*
* @see java.lang.Object#toString()
*/
@Override
public String toString() {
return "FontRange [font=" + font + ", text=" + text + "]";
}
}
}