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 Main.trace(e); 206 break; 207 } 208 newState = State.BOUNDS; 209 } else if ("projections".equals(qName)) { 210 projections = new ArrayList<>(); 211 newState = State.PROJECTIONS; 212 } else if ("mirror".equals(qName)) { 213 projections = new ArrayList<>(); 214 newState = State.MIRROR; 215 mirrorEntry = new ImageryInfo(); 216 } else if ("no-tile-header".equals(qName)) { 217 noTileHeaders.put(atts.getValue("name"), atts.getValue("value")); 218 newState = State.NO_TILE; 219 } else if ("no-tile-checksum".equals(qName)) { 220 noTileChecksums.put(atts.getValue("type"), atts.getValue("value")); 221 newState = State.NO_TILESUM; 222 } else if ("metadata-header".equals(qName)) { 223 metadataHeaders.put(atts.getValue("header-name"), atts.getValue("metadata-key")); 224 newState = State.METADATA; 225 } 226 break; 227 case BOUNDS: 228 if ("shape".equals(qName)) { 229 shape = new Shape(); 230 newState = State.SHAPE; 231 } 232 break; 233 case SHAPE: 234 if ("point".equals(qName)) { 235 try { 236 shape.addPoint(atts.getValue("lat"), atts.getValue("lon")); 237 } catch (IllegalArgumentException e) { 238 Main.trace(e); 239 break; 240 } 241 } 242 break; 243 case PROJECTIONS: 244 case MIRROR_PROJECTIONS: 245 if ("code".equals(qName)) { 246 newState = State.CODE; 247 } 248 break; 249 default: // Do nothing 250 } 251 /** 252 * Did not recognize the element, so the new state is UNKNOWN. 253 * This includes the case where we are already inside an unknown 254 * element, i.e. we do not try to understand the inner content 255 * of an unknown element, but wait till it's over. 256 */ 257 if (newState == null) { 258 newState = State.UNKNOWN; 259 } 260 states.push(newState); 261 if (newState == State.UNKNOWN && "true".equals(atts.getValue("mandatory"))) { 262 skipEntry = true; 263 } 264 } 265 266 @Override 267 public void characters(char[] ch, int start, int length) { 268 accumulator.append(ch, start, length); 269 } 270 271 @Override 272 public void endElement(String namespaceURI, String qName, String rqName) { 273 switch (states.pop()) { 274 case INIT: 275 throw new RuntimeException("parsing error: more closing than opening elements"); 276 case ENTRY: 277 if ("entry".equals(qName)) { 278 entry.setNoTileHeaders(noTileHeaders); 279 noTileHeaders = null; 280 entry.setNoTileChecksums(noTileChecksums); 281 noTileChecksums = null; 282 entry.setMetadataHeaders(metadataHeaders); 283 metadataHeaders = null; 284 285 if (!skipEntry) { 286 entries.add(entry); 287 } 288 entry = null; 289 } 290 break; 291 case MIRROR: 292 if ("mirror".equals(qName)) { 293 if (mirrorEntry != null) { 294 entry.addMirror(mirrorEntry); 295 mirrorEntry = null; 296 } 297 } 298 break; 299 case MIRROR_ATTRIBUTE: 300 if (mirrorEntry != null) { 301 switch(qName) { 302 case "type": 303 boolean found = false; 304 for (ImageryType type : ImageryType.values()) { 305 if (Objects.equals(accumulator.toString(), type.getTypeString())) { 306 mirrorEntry.setImageryType(type); 307 found = true; 308 break; 309 } 310 } 311 if (!found) { 312 mirrorEntry = null; 313 } 314 break; 315 case "url": 316 mirrorEntry.setUrl(accumulator.toString()); 317 break; 318 case "min-zoom": 319 case "max-zoom": 320 Integer val = null; 321 try { 322 val = Integer.valueOf(accumulator.toString()); 323 } catch (NumberFormatException e) { 324 val = null; 325 } 326 if (val == null) { 327 mirrorEntry = null; 328 } else { 329 if ("min-zoom".equals(qName)) { 330 mirrorEntry.setDefaultMinZoom(val); 331 } else { 332 mirrorEntry.setDefaultMaxZoom(val); 333 } 334 } 335 break; 336 case "tile-size": 337 Integer tileSize = null; 338 try { 339 tileSize = Integer.valueOf(accumulator.toString()); 340 } catch (NumberFormatException e) { 341 tileSize = null; 342 } 343 if (tileSize == null) { 344 mirrorEntry = null; 345 } else { 346 entry.setTileSize(tileSize.intValue()); 347 } 348 break; 349 default: // Do nothing 350 } 351 } 352 break; 353 case ENTRY_ATTRIBUTE: 354 switch(qName) { 355 case "name": 356 entry.setName(lang == null ? LanguageInfo.getJOSMLocaleCode(null) : lang, accumulator.toString()); 357 break; 358 case "description": 359 entry.setDescription(lang, accumulator.toString()); 360 break; 361 case "id": 362 entry.setId(accumulator.toString()); 363 break; 364 case "type": 365 boolean found = false; 366 for (ImageryType type : ImageryType.values()) { 367 if (Objects.equals(accumulator.toString(), type.getTypeString())) { 368 entry.setImageryType(type); 369 found = true; 370 break; 371 } 372 } 373 if (!found) { 374 skipEntry = true; 375 } 376 break; 377 case "default": 378 switch (accumulator.toString()) { 379 case "true": 380 entry.setDefaultEntry(true); 381 break; 382 case "false": 383 entry.setDefaultEntry(false); 384 break; 385 default: 386 skipEntry = true; 387 } 388 break; 389 case "url": 390 entry.setUrl(accumulator.toString()); 391 break; 392 case "eula": 393 entry.setEulaAcceptanceRequired(accumulator.toString()); 394 break; 395 case "min-zoom": 396 case "max-zoom": 397 Integer val = null; 398 try { 399 val = Integer.valueOf(accumulator.toString()); 400 } catch (NumberFormatException e) { 401 val = null; 402 } 403 if (val == null) { 404 skipEntry = true; 405 } else { 406 if ("min-zoom".equals(qName)) { 407 entry.setDefaultMinZoom(val); 408 } else { 409 entry.setDefaultMaxZoom(val); 410 } 411 } 412 break; 413 case "attribution-text": 414 entry.setAttributionText(accumulator.toString()); 415 break; 416 case "attribution-url": 417 entry.setAttributionLinkURL(accumulator.toString()); 418 break; 419 case "logo-image": 420 entry.setAttributionImage(accumulator.toString()); 421 break; 422 case "logo-url": 423 entry.setAttributionImageURL(accumulator.toString()); 424 break; 425 case "terms-of-use-text": 426 entry.setTermsOfUseText(accumulator.toString()); 427 break; 428 case "terms-of-use-url": 429 entry.setTermsOfUseURL(accumulator.toString()); 430 break; 431 case "country-code": 432 entry.setCountryCode(accumulator.toString()); 433 break; 434 case "icon": 435 entry.setIcon(accumulator.toString()); 436 break; 437 case "tile-size": 438 Integer tileSize = null; 439 try { 440 tileSize = Integer.valueOf(accumulator.toString()); 441 } catch (NumberFormatException e) { 442 tileSize = null; 443 } 444 if (tileSize == null) { 445 skipEntry = true; 446 } else { 447 entry.setTileSize(tileSize.intValue()); 448 } 449 break; 450 case "valid-georeference": 451 entry.setGeoreferenceValid(Boolean.valueOf(accumulator.toString())); 452 break; 453 case "epsg4326to3857Supported": 454 entry.setEpsg4326To3857Supported(Boolean.valueOf(accumulator.toString())); 455 break; 456 default: // Do nothing 457 } 458 break; 459 case BOUNDS: 460 entry.setBounds(bounds); 461 bounds = null; 462 break; 463 case SHAPE: 464 bounds.addShape(shape); 465 shape = null; 466 break; 467 case CODE: 468 projections.add(accumulator.toString()); 469 break; 470 case PROJECTIONS: 471 entry.setServerProjections(projections); 472 projections = null; 473 break; 474 case MIRROR_PROJECTIONS: 475 mirrorEntry.setServerProjections(projections); 476 projections = null; 477 break; 478 case NO_TILE: 479 case NO_TILESUM: 480 case METADATA: 481 case UNKNOWN: 482 default: 483 // nothing to do for these or the unknown type 484 } 485 } 486 } 487 488 /** 489 * Sets whether opening HTTP connections should fail fast, i.e., whether a 490 * {@link HttpClient#setConnectTimeout(int) low connect timeout} should be used. 491 * @param fastFail whether opening HTTP connections should fail fast 492 * @see CachedFile#setFastFail(boolean) 493 */ 494 public void setFastFail(boolean fastFail) { 495 this.fastFail = fastFail; 496 } 497 498 @Override 499 public void close() throws IOException { 500 Utils.close(cachedFile); 501 } 502}