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