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