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