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