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 -&gt; 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}