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.IOException; 007import java.io.InputStream; 008import java.io.Reader; 009import java.time.DateTimeException; 010import java.util.ArrayList; 011import java.util.Collection; 012import java.util.HashMap; 013import java.util.LinkedList; 014import java.util.List; 015import java.util.Map; 016import java.util.Stack; 017 018import javax.xml.parsers.ParserConfigurationException; 019 020import org.openstreetmap.josm.data.Bounds; 021import org.openstreetmap.josm.data.coor.LatLon; 022import org.openstreetmap.josm.data.gpx.GpxConstants; 023import org.openstreetmap.josm.data.gpx.GpxData; 024import org.openstreetmap.josm.data.gpx.GpxData.XMLNamespace; 025import org.openstreetmap.josm.data.gpx.GpxExtensionCollection; 026import org.openstreetmap.josm.data.gpx.GpxLink; 027import org.openstreetmap.josm.data.gpx.GpxRoute; 028import org.openstreetmap.josm.data.gpx.GpxTrack; 029import org.openstreetmap.josm.data.gpx.GpxTrackSegment; 030import org.openstreetmap.josm.data.gpx.IGpxTrackSegment; 031import org.openstreetmap.josm.data.gpx.WayPoint; 032import org.openstreetmap.josm.tools.Logging; 033import org.openstreetmap.josm.tools.UncheckedParseException; 034import org.openstreetmap.josm.tools.Utils; 035import org.openstreetmap.josm.tools.XmlUtils; 036import org.openstreetmap.josm.tools.date.DateUtils; 037import org.xml.sax.Attributes; 038import org.xml.sax.InputSource; 039import org.xml.sax.SAXException; 040import org.xml.sax.SAXParseException; 041import org.xml.sax.helpers.DefaultHandler; 042 043/** 044 * Read a gpx file. 045 * 046 * Bounds are read, even if we calculate them, see {@link GpxData#recalculateBounds}.<br> 047 * Both GPX version 1.0 and 1.1 are supported. 048 * 049 * @author imi, ramack 050 */ 051public class GpxReader implements GpxConstants, IGpxReader { 052 053 private enum State { 054 INIT, 055 GPX, 056 METADATA, 057 WPT, 058 RTE, 059 TRK, 060 EXT, 061 AUTHOR, 062 LINK, 063 TRKSEG, 064 COPYRIGHT 065 } 066 067 private String version; 068 /** The resulting gpx data */ 069 private GpxData gpxData; 070 private final InputSource inputSource; 071 072 private class Parser extends DefaultHandler { 073 074 private GpxData data; 075 private Collection<IGpxTrackSegment> currentTrack; 076 private Map<String, Object> currentTrackAttr; 077 private Collection<WayPoint> currentTrackSeg; 078 private GpxRoute currentRoute; 079 private WayPoint currentWayPoint; 080 081 private State currentState = State.INIT; 082 083 private GpxLink currentLink; 084 private GpxExtensionCollection currentExtensionCollection; 085 private GpxExtensionCollection currentTrackExtensionCollection; 086 private Stack<State> states; 087 private final Stack<String[]> elements = new Stack<>(); 088 089 private StringBuilder accumulator = new StringBuilder(); 090 091 private boolean nokiaSportsTrackerBug; 092 093 @Override 094 public void startDocument() { 095 accumulator = new StringBuilder(); 096 states = new Stack<>(); 097 data = new GpxData(true); 098 currentExtensionCollection = new GpxExtensionCollection(); 099 currentTrackExtensionCollection = new GpxExtensionCollection(); 100 } 101 102 @Override 103 public void startPrefixMapping(String prefix, String uri) throws SAXException { 104 data.getNamespaces().add(new XMLNamespace(prefix, uri)); 105 } 106 107 private double parseCoord(Attributes atts, String key) { 108 String val = atts.getValue(key); 109 if (val != null) { 110 return parseCoord(val); 111 } else { 112 // Some software do not respect GPX schema and use "minLat" / "minLon" instead of "minlat" / "minlon" 113 return parseCoord(atts.getValue(key.replaceFirst("l", "L"))); 114 } 115 } 116 117 private double parseCoord(String s) { 118 if (s != null) { 119 try { 120 return Double.parseDouble(s); 121 } catch (NumberFormatException ex) { 122 Logging.trace(ex); 123 } 124 } 125 return Double.NaN; 126 } 127 128 private LatLon parseLatLon(Attributes atts) { 129 return new LatLon( 130 parseCoord(atts, "lat"), 131 parseCoord(atts, "lon")); 132 } 133 134 @Override 135 public void startElement(String namespaceURI, String localName, String qName, Attributes atts) throws SAXException { 136 elements.push(new String[] {namespaceURI, localName, qName}); 137 switch(currentState) { 138 case INIT: 139 states.push(currentState); 140 currentState = State.GPX; 141 data.creator = atts.getValue("creator"); 142 version = atts.getValue("version"); 143 if (version != null && version.startsWith("1.0")) { 144 version = "1.0"; 145 } else if (!"1.1".equals(version)) { 146 // unknown version, assume 1.1 147 version = "1.1"; 148 } 149 String schemaLocation = atts.getValue(GpxConstants.XML_URI_XSD, "schemaLocation"); 150 if (schemaLocation != null) { 151 String[] schemaLocations = schemaLocation.split(" ", -1); 152 for (int i = 0; i < schemaLocations.length - 1; i += 2) { 153 final String schemaURI = schemaLocations[i]; 154 final String schemaXSD = schemaLocations[i + 1]; 155 data.getNamespaces().stream().filter(xml -> xml.getURI().equals(schemaURI)).forEach(xml -> { 156 xml.setLocation(schemaXSD); 157 }); 158 } 159 } 160 break; 161 case GPX: 162 switch (localName) { 163 case "metadata": 164 states.push(currentState); 165 currentState = State.METADATA; 166 break; 167 case "wpt": 168 states.push(currentState); 169 currentState = State.WPT; 170 currentWayPoint = new WayPoint(parseLatLon(atts)); 171 break; 172 case "rte": 173 states.push(currentState); 174 currentState = State.RTE; 175 currentRoute = new GpxRoute(); 176 break; 177 case "trk": 178 states.push(currentState); 179 currentState = State.TRK; 180 currentTrack = new ArrayList<>(); 181 currentTrackAttr = new HashMap<>(); 182 break; 183 case "extensions": 184 states.push(currentState); 185 currentState = State.EXT; 186 break; 187 case "gpx": 188 if (atts.getValue("creator") != null && atts.getValue("creator").startsWith("Nokia Sports Tracker")) { 189 nokiaSportsTrackerBug = true; 190 } 191 break; 192 default: // Do nothing 193 } 194 break; 195 case METADATA: 196 switch (localName) { 197 case "author": 198 states.push(currentState); 199 currentState = State.AUTHOR; 200 break; 201 case "extensions": 202 states.push(currentState); 203 currentState = State.EXT; 204 break; 205 case "copyright": 206 states.push(currentState); 207 currentState = State.COPYRIGHT; 208 data.put(META_COPYRIGHT_AUTHOR, atts.getValue("author")); 209 break; 210 case "link": 211 states.push(currentState); 212 currentState = State.LINK; 213 currentLink = new GpxLink(atts.getValue("href")); 214 break; 215 case "bounds": 216 data.put(META_BOUNDS, new Bounds( 217 parseCoord(atts, "minlat"), 218 parseCoord(atts, "minlon"), 219 parseCoord(atts, "maxlat"), 220 parseCoord(atts, "maxlon"))); 221 break; 222 default: // Do nothing 223 } 224 break; 225 case AUTHOR: 226 switch (localName) { 227 case "link": 228 states.push(currentState); 229 currentState = State.LINK; 230 currentLink = new GpxLink(atts.getValue("href")); 231 break; 232 case "email": 233 data.put(META_AUTHOR_EMAIL, atts.getValue("id") + '@' + atts.getValue("domain")); 234 break; 235 default: // Do nothing 236 } 237 break; 238 case TRK: 239 switch (localName) { 240 case "trkseg": 241 states.push(currentState); 242 currentState = State.TRKSEG; 243 currentTrackSeg = new ArrayList<>(); 244 break; 245 case "link": 246 states.push(currentState); 247 currentState = State.LINK; 248 currentLink = new GpxLink(atts.getValue("href")); 249 break; 250 case "extensions": 251 states.push(currentState); 252 currentState = State.EXT; 253 break; 254 default: // Do nothing 255 } 256 break; 257 case TRKSEG: 258 switch (localName) { 259 case "trkpt": 260 states.push(currentState); 261 currentState = State.WPT; 262 currentWayPoint = new WayPoint(parseLatLon(atts)); 263 break; 264 case "extensions": 265 states.push(currentState); 266 currentState = State.EXT; 267 break; 268 default: // Do nothing 269 } 270 break; 271 case WPT: 272 switch (localName) { 273 case "link": 274 states.push(currentState); 275 currentState = State.LINK; 276 currentLink = new GpxLink(atts.getValue("href")); 277 break; 278 case "extensions": 279 states.push(currentState); 280 currentState = State.EXT; 281 break; 282 default: // Do nothing 283 } 284 break; 285 case RTE: 286 switch (localName) { 287 case "link": 288 states.push(currentState); 289 currentState = State.LINK; 290 currentLink = new GpxLink(atts.getValue("href")); 291 break; 292 case "rtept": 293 states.push(currentState); 294 currentState = State.WPT; 295 currentWayPoint = new WayPoint(parseLatLon(atts)); 296 break; 297 case "extensions": 298 states.push(currentState); 299 currentState = State.EXT; 300 break; 301 default: // Do nothing 302 } 303 break; 304 case EXT: 305 if (states.lastElement() == State.TRK) { 306 currentTrackExtensionCollection.openChild(namespaceURI, qName, atts); 307 } else { 308 currentExtensionCollection.openChild(namespaceURI, qName, atts); 309 } 310 break; 311 default: // Do nothing 312 } 313 accumulator.setLength(0); 314 } 315 316 @Override 317 public void characters(char[] ch, int start, int length) { 318 /** 319 * Remove illegal characters generated by the Nokia Sports Tracker device. 320 * Don't do this crude substitution for all files, since it would destroy 321 * certain unicode characters. 322 */ 323 if (nokiaSportsTrackerBug) { 324 for (int i = 0; i < ch.length; ++i) { 325 if (ch[i] == 1) { 326 ch[i] = 32; 327 } 328 } 329 nokiaSportsTrackerBug = false; 330 } 331 332 accumulator.append(ch, start, length); 333 } 334 335 private Map<String, Object> getAttr() { 336 switch (currentState) { 337 case RTE: return currentRoute.attr; 338 case METADATA: return data.attr; 339 case WPT: return currentWayPoint.attr; 340 case TRK: return currentTrackAttr; 341 default: return null; 342 } 343 } 344 345 @SuppressWarnings("unchecked") 346 @Override 347 public void endElement(String namespaceURI, String localName, String qName) { 348 elements.pop(); 349 switch (currentState) { 350 case GPX: // GPX 1.0 351 case METADATA: // GPX 1.1 352 switch (localName) { 353 case "name": 354 data.put(META_NAME, accumulator.toString()); 355 break; 356 case "desc": 357 data.put(META_DESC, accumulator.toString()); 358 break; 359 case "time": 360 data.put(META_TIME, accumulator.toString()); 361 break; 362 case "keywords": 363 data.put(META_KEYWORDS, accumulator.toString()); 364 break; 365 case "author": 366 if ("1.0".equals(version)) { 367 // author is a string in 1.0, but complex element in 1.1 368 data.put(META_AUTHOR_NAME, accumulator.toString()); 369 } 370 break; 371 case "email": 372 if ("1.0".equals(version)) { 373 data.put(META_AUTHOR_EMAIL, accumulator.toString()); 374 } 375 break; 376 case "url": 377 case "urlname": 378 data.put(localName, accumulator.toString()); 379 break; 380 case "metadata": 381 case "gpx": 382 if ((currentState == State.METADATA && "metadata".equals(localName)) || 383 (currentState == State.GPX && "gpx".equals(localName))) { 384 convertUrlToLink(data.attr); 385 data.getExtensions().addAll(currentExtensionCollection); 386 currentExtensionCollection.clear(); 387 currentState = states.pop(); 388 } 389 break; 390 case "bounds": 391 // do nothing, has been parsed on startElement 392 break; 393 default: 394 } 395 break; 396 case AUTHOR: 397 switch (localName) { 398 case "author": 399 currentState = states.pop(); 400 break; 401 case "name": 402 data.put(META_AUTHOR_NAME, accumulator.toString()); 403 break; 404 case "email": 405 // do nothing, has been parsed on startElement 406 break; 407 case "link": 408 data.put(META_AUTHOR_LINK, currentLink); 409 break; 410 default: // Do nothing 411 } 412 break; 413 case COPYRIGHT: 414 switch (localName) { 415 case "copyright": 416 currentState = states.pop(); 417 break; 418 case "year": 419 data.put(META_COPYRIGHT_YEAR, accumulator.toString()); 420 break; 421 case "license": 422 data.put(META_COPYRIGHT_LICENSE, accumulator.toString()); 423 break; 424 default: // Do nothing 425 } 426 break; 427 case LINK: 428 switch (localName) { 429 case "text": 430 currentLink.text = accumulator.toString(); 431 break; 432 case "type": 433 currentLink.type = accumulator.toString(); 434 break; 435 case "link": 436 if (currentLink.uri == null && !accumulator.toString().isEmpty()) { 437 currentLink = new GpxLink(accumulator.toString()); 438 } 439 currentState = states.pop(); 440 break; 441 default: // Do nothing 442 } 443 if (currentState == State.AUTHOR) { 444 data.put(META_AUTHOR_LINK, currentLink); 445 } else if (currentState != State.LINK) { 446 Map<String, Object> attr = getAttr(); 447 if (attr != null && !attr.containsKey(META_LINKS)) { 448 attr.put(META_LINKS, new LinkedList<GpxLink>()); 449 } 450 if (attr != null) 451 ((Collection<GpxLink>) attr.get(META_LINKS)).add(currentLink); 452 } 453 break; 454 case WPT: 455 switch (localName) { 456 case "ele": 457 case "magvar": 458 case "name": 459 case "src": 460 case "geoidheight": 461 case "type": 462 case "sym": 463 case "url": 464 case "urlname": 465 case "cmt": 466 case "desc": 467 currentWayPoint.put(localName, accumulator.toString()); 468 break; 469 case "hdop": 470 case "vdop": 471 case "pdop": 472 try { 473 currentWayPoint.put(localName, Float.valueOf(accumulator.toString())); 474 } catch (NumberFormatException e) { 475 currentWayPoint.put(localName, 0f); 476 } 477 break; 478 case PT_TIME: 479 try { 480 currentWayPoint.setInstant(DateUtils.parseInstant(accumulator.toString())); 481 } catch (UncheckedParseException | DateTimeException e) { 482 Logging.error(e); 483 } 484 break; 485 case "rtept": 486 currentState = states.pop(); 487 convertUrlToLink(currentWayPoint.attr); 488 currentRoute.routePoints.add(currentWayPoint); 489 break; 490 case "trkpt": 491 currentState = states.pop(); 492 convertUrlToLink(currentWayPoint.attr); 493 currentTrackSeg.add(currentWayPoint); 494 break; 495 case "wpt": 496 currentState = states.pop(); 497 convertUrlToLink(currentWayPoint.attr); 498 currentWayPoint.getExtensions().addAll(currentExtensionCollection); 499 data.waypoints.add(currentWayPoint); 500 currentExtensionCollection.clear(); 501 break; 502 default: // Do nothing 503 } 504 break; 505 case TRKSEG: 506 if ("trkseg".equals(localName)) { 507 currentState = states.pop(); 508 if (!currentTrackSeg.isEmpty()) { 509 GpxTrackSegment seg = new GpxTrackSegment(currentTrackSeg); 510 if (!currentExtensionCollection.isEmpty()) { 511 seg.getExtensions().addAll(currentExtensionCollection); 512 } 513 currentTrack.add(seg); 514 } 515 currentExtensionCollection.clear(); 516 } 517 break; 518 case TRK: 519 switch (localName) { 520 case "trk": 521 currentState = states.pop(); 522 convertUrlToLink(currentTrackAttr); 523 GpxTrack trk = new GpxTrack(new ArrayList<>(currentTrack), currentTrackAttr); 524 if (!currentTrackExtensionCollection.isEmpty()) { 525 trk.getExtensions().addAll(currentTrackExtensionCollection); 526 } 527 data.addTrack(trk); 528 currentTrackExtensionCollection.clear(); 529 break; 530 case "name": 531 case "cmt": 532 case "desc": 533 case "src": 534 case "type": 535 case "number": 536 case "url": 537 case "urlname": 538 currentTrackAttr.put(localName, accumulator.toString()); 539 break; 540 default: // Do nothing 541 } 542 break; 543 case EXT: 544 if ("extensions".equals(localName)) { 545 currentState = states.pop(); 546 } else if (currentExtensionCollection != null) { 547 String acc = accumulator.toString().trim(); 548 if (states.lastElement() == State.TRK) { 549 currentTrackExtensionCollection.closeChild(qName, acc); //a segment inside the track can have an extension too 550 } else { 551 currentExtensionCollection.closeChild(qName, acc); 552 } 553 } 554 break; 555 default: 556 switch (localName) { 557 case "wpt": 558 currentState = states.pop(); 559 break; 560 case "rte": 561 currentState = states.pop(); 562 convertUrlToLink(currentRoute.attr); 563 data.addRoute(currentRoute); 564 break; 565 default: // Do nothing 566 } 567 } 568 accumulator.setLength(0); 569 } 570 571 @Override 572 public void endDocument() throws SAXException { 573 if (!states.empty()) 574 throw new SAXException(tr("Parse error: invalid document structure for GPX document.")); 575 576 data.getExtensions().stream("josm", "from-server").findAny().ifPresent(ext -> { 577 data.fromServer = "true".equals(ext.getValue()); 578 }); 579 580 data.getExtensions().stream("josm", "layerPreferences").forEach(prefs -> { 581 prefs.getExtensions().stream("josm", "entry").forEach(prefEntry -> { 582 Object key = prefEntry.get("key"); 583 Object val = prefEntry.get("value"); 584 if (key != null && val != null) { 585 data.getLayerPrefs().put(key.toString(), val.toString()); 586 } 587 }); 588 }); 589 data.endUpdate(); 590 gpxData = data; 591 } 592 593 /** 594 * convert url/urlname to link element (GPX 1.0 -> GPX 1.1). 595 * @param attr attributes 596 */ 597 private void convertUrlToLink(Map<String, Object> attr) { 598 String url = (String) attr.get("url"); 599 String urlname = (String) attr.get("urlname"); 600 if (url != null) { 601 if (!attr.containsKey(META_LINKS)) { 602 attr.put(META_LINKS, new LinkedList<GpxLink>()); 603 } 604 GpxLink link = new GpxLink(url); 605 link.text = urlname; 606 @SuppressWarnings("unchecked") 607 Collection<GpxLink> links = (Collection<GpxLink>) attr.get(META_LINKS); 608 links.add(link); 609 } 610 } 611 612 void tryToFinish() throws SAXException { 613 List<String[]> remainingElements = new ArrayList<>(elements); 614 for (int i = remainingElements.size() - 1; i >= 0; i--) { 615 String[] e = remainingElements.get(i); 616 endElement(e[0], e[1], e[2]); 617 } 618 endDocument(); 619 } 620 } 621 622 /** 623 * Constructs a new {@code GpxReader}, which can later parse the input stream 624 * and store the result in trackData and markerData 625 * 626 * @param source the source input stream 627 * @throws IOException if an IO error occurs, e.g. the input stream is closed. 628 */ 629 public GpxReader(InputStream source) throws IOException { 630 Reader utf8stream = UTFInputStreamReader.create(source); // NOPMD 631 Reader filtered = new InvalidXmlCharacterFilter(utf8stream); // NOPMD 632 this.inputSource = new InputSource(filtered); 633 } 634 635 /** 636 * Parse the GPX data. 637 * 638 * @param tryToFinish true, if the reader should return at least part of the GPX 639 * data in case of an error. 640 * @return true if file was properly parsed, false if there was error during 641 * parsing but some data were parsed anyway 642 * @throws SAXException if any SAX parsing error occurs 643 * @throws IOException if any I/O error occurs 644 */ 645 @Override 646 public boolean parse(boolean tryToFinish) throws SAXException, IOException { 647 Parser parser = new Parser(); 648 try { 649 XmlUtils.parseSafeSAX(inputSource, parser); 650 return true; 651 } catch (SAXException e) { 652 if (tryToFinish) { 653 parser.tryToFinish(); 654 String message = e.getLocalizedMessage(); 655 if (e instanceof SAXParseException) { 656 boolean dot = message.lastIndexOf('.') == message.length() - 1; 657 if (dot) 658 message = message.substring(0, message.length() - 1); 659 SAXParseException spe = (SAXParseException) e; 660 message += ' ' + tr("(at line {0}, column {1})", spe.getLineNumber(), spe.getColumnNumber()); 661 if (dot) 662 message += '.'; 663 } 664 if (!Utils.isBlank(parser.data.creator)) { 665 message += "\n" + tr("The file was created by \"{0}\".", parser.data.creator); 666 } 667 SAXException ex = new SAXException(message, e); 668 if (parser.data.isEmpty()) 669 throw ex; 670 Logging.warn(ex); 671 return false; 672 } else 673 throw e; 674 } catch (ParserConfigurationException e) { 675 Logging.error(e); // broken SAXException chaining 676 throw new SAXException(e); 677 } 678 } 679 680 @Override 681 public GpxData getGpxData() { 682 return gpxData; 683 } 684}