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.BufferedWriter;
007import java.io.OutputStream;
008import java.io.OutputStreamWriter;
009import java.io.PrintWriter;
010import java.nio.charset.StandardCharsets;
011import java.util.ArrayList;
012import java.util.Collection;
013import java.util.Date;
014import java.util.List;
015import java.util.Map;
016import java.util.Objects;
017import java.util.stream.Collectors;
018
019import javax.xml.XMLConstants;
020
021import org.openstreetmap.josm.data.Bounds;
022import org.openstreetmap.josm.data.coor.LatLon;
023import org.openstreetmap.josm.data.gpx.GpxConstants;
024import org.openstreetmap.josm.data.gpx.GpxData;
025import org.openstreetmap.josm.data.gpx.GpxData.XMLNamespace;
026import org.openstreetmap.josm.data.gpx.GpxExtension;
027import org.openstreetmap.josm.data.gpx.GpxExtensionCollection;
028import org.openstreetmap.josm.data.gpx.GpxLink;
029import org.openstreetmap.josm.data.gpx.GpxRoute;
030import org.openstreetmap.josm.data.gpx.GpxTrack;
031import org.openstreetmap.josm.data.gpx.IGpxTrack;
032import org.openstreetmap.josm.data.gpx.IGpxTrackSegment;
033import org.openstreetmap.josm.data.gpx.IWithAttributes;
034import org.openstreetmap.josm.data.gpx.WayPoint;
035import org.openstreetmap.josm.tools.JosmRuntimeException;
036import org.openstreetmap.josm.tools.Logging;
037import org.openstreetmap.josm.tools.date.DateUtils;
038
039/**
040 * Writes GPX files from GPX data or OSM data.
041 */
042public class GpxWriter extends XmlWriter implements GpxConstants {
043
044    /**
045     * Constructs a new {@code GpxWriter}.
046     * @param out The output writer
047     */
048    public GpxWriter(PrintWriter out) {
049        super(out);
050    }
051
052    /**
053     * Constructs a new {@code GpxWriter}.
054     * @param out The output stream
055     */
056    public GpxWriter(OutputStream out) {
057        super(new PrintWriter(new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))));
058    }
059
060    private GpxData data;
061    private String indent = "";
062    private List<String> validprefixes;
063
064    private static final int WAY_POINT = 0;
065    private static final int ROUTE_POINT = 1;
066    private static final int TRACK_POINT = 2;
067
068    /**
069     * Writes the given GPX data.
070     * @param data The data to write
071     */
072    public void write(GpxData data) {
073        write(data, ColorFormat.GPXD, true);
074    }
075
076    /**
077     * Writes the given GPX data.
078     *
079     * @param data The data to write
080     * @param colorFormat determines if colors are saved and which extension is to be used
081     * @param savePrefs whether layer specific preferences are saved
082     */
083    public void write(GpxData data, ColorFormat colorFormat, boolean savePrefs) {
084        this.data = data;
085
086        //Prepare extensions for writing
087        data.beginUpdate();
088        data.getTracks().stream()
089        .filter(GpxTrack.class::isInstance).map(GpxTrack.class::cast)
090        .forEach(trk -> trk.convertColor(colorFormat));
091        data.getExtensions().removeAllWithPrefix("josm");
092        if (data.fromServer) {
093            data.getExtensions().add("josm", "from-server", "true");
094        }
095        if (savePrefs && !data.getLayerPrefs().isEmpty()) {
096            GpxExtensionCollection layerExts = data.getExtensions().add("josm", "layerPreferences").getExtensions();
097            data.getLayerPrefs().entrySet()
098            .stream()
099            .sorted((e1, e2) -> e1.getKey().compareTo(e2.getKey()))
100            .forEach(entry -> {
101                GpxExtension e = layerExts.add("josm", "entry");
102                e.put("key", entry.getKey());
103                e.put("value", entry.getValue());
104            });
105        }
106        data.endUpdate();
107
108        Collection<IWithAttributes> all = new ArrayList<>();
109
110        all.add(data);
111        all.addAll(data.getWaypoints());
112        all.addAll(data.getRoutes());
113        all.addAll(data.getTracks());
114        all.addAll(data.getTrackSegmentsStream().collect(Collectors.toList()));
115
116        List<XMLNamespace> namespaces = all
117                .stream()
118                .flatMap(w -> w.getExtensions().getPrefixesStream())
119                .distinct()
120                .map(p -> data.getNamespaces()
121                        .stream()
122                        .filter(s -> s.getPrefix().equals(p))
123                        .findAny()
124                        .orElse(GpxExtension.findNamespace(p)))
125                .filter(Objects::nonNull)
126                .collect(Collectors.toList());
127
128        validprefixes = namespaces.stream().map(n -> n.getPrefix()).collect(Collectors.toList());
129
130        out.println("<?xml version='1.0' encoding='UTF-8'?>");
131        out.println("<gpx version=\"1.1\" creator=\"JOSM GPX export\" xmlns=\"http://www.topografix.com/GPX/1/1\"");
132
133        StringBuilder schemaLocations = new StringBuilder("http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd");
134
135        for (XMLNamespace n : namespaces) {
136            if (n.getURI() != null && n.getPrefix() != null && !n.getPrefix().isEmpty()) {
137                out.println(String.format("    xmlns:%s=\"%s\"", n.getPrefix(), n.getURI()));
138                if (n.getLocation() != null) {
139                    schemaLocations.append(' ').append(n.getURI()).append(' ').append(n.getLocation());
140                }
141            }
142        }
143
144        out.println("    xmlns:xsi=\""+XMLConstants.W3C_XML_SCHEMA_INSTANCE_NS_URI+"\"");
145        out.println(String.format("    xsi:schemaLocation=\"%s\">", schemaLocations));
146        indent = "  ";
147        writeMetaData();
148        writeWayPoints();
149        writeRoutes();
150        writeTracks();
151        out.print("</gpx>");
152        out.flush();
153    }
154
155    private void writeAttr(IWithAttributes obj, List<String> keys) {
156        for (String key : keys) {
157            if (META_LINKS.equals(key)) {
158                Collection<GpxLink> lValue = obj.<GpxLink>getCollection(key);
159                if (lValue != null) {
160                    for (GpxLink link : lValue) {
161                        gpxLink(link);
162                    }
163                }
164            } else {
165                String value = obj.getString(key);
166                if (value != null) {
167                    simpleTag(key, value);
168                } else {
169                    Object val = obj.get(key);
170                    if (val instanceof Date) {
171                        simpleTag(key, DateUtils.fromDate((Date) val));
172                    } else if (val instanceof Number) {
173                        simpleTag(key, val.toString());
174                    } else if (val != null) {
175                        Logging.warn("GPX attribute '"+key+"' not managed: " + val);
176                    }
177                }
178            }
179        }
180    }
181
182    private void writeMetaData() {
183        Map<String, Object> attr = data.attr;
184        openln("metadata");
185
186        // write the description
187        if (attr.containsKey(META_DESC)) {
188            simpleTag("desc", data.getString(META_DESC));
189        }
190
191        // write the author details
192        if (attr.containsKey(META_AUTHOR_NAME)
193                || attr.containsKey(META_AUTHOR_EMAIL)) {
194            openln("author");
195            // write the name
196            simpleTag("name", data.getString(META_AUTHOR_NAME));
197            // write the email address
198            if (attr.containsKey(META_AUTHOR_EMAIL)) {
199                String[] tmp = data.getString(META_AUTHOR_EMAIL).split("@");
200                if (tmp.length == 2) {
201                    inline("email", "id=\"" + encode(tmp[0]) + "\" domain=\"" + encode(tmp[1]) +'\"');
202                }
203            }
204            // write the author link
205            gpxLink((GpxLink) data.get(META_AUTHOR_LINK));
206            closeln("author");
207        }
208
209        // write the copyright details
210        if (attr.containsKey(META_COPYRIGHT_LICENSE)
211                || attr.containsKey(META_COPYRIGHT_YEAR)) {
212            openln("copyright", "author=\""+ encode(data.get(META_COPYRIGHT_AUTHOR).toString()) +'\"');
213            if (attr.containsKey(META_COPYRIGHT_YEAR)) {
214                simpleTag("year", (String) data.get(META_COPYRIGHT_YEAR));
215            }
216            if (attr.containsKey(META_COPYRIGHT_LICENSE)) {
217                simpleTag("license", encode((String) data.get(META_COPYRIGHT_LICENSE)));
218            }
219            closeln("copyright");
220        }
221
222        // write links
223        if (attr.containsKey(META_LINKS)) {
224            for (GpxLink link : data.<GpxLink>getCollection(META_LINKS)) {
225                gpxLink(link);
226            }
227        }
228
229        // write keywords
230        if (attr.containsKey(META_KEYWORDS)) {
231            simpleTag("keywords", data.getString(META_KEYWORDS));
232        }
233
234        Bounds bounds = data.recalculateBounds();
235        if (bounds != null) {
236            String b = "minlat=\"" + bounds.getMinLat() + "\" minlon=\"" + bounds.getMinLon() +
237            "\" maxlat=\"" + bounds.getMaxLat() + "\" maxlon=\"" + bounds.getMaxLon() + '\"';
238            inline("bounds", b);
239        }
240
241        gpxExtensions(data.getExtensions());
242        closeln("metadata");
243    }
244
245    private void writeWayPoints() {
246        for (WayPoint pnt : data.getWaypoints()) {
247            wayPoint(pnt, WAY_POINT);
248        }
249    }
250
251    private void writeRoutes() {
252        for (GpxRoute rte : data.getRoutes()) {
253            openln("rte");
254            writeAttr(rte, RTE_TRK_KEYS);
255            gpxExtensions(rte.getExtensions());
256            for (WayPoint pnt : rte.routePoints) {
257                wayPoint(pnt, ROUTE_POINT);
258            }
259            closeln("rte");
260        }
261    }
262
263    private void writeTracks() {
264        for (IGpxTrack trk : data.getTracks()) {
265            openln("trk");
266            writeAttr(trk, RTE_TRK_KEYS);
267            gpxExtensions(trk.getExtensions());
268            for (IGpxTrackSegment seg : trk.getSegments()) {
269                openln("trkseg");
270                gpxExtensions(seg.getExtensions());
271                for (WayPoint pnt : seg.getWayPoints()) {
272                    wayPoint(pnt, TRACK_POINT);
273                }
274                closeln("trkseg");
275            }
276            closeln("trk");
277        }
278    }
279
280    private void openln(String tag) {
281        open(tag);
282        out.println();
283    }
284
285    private void openln(String tag, String attributes) {
286        open(tag, attributes);
287        out.println();
288    }
289
290    private void open(String tag) {
291        out.print(indent + '<' + tag + '>');
292        indent += "  ";
293    }
294
295    private void open(String tag, String attributes) {
296        out.print(indent + '<' + tag + (attributes.isEmpty() ? "" : ' ') + attributes + '>');
297        indent += "  ";
298    }
299
300    private void inline(String tag, String attributes) {
301        out.println(indent + '<' + tag + (attributes.isEmpty() ? "" : ' ') + attributes + "/>");
302    }
303
304    private void close(String tag) {
305        indent = indent.substring(2);
306        out.print(indent + "</" + tag + '>');
307    }
308
309    private void closeln(String tag) {
310        close(tag);
311        out.println();
312    }
313
314    /**
315     * if content not null, open tag, write encoded content, and close tag
316     * else do nothing.
317     * @param tag GPX tag
318     * @param content content
319     */
320    private void simpleTag(String tag, String content) {
321        if (content != null && !content.isEmpty()) {
322            open(tag);
323            out.print(encode(content));
324            out.println("</" + tag + '>');
325            indent = indent.substring(2);
326        }
327    }
328
329    private void simpleTag(String tag, String content, String attributes) {
330        if (content != null && !content.isEmpty()) {
331            open(tag, attributes);
332            out.print(encode(content));
333            out.println("</" + tag + '>');
334            indent = indent.substring(2);
335        }
336    }
337
338    /**
339     * output link
340     * @param link link
341     */
342    private void gpxLink(GpxLink link) {
343        if (link != null) {
344            openln("link", "href=\"" + encode(link.uri) + '\"');
345            simpleTag("text", link.text);
346            simpleTag("type", link.type);
347            closeln("link");
348        }
349    }
350
351    /**
352     * output a point
353     * @param pnt waypoint
354     * @param mode {@code WAY_POINT} for {@code wpt}, {@code ROUTE_POINT} for {@code rtept}, {@code TRACK_POINT} for {@code trkpt}
355     */
356    private void wayPoint(WayPoint pnt, int mode) {
357        String type;
358        switch(mode) {
359        case WAY_POINT:
360            type = "wpt";
361            break;
362        case ROUTE_POINT:
363            type = "rtept";
364            break;
365        case TRACK_POINT:
366            type = "trkpt";
367            break;
368        default:
369            throw new JosmRuntimeException(tr("Unknown mode {0}.", mode));
370        }
371        if (pnt != null) {
372            LatLon c = pnt.getCoor();
373            String coordAttr = "lat=\"" + c.lat() + "\" lon=\"" + c.lon() + '\"';
374            if (pnt.attr.isEmpty() && pnt.getExtensions().isEmpty()) {
375                inline(type, coordAttr);
376            } else {
377                openln(type, coordAttr);
378                writeAttr(pnt, WPT_KEYS);
379                gpxExtensions(pnt.getExtensions());
380                closeln(type);
381            }
382        }
383    }
384
385    private void gpxExtensions(GpxExtensionCollection allExtensions) {
386        if (allExtensions.isVisible()) {
387            openln("extensions");
388            writeExtension(allExtensions);
389            closeln("extensions");
390        }
391    }
392
393    private void writeExtension(List<GpxExtension> extensions) {
394        for (GpxExtension e : extensions) {
395            if (validprefixes.contains(e.getPrefix()) && e.isVisible()) {
396                // this might lead to loss of an unknown extension *after* the file was saved as .osm,
397                // but otherwise the file is invalid and can't even be parsed by SAX anymore
398                String k = (e.getPrefix().isEmpty() ? "" : e.getPrefix() + ":") + e.getKey();
399                String attr = String.join(" ", e.getAttributes().entrySet().stream()
400                        .map(a -> encode(a.getKey()) + "=\"" + encode(a.getValue().toString()) + "\"").sorted().collect(Collectors.toList()));
401                if (e.getValue() == null && e.getExtensions().isEmpty()) {
402                    inline(k, attr);
403                } else if (e.getExtensions().isEmpty()) {
404                    simpleTag(k, e.getValue(), attr);
405                } else {
406                    openln(k, attr);
407                    if (e.getValue() != null) {
408                        out.print(encode(e.getValue()));
409                    }
410                    writeExtension(e.getExtensions());
411                    closeln(k);
412                }
413            }
414        }
415    }
416}