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                }
154                break;
155            case metadata:
156                switch (localName) {
157                case "author":
158                    states.push(currentState);
159                    currentState = State.author;
160                    break;
161                case "extensions":
162                    states.push(currentState);
163                    currentState = State.ext;
164                    currentExtensions = new Extensions();
165                    break;
166                case "copyright":
167                    states.push(currentState);
168                    currentState = State.copyright;
169                    data.put(META_COPYRIGHT_AUTHOR, atts.getValue("author"));
170                    break;
171                case "link":
172                    states.push(currentState);
173                    currentState = State.link;
174                    currentLink = new GpxLink(atts.getValue("href"));
175                    break;
176                case "bounds":
177                    data.put(META_BOUNDS, new Bounds(
178                                parseCoord(atts.getValue("minlat")),
179                                parseCoord(atts.getValue("minlon")),
180                                parseCoord(atts.getValue("maxlat")),
181                                parseCoord(atts.getValue("maxlon"))));
182                }
183                break;
184            case author:
185                switch (localName) {
186                case "link":
187                    states.push(currentState);
188                    currentState = State.link;
189                    currentLink = new GpxLink(atts.getValue("href"));
190                    break;
191                case "email":
192                    data.put(META_AUTHOR_EMAIL, atts.getValue("id") + '@' + atts.getValue("domain"));
193                }
194                break;
195            case trk:
196                switch (localName) {
197                case "trkseg":
198                    states.push(currentState);
199                    currentState = State.trkseg;
200                    currentTrackSeg = new ArrayList<>();
201                    break;
202                case "link":
203                    states.push(currentState);
204                    currentState = State.link;
205                    currentLink = new GpxLink(atts.getValue("href"));
206                    break;
207                case "extensions":
208                    states.push(currentState);
209                    currentState = State.ext;
210                    currentExtensions = new Extensions();
211                }
212                break;
213            case trkseg:
214                if ("trkpt".equals(localName)) {
215                    states.push(currentState);
216                    currentState = State.wpt;
217                    currentWayPoint = new WayPoint(parseLatLon(atts));
218                }
219                break;
220            case wpt:
221                switch (localName) {
222                case "link":
223                    states.push(currentState);
224                    currentState = State.link;
225                    currentLink = new GpxLink(atts.getValue("href"));
226                    break;
227                case "extensions":
228                    states.push(currentState);
229                    currentState = State.ext;
230                    currentExtensions = new Extensions();
231                    break;
232                }
233                break;
234            case rte:
235                switch (localName) {
236                case "link":
237                    states.push(currentState);
238                    currentState = State.link;
239                    currentLink = new GpxLink(atts.getValue("href"));
240                    break;
241                case "rtept":
242                    states.push(currentState);
243                    currentState = State.wpt;
244                    currentWayPoint = new WayPoint(parseLatLon(atts));
245                    break;
246                case "extensions":
247                    states.push(currentState);
248                    currentState = State.ext;
249                    currentExtensions = new Extensions();
250                    break;
251                }
252                break;
253            }
254            accumulator.setLength(0);
255        }
256
257        @Override
258        public void characters(char[] ch, int start, int length) {
259            /**
260             * Remove illegal characters generated by the Nokia Sports Tracker device.
261             * Don't do this crude substitution for all files, since it would destroy
262             * certain unicode characters.
263             */
264            if (nokiaSportsTrackerBug) {
265                for (int i = 0; i < ch.length; ++i) {
266                    if (ch[i] == 1) {
267                        ch[i] = 32;
268                    }
269                }
270                nokiaSportsTrackerBug = false;
271            }
272
273            accumulator.append(ch, start, length);
274        }
275
276        private Map<String, Object> getAttr() {
277            switch (currentState) {
278            case rte: return currentRoute.attr;
279            case metadata: return data.attr;
280            case wpt: return currentWayPoint.attr;
281            case trk: return currentTrackAttr;
282            default: return null;
283            }
284        }
285
286        @SuppressWarnings("unchecked")
287        @Override
288        public void endElement(String namespaceURI, String localName, String qName) {
289            elements.pop();
290            switch (currentState) {
291            case gpx:       // GPX 1.0
292            case metadata:  // GPX 1.1
293                switch (localName) {
294                case "name":
295                    data.put(META_NAME, accumulator.toString());
296                    break;
297                case "desc":
298                    data.put(META_DESC, accumulator.toString());
299                    break;
300                case "time":
301                    data.put(META_TIME, accumulator.toString());
302                    break;
303                case "keywords":
304                    data.put(META_KEYWORDS, accumulator.toString());
305                    break;
306                case "author":
307                    if ("1.0".equals(version)) {
308                        // author is a string in 1.0, but complex element in 1.1
309                        data.put(META_AUTHOR_NAME, accumulator.toString());
310                    }
311                    break;
312                case "email":
313                    if ("1.0".equals(version)) {
314                        data.put(META_AUTHOR_EMAIL, accumulator.toString());
315                    }
316                    break;
317                case "url":
318                case "urlname":
319                    data.put(localName, accumulator.toString());
320                    break;
321                case "metadata":
322                case "gpx":
323                    if ((currentState == State.metadata && "metadata".equals(localName)) ||
324                        (currentState == State.gpx && "gpx".equals(localName))) {
325                        convertUrlToLink(data.attr);
326                        if (currentExtensions != null && !currentExtensions.isEmpty()) {
327                            data.put(META_EXTENSIONS, currentExtensions);
328                        }
329                        currentState = states.pop();
330                        break;
331                    }
332                case "bounds":
333                    // do nothing, has been parsed on startElement
334                    break;
335                default:
336                    //TODO: parse extensions
337                }
338                break;
339            case author:
340                switch (localName) {
341                case "author":
342                    currentState = states.pop();
343                    break;
344                case "name":
345                    data.put(META_AUTHOR_NAME, accumulator.toString());
346                    break;
347                case "email":
348                    // do nothing, has been parsed on startElement
349                    break;
350                case "link":
351                    data.put(META_AUTHOR_LINK, currentLink);
352                    break;
353                }
354                break;
355            case copyright:
356                switch (localName) {
357                case "copyright":
358                    currentState = states.pop();
359                    break;
360                case "year":
361                    data.put(META_COPYRIGHT_YEAR, accumulator.toString());
362                    break;
363                case "license":
364                    data.put(META_COPYRIGHT_LICENSE, accumulator.toString());
365                    break;
366                }
367                break;
368            case link:
369                switch (localName) {
370                case "text":
371                    currentLink.text = accumulator.toString();
372                    break;
373                case "type":
374                    currentLink.type = accumulator.toString();
375                    break;
376                case "link":
377                    if (currentLink.uri == null && accumulator != null && !accumulator.toString().isEmpty()) {
378                        currentLink = new GpxLink(accumulator.toString());
379                    }
380                    currentState = states.pop();
381                    break;
382                }
383                if (currentState == State.author) {
384                    data.put(META_AUTHOR_LINK, currentLink);
385                } else if (currentState != State.link) {
386                    Map<String, Object> attr = getAttr();
387                    if (!attr.containsKey(META_LINKS)) {
388                        attr.put(META_LINKS, new LinkedList<GpxLink>());
389                    }
390                    ((Collection<GpxLink>) attr.get(META_LINKS)).add(currentLink);
391                }
392                break;
393            case wpt:
394                switch (localName) {
395                case "ele":
396                case "magvar":
397                case "name":
398                case "src":
399                case "geoidheight":
400                case "type":
401                case "sym":
402                case "url":
403                case "urlname":
404                    currentWayPoint.put(localName, accumulator.toString());
405                    break;
406                case "hdop":
407                case "vdop":
408                case "pdop":
409                    try {
410                        currentWayPoint.put(localName, Float.valueOf(accumulator.toString()));
411                    } catch (Exception e) {
412                        currentWayPoint.put(localName, new Float(0));
413                    }
414                    break;
415                case "time":
416                case "cmt":
417                case "desc":
418                    currentWayPoint.put(localName, accumulator.toString());
419                    currentWayPoint.setTime();
420                    break;
421                case "rtept":
422                    currentState = states.pop();
423                    convertUrlToLink(currentWayPoint.attr);
424                    currentRoute.routePoints.add(currentWayPoint);
425                    break;
426                case "trkpt":
427                    currentState = states.pop();
428                    convertUrlToLink(currentWayPoint.attr);
429                    currentTrackSeg.add(currentWayPoint);
430                    break;
431                case "wpt":
432                    currentState = states.pop();
433                    convertUrlToLink(currentWayPoint.attr);
434                    if (currentExtensions != null && !currentExtensions.isEmpty()) {
435                        currentWayPoint.put(META_EXTENSIONS, currentExtensions);
436                    }
437                    data.waypoints.add(currentWayPoint);
438                    break;
439                }
440                break;
441            case trkseg:
442                if ("trkseg".equals(localName)) {
443                    currentState = states.pop();
444                    currentTrack.add(currentTrackSeg);
445                }
446                break;
447            case trk:
448                switch (localName) {
449                case "trk":
450                    currentState = states.pop();
451                    convertUrlToLink(currentTrackAttr);
452                    data.tracks.add(new ImmutableGpxTrack(currentTrack, currentTrackAttr));
453                    break;
454                case "name":
455                case "cmt":
456                case "desc":
457                case "src":
458                case "type":
459                case "number":
460                case "url":
461                case "urlname":
462                    currentTrackAttr.put(localName, accumulator.toString());
463                    break;
464                }
465                break;
466            case ext:
467                if ("extensions".equals(localName)) {
468                    currentState = states.pop();
469                } else if (JOSM_EXTENSIONS_NAMESPACE_URI.equals(namespaceURI)) {
470                    // only interested in extensions written by JOSM
471                    currentExtensions.put(localName, accumulator.toString());
472                }
473                break;
474            default:
475                switch (localName) {
476                case "wpt":
477                    currentState = states.pop();
478                    break;
479                case "rte":
480                    currentState = states.pop();
481                    convertUrlToLink(currentRoute.attr);
482                    data.routes.add(currentRoute);
483                    break;
484                }
485            }
486        }
487
488        @Override
489        public void endDocument() throws SAXException  {
490            if (!states.empty())
491                throw new SAXException(tr("Parse error: invalid document structure for GPX document."));
492            Extensions metaExt = (Extensions) data.get(META_EXTENSIONS);
493            if (metaExt != null && "true".equals(metaExt.get("from-server"))) {
494                data.fromServer = true;
495            }
496            gpxData = data;
497        }
498
499        /**
500         * convert url/urlname to link element (GPX 1.0 -&gt; GPX 1.1).
501         * @param attr attributes
502         */
503        private void convertUrlToLink(Map<String, Object> attr) {
504            String url = (String) attr.get("url");
505            String urlname = (String) attr.get("urlname");
506            if (url != null) {
507                if (!attr.containsKey(META_LINKS)) {
508                    attr.put(META_LINKS, new LinkedList<GpxLink>());
509                }
510                GpxLink link = new GpxLink(url);
511                link.text = urlname;
512                @SuppressWarnings("unchecked")
513                Collection<GpxLink> links = (Collection<GpxLink>) attr.get(META_LINKS);
514                links.add(link);
515            }
516        }
517
518        public void tryToFinish() throws SAXException {
519            List<String> remainingElements = new ArrayList<>(elements);
520            for (int i = remainingElements.size() - 1; i >= 0; i--) {
521                endElement(null, remainingElements.get(i), remainingElements.get(i));
522            }
523            endDocument();
524        }
525    }
526
527    /**
528     * Constructs a new {@code GpxReader}, which can later parse the input stream
529     * and store the result in trackData and markerData
530     *
531     * @param source the source input stream
532     * @throws IOException if an IO error occurs, e.g. the input stream is closed.
533     */
534    public GpxReader(InputStream source) throws IOException {
535        Reader utf8stream = UTFInputStreamReader.create(source);
536        Reader filtered = new InvalidXmlCharacterFilter(utf8stream);
537        this.inputSource = new InputSource(filtered);
538    }
539
540    /**
541     * Parse the GPX data.
542     *
543     * @param tryToFinish true, if the reader should return at least part of the GPX
544     * data in case of an error.
545     * @return true if file was properly parsed, false if there was error during
546     * parsing but some data were parsed anyway
547     * @throws SAXException if any SAX parsing error occurs
548     * @throws IOException if any I/O error occurs
549     */
550    public boolean parse(boolean tryToFinish) throws SAXException, IOException {
551        Parser parser = new Parser();
552        try {
553            Utils.parseSafeSAX(inputSource, parser);
554            return true;
555        } catch (SAXException e) {
556            if (tryToFinish) {
557                parser.tryToFinish();
558                if (parser.data.isEmpty())
559                    throw e;
560                String message = e.getMessage();
561                if (e instanceof SAXParseException) {
562                    SAXParseException spe = (SAXParseException) e;
563                    message += ' ' + tr("(at line {0}, column {1})", spe.getLineNumber(), spe.getColumnNumber());
564                }
565                Main.warn(message);
566                return false;
567            } else
568                throw e;
569        } catch (ParserConfigurationException e) {
570            Main.error(e); // broken SAXException chaining
571            throw new SAXException(e);
572        }
573    }
574
575    /**
576     * Replies the GPX data.
577     * @return The GPX data
578     */
579    public GpxData getGpxData() {
580        return gpxData;
581    }
582}