package cgeo.geocaching.location;
import cgeo.geocaching.utils.MatcherWrapper;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
/**
* Parse coordinates.
*/
public class GeopointParser {
private static final Pattern PATTERN_BAD_BLANK = Pattern.compile("(\\d)[,.] (\\d{2,})");
private static final List<AbstractParser> parsers = Arrays.asList(new MinDecParser(), new MinParser(), new DegParser(), new DMSParser(), new ShortDMSParser(), new DegDecParser(), new ShortDegDecParser(), new UTMParser());
private GeopointParser() {
// utility class
}
private static class ResultWrapper {
final double result;
final int matcherLength;
ResultWrapper(final double result, final int stringLength) {
this.result = result;
this.matcherLength = stringLength;
}
}
private static class GeopointWrapper {
final Geopoint geopoint;
final int matcherStart;
final int matcherLength;
GeopointWrapper(final Geopoint geopoint, final int stringStart, final int stringLength) {
this.geopoint = geopoint;
this.matcherStart = stringStart;
this.matcherLength = stringLength;
}
}
/**
* Abstract parser for coordinate formats.
*/
private abstract static class AbstractParser {
/**
* Parses coordinates out of the given string.
*
* @param text
* the string to be parsed
* @return a wrapper with the parsed coordinates and the length of the match, or null if parsing failed
*/
@Nullable
abstract GeopointWrapper parse(@NonNull String text);
/**
* Parses latitude or longitude out of the given string.
*
* @param text
* the string to be parsed
* @param latlon
* whether to parse latitude or longitude
* @return a wrapper with the parsed latitude/longitude and the length of the match, or null if parsing failed
*/
@Nullable
abstract ResultWrapper parse(@NonNull String text, @NonNull LatLon latlon);
}
/**
* Abstract parser for coordinates that consist of two syntactic parts: latitude and longitude.
*/
private abstract static class AbstractLatLonParser extends AbstractParser {
private final Pattern latPattern;
private final Pattern lonPattern;
private final Pattern latLonPattern;
AbstractLatLonParser(@NonNull final Pattern latPattern, @NonNull final Pattern lonPattern, @NonNull final Pattern latLonPattern) {
this.latPattern = latPattern;
this.lonPattern = lonPattern;
this.latLonPattern = latLonPattern;
}
/**
* Creates latitude or longitude out of matches groups for sign, degrees, minutes and seconds.
*
* @param signGroup
* a string representing the sign of the coordinate, ignored if empty
* @param degreesGroup
* a string representing the degrees of the coordinate, ignored if empty
* @param minutesGroup
* a string representing the minutes of the coordinate, ignored if empty
* @param secondsGroup
* a string representing the seconds of the coordinate, ignored if empty
* @return the latitude/longitude in decimal degrees, or null if creation failed
*/
@Nullable
Double createCoordinate(@NonNull final String signGroup, @NonNull final String degreesGroup, @NonNull final String minutesGroup, @NonNull final String secondsGroup) {
try {
final double seconds = Double.parseDouble(StringUtils.defaultIfEmpty(secondsGroup.replace(",", "."), "0"));
if (seconds >= 60.0) {
return null;
}
final double minutes = Double.parseDouble(StringUtils.defaultIfEmpty(minutesGroup.replace(",", "."), "0"));
if (minutes >= 60.0) {
return null;
}
final double degrees = Double.parseDouble(StringUtils.defaultIfEmpty(degreesGroup.replace(",", "."), "0"));
final double sign = signGroup.equalsIgnoreCase("S") || signGroup.equalsIgnoreCase("W") ? -1.0 : 1.0;
return sign * (degrees + minutes / 60.0 + seconds / 3600.0);
} catch (final NumberFormatException ignored) {
// We might have encountered too large a number
}
return null;
}
/**
* Checks whether is not zero.
*
* @return true if the given coordinate does not represent a zero.
*/
boolean isNotZero(@Nullable final Double coordinate) {
return coordinate == null || Double.doubleToRawLongBits(coordinate) != 0L;
}
/**
* Parses latitude or longitude out of a given range of matched groups.
*
* @param matcher
* the matcher that holds the matches groups
* @param first
* the first group to parse
* @param last
* the last group to parse
* @return the parsed latitude/longitude, or null if parsing failed
*/
@Nullable
private Double parseGroups(@NonNull final MatcherWrapper matcher, final int first, final int last) {
final List<String> groups = new ArrayList<>(last - first + 1);
for (int i = first; i <= last; i++) {
groups.add(matcher.group(i));
}
return parse(groups);
}
/**
* @see AbstractParser#parse(String)
*/
@Override
@Nullable
final GeopointWrapper parse(@NonNull final String text) {
final String withoutSpaceAfterComma = removeAllSpaceAfterComma(text);
final MatcherWrapper matcher = new MatcherWrapper(latLonPattern, withoutSpaceAfterComma);
if (matcher.find()) {
final int groupCount = matcher.groupCount();
final int partCount = groupCount / 2;
final Double lat = parseGroups(matcher, 1, partCount);
if (lat == null || !Geopoint.isValidLatitude(lat)) {
return null;
}
final Double lon = parseGroups(matcher, partCount + 1, groupCount);
if (lon == null || !Geopoint.isValidLongitude(lon)) {
return null;
}
return new GeopointWrapper(new Geopoint(lat, lon), matcher.start(), matcher.group().length());
}
return null;
}
/**
* @see AbstractParser#parse(String, LatLon)
*/
@Override
@Nullable
final ResultWrapper parse(@NonNull final String text, @NonNull final LatLon latlon) {
final MatcherWrapper matcher = new MatcherWrapper(latlon == LatLon.LAT ? latPattern : lonPattern, text);
if (matcher.find()) {
final Double res = parseGroups(matcher, 1, matcher.groupCount());
if (res != null) {
return new ResultWrapper(res, matcher.group().length());
}
}
return null;
}
/**
* Parses latitude or longitude from matched groups of corresponding pattern.
*
* @param groups
* the groups matched by latitude/longitude pattern
* @return parsed latitude/longitude, or null if parsing failed
*/
@Nullable
abstract Double parse(@NonNull List<String> groups);
}
/**
* Parser for partial MinDec format: X DD°.
*/
private static final class DegParser extends AbstractLatLonParser {
// ( 1 ) ( 2 )
private static final String STRING_LAT = "\\b([NS]?)\\s*(\\d++)°";
// ( 1 ) ( 2 )
private static final String STRING_LON = "([WEO]?)\\s*(\\d++)\\b°";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile("\\b" + STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
DegParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
final String group2 = groups.get(1);
final Double result = createCoordinate(group1, group2, "", "");
if (StringUtils.isBlank(group1) && isNotZero(result)) {
return null;
}
return result;
}
}
/**
* Parser for partial MinDec format: X DD° MM'.
*/
private static final class MinParser extends AbstractLatLonParser {
// ( 1 ) ( 2 )( 3) ( 4 )
private static final String STRING_LAT = "\\b([NS]?)\\s*(\\d++)(°?)\\s*(\\d++)['′]?";
// ( 1 ) ( 2 )( 3) ( 4 )
private static final String STRING_LON = "([WEO]?)\\s*(\\d++)(°?)\\s*(\\d++)\\b['′]?";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile("\\b" + STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
MinParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
final String group2 = groups.get(1);
final String group3 = groups.get(2);
final String group4 = groups.get(3);
final Double result = createCoordinate(group1, group2, group4, "");
if (StringUtils.isBlank(group1) && (StringUtils.isBlank(group3) || isNotZero(result))) {
return null;
}
return result;
}
}
/**
* Parser for MinDec format: X DD° MM.MMM'.
*/
private static final class MinDecParser extends AbstractLatLonParser {
// ( 1 ) ( 2 ) ( 3 )
private static final String STRING_LAT = "\\b([NS]?)\\s*(\\d++°?|°)\\s*(\\d++[.,]\\d++)['′]?";
// ( 1 ) ( 2 ) ( 3 )
private static final String STRING_LON = "([WEO]?)\\s*(\\d++°?|°)\\s*(\\d++[.,]\\d++)\\b['′]?";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile("\\b" + STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
MinDecParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
final String group2 = groups.get(1);
final String group3 = groups.get(2);
// Handle empty degrees part (see #4620)
final String strippedGroup2 = StringUtils.stripEnd(group2, "°");
final Double result = createCoordinate(group1, strippedGroup2, group3, "");
if (StringUtils.isBlank(group1) && (!StringUtils.endsWith(group2, "°") || isNotZero(result))) {
return null;
}
return result;
}
}
/**
* Parser for DMS format: X DD° MM' SS.SS".
*/
private static final class DMSParser extends AbstractLatLonParser {
// ( 1 ) ( 2 )( 3) ( 4 ) ( 5 )
private static final String STRING_LAT = "\\b([NS]?)\\s*(\\d++)(°?)\\s*(\\d++)['′]?\\s*(\\d++[.,]\\d++)(?:''|\"|″)?";
// ( 1 ) ( 2 )( 3) ( 4 ) ( 5 )
private static final String STRING_LON = "([WEO]?)\\s*(\\d++)(°?)\\s*(\\d++)['′]?\\s*(\\d++[.,]\\d++)\\b(?:''|\"|″)?";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile("\\b" + STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
DMSParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
final String group2 = groups.get(1);
final String group3 = groups.get(2);
final String group4 = groups.get(3);
final String group5 = groups.get(4);
final Double result = createCoordinate(group1, group2, group4, group5);
if (StringUtils.isBlank(group1) && (StringUtils.isBlank(group3) || isNotZero(result))) {
return null;
}
return result;
}
}
/**
* Parser for DMS format: X DD° MM' SS".
*/
private static final class ShortDMSParser extends AbstractLatLonParser {
// ( 1 ) ( 2 )( 3) ( 4 ) ( 5 )
private static final String STRING_LAT = "\\b([NS]?)\\s*(\\d++)(°?)\\s*(\\d++)['′]?\\s*(\\d++)(?:''|\"|″)?";
// ( 1 ) ( 2 )( 3) ( 4 ) ( 5 )
private static final String STRING_LON = "([WEO]?)\\s*(\\d++)(°?)\\s*(\\d++)['′]?\\s*(\\d++)\\b(?:''|\"|″)?";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile("\\b" + STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
ShortDMSParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
final String group2 = groups.get(1);
final String group3 = groups.get(2);
final String group4 = groups.get(3);
final String group5 = groups.get(4);
final Double result = createCoordinate(group1, group2, group4, group5);
if (StringUtils.isBlank(group1) && (StringUtils.isBlank(group3) || isNotZero(result))) {
return null;
}
return result;
}
}
/**
* Parser for DegDec format: DD.DDDDDDD°.
*/
private static final class DegDecParser extends AbstractLatLonParser {
// ( 1 )
private static final String STRING_LAT = "(-?\\d++[.,]\\d++)°?";
// ( 1 )
private static final String STRING_LON = "(-?\\d++[.,]\\d++)\\b°?";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°-]*";
private static final Pattern PATTERN_LAT = Pattern.compile(STRING_LAT, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LON = Pattern.compile(STRING_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT + STRING_SEPARATOR + STRING_LON, Pattern.CASE_INSENSITIVE);
DegDecParser() {
super(PATTERN_LAT, PATTERN_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
return createCoordinate("", group1, "", "");
}
}
/**
* Parser for DegDec format: -DD°.
*/
private static final class ShortDegDecParser extends AbstractLatLonParser {
// ( 1 )
private static final String STRING_LAT_OR_LON = "(-?\\d++)°";
private static final String STRING_SEPARATOR = "[^\\w'′\"″°-]*";
private static final Pattern PATTERN_LAT_OR_LON = Pattern.compile(STRING_LAT_OR_LON, Pattern.CASE_INSENSITIVE);
private static final Pattern PATTERN_LATLON = Pattern.compile(STRING_LAT_OR_LON + STRING_SEPARATOR + STRING_LAT_OR_LON, Pattern.CASE_INSENSITIVE);
ShortDegDecParser() {
super(PATTERN_LAT_OR_LON, PATTERN_LAT_OR_LON, PATTERN_LATLON);
}
/**
* @see AbstractLatLonParser#parse(List)
*/
@Override
@Nullable
Double parse(@NonNull final List<String> groups) {
final String group1 = groups.get(0);
return createCoordinate("", group1, "", "");
}
}
/**
* Parser for UTM format: ZZZ E EEEEEE N NNNNNNN
*/
private static final class UTMParser extends AbstractParser {
/**
* @see AbstractParser#parse(String)
*/
@Override
@Nullable
GeopointWrapper parse(@NonNull final String text) {
final MatcherWrapper matcher = new MatcherWrapper(UTMPoint.PATTERN_UTM, text);
if (matcher.find()) {
try {
final UTMPoint utmPoint = new UTMPoint(text);
return new GeopointWrapper(utmPoint.toLatLong(), matcher.start(), matcher.group().length());
} catch (final Exception ignored) {
// Ignore parse errors
}
}
return null;
}
/**
* @see AbstractParser#parse(String, LatLon)
*/
@Override
@Nullable
ResultWrapper parse(@NonNull final String text, @NonNull final LatLon latlon) {
return null;
}
}
enum LatLon {
LAT,
LON
}
/**
* Removes all single spaces after a comma (see #2404)
*
* @param text
* the string to substitute
* @return the substituted string without the single spaces
*/
@NonNull
private static String removeAllSpaceAfterComma(@NonNull final String text) {
return new MatcherWrapper(PATTERN_BAD_BLANK, text).replaceAll("$1.$2");
}
/**
* Parses latitude/longitude from the given string.
*
* @param text
* the text to parse
* @param latlon
* whether to parse latitude or longitude
* @return a wrapper with the best latitude/longitude and the length of the match, or null if parsing failed
*/
@Nullable
private static ResultWrapper parseHelper(@NonNull final String text, @NonNull final LatLon latlon) {
final String withoutSpaceAfterComma = removeAllSpaceAfterComma(text);
ResultWrapper best = null;
for (final AbstractParser parser : parsers) {
final ResultWrapper wrapper = parser.parse(withoutSpaceAfterComma, latlon);
if (wrapper != null && (best == null || wrapper.matcherLength > best.matcherLength)) {
best = wrapper;
}
}
return best;
}
/**
* Parses a pair of coordinates (latitude and longitude) out of the given string.
*
* Accepts following formats:
* - X DD
* - X DD°
* - X DD° MM
* - X DD° MM.MMM
* - X DD° MM SS
* - DD.DDDDDDD
* - UTM
*
* Both . and , are accepted, also variable count of spaces (also 0)
*
* @param text
* the string to be parsed
* @return an Geopoint with parsed latitude and longitude
* @throws Geopoint.ParseException
* if coordinates could not be parsed
*/
@NonNull
public static Geopoint parse(@NonNull final String text) {
final String withoutSpaceAfterComma = removeAllSpaceAfterComma(text);
GeopointWrapper best = null;
for (final AbstractParser parser : parsers) {
final GeopointWrapper geopointWrapper = parser.parse(withoutSpaceAfterComma);
if (geopointWrapper == null) {
continue;
}
if (best == null || geopointWrapper.matcherLength > best.matcherLength) {
best = geopointWrapper;
}
}
if (best != null) {
return best.geopoint;
}
throw new Geopoint.ParseException("Cannot parse coordinates");
}
/**
* Detects all coordinates in the given text.
*
* @param initialText Text to parse for coordinates
* @return a collection of parsed geopoints and their starting position in the given text
*/
@NonNull
public static Collection<ImmutablePair<Geopoint, Integer>> parseAll(@NonNull final String initialText) {
final List<ImmutablePair<Geopoint, Integer>> waypoints = new LinkedList<>();
String text = initialText;
int start = 0;
GeopointWrapper best;
do {
best = null;
final String withoutSpaceAfterComma = removeAllSpaceAfterComma(text);
for (final AbstractParser parser : parsers) {
final GeopointWrapper geopointWrapper = parser.parse(withoutSpaceAfterComma);
if (geopointWrapper == null) {
continue;
}
if (best == null || geopointWrapper.matcherStart < best.matcherStart || (geopointWrapper.matcherStart == best.matcherStart && geopointWrapper.matcherLength > best.matcherLength)) {
best = geopointWrapper;
}
}
if (best != null) {
waypoints.add(new ImmutablePair<>(best.geopoint, start + best.matcherStart));
final int nextOffset = best.matcherStart + best.matcherLength;
text = text.substring(nextOffset);
start += nextOffset;
}
} while (best != null);
return waypoints;
}
/**
* Parses latitude out of the given string.
*
* @see #parse(String)
* @param text
* the string to be parsed
* @return the latitude as decimal degrees
* @throws Geopoint.ParseException
* if latitude could not be parsed
*/
public static double parseLatitude(@Nullable final String text) {
if (text != null) {
final ResultWrapper wrapper = parseHelper(text, LatLon.LAT);
if (wrapper != null) {
return wrapper.result;
}
}
throw new Geopoint.ParseException("Cannot parse latitude", LatLon.LAT);
}
/**
* Parses longitude out of the given string.
*
* @see #parse(String)
* @param text
* the string to be parsed
* @return the longitude as decimal degrees
* @throws Geopoint.ParseException
* if longitude could not be parsed
*/
public static double parseLongitude(@Nullable final String text) {
if (text != null) {
final ResultWrapper wrapper = parseHelper(text, LatLon.LON);
if (wrapper != null) {
return wrapper.result;
}
}
throw new Geopoint.ParseException("Cannot parse longitude", LatLon.LON);
}
}