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