001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.io.imagery; 003 004import java.io.BufferedReader; 005import java.io.Closeable; 006import java.io.IOException; 007import java.util.ArrayList; 008import java.util.Arrays; 009import java.util.HashMap; 010import java.util.List; 011import java.util.Map; 012import java.util.Objects; 013import java.util.Stack; 014 015import javax.xml.parsers.ParserConfigurationException; 016 017import org.openstreetmap.josm.Main; 018import org.openstreetmap.josm.data.imagery.ImageryInfo; 019import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryBounds; 020import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType; 021import org.openstreetmap.josm.data.imagery.Shape; 022import org.openstreetmap.josm.io.CachedFile; 023import org.openstreetmap.josm.tools.HttpClient; 024import org.openstreetmap.josm.tools.LanguageInfo; 025import org.openstreetmap.josm.tools.MultiMap; 026import org.openstreetmap.josm.tools.Utils; 027import org.xml.sax.Attributes; 028import org.xml.sax.InputSource; 029import org.xml.sax.SAXException; 030import org.xml.sax.helpers.DefaultHandler; 031 032public class ImageryReader implements Closeable { 033 034 private final String source; 035 private CachedFile cachedFile; 036 private boolean fastFail; 037 038 private enum State { 039 INIT, // initial state, should always be at the bottom of the stack 040 IMAGERY, // inside the imagery element 041 ENTRY, // inside an entry 042 ENTRY_ATTRIBUTE, // note we are inside an entry attribute to collect the character data 043 PROJECTIONS, // inside projections block of an entry 044 MIRROR, // inside an mirror entry 045 MIRROR_ATTRIBUTE, // note we are inside an mirror attribute to collect the character data 046 MIRROR_PROJECTIONS, // inside projections block of an mirror entry 047 CODE, 048 BOUNDS, 049 SHAPE, 050 NO_TILE, 051 NO_TILESUM, 052 METADATA, 053 UNKNOWN, // element is not recognized in the current context 054 } 055 056 /** 057 * Constructs a {@code ImageryReader} from a given filename, URL or internal resource. 058 * 059 * @param source can be:<ul> 060 * <li>relative or absolute file name</li> 061 * <li>{@code file:///SOME/FILE} the same as above</li> 062 * <li>{@code http://...} a URL. It will be cached on disk.</li> 063 * <li>{@code resource://SOME/FILE} file from the classpath (usually in the current *.jar)</li> 064 * <li>{@code josmdir://SOME/FILE} file inside josm user data directory (since r7058)</li> 065 * <li>{@code josmplugindir://SOME/FILE} file inside josm plugin directory (since r7834)</li></ul> 066 */ 067 public ImageryReader(String source) { 068 this.source = source; 069 } 070 071 /** 072 * Parses imagery source. 073 * @return list of imagery info 074 * @throws SAXException if any SAX error occurs 075 * @throws IOException if any I/O error occurs 076 */ 077 public List<ImageryInfo> parse() throws SAXException, IOException { 078 Parser parser = new Parser(); 079 try { 080 cachedFile = new CachedFile(source); 081 cachedFile.setFastFail(fastFail); 082 try (BufferedReader in = cachedFile 083 .setMaxAge(CachedFile.DAYS) 084 .setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince) 085 .getContentReader()) { 086 InputSource is = new InputSource(in); 087 Utils.parseSafeSAX(is, parser); 088 return parser.entries; 089 } 090 } catch (SAXException e) { 091 throw e; 092 } catch (ParserConfigurationException e) { 093 Main.error(e); // broken SAXException chaining 094 throw new SAXException(e); 095 } 096 } 097 098 private static class Parser extends DefaultHandler { 099 private StringBuilder accumulator = new StringBuilder(); 100 101 private Stack<State> states; 102 103 private List<ImageryInfo> entries; 104 105 /** 106 * Skip the current entry because it has mandatory attributes 107 * that this version of JOSM cannot process. 108 */ 109 private boolean skipEntry; 110 111 private ImageryInfo entry; 112 /** In case of mirror parsing this contains the mirror entry */ 113 private ImageryInfo mirrorEntry; 114 private ImageryBounds bounds; 115 private Shape shape; 116 // language of last element, does only work for simple ENTRY_ATTRIBUTE's 117 private String lang; 118 private List<String> projections; 119 private MultiMap<String, String> noTileHeaders; 120 private MultiMap<String, String> noTileChecksums; 121 private Map<String, String> metadataHeaders; 122 123 @Override 124 public void startDocument() { 125 accumulator = new StringBuilder(); 126 skipEntry = false; 127 states = new Stack<>(); 128 states.push(State.INIT); 129 entries = new ArrayList<>(); 130 entry = null; 131 bounds = null; 132 projections = null; 133 noTileHeaders = null; 134 noTileChecksums = null; 135 } 136 137 @Override 138 public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException { 139 accumulator.setLength(0); 140 State newState = null; 141 switch (states.peek()) { 142 case INIT: 143 if ("imagery".equals(qName)) { 144 newState = State.IMAGERY; 145 } 146 break; 147 case IMAGERY: 148 if ("entry".equals(qName)) { 149 entry = new ImageryInfo(); 150 skipEntry = false; 151 newState = State.ENTRY; 152 noTileHeaders = new MultiMap<>(); 153 noTileChecksums = new MultiMap<>(); 154 metadataHeaders = new HashMap<>(); 155 } 156 break; 157 case MIRROR: 158 if (Arrays.asList(new String[] { 159 "type", 160 "url", 161 "min-zoom", 162 "max-zoom", 163 "tile-size", 164 }).contains(qName)) { 165 newState = State.MIRROR_ATTRIBUTE; 166 lang = atts.getValue("lang"); 167 } else if ("projections".equals(qName)) { 168 projections = new ArrayList<>(); 169 newState = State.MIRROR_PROJECTIONS; 170 } 171 break; 172 case ENTRY: 173 if (Arrays.asList(new String[] { 174 "name", 175 "id", 176 "type", 177 "description", 178 "default", 179 "url", 180 "eula", 181 "min-zoom", 182 "max-zoom", 183 "attribution-text", 184 "attribution-url", 185 "logo-image", 186 "logo-url", 187 "terms-of-use-text", 188 "terms-of-use-url", 189 "country-code", 190 "icon", 191 "tile-size", 192 "valid-georeference", 193 "epsg4326to3857Supported", 194 }).contains(qName)) { 195 newState = State.ENTRY_ATTRIBUTE; 196 lang = atts.getValue("lang"); 197 } else if ("bounds".equals(qName)) { 198 try { 199 bounds = new ImageryBounds( 200 atts.getValue("min-lat") + ',' + 201 atts.getValue("min-lon") + ',' + 202 atts.getValue("max-lat") + ',' + 203 atts.getValue("max-lon"), ","); 204 } catch (IllegalArgumentException e) { 205 break; 206 } 207 newState = State.BOUNDS; 208 } else if ("projections".equals(qName)) { 209 projections = new ArrayList<>(); 210 newState = State.PROJECTIONS; 211 } else if ("mirror".equals(qName)) { 212 projections = new ArrayList<>(); 213 newState = State.MIRROR; 214 mirrorEntry = new ImageryInfo(); 215 } else if ("no-tile-header".equals(qName)) { 216 noTileHeaders.put(atts.getValue("name"), atts.getValue("value")); 217 newState = State.NO_TILE; 218 } else if ("no-tile-checksum".equals(qName)) { 219 noTileChecksums.put(atts.getValue("type"), atts.getValue("value")); 220 newState = State.NO_TILESUM; 221 } else if ("metadata-header".equals(qName)) { 222 metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key")); 223 newState = State.METADATA; 224 } 225 break; 226 case BOUNDS: 227 if ("shape".equals(qName)) { 228 shape = new Shape(); 229 newState = State.SHAPE; 230 } 231 break; 232 case SHAPE: 233 if ("point".equals(qName)) { 234 try { 235 shape.addPoint(atts.getValue("lat"), atts.getValue("lon")); 236 } catch (IllegalArgumentException e) { 237 break; 238 } 239 } 240 break; 241 case PROJECTIONS: 242 case MIRROR_PROJECTIONS: 243 if ("code".equals(qName)) { 244 newState = State.CODE; 245 } 246 break; 247 default: // Do nothing 248 } 249 /** 250 * Did not recognize the element, so the new state is UNKNOWN. 251 * This includes the case where we are already inside an unknown 252 * element, i.e. we do not try to understand the inner content 253 * of an unknown element, but wait till it's over. 254 */ 255 if (newState == null) { 256 newState = State.UNKNOWN; 257 } 258 states.push(newState); 259 if (newState == State.UNKNOWN && "true".equals(atts.getValue("mandatory"))) { 260 skipEntry = true; 261 } 262 } 263 264 @Override 265 public void characters(char[] ch, int start, int length) { 266 accumulator.append(ch, start, length); 267 } 268 269 @Override 270 public void endElement(String namespaceURI, String qName, String rqName) { 271 switch (states.pop()) { 272 case INIT: 273 throw new RuntimeException("parsing error: more closing than opening elements"); 274 case ENTRY: 275 if ("entry".equals(qName)) { 276 entry.setNoTileHeaders(noTileHeaders); 277 noTileHeaders = null; 278 entry.setNoTileChecksums(noTileChecksums); 279 noTileChecksums = null; 280 entry.setMetadataHeaders(metadataHeaders); 281 metadataHeaders = null; 282 283 if (!skipEntry) { 284 entries.add(entry); 285 } 286 entry = null; 287 } 288 break; 289 case MIRROR: 290 if ("mirror".equals(qName)) { 291 if (mirrorEntry != null) { 292 entry.addMirror(mirrorEntry); 293 mirrorEntry = null; 294 } 295 } 296 break; 297 case MIRROR_ATTRIBUTE: 298 if (mirrorEntry != null) { 299 switch(qName) { 300 case "type": 301 boolean found = false; 302 for (ImageryType type : ImageryType.values()) { 303 if (Objects.equals(accumulator.toString(), type.getTypeString())) { 304 mirrorEntry.setImageryType(type); 305 found = true; 306 break; 307 } 308 } 309 if (!found) { 310 mirrorEntry = null; 311 } 312 break; 313 case "url": 314 mirrorEntry.setUrl(accumulator.toString()); 315 break; 316 case "min-zoom": 317 case "max-zoom": 318 Integer val = null; 319 try { 320 val = Integer.valueOf(accumulator.toString()); 321 } catch (NumberFormatException e) { 322 val = null; 323 } 324 if (val == null) { 325 mirrorEntry = null; 326 } else { 327 if ("min-zoom".equals(qName)) { 328 mirrorEntry.setDefaultMinZoom(val); 329 } else { 330 mirrorEntry.setDefaultMaxZoom(val); 331 } 332 } 333 break; 334 case "tile-size": 335 Integer tileSize = null; 336 try { 337 tileSize = Integer.valueOf(accumulator.toString()); 338 } catch (NumberFormatException e) { 339 tileSize = null; 340 } 341 if (tileSize == null) { 342 mirrorEntry = null; 343 } else { 344 entry.setTileSize(tileSize.intValue()); 345 } 346 break; 347 default: // Do nothing 348 } 349 } 350 break; 351 case ENTRY_ATTRIBUTE: 352 switch(qName) { 353 case "name": 354 entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString()); 355 break; 356 case "description": 357 entry.setDescription(lang, accumulator.toString()); 358 break; 359 case "id": 360 entry.setId(accumulator.toString()); 361 break; 362 case "type": 363 boolean found = false; 364 for (ImageryType type : ImageryType.values()) { 365 if (Objects.equals(accumulator.toString(), type.getTypeString())) { 366 entry.setImageryType(type); 367 found = true; 368 break; 369 } 370 } 371 if (!found) { 372 skipEntry = true; 373 } 374 break; 375 case "default": 376 switch (accumulator.toString()) { 377 case "true": 378 entry.setDefaultEntry(true); 379 break; 380 case "false": 381 entry.setDefaultEntry(false); 382 break; 383 default: 384 skipEntry = true; 385 } 386 break; 387 case "url": 388 entry.setUrl(accumulator.toString()); 389 break; 390 case "eula": 391 entry.setEulaAcceptanceRequired(accumulator.toString()); 392 break; 393 case "min-zoom": 394 case "max-zoom": 395 Integer val = null; 396 try { 397 val = Integer.valueOf(accumulator.toString()); 398 } catch (NumberFormatException e) { 399 val = null; 400 } 401 if (val == null) { 402 skipEntry = true; 403 } else { 404 if ("min-zoom".equals(qName)) { 405 entry.setDefaultMinZoom(val); 406 } else { 407 entry.setDefaultMaxZoom(val); 408 } 409 } 410 break; 411 case "attribution-text": 412 entry.setAttributionText(accumulator.toString()); 413 break; 414 case "attribution-url": 415 entry.setAttributionLinkURL(accumulator.toString()); 416 break; 417 case "logo-image": 418 entry.setAttributionImage(accumulator.toString()); 419 break; 420 case "logo-url": 421 entry.setAttributionImageURL(accumulator.toString()); 422 break; 423 case "terms-of-use-text": 424 entry.setTermsOfUseText(accumulator.toString()); 425 break; 426 case "terms-of-use-url": 427 entry.setTermsOfUseURL(accumulator.toString()); 428 break; 429 case "country-code": 430 entry.setCountryCode(accumulator.toString()); 431 break; 432 case "icon": 433 entry.setIcon(accumulator.toString()); 434 break; 435 case "tile-size": 436 Integer tileSize = null; 437 try { 438 tileSize = Integer.valueOf(accumulator.toString()); 439 } catch (NumberFormatException e) { 440 tileSize = null; 441 } 442 if (tileSize == null) { 443 skipEntry = true; 444 } else { 445 entry.setTileSize(tileSize.intValue()); 446 } 447 break; 448 case "valid-georeference": 449 entry.setGeoreferenceValid(Boolean.valueOf(accumulator.toString())); 450 break; 451 case "epsg4326to3857Supported": 452 entry.setEpsg4326To3857Supported(Boolean.valueOf(accumulator.toString())); 453 break; 454 default: // Do nothing 455 } 456 break; 457 case BOUNDS: 458 entry.setBounds(bounds); 459 bounds = null; 460 break; 461 case SHAPE: 462 bounds.addShape(shape); 463 shape = null; 464 break; 465 case CODE: 466 projections.add(accumulator.toString()); 467 break; 468 case PROJECTIONS: 469 entry.setServerProjections(projections); 470 projections = null; 471 break; 472 case MIRROR_PROJECTIONS: 473 mirrorEntry.setServerProjections(projections); 474 projections = null; 475 break; 476 case NO_TILE: 477 case NO_TILESUM: 478 case METADATA: 479 case UNKNOWN: 480 default: 481 // nothing to do for these or the unknown type 482 } 483 } 484 } 485 486 /** 487 * Sets whether opening HTTP connections should fail fast, i.e., whether a 488 * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used. 489 * @param fastFail whether opening HTTP connections should fail fast 490 * @see CachedFile#setFastFail(boolean) 491 */ 492 public void setFastFail(boolean fastFail) { 493 this.fastFail = fastFail; 494 } 495 496 @Override 497 public void close() throws IOException { 498 Utils.close(cachedFile); 499 } 500}