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); } }