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                case "cmt":
408                case "desc":
409                    currentWayPoint.put(localName, accumulator.toString());
410                    currentWayPoint.setTime();
411                    break;
412                case "rtept":
413                    currentState = states.pop();
414                    convertUrlToLink(currentWayPoint.attr);
415                    currentRoute.routePoints.add(currentWayPoint);
416                    break;
417                case "trkpt":
418                    currentState = states.pop();
419                    convertUrlToLink(currentWayPoint.attr);
420                    currentTrackSeg.add(currentWayPoint);
421                    break;
422                case "wpt":
423                    currentState = states.pop();
424                    convertUrlToLink(currentWayPoint.attr);
425                    if (currentExtensions != null && !currentExtensions.isEmpty()) {
426                        currentWayPoint.put(META_EXTENSIONS, currentExtensions);
427                    }
428                    data.waypoints.add(currentWayPoint);
429                    break;
430                }
431                break;
432            case trkseg:
433                if ("trkseg".equals(localName)) {
434                    currentState = states.pop();
435                    currentTrack.add(currentTrackSeg);
436                }
437                break;
438            case trk:
439                switch (localName) {
440                case "trk":
441                    currentState = states.pop();
442                    convertUrlToLink(currentTrackAttr);
443                    data.tracks.add(new ImmutableGpxTrack(currentTrack, currentTrackAttr));
444                    break;
445                case "name":
446                case "cmt":
447                case "desc":
448                case "src":
449                case "type":
450                case "number":
451                case "url":
452                case "urlname":
453                    currentTrackAttr.put(localName, accumulator.toString());
454                    break;
455                }
456                break;
457            case ext:
458                if ("extensions".equals(localName)) {
459                    currentState = states.pop();
460                } else if (JOSM_EXTENSIONS_NAMESPACE_URI.equals(namespaceURI)) {
461                    // only interested in extensions written by JOSM
462                    currentExtensions.put(localName, accumulator.toString());
463                }
464                break;
465            default:
466                switch (localName) {
467                case "wpt":
468                    currentState = states.pop();
469                    break;
470                case "rte":
471                    currentState = states.pop();
472                    convertUrlToLink(currentRoute.attr);
473                    data.routes.add(currentRoute);
474                    break;
475                }
476            }
477        }
478
479        @Override
480        public void endDocument() throws SAXException  {
481            if (!states.empty())
482                throw new SAXException(tr("Parse error: invalid document structure for GPX document."));
483            Extensions metaExt = (Extensions) data.get(META_EXTENSIONS);
484            if (metaExt != null && "true".equals(metaExt.get("from-server"))) {
485                data.fromServer = true;
486            }
487            gpxData = data;
488        }
489
490        /**
491         * convert url/urlname to link element (GPX 1.0 -&gt; GPX 1.1).
492         */
493        private void convertUrlToLink(Map<String, Object> attr) {
494            String url = (String) attr.get("url");
495            String urlname = (String) attr.get("urlname");
496            if (url != null) {
497                if (!attr.containsKey(META_LINKS)) {
498                    attr.put(META_LINKS, new LinkedList<GpxLink>());
499                }
500                GpxLink link = new GpxLink(url);
501                link.text = urlname;
502                @SuppressWarnings({ "unchecked", "rawtypes" })
503                Collection<GpxLink> links = (Collection<GpxLink>) attr.get(META_LINKS);
504                links.add(link);
505            }
506        }
507
508        public void tryToFinish() throws SAXException {
509            List<String> remainingElements = new ArrayList<>(elements);
510            for (int i=remainingElements.size() - 1; i >= 0; i--) {
511                endElement(null, remainingElements.get(i), remainingElements.get(i));
512            }
513            endDocument();
514        }
515    }
516
517    /**
518     * Constructs a new {@code GpxReader}, which can later parse the input stream
519     * and store the result in trackData and markerData
520     *
521     * @param source the source input stream
522     * @throws IOException if an IO error occurs, e.g. the input stream is closed.
523     */
524    @SuppressWarnings("resource")
525    public GpxReader(InputStream source) throws IOException {
526        Reader utf8stream = UTFInputStreamReader.create(source);
527        Reader filtered = new InvalidXmlCharacterFilter(utf8stream);
528        this.inputSource = new InputSource(filtered);
529    }
530
531    /**
532     * Parse the GPX data.
533     *
534     * @param tryToFinish true, if the reader should return at least part of the GPX
535     * data in case of an error.
536     * @return true if file was properly parsed, false if there was error during
537     * parsing but some data were parsed anyway
538     * @throws SAXException
539     * @throws IOException
540     */
541    public boolean parse(boolean tryToFinish) throws SAXException, IOException {
542        Parser parser = new Parser();
543        try {
544            SAXParserFactory factory = SAXParserFactory.newInstance();
545            factory.setNamespaceAware(true);
546            factory.newSAXParser().parse(inputSource, parser);
547            return true;
548        } catch (SAXException e) {
549            if (tryToFinish) {
550                parser.tryToFinish();
551                if (parser.data.isEmpty())
552                    throw e;
553                String message = e.getMessage();
554                if (e instanceof SAXParseException) {
555                    SAXParseException spe = ((SAXParseException)e);
556                    message += " " + tr("(at line {0}, column {1})", spe.getLineNumber(), spe.getColumnNumber());
557                }
558                Main.warn(message);
559                return false;
560            } else
561                throw e;
562        } catch (ParserConfigurationException e) {
563            Main.error(e); // broken SAXException chaining
564            throw new SAXException(e);
565        }
566    }
567
568    /**
569     * Replies the GPX data.
570     * @return The GPX data
571     */
572    public GpxData getGpxData() {
573        return gpxData;
574    }
575}