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