/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2002-2008, 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.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Paint;
import java.awt.Shape;
import java.awt.font.FontRenderContext;
import java.awt.font.GlyphVector;
import java.awt.font.LineBreakMeasurer;
import java.awt.font.LineMetrics;
import java.awt.font.TextAttribute;
import java.awt.font.TextLayout;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.text.AttributedCharacterIterator;
import java.text.AttributedString;
import java.text.Bidi;
import java.text.BreakIterator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.geotools.geometry.jts.LiteShape2;
import org.geotools.renderer.lite.StyledShapePainter;
import org.geotools.renderer.style.TextStyle2D;
import com.vividsolutions.jts.geom.Coordinate;
import com.vividsolutions.jts.geom.GeometryFactory;
/**
* This class performs the layouting and painting of the single label (leaving
* the label cache the task to sort labels and locate the best label points)
*
* @author Andrea Aime
*
*
* @source $URL$
*/
public class LabelPainter {
/**
* The current label we're tring to draw
*/
LabelCacheItem labelItem;
/**
* The lines in which the label has been split (if any)
*/
List<LineInfo> lines;
/**
* The graphics object used during painting
*/
Graphics2D graphics;
/**
* Wheter we draw text using its {@link Shape} outline, or we use a plain
* {@link Graphics2D#drawGlyphVector(GlyphVector, float, float)} instead
*/
boolean outlineRenderingEnabled;
/**
* Used to build JTS geometries during label painting
*/
GeometryFactory gf = new GeometryFactory();
/**
* The cached label bounds
*/
Rectangle2D labelBounds;
/**
* Builds a new painter
*
* @param graphics
* @param outlineRenderingEnabled
*/
public LabelPainter(Graphics2D graphics, boolean outlineRenderingEnabled) {
this.graphics = graphics;
this.outlineRenderingEnabled = outlineRenderingEnabled;
}
/**
* Sets the current label. The label will be laid out according to the label
* item settings (curved lines, auto wrapping, curved line usage) and the
* painter will be ready to draw it.
*
* @param labelItem
*/
public void setLabel(LabelCacheItem labelItem) {
this.labelItem = labelItem;
labelItem.getTextStyle().setLabel(labelItem.getLabel());
// reset previous caches
labelBounds = null;
lines = null;
// split the label into lines
String text = labelItem.getLabel();
// set the multiline labeller only if we're not using curved labels, and
// also only if makes sense to have multiple lines (at least a newline
if (!(text.contains("\n") || labelItem.getAutoWrap() > 0)
|| labelItem.isFollowLineEnabled()) {
FontRenderContext frc = graphics.getFontRenderContext();
TextLayout layout = new TextLayout(text, labelItem.getTextStyle().getFont(), frc);
LineInfo line = new LineInfo(text, layoutSentence(text, labelItem), layout);
labelBounds = line.gv.getVisualBounds();
normalizeBounds(labelBounds);
lines = Collections.singletonList(line);
return;
}
// first split along the newlines
String[] splitted = text.split("\\n");
lines = new ArrayList<LineInfo>();
if(labelItem.getAutoWrap() <= 0) {
// no need for auto-wrapping, we already have the proper split
for (String line : splitted) {
FontRenderContext frc = graphics.getFontRenderContext();
TextLayout layout = new TextLayout(line, labelItem.getTextStyle().getFont(), frc);
LineInfo info = new LineInfo(line, layoutSentence(line, labelItem), layout);
lines.add(info);
}
} 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, labelItem.getTextStyle().getFont());
// accumulate the lines
for (int i = 0; i < splitted.length; i++) {
String line = splitted[i];
// build the line break iterator that will split lines at word
// boundaries when the wrapping length is exceeded
AttributedString attributed = new AttributedString(line, map);
AttributedCharacterIterator iter = attributed.getIterator();
LineBreakMeasurer lineMeasurer = new LineBreakMeasurer(iter, BreakIterator
.getWordInstance(), graphics.getFontRenderContext());
// 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());
int newPosition = lineMeasurer.getPosition();
// extract the text, and trim it since leading and trailing
// and ... spaces can affect label alignment in an
// unpleasant way (improper left or right alignment, or bad
// centering)
String extracted = line.substring(prevPosition, newPosition).trim();
prevPosition = newPosition;
LineInfo info = new LineInfo(extracted, layoutSentence(extracted, labelItem),
layout);
lines.add(info);
}
}
}
// compute the max line length
double maxWidth = 0;
for (LineInfo line : lines) {
maxWidth = Math.max(line.gv.getVisualBounds().getWidth(), maxWidth);
}
// now that we know how big each line and how big is the longest,
// we can layout the items and compute the total bounds
double boundsY = 0;
double labelY = 0;
for (LineInfo info : lines) {
Rectangle2D currBounds = info.gv.getVisualBounds();
TextLayout layout = info.layout;
// the position at which we start to draw, x and y
// for x we have to take into consideration alignment as
// well since that affects the horizontal size of the
// bounds,
// for y we don't care right now as we're computing
// only the total bounds for a text located in the origin
double minX = (maxWidth - currBounds.getWidth())
* labelItem.getTextStyle().getAnchorX() - currBounds.getMinX();
info.x = minX;
if (labelBounds == null) {
labelBounds = currBounds;
boundsY = currBounds.getMinY() + layout.getAscent() + layout.getDescent()
+ layout.getLeading();
} else {
Rectangle2D translated = new Rectangle2D.Double(minX, boundsY, currBounds
.getWidth(), currBounds.getHeight());
boundsY += layout.getAscent() + layout.getDescent() + layout.getLeading();
labelY += layout.getAscent() + layout.getDescent() + layout.getLeading();
labelBounds = labelBounds.createUnion(translated);
}
info.y = labelY;
}
normalizeBounds(labelBounds);
}
/**
* If, for any reason, a font size of 0 is provided to the renderer, resulting bounds
* will become empty and this will ruin most geometric computations dealing with spacing
* and orientations. Enlarge the envelope a tiny bit
* @param bounds
*/
void normalizeBounds(Rectangle2D bounds) {
if(bounds.isEmpty()) {
bounds.setRect(bounds.getCenterX() -1 , bounds.getCenterY() -1, 2, 2);
}
}
/**
* Turns a string into the corresponding {@link GlyphVector}
*
* @param label
* @param item
* @return
*/
GlyphVector layoutSentence(String label, LabelCacheItem item) {
final Font font = item.getTextStyle().getFont();
final char[] chars = label.toCharArray();
final int length = label.length();
if (Bidi.requiresBidi(chars, 0, length)
&& new Bidi(label, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft())
return font.layoutGlyphVector(graphics.getFontRenderContext(), chars, 0, length,
Font.LAYOUT_RIGHT_TO_LEFT);
else
return font.createGlyphVector(graphics.getFontRenderContext(), chars);
}
/**
* Returns the current label item
*
* @return
*/
public LabelCacheItem getLabel() {
return labelItem;
}
/**
* Returns the line height for this label in pixels (for multiline labels,
* it's the height of the first line)
*
* @return
*/
public double getLineHeight() {
return lines.get(0).gv.getVisualBounds().getHeight() - lines.get(0).layout.getDescent();
}
/**
* The full size above the baseline
* @return
*/
public double getAscent() {
return lines.get(0).layout.getAscent();
}
/**
* Returns the width of the label, as painted in straight form (
*
* @return
*/
public int getStraightLabelWidth() {
return (int) Math.round(getLabelBounds().getWidth());
}
/**
* Number of lines for this label (more than 1 if the label has embedded
* newlines or if we're auto-wrapping it)
*
* @return
*/
public int getLineCount() {
return lines.size();
}
/**
* Get the straight label bounds, taking into account halo, shield and line
* wrapping
*
* @return
*/
public Rectangle2D getFullLabelBounds() {
// base bounds (clone them, we're going to alter the bounds directly)
Rectangle2D bounds = (Rectangle2D) getLabelBounds().clone();
// take into account halo
int haloRadius = Math.round(labelItem.getTextStyle().getHaloFill() != null ? labelItem
.getTextStyle().getHaloRadius() : 0);
bounds.add(bounds.getMinX() - haloRadius, bounds.getMinY() - haloRadius);
bounds.add(bounds.getMaxX() + haloRadius, bounds.getMaxY() + haloRadius);
// if there is a shield, expand the bounds to account for it as well
if (labelItem.getTextStyle().getGraphic() != null) {
Rectangle2D area = labelItem.getTextStyle().getGraphicDimensions();
// center the graphics on the labels back
Rectangle2D shieldBounds = new Rectangle2D.Double(-area.getWidth() / 2
+ bounds.getMinX() - bounds.getWidth() / 2, -area.getHeight() / 2
+ bounds.getMinY() - bounds.getHeight() / 2, area.getWidth(), area.getHeight());
bounds = bounds.createUnion(shieldBounds);
}
normalizeBounds(bounds);
return bounds;
}
/**
* Get the straight label bounds, without taking into account halo and
* shield
*
* @return
*/
public Rectangle2D getLabelBounds() {
return labelBounds;
}
/**
* Paints the label as a non curved one. The positioning and rotation are
* provided by the transformation
*
* @param transform
* @throws Exception
*/
public void paintStraightLabel(AffineTransform transform) throws Exception {
Rectangle2D glyphBounds = getLabelBounds();
glyphBounds = transform.createTransformedShape(glyphBounds).getBounds();
AffineTransform oldTransform = graphics.getTransform();
try {
AffineTransform newTransform = new AffineTransform(oldTransform);
newTransform.concatenate(transform);
graphics.setTransform(newTransform);
// draw the shield
if (labelItem.getTextStyle().getGraphic() != null) {
// draw the label shield first, underneath the halo
LiteShape2 tempShape = new LiteShape2(gf.createPoint(new Coordinate(glyphBounds
.getWidth() / 2.0, -1.0 * glyphBounds.getHeight() / 2.0)), null, null,
false, false);
// labels should always draw, so we'll just force this
// one to draw by setting it's min/max scale to 0<10 and
// then drawing at scale 5.0 on the next line
labelItem.getTextStyle().getGraphic().setMinMaxScale(0.0, 10.0);
new StyledShapePainter(null).paint(graphics, tempShape, labelItem.getTextStyle()
.getGraphic(), 5.0);
// graphics.setTransform(transform);
}
// 0 is unfortunately an acceptable value if people only want to draw shields
if(labelItem.getTextStyle().getFont().getSize() == 0)
return;
// draw the label
if (lines.size() == 1) {
drawGlyphVector(lines.get(0).gv);
} else {
// for multiline labels we have to go thru the lines and apply
// the proper transformation
// to position each row within the label bounds
AffineTransform lineTx = new AffineTransform(transform);
for (LineInfo line : lines) {
lineTx.setTransform(transform);
lineTx.translate(line.x, line.y);
graphics.setTransform(lineTx);
drawGlyphVector(line.gv);
}
}
} finally {
graphics.setTransform(oldTransform);
}
}
/**
* Draws the glyph vector respecting the label item options
*
* @param gv
*/
private void drawGlyphVector(GlyphVector gv) {
java.awt.Shape outline = gv.getOutline();
if (labelItem.getTextStyle().getHaloFill() != null) {
configureHalo();
graphics.draw(outline);
}
configureLabelStyle();
if (outlineRenderingEnabled)
graphics.fill(outline);
else
graphics.drawGlyphVector(gv, 0, 0);
}
/**
* Configures the graphic to do the halo drawing
*/
private void configureHalo() {
graphics.setPaint(labelItem.getTextStyle().getHaloFill());
graphics.setComposite(labelItem.getTextStyle().getHaloComposite());
float haloRadius = labelItem.getTextStyle().getHaloFill() != null ? labelItem.getTextStyle().getHaloRadius() : 0;
graphics.setStroke(new BasicStroke(2 * haloRadius, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
}
/**
* Configures the graphic to do the text drawing
*/
private void configureLabelStyle() {
// DJB: added this because several people were using
// "font-color" instead of fill
// It legal to have a label w/o fill (which means dont
// render it)
// This causes people no end of trouble.
// If they dont want to colour it, then they should use a
// filter
// DEFAULT (no <Fill>) --> BLACK
// NOTE: re-reading the spec says this is the correct
// assumption.
Paint fill = labelItem.getTextStyle().getFill();
Composite comp = labelItem.getTextStyle().getComposite();
if (fill == null) {
fill = Color.BLACK;
comp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f); // 100%
// opaque
}
graphics.setPaint(fill);
graphics.setComposite(comp);
}
/**
* Paints a label that follows the line, centered in the current cursor
* position
*
* @param cursor
*/
public void paintCurvedLabel(LineStringCursor cursor) {
// 0 is unfortunately an acceptable value if people only want to draw shields
if(labelItem.getTextStyle().getFont().getSize() == 0)
return;
GlyphVector glyphVector = lines.get(0).gv;
AffineTransform oldTransform = graphics.getTransform();
try {
// first off, check if we are walking the line so that the label is
// looking up, if not, reverse the line
if (!isLabelUpwards(cursor) && labelItem.isForceLeftToRightEnabled()) {
LineStringCursor reverse = cursor.reverse();
reverse.moveTo(cursor.getLineStringLength() - cursor.getCurrentOrdinate());
cursor = reverse;
}
// find out the true centering position
double anchorY = getLinePlacementYAnchor();
// init, move to the starting position
double mid = cursor.getCurrentOrdinate();
Coordinate c = new Coordinate();
c = cursor.getCurrentPosition(c);
graphics.setPaint(Color.BLACK);
double startOrdinate = mid - getStraightLabelWidth() / 2;
if (startOrdinate < 0)
startOrdinate = 0;
cursor.moveTo(startOrdinate);
final int numGlyphs = glyphVector.getNumGlyphs();
float nextAdvance = glyphVector.getGlyphMetrics(0).getAdvance() * 0.5f;
Shape[] outlines = new Shape[numGlyphs];
AffineTransform[] transforms = new AffineTransform[numGlyphs];
for (int i = 0; i < numGlyphs; i++) {
outlines[i] = glyphVector.getGlyphOutline(i);
Point2D p = glyphVector.getGlyphPosition(i);
float advance = nextAdvance;
nextAdvance = i < numGlyphs - 1 ? glyphVector.getGlyphMetrics(i + 1).getAdvance() * 0.5f
: 0;
c = cursor.getCurrentPosition(c);
AffineTransform t = new AffineTransform();
t.setToTranslation(c.x, c.y);
t.rotate(cursor.getCurrentAngle());
t.translate(-p.getX() - advance, -p.getY() + getLineHeight() * anchorY);
transforms[i] = t;
cursor.moveTo(cursor.getCurrentOrdinate() + advance + nextAdvance);
}
// draw halo and label
if (labelItem.getTextStyle().getHaloFill() != null) {
configureHalo();
for (int i = 0; i < numGlyphs; i++) {
graphics.setTransform(transforms[i]);
graphics.draw(outlines[i]);
}
}
configureLabelStyle();
for (int i = 0; i < numGlyphs; i++) {
graphics.setTransform(transforms[i]);
graphics.fill(outlines[i]);
}
} finally {
graphics.setTransform(oldTransform);
}
}
/**
* Vertical centering is not trivial, because visually we want centering on
* characters such as a,m,e, and not centering on d,g whose center is
* affected by the full ascent or the full descent. This method tries to
* computes the y anchor taking into account those.
*/
public double getLinePlacementYAnchor() {
TextStyle2D textStyle = getLabel().getTextStyle();
LineMetrics lm = textStyle.getFont().getLineMetrics(textStyle.getLabel(),
graphics.getFontRenderContext());
// gracefully handle font size = 0
if(lm.getHeight() > 0)
return (Math.abs(lm.getStrikethroughOffset()) + lm.getDescent() + lm.getLeading() / 2)
/ lm.getHeight();
else
return 0;
}
/**
* Returns true if a label placed in the current cursor position would look
* upwards or not, defining upwards a label whose bottom to top direction is
* greater than zero, and less or equal to 180 degrees.
*
* @param cursor
* @return
*/
boolean isLabelUpwards(LineStringCursor cursor) {
// label angle is orthogonal to the line direction
double labelAngle = cursor.getCurrentAngle() + Math.PI / 2;
// normalize the angle so that it's comprised between 0 and 360°
labelAngle = labelAngle % (Math.PI * 2);
return labelAngle >= 0 && labelAngle < Math.PI;
}
/**
* Core information needed to draw out a line of text
*/
private static class LineInfo {
// the coordinates at which the label should be drawn withing the global
// label bounds (so these are relative coordinates)
double x;
double y;
// the text to be drawn
String text;
// the text represented as a glyph vector
GlyphVector gv;
// the text layout
TextLayout layout;
public LineInfo(String text, GlyphVector gv, TextLayout layout) {
super();
this.text = text;
this.gv = gv;
this.layout = layout;
}
public LineInfo(String text, GlyphVector gv) {
super();
this.text = text;
this.gv = gv;
}
}
}