KMLReader.java

/*
 * Copyright (c) 2016 Vivid Solutions.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * and Eclipse Distribution License v. 1.0 which accompanies this distribution.
 * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
 * and the Eclipse Distribution License is available at
 *
 * http://www.eclipse.org/org/documents/edl-v10.php.
 */

package org.locationtech.jts.io.kml;

import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.LinearRing;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.io.ParseException;

import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
import java.io.StringReader;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Constructs a {@link Geometry} object from the OGC KML representation.
 * Works only with KML geometry elements and may also parse attributes within these elements
 */
public class KMLReader {
    private final XMLInputFactory inputFactory = XMLInputFactory.newInstance();
    private final GeometryFactory geometryFactory;
    private final Set<String> attributeNames;

    private final Pattern whitespaceRegex = Pattern.compile("\\s+");

    private static final String POINT = "Point";
    private static final String LINESTRING = "LineString";
    private static final String POLYGON = "Polygon";
    private static final String MULTIGEOMETRY = "MultiGeometry";

    private static final String COORDINATES = "coordinates";
    private static final String OUTER_BOUNDARY_IS = "outerBoundaryIs";
    private static final String INNER_BOUNDARY_IS = "innerBoundaryIs";

    private static final String NO_ELEMENT_ERROR = "No element %s found in %s";

    /**
     * Creates a reader that creates objects using the default {@link GeometryFactory}.
     */
    public KMLReader() {
        this(new GeometryFactory(), Collections.emptyList());
    }

    /**
     * Creates a reader that creates objects using the given
     * {@link GeometryFactory}.
     *
     * @param geometryFactory the factory used to create <code>Geometry</code>s.
     */
    public KMLReader(GeometryFactory geometryFactory) {
        this(geometryFactory, Collections.emptyList());
    }

    /**
     * Creates a reader that creates objects using the default {@link GeometryFactory}.
     *
     * @param attributeNames names of attributes that should be parsed (i.e. extrude, altitudeMode, tesselate, etc).
     */
    public KMLReader(Collection<String> attributeNames) {
        this(new GeometryFactory(), attributeNames);
    }

    /**
     * Creates a reader that creates objects using the given
     * {@link GeometryFactory}.
     *
     * @param geometryFactory the factory used to create <code>Geometry</code>s.
     * @param attributeNames  names of attributes that should be parsed (i.e. extrude, altitudeMode, tesselate, etc).
     */
    public KMLReader(GeometryFactory geometryFactory, Collection<String> attributeNames) {
        this.geometryFactory = geometryFactory;
        this.attributeNames = attributeNames == null
                ? Collections.emptySet()
                : new HashSet<>(attributeNames);
    }

    /**
     * Reads a KML representation of a {@link Geometry} from a {@link String}.
     * If any attribute names were specified during {@link KMLReader} construction,
     * they will be stored as {@link Map} in {@link Geometry#setUserData(Object)}
     *
     * @param kmlGeometryString string that specifies kml representation of geometry
     * @return a <code>Geometry</code> specified by <code>kmlGeometryString</code>
     * @throws ParseException if a parsing problem occurs
     */
    public Geometry read(String kmlGeometryString) throws ParseException {
        try (StringReader sr = new StringReader(kmlGeometryString)) {
            XMLStreamReader xmlSr = inputFactory.createXMLStreamReader(sr);
            return parseKML(xmlSr);
        } catch (XMLStreamException e) {
            throw new ParseException(e);
        }
    }

