/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2001-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.coverage.io;
import java.io.IOException;
import java.io.StringWriter;
import java.text.ParseException;
import java.util.Collections;
import java.util.Map;
import javax.measure.unit.NonSI;
import javax.measure.unit.SI;
import javax.measure.unit.Unit;
import javax.measure.unit.UnitFormat;
import org.geotools.coverage.grid.GeneralGridEnvelope;
import org.geotools.geometry.GeneralEnvelope;
import org.geotools.image.io.metadata.Axis;
import org.geotools.image.io.metadata.GeographicMetadata;
import org.geotools.image.io.metadata.GeographicMetadataFormat;
import org.geotools.image.io.metadata.Identification;
import org.geotools.image.io.metadata.ImageGeometry;
import org.geotools.image.io.metadata.ImageReferencing;
import org.geotools.image.io.metadata.Parameter;
import org.geotools.image.io.text.TextMetadataParser;
import org.geotools.io.TableWriter;
import org.geotools.metadata.iso.extent.GeographicBoundingBoxImpl;
import org.geotools.referencing.crs.DefaultGeographicCRS;
import org.geotools.referencing.cs.DefaultCoordinateSystemAxis;
import org.geotools.referencing.cs.DefaultEllipsoidalCS;
import org.geotools.referencing.datum.DefaultEllipsoid;
import org.geotools.referencing.datum.DefaultGeodeticDatum;
import org.geotools.referencing.datum.DefaultPrimeMeridian;
import org.geotools.referencing.factory.ReferencingFactoryContainer;
import org.geotools.referencing.operation.DefiningConversion;
import org.geotools.resources.OptionalDependencies;
import org.geotools.util.NumberRange;
import org.opengis.coverage.grid.GridEnvelope;
import org.opengis.geometry.Envelope;
import org.opengis.metadata.extent.GeographicBoundingBox;
import org.opengis.parameter.ParameterNotFoundException;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.referencing.FactoryException;
import org.opengis.referencing.NoSuchIdentifierException;
import org.opengis.referencing.crs.CRSFactory;
import org.opengis.referencing.cs.AxisDirection;
import org.opengis.referencing.cs.CartesianCS;
import org.opengis.referencing.cs.CoordinateSystem;
import org.opengis.referencing.cs.CoordinateSystemAxis;
import org.opengis.referencing.cs.CSFactory;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.opengis.referencing.crs.GeographicCRS;
import org.opengis.referencing.cs.EllipsoidalCS;
import org.opengis.referencing.datum.Datum;
import org.opengis.referencing.datum.DatumFactory;
import org.opengis.referencing.datum.Ellipsoid;
import org.opengis.referencing.datum.GeodeticDatum;
import org.opengis.referencing.datum.PrimeMeridian;
import org.opengis.referencing.operation.Conversion;
import org.opengis.referencing.operation.MathTransformFactory;
import org.opengis.referencing.operation.TransformException;
/**
* Helper class for creating OpenGIS's object from a set of metadata.
* <p>Metadata are already stored into a {@linkplain GeographicMetadata geographic metadata}
* object, which provides a representation of these metadata as a tree, trying to respect
* the <a href="http://www.opengeospatial.org/standards/gmljp2">GML in JPEG 2000</a>
* standard.</p>
* <p>It provides a set of {@code getXXX()} methods for constructing various objects from
* those information. For example, the {@link #getCoordinateReferenceSystem} method
* constructs a {@link CoordinateReferenceSystem} object using available information.</p>
*
*
* @source $URL$
* @version $Id$
* @author Martin Desruisseaux (IRD)
* @author Cédric Briançon
*
* @since 2.2
*/
public class MetadataReader {
/**
* Set of commonly used symbols for "metres".
*
* @todo Needs a more general way to set unit symbols once the Unit API is completed.
*/
private static final String[] METRES = {
"meter", "meters", "metre", "metres", "m"
};
/**
* Set of commonly used symbols for "degrees".
*
* @todo Needs a more general way to set unit symbols once the Unit API is completed.
*/
private static final String[] DEGREES = {
"degree", "degrees", "deg", "°"
};
/**
* Set of {@linkplain DefaultEllipsoid ellipsoids} already defined.
*/
private static final DefaultEllipsoid[] ELLIPSOIDS = new DefaultEllipsoid[] {
DefaultEllipsoid.CLARKE_1866, DefaultEllipsoid.GRS80, DefaultEllipsoid.INTERNATIONAL_1924,
DefaultEllipsoid.SPHERE, DefaultEllipsoid.WGS84
};
/**
* The factories to use for constructing ellipsoids, projections, coordinate reference
* systems...
*/
private final ReferencingFactoryContainer factories;
/**
* The geographic metadata to consider. It should be filled with the
* {@link #setGeographicMetadata} method, before to called to any getter of this
* class, since the constructor does not allow to fix its value.
*/
private GeographicMetadata metadata;
/**
* The object to use for parsing and formatting units.
*/
private UnitFormat unitFormat;
/**
* Constructs a new {@code MetadataReader} using default factories and geographic
* metadata. Do not forget to call {@link #setGeographicMetadata(GeographicMetadata)}
* in order to fix the metadata value.
*/
public MetadataReader() {
factories = ReferencingFactoryContainer.instance(null);
}
/**
* Constructs a new {@code MetadataReader} using the specified factories. Do not
* forget to call {@link #setGeographicMetadata} in order to
* fix the metadata value.
*
* @param factories The specified factories. Should not be {@code null}.
*/
public MetadataReader(final ReferencingFactoryContainer factories) {
this.factories = factories;
}
/**
* Constructs an {@linkplain CoordinateSystemAxis axis} using the information from
* the {@linkplain GeographicMetadata metadata}, or returns {@code null} if the axis
* does not exist in the metadata tree.
*
* @param dimension The dimension to consider. It should be lower than
* {@link ImageGeometry#getDimension()}
* @return An axis with information gotten from the
* {@linkplain GeographicMetadata metadata}, or {@code null} if the axis
* does not exist for the specified dimension.
* @throws MetadataException if the method {@link CSFactory#createCoordinateSystemAxis}
* has not succeed.
*/
public synchronized CoordinateSystemAxis getAxis(final int dimension)
throws MetadataException
{
final ImageReferencing referencing = metadata.getReferencing();
if (metadata.getGeometry().getDimension() < dimension) {
return null;
}
final Axis axis = referencing.getAxis(dimension);
String axisName = axis.getName();
AxisDirection direction = AxisDirection.valueOf(axis.getDirection());
if (axisName == null) {
final String projectionName = referencing.getProjectionName();
switch (dimension) {
case 0: axisName = (projectionName == null) ? "longitude" : "x";
direction = (direction == null) ? AxisDirection.EAST : direction; break;
case 1: axisName = (projectionName == null) ? "latitude" : "y";
direction = (direction == null) ? AxisDirection.NORTH : direction; break;
case 2: axisName = (projectionName == null) ? "depth" : "z";
direction = (direction == null) ? AxisDirection.UP : direction; break;
case 3: axisName = (projectionName == null) ? "time" : "t";
direction = (direction == null) ? AxisDirection.FUTURE : direction; break;
}
}
final DefaultCoordinateSystemAxis axisFound =
DefaultCoordinateSystemAxis.getPredefined(axisName, direction);
if (axisFound != null) {
return axisFound;
}
/* The current axis defined in the metadata tree is not already known in the Geotools
* implementation, so one will build it using those information.
*/
final String unitName = axis.getUnits();
final Unit<?> unit = getUnit(unitName);
final Map<String,String> map = Collections.singletonMap("name", axisName);
try {
return factories.getCSFactory().createCoordinateSystemAxis(map, axisName, direction, unit);
} catch (FactoryException e) {
throw new MetadataException(e.getLocalizedMessage());
}
}
/**
* Returns the unit which matches with the name given.
*
* @param unitName The name of the unit. Should not be {@code null}.
* @return The unit matching with the specified name.
* @throws MetadataException if the unit name does not match with the
* {@linkplain #unitFormat unit format}.
*/
private Unit<?> getUnit(final String unitName) throws MetadataException {
if (contains(unitName, METRES)) {
return SI.METER;
} else if (contains(unitName, DEGREES)) {
return NonSI.DEGREE_ANGLE;
} else {
if (unitFormat == null) {
unitFormat = UnitFormat.getInstance();
}
try {
return (Unit) unitFormat.parseObject(unitName);
} catch (ParseException e) {
throw new MetadataException("Unit not known : " + unitName, e);
}
}
}
/**
* Check if {@code toSearch} appears in the {@code list} array.
* Search is case-insensitive. This is a temporary patch (will be removed
* when the final API for JSR-108: Units specification will be available).
*/
private static boolean contains(final String toSearch, final String[] list) {
for (int i=list.length; --i>=0;) {
if (toSearch.equalsIgnoreCase(list[i])) {
return true;
}
}
return false;
}
/**
* Returns the datum. The default implementation performs the following steps:
* <p>
* <ul>
* <li>Verifies if the datum name contains {@code WGS84}, and returns a
* {@link DefaultGeodeticDatum#WGS84} geodetic datum if it is the case.
* </li>
* <li>Builds a {@linkplain PrimeMeridian prime meridian} using information
* stored into the metadata tree.
* </li>
* <li>Returns a {@linkplain DefaultGeodeticDatum geodetic datum} built on the
* prime meridian.
* </li>
* </ul>
* </p>
* @throws MetadataException if the datum is not defined, or if the {@link #getEllipsoid}
* method fails.
*
* @todo: The current implementation only returns a
* {@linkplain GeodeticDatum geodetic datum}, other kind of datum have
* to be generated too.
*
* @see #getEllipsoid
*/
public synchronized Datum getDatum() throws MetadataException {
final ImageReferencing referencing = metadata.getReferencing();
final Identification identDatum = referencing.getDatum();
if (identDatum == null) {
throw new MetadataException("The datum is not defined.");
}
final String name = identDatum.name;
if (name == null) {
throw new MetadataException("Datum name not defined.");
}
if (name.toUpperCase().contains("WGS84")) {
return DefaultGeodeticDatum.WGS84;
}
final String primeMeridianName = referencing.getPrimeMeridianName();
final PrimeMeridian primeMeridian;
/* By default, if the prime meridian name is not defined, or if it is defined with
* {@code Greenwich}, one chooses the {@code Greenwich} meridian as prime meridian.
* Otherwise one builds it, using the {@code greenwichLongitude} parameter.
*/
if ((primeMeridianName == null) ||
(primeMeridianName != null && primeMeridianName.toLowerCase().contains("greenwich"))) {
primeMeridian = DefaultPrimeMeridian.GREENWICH;
} else {
final double greenwichLon = referencing.getPrimeMeridianGreenwichLongitude();
primeMeridian = (Double.isNaN(greenwichLon)) ? DefaultPrimeMeridian.GREENWICH :
new DefaultPrimeMeridian(primeMeridianName, greenwichLon);
}
return new DefaultGeodeticDatum(name, getEllipsoid(), primeMeridian);
}
/**
* Returns the ellipsoid. Depending on whether {@link ImageReferencing#semiMinorAxis}
* or {@link ImageReferencing#inverseFlattening} has been defined, the default
* implementation will construct an ellispoid using {@link DatumFactory#createEllipsoid}
* or {@link DatumFactory#createFlattenedSphere} respectively.
*
* @throws MetadataException if the operation failed to create the
* {@linkplain Ellipsoid ellipsoid}.
*
* @see #getUnit(String)
*/
public synchronized Ellipsoid getEllipsoid() throws MetadataException {
final ImageReferencing referencing = metadata.getReferencing();
final String name = referencing.getEllipsoidName();
if (name != null) {
for (final DefaultEllipsoid ellipsoid : ELLIPSOIDS) {
if (ellipsoid.nameMatches(name)) {
return ellipsoid;
}
}
} else {
throw new MetadataException("Ellipsoid name not defined.");
}
// It has a name defined, but it is not present in the list of known ellipsoids.
final double semiMajorAxis = referencing.getSemiMajorAxis();
if (Double.isNaN(semiMajorAxis)) {
throw new MetadataException("Ellipsoid semi major axis not defined.");
}
final double semiMinorAxis = referencing.getSemiMinorAxis();
final String ellipsoidUnit = referencing.getEllipsoidUnit();
if (ellipsoidUnit == null) {
throw new MetadataException("Ellipsoid unit not defined.");
}
final Unit unit = getUnit(ellipsoidUnit);
final Map<String,String> map = Collections.singletonMap("name", name);
try {
final DatumFactory datumFactory = factories.getDatumFactory();
return (!Double.isNaN(semiMinorAxis)) ?
datumFactory.createEllipsoid(map, semiMajorAxis, semiMinorAxis, unit) :
datumFactory.createFlattenedSphere(
map, semiMajorAxis, referencing.getInverseFlattening(), unit);
} catch (FactoryException e) {
throw new MetadataException(e.getLocalizedMessage());
}
}
/**
* Returns the projection. The default implementation performs the following steps:
* <p>
* <ul>
* <li>Gets a {@linkplain ParameterValueGroup parameter value group} from a
* {@link MathTransformFactory}</li>
*
* <li>Gets the metadata values for each parameters in the above step. If a parameter is not
* defined in this {@code MetadataReader}, then it will be left to its (projection
* dependent) default value. Parameters are projection dependent, but will typically
* include
*
* {@code "semi_major"},
* {@code "semi_minor"} (or {@code "inverse_flattening"}),
* {@code "central_meridian"},
* {@code "latitude_of_origin"},
* {@code "false_easting"} and
* {@code "false_northing"}.
*
* The names actually used in the metadata file to be parsed must be declared as usual,
* e.g. <code>{@linkplain TextMetadataParser#addAlias addAlias}
* ({@linkplain TextMetadataParser#SEMI_MAJOR}, ...)</code></li>
*
* <li>Computes and returns a {@linkplain DefiningConversion conversion} using the name of the
* projection and the parameter value group previously filled.</li>
* </ul>
* </p>
* @return The projection.
* @throws MetadataException if the operation failed for some other reason
* (for example if a parameter value can't be parsed as a {@code double}).
*
* @see TextMetadataParser#SEMI_MAJOR
* @see TextMetadataParser#SEMI_MINOR
* @see TextMetadataParser#INVERSE_FLATTENING
* @see TextMetadataParser#LATITUDE_OF_ORIGIN
* @see TextMetadataParser#CENTRAL_MERIDIAN
* @see TextMetadataParser#FALSE_EASTING
* @see TextMetadataParser#FALSE_NORTHING
*/
public synchronized Conversion getProjection() throws MetadataException {
final MathTransformFactory mathTransformFactory = factories.getMathTransformFactory();
final ImageReferencing referencing = metadata.getReferencing();
final String projectionName = referencing.getProjectionName();
if (projectionName == null) {
throw new MetadataException("Projection name is not defined.");
}
final ParameterValueGroup paramValueGroup;
try {
paramValueGroup = mathTransformFactory.getDefaultParameters(projectionName);
} catch (NoSuchIdentifierException e) {
throw new MetadataException(e.getLocalizedMessage());
}
for (final Parameter parameter : referencing.getParameters()) {
final String name = parameter.getName();
if (name == null) {
continue;
}
final double value = parameter.getValue();
if (Double.isNaN(value)) {
continue;
}
try {
paramValueGroup.parameter(name).setValue(value);
} catch (ParameterNotFoundException e) {
// Should not happened. Continue with the next parameter, the current one
// will be ignored.
continue;
}
}
return new DefiningConversion(projectionName, paramValueGroup);
}
/**
* Returns the {@linkplain CoordinateReferenceSystem coordinate reference system}.
* The default implementation builds a coordinate reference system using the
* {@linkplain #getDatum datum} and the {@linkplain #getCoordinateSystem coordinate system}
* defined in the metadata.
*
* @throws MetadataException if the creation of the coordinate reference system fails.
*
* @see #getDatum
* @see #getCoordinateSystem
* @see CoordinateReferenceSystem
*/
public synchronized CoordinateReferenceSystem getCoordinateReferenceSystem()
throws MetadataException
{
final ImageReferencing referencing = metadata.getReferencing();
String name = referencing.getCoordinateReferenceSystem().name;
if (name == null) {
name = "Unknown";
}
if (name.contains("WGS84")){
return (name.contains("3D")) ? DefaultGeographicCRS.WGS84_3D :
DefaultGeographicCRS.WGS84;
}
String type = referencing.getCoordinateReferenceSystem().type;
if (type == null) {
type = (referencing.getProjectionName() == null) ?
GeographicMetadataFormat.GEOGRAPHIC : GeographicMetadataFormat.PROJECTED;
}
final CRSFactory factory = factories.getCRSFactory();
final Map<String,String> map = Collections.singletonMap("name", name);
try {
if (type.equalsIgnoreCase(GeographicMetadataFormat.GEOGRAPHIC)) {
return factory.createGeographicCRS(map, (GeodeticDatum)getDatum(),
(EllipsoidalCS)getCoordinateSystem());
} else {
final GeographicCRS baseCRS = factory.createGeographicCRS(map,
(GeodeticDatum) getDatum(), DefaultEllipsoidalCS.GEODETIC_2D);
return factory.createProjectedCRS(map, baseCRS, getProjection(),
(CartesianCS) getCoordinateSystem());
}
} catch (FactoryException e) {
throw new MetadataException(e.getLocalizedMessage());
}
}
/**
* Returns the {@linkplain CoordinateSystem coordinate system}. The default implementation
* builds a coordinate system using the {@linkplain #getAxis axes} defined in the
* metadata.
*
* @throws MetadataException if there is less than 2 axes defined in the metadata, or if
* the creation of the coordinate system failed.
*
* @see #getAxis
* @see CoordinateSystem
*/
public synchronized CoordinateSystem getCoordinateSystem() throws MetadataException {
final ImageReferencing referencing = metadata.getReferencing();
Identification cs = referencing.getCoordinateSystem();
if (cs == null) {
cs = new Identification("Unknown", null);
}
String name = cs.name;
if (name == null) {
name = "Unknown";
}
String type = cs.type;
if (type == null) {
type = (referencing.getProjectionName() == null) ?
GeographicMetadataFormat.ELLIPSOIDAL :
GeographicMetadataFormat.CARTESIAN;
}
final CSFactory factory = factories.getCSFactory();
final Map<String,String> map = Collections.singletonMap("name", name);
final int dimension = metadata.getGeometry().getDimension();
if (dimension < 2) {
throw new MetadataException("Number of dimension error : " + dimension);
}
try {
if (type.equalsIgnoreCase(GeographicMetadataFormat.CARTESIAN)) {
return (dimension < 3) ?
factory.createCartesianCS(map, getAxis(0), getAxis(1)) :
factory.createCartesianCS(map, getAxis(0), getAxis(1), getAxis(2));
}
if (type.equalsIgnoreCase(GeographicMetadataFormat.ELLIPSOIDAL)) {
return (dimension < 3) ?
factory.createEllipsoidalCS(map, getAxis(0), getAxis(1)) :
factory.createEllipsoidalCS(map, getAxis(0), getAxis(1), getAxis(2));
}
/* Should not happened, since the type value should be contained in the
* {@link GeographicMetadataFormat#CS_TYPES} list.
*/
throw new MetadataException("Coordinate system type not known : " + type);
} catch (FactoryException e) {
throw new MetadataException(e.getLocalizedMessage());
}
}
/**
* Returns the envelope. The default implementation constructs an envelope
* using the values from the {@linkplain GeographicMetadata metadata} tree
* <ul>
* <li>The horizontal limits {@link ImageGeometry#lowerCorner}.</li>
* <li>The vertical limits {@link ImageGeometry#upperCorner}.</li>
* </ul>
*
* @throws MetadataException if the dimension specified for the envelope is illegal
*/
public synchronized Envelope getEnvelope() throws MetadataException {
final ImageGeometry geometry = metadata.getGeometry();
final int dimension = geometry.getDimension();
final GeneralEnvelope envelope = new GeneralEnvelope(dimension);
for (int i=0; i<dimension; i++) {
final NumberRange<Double> range = geometry.getOrdinateRange(i);
final double min = range .getMinimum();
final double max = range .getMaximum();
try {
envelope.setRange(i, min, max);
} catch (IndexOutOfBoundsException e) {
// Should not occur, but if it comes, throws the exception.
throw new MetadataException(e.getLocalizedMessage());
}
}
return envelope.clone();
}
/**
* Convenience method returning the envelope in geographic coordinate reference system.
* Note that the geographic CRS doesn't need to use the 1984 datum, since geographic
* bounding boxes are approximative.
*
* @throws MetadataException if the operation failed. This exception
* may contains a {@link TransformException} as its cause.
*
* @see #getEnvelope
*/
public synchronized GeographicBoundingBox getGeographicBoundingBox() throws MetadataException {
final GeographicBoundingBoxImpl box;
try {
box = new GeographicBoundingBoxImpl(getEnvelope());
} catch (TransformException exception) {
throw new MetadataException(exception.getLocalizedMessage());
}
box.freeze();
return box;
}
/**
* Returns the current {@linkplain GeographicMetadata geographic metadata}, or
* {@code null} if not already defined.
*/
public GeographicMetadata getGeographicMetadata() {
return metadata;
}
/**
* Sets the current geographic metadata. It should be called before using a getter
* of this class.
*/
public void setGeographicMetadata(final GeographicMetadata metadata) {
this.metadata = metadata;
}
/**
* Returns the grid range. Default implementation fetchs the metadata values
* for nodes {@link ImageGeometry#low} and {@link ImageGeometry#high}, and
* transform the resulting strings into a {@linkplain GridEnvelope grid range}
* object.
*
* @throws MissingMetadataException if a required value is missing.
* @throws MetadataException if the operation failed for some other reason.
*
* @see ImageGeometry#getGridRange
*/
public synchronized GridEnvelope getGridRange() throws MetadataException {
final ImageGeometry geometry = metadata.getGeometry();
final int dimension = geometry.getDimension();
final int[] lowers = new int[dimension];
final int[] uppers = new int[dimension];
for (int i=0; i<dimension; i++) {
final NumberRange<Integer> range = geometry.getGridRange(i);
lowers[i] = range.getMinValue();
uppers[i] = range.getMaxValue();
if (!range.isMinIncluded()) lowers[i]++;
if (!range.isMaxIncluded()) uppers[i]--;
}
return new GeneralGridEnvelope(lowers, uppers, true);
}
/**
* Returns a string representation of this metadata set. The default implementation
* write the class name and the envelope in geographic coordinates, as returned by
* {@link #getGeographicBoundingBox}. Then, it append the list of all metadata as
* formatted by {@link GeographicMetadata#getAsTree}.
*/
@Override
public String toString() {
final String lineSeparator = System.getProperty("line.separator", "\n");
final StringWriter buffer = new StringWriter();
buffer.write(lineSeparator);
try {
final GeographicBoundingBox box = getGeographicBoundingBox();
buffer.write(GeographicBoundingBoxImpl.toString(box, "DD°MM'SS\"", null));
buffer.write(lineSeparator);
} catch (MetadataException exception) {
// Ignore.
}
buffer.write('{');
buffer.write(lineSeparator);
try {
final TableWriter table = new TableWriter(buffer, 2);
table.setMultiLinesCells(true);
table.nextColumn();
table.write(OptionalDependencies.toString(OptionalDependencies.xmlToSwing(
metadata.getAsTree(GeographicMetadataFormat.FORMAT_NAME))));
table.flush();
} catch (IOException exception) {
buffer.write(exception.getLocalizedMessage());
}
buffer.write('}');
buffer.write(lineSeparator);
return buffer.toString();
}
/**
* Trim a character string. Leading and trailing spaces are removed. Any succession of
* one ore more unicode whitespace characters (as of {@link Character#isSpaceChar(char)}
* are replaced by a single <code>'_'</code> character. Example:
*
* <pre>"This is a test"</pre>
* will be returned as <pre>"This_is_a_test"</pre>
*
* @param str The string to trim (may be {@code null}).
* @param separator The separator to insert in place of succession of whitespaces.
* Usually "_" for keys and " " for values.
* @return The trimed string, or {@code null} if <code>str</code> was null.
*/
static String trim(String str, final String separator) {
if (str != null) {
str = str.trim();
StringBuilder buffer = null;
loop: for (int i=str.length(); --i>=0;) {
if (Character.isSpaceChar(str.charAt(i))) {
final int upper = i;
do if (--i < 0) break loop;
while (Character.isSpaceChar(str.charAt(i)));
if (buffer == null) {
buffer = new StringBuilder(str);
}
buffer.replace(i+1, upper+1, separator);
}
}
if (buffer != null) {
return buffer.toString();
}
}
return str;
}
}