001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.io; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.io.InputStream; 007import java.util.List; 008import java.util.Map; 009import java.util.Objects; 010import java.util.Optional; 011import java.util.TreeMap; 012import java.util.stream.Collectors; 013 014import javax.json.Json; 015import javax.json.JsonArray; 016import javax.json.JsonNumber; 017import javax.json.JsonObject; 018import javax.json.JsonString; 019import javax.json.JsonStructure; 020import javax.json.JsonValue; 021import javax.json.stream.JsonParser; 022import javax.json.stream.JsonParser.Event; 023import javax.json.stream.JsonParsingException; 024 025import org.openstreetmap.josm.data.coor.EastNorth; 026import org.openstreetmap.josm.data.coor.LatLon; 027import org.openstreetmap.josm.data.osm.DataSet; 028import org.openstreetmap.josm.data.osm.Node; 029import org.openstreetmap.josm.data.osm.OsmPrimitive; 030import org.openstreetmap.josm.data.osm.Relation; 031import org.openstreetmap.josm.data.osm.RelationMember; 032import org.openstreetmap.josm.data.osm.Way; 033import org.openstreetmap.josm.data.projection.Projection; 034import org.openstreetmap.josm.data.projection.Projections; 035import org.openstreetmap.josm.gui.progress.NullProgressMonitor; 036import org.openstreetmap.josm.gui.progress.ProgressMonitor; 037import org.openstreetmap.josm.tools.Logging; 038 039/** 040 * Reader that reads GeoJSON files. See <a href="https://tools.ietf.org/html/rfc7946">RFC7946</a> for more information. 041 * @since 15424 042 */ 043public class GeoJSONReader extends AbstractReader { 044 045 private static final String CRS = "crs"; 046 private static final String NAME = "name"; 047 private static final String LINK = "link"; 048 private static final String COORDINATES = "coordinates"; 049 private static final String FEATURES = "features"; 050 private static final String PROPERTIES = "properties"; 051 private static final String GEOMETRY = "geometry"; 052 private static final String TYPE = "type"; 053 private JsonParser parser; 054 private Projection projection = Projections.getProjectionByCode("EPSG:4326"); // WGS 84 055 056 GeoJSONReader() { 057 // Restricts visibility 058 } 059 060 private void setParser(final JsonParser parser) { 061 this.parser = parser; 062 } 063 064 private void parse() throws IllegalDataException { 065 while (parser.hasNext()) { 066 Event event = parser.next(); 067 if (event == Event.START_OBJECT) { 068 parseRoot(parser.getObject()); 069 } 070 } 071 parser.close(); 072 } 073 074 private void parseRoot(final JsonObject object) throws IllegalDataException { 075 parseCrs(object.getJsonObject(CRS)); 076 switch (object.getString(TYPE)) { 077 case "FeatureCollection": 078 parseFeatureCollection(object.getJsonArray(FEATURES)); 079 break; 080 case "Feature": 081 parseFeature(object); 082 break; 083 case "GeometryCollection": 084 parseGeometryCollection(null, object); 085 break; 086 default: 087 parseGeometry(null, object); 088 } 089 } 090 091 /** 092 * Parse CRS as per https://geojson.org/geojson-spec.html#coordinate-reference-system-objects. 093 * CRS are obsolete in RFC7946 but still allowed for interoperability with older applications. 094 * Only named CRS are supported. 095 * 096 * @param crs CRS JSON object 097 * @throws IllegalDataException in case of error 098 */ 099 private void parseCrs(final JsonObject crs) throws IllegalDataException { 100 if (crs != null) { 101 // Inspired by https://github.com/JOSM/geojson/commit/f13ceed4645244612a63581c96e20da802779c56 102 JsonObject properties = crs.getJsonObject("properties"); 103 if (properties != null) { 104 switch (crs.getString(TYPE)) { 105 case NAME: 106 String crsName = properties.getString(NAME); 107 if ("urn:ogc:def:crs:OGC:1.3:CRS84".equals(crsName)) { 108 // https://osgeo-org.atlassian.net/browse/GEOT-1710 109 crsName = "EPSG:4326"; 110 } else if (crsName.startsWith("urn:ogc:def:crs:EPSG:")) { 111 crsName = crsName.replace("urn:ogc:def:crs:", ""); 112 } 113 projection = Optional.ofNullable(Projections.getProjectionByCode(crsName)) 114 .orElse(Projections.getProjectionByCode("EPSG:4326")); // WGS84 115 break; 116 case LINK: // Not supported (security risk) 117 default: 118 throw new IllegalDataException(crs.toString()); 119 } 120 } 121 } 122 } 123 124 private void parseFeatureCollection(final JsonArray features) { 125 for (JsonValue feature : features) { 126 if (feature instanceof JsonObject) { 127 parseFeature((JsonObject) feature); 128 } 129 } 130 } 131 132 private void parseFeature(final JsonObject feature) { 133 JsonValue geometry = feature.get(GEOMETRY); 134 if (geometry != null && geometry.getValueType() == JsonValue.ValueType.OBJECT) { 135 parseGeometry(feature, geometry.asJsonObject()); 136 } else { 137 JsonValue properties = feature.get(PROPERTIES); 138 if (properties != null && properties.getValueType() == JsonValue.ValueType.OBJECT) { 139 parseNonGeometryFeature(feature, properties.asJsonObject()); 140 } else { 141 Logging.warn(tr("Relation/non-geometry feature without properties found: {0}", feature)); 142 } 143 } 144 } 145 146 private void parseNonGeometryFeature(final JsonObject feature, final JsonObject properties) { 147 // get relation type 148 JsonValue type = properties.get(TYPE); 149 if (type == null || properties.getValueType() == JsonValue.ValueType.STRING) { 150 Logging.warn(tr("Relation/non-geometry feature without type found: {0}", feature)); 151 return; 152 } 153 154 // create misc. non-geometry feature 155 final Relation relation = new Relation(); 156 relation.put(TYPE, type.toString()); 157 fillTagsFromFeature(feature, relation); 158 getDataSet().addPrimitive(relation); 159 } 160 161 private void parseGeometryCollection(final JsonObject feature, final JsonObject geometry) { 162 for (JsonValue jsonValue : geometry.getJsonArray("geometries")) { 163 parseGeometry(feature, jsonValue.asJsonObject()); 164 } 165 } 166 167 private void parseGeometry(final JsonObject feature, final JsonObject geometry) { 168 if (geometry == null) { 169 parseNullGeometry(feature); 170 return; 171 } 172 173 switch (geometry.getString(TYPE)) { 174 case "Point": 175 parsePoint(feature, geometry.getJsonArray(COORDINATES)); 176 break; 177 case "MultiPoint": 178 parseMultiPoint(feature, geometry); 179 break; 180 case "LineString": 181 parseLineString(feature, geometry.getJsonArray(COORDINATES)); 182 break; 183 case "MultiLineString": 184 parseMultiLineString(feature, geometry); 185 break; 186 case "Polygon": 187 parsePolygon(feature, geometry.getJsonArray(COORDINATES)); 188 break; 189 case "MultiPolygon": 190 parseMultiPolygon(feature, geometry); 191 break; 192 case "GeometryCollection": 193 parseGeometryCollection(feature, geometry); 194 break; 195 default: 196 parseUnknown(geometry); 197 } 198 } 199 200 private LatLon getLatLon(final JsonArray coordinates) { 201 return projection.eastNorth2latlon(new EastNorth( 202 parseCoordinate(coordinates.get(0)), 203 parseCoordinate(coordinates.get(1)))); 204 } 205 206 private static double parseCoordinate(JsonValue coordinate) { 207 if (coordinate instanceof JsonString) { 208 return Double.parseDouble(((JsonString) coordinate).getString()); 209 } else if (coordinate instanceof JsonNumber) { 210 return ((JsonNumber) coordinate).doubleValue(); 211 } else { 212 throw new IllegalArgumentException(Objects.toString(coordinate)); 213 } 214 } 215 216 private void parsePoint(final JsonObject feature, final JsonArray coordinates) { 217 fillTagsFromFeature(feature, createNode(getLatLon(coordinates))); 218 } 219 220 private void parseMultiPoint(final JsonObject feature, final JsonObject geometry) { 221 for (JsonValue coordinate : geometry.getJsonArray(COORDINATES)) { 222 parsePoint(feature, coordinate.asJsonArray()); 223 } 224 } 225 226 private void parseLineString(final JsonObject feature, final JsonArray coordinates) { 227 if (!coordinates.isEmpty()) { 228 createWay(coordinates, false) 229 .ifPresent(way -> fillTagsFromFeature(feature, way)); 230 } 231 } 232 233 private void parseMultiLineString(final JsonObject feature, final JsonObject geometry) { 234 for (JsonValue coordinate : geometry.getJsonArray(COORDINATES)) { 235 parseLineString(feature, coordinate.asJsonArray()); 236 } 237 } 238 239 private void parsePolygon(final JsonObject feature, final JsonArray coordinates) { 240 final int size = coordinates.size(); 241 if (size == 1) { 242 createWay(coordinates.getJsonArray(0), true) 243 .ifPresent(way -> fillTagsFromFeature(feature, way)); 244 } else if (size > 1) { 245 // create multipolygon 246 final Relation multipolygon = new Relation(); 247 multipolygon.put(TYPE, "multipolygon"); 248 createWay(coordinates.getJsonArray(0), true) 249 .ifPresent(way -> multipolygon.addMember(new RelationMember("outer", way))); 250 251 for (JsonValue interiorRing : coordinates.subList(1, size)) { 252 createWay(interiorRing.asJsonArray(), true) 253 .ifPresent(way -> multipolygon.addMember(new RelationMember("inner", way))); 254 } 255 256 fillTagsFromFeature(feature, multipolygon); 257 getDataSet().addPrimitive(multipolygon); 258 } 259 } 260 261 private void parseMultiPolygon(final JsonObject feature, final JsonObject geometry) { 262 for (JsonValue coordinate : geometry.getJsonArray(COORDINATES)) { 263 parsePolygon(feature, coordinate.asJsonArray()); 264 } 265 } 266 267 private Node createNode(final LatLon latlon) { 268 final Node node = new Node(latlon); 269 getDataSet().addPrimitive(node); 270 return node; 271 } 272 273 private Optional<Way> createWay(final JsonArray coordinates, final boolean autoClose) { 274 if (coordinates.isEmpty()) { 275 return Optional.empty(); 276 } 277 278 final List<LatLon> latlons = coordinates.stream().map( 279 coordinate -> getLatLon(coordinate.asJsonArray())).collect(Collectors.toList()); 280 281 final int size = latlons.size(); 282 final boolean doAutoclose; 283 if (size > 1) { 284 if (latlons.get(0).equals(latlons.get(size - 1))) { 285 // Remove last coordinate, but later add first node to the end 286 latlons.remove(size - 1); 287 doAutoclose = true; 288 } else { 289 doAutoclose = autoClose; 290 } 291 } else { 292 doAutoclose = false; 293 } 294 295 final Way way = new Way(); 296 way.setNodes(latlons.stream().map(Node::new).collect(Collectors.toList())); 297 if (doAutoclose) { 298 way.addNode(way.getNode(0)); 299 } 300 301 way.getNodes().stream().distinct().forEach(it -> getDataSet().addPrimitive(it)); 302 getDataSet().addPrimitive(way); 303 304 return Optional.of(way); 305 } 306 307 private static void fillTagsFromFeature(final JsonObject feature, final OsmPrimitive primitive) { 308 if (feature != null) { 309 primitive.setKeys(getTags(feature)); 310 } 311 } 312 313 private static void parseUnknown(final JsonObject object) { 314 Logging.warn(tr("Unknown json object found {0}", object)); 315 } 316 317 private static void parseNullGeometry(JsonObject feature) { 318 Logging.warn(tr("Geometry of feature {0} is null", feature)); 319 } 320 321 private static Map<String, String> getTags(final JsonObject feature) { 322 final Map<String, String> tags = new TreeMap<>(); 323 324 if (feature.containsKey(PROPERTIES) && !feature.isNull(PROPERTIES)) { 325 JsonValue properties = feature.get(PROPERTIES); 326 if (properties != null && properties.getValueType() == JsonValue.ValueType.OBJECT) { 327 for (Map.Entry<String, JsonValue> stringJsonValueEntry : properties.asJsonObject().entrySet()) { 328 final JsonValue value = stringJsonValueEntry.getValue(); 329 330 if (value instanceof JsonString) { 331 tags.put(stringJsonValueEntry.getKey(), ((JsonString) value).getString()); 332 } else if (value instanceof JsonStructure) { 333 Logging.warn( 334 "The GeoJSON contains an object with property '" + stringJsonValueEntry.getKey() 335 + "' whose value has the unsupported type '" + value.getClass().getSimpleName() 336 + "'. That key-value pair is ignored!" 337 ); 338 } else if (value.getValueType() != JsonValue.ValueType.NULL) { 339 tags.put(stringJsonValueEntry.getKey(), value.toString()); 340 } 341 } 342 } 343 } 344 return tags; 345 } 346 347 @Override 348 protected DataSet doParseDataSet(InputStream source, ProgressMonitor progressMonitor) throws IllegalDataException { 349 setParser(Json.createParser(source)); 350 try { 351 parse(); 352 } catch (JsonParsingException e) { 353 throw new IllegalDataException(e); 354 } 355 return getDataSet(); 356 } 357 358 /** 359 * Parse the given input source and return the dataset. 360 * 361 * @param source the source input stream. Must not be null. 362 * @param progressMonitor the progress monitor. If null, {@link NullProgressMonitor#INSTANCE} is assumed 363 * @return the dataset with the parsed data 364 * @throws IllegalDataException if an error was found while parsing the data from the source 365 * @throws IllegalArgumentException if source is null 366 */ 367 public static DataSet parseDataSet(InputStream source, ProgressMonitor progressMonitor) throws IllegalDataException { 368 return new GeoJSONReader().doParseDataSet(source, progressMonitor); 369 } 370}