    private Coordinate[] parseKMLCoordinates(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        String coordinates = xmlStreamReader.getElementText();

        if (coordinates.isEmpty()) {
            raiseParseError("Empty coordinates");
        }
        Matcher matcher= whitespaceRegex.matcher(coordinates.trim());
        coordinates = matcher.replaceAll(" ");

        double[] parsedOrdinates = {Double.NaN, Double.NaN, Double.NaN};
        List<Coordinate> coordinateList = new ArrayList();

        int spaceIdx = coordinates.indexOf(' ');
        int currentIdx = 0;

        while (currentIdx < coordinates.length()) {
            if (spaceIdx == -1) {
                spaceIdx = coordinates.length();
            }

            String coordinate = coordinates.substring(currentIdx, spaceIdx);

            int yOrdinateComma = coordinate.indexOf(',');
            if (yOrdinateComma == -1 || yOrdinateComma == coordinate.length() - 1 || yOrdinateComma == 0) {
                raiseParseError("Invalid coordinate format");
            }

            parsedOrdinates[0] = Double.parseDouble(coordinate.substring(0, yOrdinateComma));

            int zOrdinateComma = coordinate.indexOf(',', yOrdinateComma + 1);
            if (zOrdinateComma == -1) {
                parsedOrdinates[1] = Double.parseDouble(coordinate.substring(yOrdinateComma + 1));
            } else {
                parsedOrdinates[1] = Double.parseDouble(coordinate.substring(yOrdinateComma + 1, zOrdinateComma));
                parsedOrdinates[2] = Double.parseDouble(coordinate.substring(zOrdinateComma + 1));
            }

            Coordinate crd = new Coordinate(parsedOrdinates[0], parsedOrdinates[1], parsedOrdinates[2]);
            geometryFactory.getPrecisionModel().makePrecise(crd);

            coordinateList.add(crd);
            currentIdx = spaceIdx + 1;
            spaceIdx = coordinates.indexOf(' ', currentIdx);
            parsedOrdinates[0] = parsedOrdinates[1] = parsedOrdinates[2] = Double.NaN;
        }

        return coordinateList.toArray(new Coordinate[]{});
    }

    private KMLCoordinatesAndAttributes parseKMLCoordinatesAndAttributes(XMLStreamReader xmlStreamReader, String objectNodeName) throws XMLStreamException, ParseException {
        Coordinate[] coordinates = null;
        Map<String, String> attributes = null;

        while (xmlStreamReader.hasNext() && !(xmlStreamReader.isEndElement() && xmlStreamReader.getLocalName().equals(objectNodeName))) {
            if (xmlStreamReader.isStartElement()) {
                String elementName = xmlStreamReader.getLocalName();

                if (elementName.equals(COORDINATES)) {
                    coordinates = parseKMLCoordinates(xmlStreamReader);
                } else if (attributeNames.contains(elementName)) {
                    if (attributes == null) {
                        attributes = new HashMap<>();
                    }

                    attributes.put(elementName, xmlStreamReader.getElementText());
                }
            }

            xmlStreamReader.next();
        }

        if (coordinates == null) {
            raiseParseError(NO_ELEMENT_ERROR, COORDINATES, objectNodeName);
        }

        return new KMLCoordinatesAndAttributes(coordinates, attributes);
    }

    private Geometry parseKMLPoint(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        KMLCoordinatesAndAttributes kmlCoordinatesAndAttributes = parseKMLCoordinatesAndAttributes(xmlStreamReader, POINT);

        Point point = geometryFactory.createPoint(kmlCoordinatesAndAttributes.coordinates[0]);
        point.setUserData(kmlCoordinatesAndAttributes.attributes);

        return point;
    }

    private Geometry parseKMLLineString(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        KMLCoordinatesAndAttributes kmlCoordinatesAndAttributes = parseKMLCoordinatesAndAttributes(xmlStreamReader, LINESTRING);

        LineString lineString = geometryFactory.createLineString(kmlCoordinatesAndAttributes.coordinates);
        lineString.setUserData(kmlCoordinatesAndAttributes.attributes);

        return lineString;
    }

    private Geometry parseKMLPolygon(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        LinearRing shell = null;
        ArrayList<LinearRing> holes = null;
        Map<String, String> attributes = null;

        while (xmlStreamReader.hasNext() && !(xmlStreamReader.isEndElement() && xmlStreamReader.getLocalName().equals(POLYGON))) {
            if (xmlStreamReader.isStartElement()) {
                String elementName = xmlStreamReader.getLocalName();

                if (elementName.equals(OUTER_BOUNDARY_IS)) {
                    moveToElement(xmlStreamReader, COORDINATES, OUTER_BOUNDARY_IS);
                    shell = geometryFactory.createLinearRing(parseKMLCoordinates(xmlStreamReader));
                } else if (elementName.equals(INNER_BOUNDARY_IS)) {
                    moveToElement(xmlStreamReader, COORDINATES, INNER_BOUNDARY_IS);

                    if (holes == null) {
                        holes = new ArrayList<>();
                    }
                    holes.add(geometryFactory.createLinearRing(parseKMLCoordinates(xmlStreamReader)));
                } else if (attributeNames.contains(elementName)) {
                    if (attributes == null) {
                        attributes = new HashMap<>();
                    }

                    attributes.put(elementName, xmlStreamReader.getElementText());
                }
            }

            xmlStreamReader.next();
        }

        if (shell == null) {
            raiseParseError("No outer boundary for Polygon");
        }

        Polygon polygon = geometryFactory.createPolygon(shell, holes == null ? null : holes.toArray(new LinearRing[]{}));
        polygon.setUserData(attributes);

        return polygon;
    }

    private Geometry parseKMLMultiGeometry(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        List<Geometry> geometries = new ArrayList<>();
        String firstParsedType = null;
        boolean allTypesAreSame = true;

        while (xmlStreamReader.hasNext()) {
            if (xmlStreamReader.isStartElement()) {
                String elementName = xmlStreamReader.getLocalName();
                switch (elementName) {
                    case POINT:
                    case LINESTRING:
                    case POLYGON:
                    case MULTIGEOMETRY:
                        Geometry geometry = parseKML(xmlStreamReader);

                        if (firstParsedType == null) {
                            firstParsedType = geometry.getGeometryType();
                        } else if (!firstParsedType.equals(geometry.getGeometryType())) {
                            allTypesAreSame = false;
                        }

                        geometries.add(geometry);
                }
            }

            xmlStreamReader.next();
        }

        if (geometries.isEmpty()) {
            return null;
        }

        if (geometries.size() == 1) {
            return geometries.get(0);
        }

        if (allTypesAreSame) {
            switch (firstParsedType) {
                case POINT:
                    return geometryFactory.createMultiPoint(prepareTypedArray(geometries, Point.class));
                case LINESTRING:
                    return geometryFactory.createMultiLineString(prepareTypedArray(geometries, LineString.class));
                case POLYGON:
                    return geometryFactory.createMultiPolygon(prepareTypedArray(geometries, Polygon.class));
                default:
                    return geometryFactory.createGeometryCollection(geometries.toArray(new Geometry[]{}));
            }
        } else {
            return geometryFactory.createGeometryCollection(geometries.toArray(new Geometry[]{}));
        }
    }

    private Geometry parseKML(XMLStreamReader xmlStreamReader) throws XMLStreamException, ParseException {
        boolean hasElement = false;

        while (xmlStreamReader.hasNext()) {
            if (xmlStreamReader.isStartElement()) {
                hasElement = true;
                break;
            }

            xmlStreamReader.next();
        }

        if (!hasElement) {
            raiseParseError("Invalid KML format");
        }

        String elementName = xmlStreamReader.getLocalName();
        switch (elementName) {
            case POINT:
                return parseKMLPoint(xmlStreamReader);
            case LINESTRING:
                return parseKMLLineString(xmlStreamReader);
            case POLYGON:
                return parseKMLPolygon(xmlStreamReader);
            case MULTIGEOMETRY:
                xmlStreamReader.next();
                return parseKMLMultiGeometry(xmlStreamReader);
        }

        raiseParseError("Unknown KML geometry type %s", elementName);
        return null;
    }

    private void moveToElement(XMLStreamReader xmlStreamReader, String elementName, String endElementName) throws XMLStreamException, ParseException {
        boolean elementFound = false;

        while (xmlStreamReader.hasNext() && !(xmlStreamReader.isEndElement() && xmlStreamReader.getLocalName().equals(endElementName))) {
            if (xmlStreamReader.isStartElement() && xmlStreamReader.getLocalName().equals(elementName)) {
                elementFound = true;
                break;
            }

            xmlStreamReader.next();
        }

        if (!elementFound) {
            raiseParseError(NO_ELEMENT_ERROR, elementName, endElementName);
        }
    }

    private void raiseParseError(String template, Object... parameters) throws ParseException {
        throw new ParseException(String.format(template, parameters));
    }

    private <T> T[] prepareTypedArray(List<Geometry> geometryList, Class<T> geomClass) {
        return geometryList.toArray((T[]) Array.newInstance(geomClass, geometryList.size()));
    }

    private static class KMLCoordinatesAndAttributes {
        private final Coordinate[] coordinates;
        private final Map<String, String> attributes;

        public KMLCoordinatesAndAttributes(Coordinate[] coordinates, Map<String, String> attributes) {

            this.coordinates = coordinates;
            this.attributes = attributes;
        }
    }
}