Skip to main content

gpx_rs/gpx/convert/
mod.rs

1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::Path;
5
6use quick_xml::events::Event;
7use quick_xml::Reader;
8use serde_json::{json, Value};
9
10use crate::gpx::error::ParseError;
11use crate::gpx::serialize;
12use crate::gpx::types::{Gpx, Route, Track, TrackSegment, Waypoint};
13
14const GPX_FORMAT: &str = "gpx";
15const GEOJSON_FORMAT: &str = "geojson";
16const JSON_FORMAT: &str = "json";
17const KML_FORMAT: &str = "kml";
18
19const KML_NS: &str = "http://www.opengis.net/kml/2.2";
20
21/// Errors raised while converting between GPX, GeoJSON, and KML.
22#[derive(Debug)]
23pub enum ConvertError {
24    Io(io::Error),
25    Parse(ParseError),
26    Json(serde_json::Error),
27    Xml(String),
28    UnsupportedFormat(String),
29    UnsupportedGeometry(String),
30    InvalidCoordinates(String),
31    MissingFormat,
32}
33
34impl fmt::Display for ConvertError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Io(err) => write!(f, "I/O error: {err}"),
38            Self::Parse(err) => write!(f, "{err}"),
39            Self::Json(err) => write!(f, "JSON error: {err}"),
40            Self::Xml(msg) => write!(f, "XML error: {msg}"),
41            Self::UnsupportedFormat(format) => write!(f, "unsupported format: {format}"),
42            Self::UnsupportedGeometry(kind) => write!(f, "unsupported geometry type: {kind}"),
43            Self::InvalidCoordinates(msg) => write!(f, "invalid coordinates: {msg}"),
44            Self::MissingFormat => write!(f, "could not detect file format from extension"),
45        }
46    }
47}
48
49impl std::error::Error for ConvertError {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            Self::Io(err) => Some(err),
53            Self::Parse(err) => Some(err),
54            Self::Json(err) => Some(err),
55            _ => None,
56        }
57    }
58}
59
60impl From<io::Error> for ConvertError {
61    fn from(err: io::Error) -> Self {
62        Self::Io(err)
63    }
64}
65
66impl From<ParseError> for ConvertError {
67    fn from(err: ParseError) -> Self {
68        Self::Parse(err)
69    }
70}
71
72impl From<serde_json::Error> for ConvertError {
73    fn from(err: serde_json::Error) -> Self {
74        Self::Json(err)
75    }
76}
77
78/// Detect a supported format from a file extension.
79pub fn detect_format(path: &Path) -> Option<&'static str> {
80    match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
81        "gpx" => Some(GPX_FORMAT),
82        "geojson" => Some(GEOJSON_FORMAT),
83        "json" => Some(JSON_FORMAT),
84        "kml" => Some(KML_FORMAT),
85        _ => None,
86    }
87}
88
89/// Read a GPX file.
90pub fn read_gpx(path: &Path) -> Result<Gpx, ConvertError> {
91    Ok(Gpx::parse_file(path)?)
92}
93
94/// Read a GeoJSON file into a [`Gpx`] document.
95pub fn read_geojson(path: &Path) -> Result<Gpx, ConvertError> {
96    let data = fs::read_to_string(path)?;
97    parse_geojson(&data)
98}
99
100/// Read a KML 2.2 file into a [`Gpx`] document.
101pub fn read_kml(path: &Path) -> Result<Gpx, ConvertError> {
102    let data = fs::read_to_string(path)?;
103    parse_kml(&data)
104}
105
106/// Write a [`Gpx`] document as GeoJSON to a file.
107pub fn write_geojson(gpx: &Gpx, path: &Path) -> Result<(), ConvertError> {
108    let data = geojson_string(gpx)?;
109    fs::write(path, data)?;
110    Ok(())
111}
112
113/// Write a [`Gpx`] document as KML 2.2 to a file.
114pub fn write_kml(gpx: &Gpx, path: &Path, pretty: bool) -> Result<(), ConvertError> {
115    fs::write(path, kml_string(gpx, pretty))?;
116    Ok(())
117}
118
119/// Convert between supported file formats.
120///
121/// When `input_format` or `output_format` is `None`, the format is inferred from
122/// the corresponding path extension. Returns the resolved `(input_format, output_format)`.
123pub fn convert_file(
124    input: &Path,
125    output: &Path,
126    input_format: Option<&str>,
127    output_format: Option<&str>,
128) -> Result<(String, String), ConvertError> {
129    let in_fmt = resolve_format(input_format, input)?;
130    let out_fmt = resolve_format(output_format, output)?;
131
132    let gpx = read_by_format(input, &in_fmt)?;
133    write_by_format(&gpx, output, &out_fmt, true)?;
134
135    Ok((in_fmt, out_fmt))
136}
137
138fn resolve_format(explicit: Option<&str>, path: &Path) -> Result<String, ConvertError> {
139    if let Some(format) = explicit {
140        return Ok(normalize_format(format)?.to_string());
141    }
142    detect_format(path)
143        .map(str::to_string)
144        .ok_or(ConvertError::MissingFormat)
145}
146
147fn normalize_format(format: &str) -> Result<&'static str, ConvertError> {
148    match format.to_ascii_lowercase().as_str() {
149        GPX_FORMAT => Ok(GPX_FORMAT),
150        GEOJSON_FORMAT | JSON_FORMAT => Ok(GEOJSON_FORMAT),
151        KML_FORMAT => Ok(KML_FORMAT),
152        other => Err(ConvertError::UnsupportedFormat(other.to_string())),
153    }
154}
155
156fn read_by_format(path: &Path, format: &str) -> Result<Gpx, ConvertError> {
157    match format {
158        GPX_FORMAT => read_gpx(path),
159        GEOJSON_FORMAT | JSON_FORMAT => read_geojson(path),
160        KML_FORMAT => read_kml(path),
161        other => Err(ConvertError::UnsupportedFormat(other.to_string())),
162    }
163}
164
165fn write_by_format(gpx: &Gpx, path: &Path, format: &str, pretty: bool) -> Result<(), ConvertError> {
166    match format {
167        GPX_FORMAT => serialize::write_file(gpx, path, pretty).map_err(ConvertError::from),
168        GEOJSON_FORMAT | JSON_FORMAT => write_geojson(gpx, path),
169        KML_FORMAT => write_kml(gpx, path, pretty),
170        other => Err(ConvertError::UnsupportedFormat(other.to_string())),
171    }
172}
173
174fn parse_geojson(data: &str) -> Result<Gpx, ConvertError> {
175    let value: Value = serde_json::from_str(data)?;
176    let mut gpx = Gpx::default();
177
178    match value.get("type").and_then(Value::as_str) {
179        Some("FeatureCollection") => {
180            if let Some(features) = value.get("features").and_then(Value::as_array) {
181                for feature in features {
182                    ingest_feature(feature, &mut gpx)?;
183                }
184            }
185        }
186        Some("Feature") => ingest_feature(&value, &mut gpx)?,
187        Some("Point" | "LineString" | "MultiPoint" | "MultiLineString") => {
188            ingest_geometry(&value, None, None, &mut gpx)?;
189        }
190        Some(other) => return Err(ConvertError::UnsupportedGeometry(other.to_string())),
191        None => return Err(ConvertError::UnsupportedGeometry("missing type".to_string())),
192    }
193
194    Ok(gpx)
195}
196
197fn ingest_feature(feature: &Value, gpx: &mut Gpx) -> Result<(), ConvertError> {
198    let properties = feature.get("properties");
199    let name = properties
200        .and_then(|p| p.get("name"))
201        .or_else(|| properties.and_then(|p| p.get("title")))
202        .and_then(Value::as_str)
203        .map(str::to_string);
204    let desc = properties
205        .and_then(|p| p.get("description"))
206        .or_else(|| properties.and_then(|p| p.get("desc")))
207        .and_then(Value::as_str)
208        .map(str::to_string);
209    let gpx_type = properties
210        .and_then(|p| p.get("gpx_type"))
211        .and_then(Value::as_str);
212
213    if let Some(geometry) = feature.get("geometry") {
214        ingest_geometry_with_hint(geometry, name, desc, gpx, gpx_type)?;
215    }
216
217    Ok(())
218}
219
220fn ingest_geometry(
221    geometry: &Value,
222    name: Option<String>,
223    desc: Option<String>,
224    gpx: &mut Gpx,
225) -> Result<(), ConvertError> {
226    ingest_geometry_with_hint(geometry, name, desc, gpx, None)
227}
228
229fn ingest_geometry_with_hint(
230    geometry: &Value,
231    name: Option<String>,
232    desc: Option<String>,
233    gpx: &mut Gpx,
234    gpx_type: Option<&str>,
235) -> Result<(), ConvertError> {
236    let geo_type = geometry
237        .get("type")
238        .and_then(Value::as_str)
239        .ok_or_else(|| ConvertError::UnsupportedGeometry("missing type".to_string()))?;
240
241    match geo_type {
242        "Point" => {
243            let waypoint = parse_geojson_point(geometry.get("coordinates").ok_or_else(|| {
244                ConvertError::InvalidCoordinates("Point missing coordinates".to_string())
245            })?)?;
246            gpx.waypoints.push(apply_waypoint_meta(waypoint, name, desc));
247        }
248        "MultiPoint" => {
249            let coords = geometry
250                .get("coordinates")
251                .and_then(Value::as_array)
252                .ok_or_else(|| {
253                    ConvertError::InvalidCoordinates("MultiPoint missing coordinates".to_string())
254                })?;
255            for coord in coords {
256                let waypoint = parse_geojson_point(coord)?;
257                gpx.waypoints.push(apply_waypoint_meta(waypoint, name.clone(), desc.clone()));
258            }
259        }
260        "LineString" => {
261            let points = parse_geojson_line(geometry.get("coordinates").ok_or_else(|| {
262                ConvertError::InvalidCoordinates("LineString missing coordinates".to_string())
263            })?)?;
264            if matches!(gpx_type, Some("track" | "trk")) {
265                gpx.tracks.push(Track {
266                    name,
267                    desc,
268                    segments: vec![TrackSegment { points, ..Default::default() }],
269                    ..Default::default()
270                });
271            } else {
272                gpx.routes.push(Route {
273                    name,
274                    desc,
275                    points,
276                    ..Default::default()
277                });
278            }
279        }
280        "MultiLineString" => {
281            let lines = geometry
282                .get("coordinates")
283                .and_then(Value::as_array)
284                .ok_or_else(|| {
285                    ConvertError::InvalidCoordinates(
286                        "MultiLineString missing coordinates".to_string(),
287                    )
288                })?;
289            let mut segments = Vec::new();
290            for line in lines {
291                segments.push(TrackSegment {
292                    points: parse_geojson_line(line)?,
293                    ..Default::default()
294                });
295            }
296            gpx.tracks.push(Track {
297                name,
298                desc,
299                segments,
300                ..Default::default()
301            });
302        }
303        other => return Err(ConvertError::UnsupportedGeometry(other.to_string())),
304    }
305
306    Ok(())
307}
308
309fn apply_waypoint_meta(mut waypoint: Waypoint, name: Option<String>, desc: Option<String>) -> Waypoint {
310    if name.is_some() {
311        waypoint.name = name;
312    }
313    if desc.is_some() {
314        waypoint.desc = desc;
315    }
316    waypoint
317}
318
319fn parse_geojson_point(value: &Value) -> Result<Waypoint, ConvertError> {
320    let coords = value
321        .as_array()
322        .ok_or_else(|| ConvertError::InvalidCoordinates("expected coordinate array".to_string()))?;
323    if coords.len() < 2 {
324        return Err(ConvertError::InvalidCoordinates(
325            "coordinate array must contain at least lon and lat".to_string(),
326        ));
327    }
328    let lon = coord_component(&coords[0])?;
329    let lat = coord_component(&coords[1])?;
330    let ele = if coords.len() > 2 {
331        Some(coord_component(&coords[2])?)
332    } else {
333        None
334    };
335    Ok(waypoint(lat, lon, ele))
336}
337
338fn parse_geojson_line(value: &Value) -> Result<Vec<Waypoint>, ConvertError> {
339    let coords = value
340        .as_array()
341        .ok_or_else(|| ConvertError::InvalidCoordinates("expected coordinate array".to_string()))?;
342    coords.iter().map(parse_geojson_point).collect()
343}
344
345fn coord_component(value: &Value) -> Result<f64, ConvertError> {
346    value
347        .as_f64()
348        .ok_or_else(|| ConvertError::InvalidCoordinates(format!("expected number, got {value}")))
349}
350
351fn waypoint(lat: f64, lon: f64, ele: Option<f64>) -> Waypoint {
352    Waypoint {
353        lat,
354        lon,
355        ele,
356        time: None,
357        magvar: None,
358        geoidheight: None,
359        name: None,
360        cmt: None,
361        desc: None,
362        src: None,
363        links: vec![],
364        sym: None,
365        waypoint_type: None,
366        fix: None,
367        sat: None,
368        hdop: None,
369        vdop: None,
370        pdop: None,
371        ageofdgpsdata: None,
372        dgpsid: None,
373        extensions: None,
374    }
375}
376
377fn geojson_string(gpx: &Gpx) -> Result<String, ConvertError> {
378    let mut features = Vec::new();
379
380    for waypoint in &gpx.waypoints {
381        features.push(waypoint_feature(waypoint));
382    }
383    for route in &gpx.routes {
384        features.push(route_feature(route));
385    }
386    for track in &gpx.tracks {
387        features.extend(track_features(track));
388    }
389
390    let collection = json!({
391        "type": "FeatureCollection",
392        "features": features,
393    });
394    Ok(serde_json::to_string_pretty(&collection)?)
395}
396
397fn waypoint_feature(waypoint: &Waypoint) -> Value {
398    let mut properties = json!({ "gpx_type": "waypoint" });
399    if let Some(name) = &waypoint.name {
400        properties["name"] = json!(name);
401    }
402    if let Some(desc) = &waypoint.desc {
403        properties["description"] = json!(desc);
404    }
405
406    json!({
407        "type": "Feature",
408        "properties": properties,
409        "geometry": {
410            "type": "Point",
411            "coordinates": waypoint_coordinates(waypoint),
412        },
413    })
414}
415
416fn route_feature(route: &Route) -> Value {
417    let mut properties = json!({ "gpx_type": "route" });
418    if let Some(name) = &route.name {
419        properties["name"] = json!(name);
420    }
421    if let Some(desc) = &route.desc {
422        properties["description"] = json!(desc);
423    }
424
425    json!({
426        "type": "Feature",
427        "properties": properties,
428        "geometry": {
429            "type": "LineString",
430            "coordinates": route.points.iter().map(waypoint_coordinates).collect::<Vec<_>>(),
431        },
432    })
433}
434
435fn track_features(track: &Track) -> Vec<Value> {
436    track
437        .segments
438        .iter()
439        .enumerate()
440        .map(|(index, segment)| {
441            let mut properties = json!({ "gpx_type": "track" });
442            if let Some(name) = &track.name {
443                properties["name"] = json!(name);
444            }
445            if let Some(desc) = &track.desc {
446                properties["description"] = json!(desc);
447            }
448            if track.segments.len() > 1 {
449                properties["segment"] = json!(index);
450            }
451
452            json!({
453                "type": "Feature",
454                "properties": properties,
455                "geometry": {
456                    "type": "LineString",
457                    "coordinates": segment.points.iter().map(waypoint_coordinates).collect::<Vec<_>>(),
458                },
459            })
460        })
461        .collect()
462}
463
464fn waypoint_coordinates(waypoint: &Waypoint) -> Value {
465    match waypoint.ele {
466        Some(ele) => json!([waypoint.lon, waypoint.lat, ele]),
467        None => json!([waypoint.lon, waypoint.lat]),
468    }
469}
470
471fn parse_kml(data: &str) -> Result<Gpx, ConvertError> {
472    let data = strip_kml_namespace(data);
473    let placemarks = parse_kml_placemarks(&data)?;
474    let mut gpx = Gpx::default();
475
476    for placemark in placemarks {
477        if let Some(point) = placemark.point {
478            let mut waypoint = parse_kml_coordinates(&point)?.into_iter().next().ok_or_else(|| {
479                ConvertError::InvalidCoordinates("Point placemark has no coordinates".to_string())
480            })?;
481            waypoint.name = placemark.name.clone();
482            waypoint.desc = placemark.description.clone();
483            gpx.waypoints.push(waypoint);
484        } else if let Some(line) = placemark.line_string {
485            let points = parse_kml_coordinates(&line)?;
486            gpx.routes.push(Route {
487                name: placemark.name.clone(),
488                desc: placemark.description.clone(),
489                points,
490                ..Default::default()
491            });
492        } else if let Some(lines) = placemark.multi_geometry {
493            let mut segments = Vec::new();
494            for line in lines {
495                segments.push(TrackSegment {
496                    points: parse_kml_coordinates(&line)?,
497                    ..Default::default()
498                });
499            }
500            gpx.tracks.push(Track {
501                name: placemark.name.clone(),
502                desc: placemark.description.clone(),
503                segments,
504                ..Default::default()
505            });
506        }
507    }
508
509    Ok(gpx)
510}
511
512fn strip_kml_namespace(xml: &str) -> String {
513    xml.replace(&format!(r#" xmlns="{KML_NS}""#), "")
514        .replace(&format!(" xmlns='{KML_NS}'"), "")
515}
516
517#[derive(Debug, Default)]
518struct KmlPlacemarkData {
519    name: Option<String>,
520    description: Option<String>,
521    point: Option<String>,
522    line_string: Option<String>,
523    multi_geometry: Option<Vec<String>>,
524}
525
526fn parse_kml_placemarks(data: &str) -> Result<Vec<KmlPlacemarkData>, ConvertError> {
527    let mut reader = Reader::from_str(data);
528    reader.config_mut().trim_text(true);
529
530    let mut placemarks = Vec::new();
531    let mut current: Option<KmlPlacemarkData> = None;
532    let mut element_stack: Vec<String> = Vec::new();
533    let mut text = String::new();
534
535    loop {
536        match reader.read_event() {
537            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
538                let name = local_name_start(&e);
539                if name == "Placemark" {
540                    current = Some(KmlPlacemarkData::default());
541                }
542                if let Some(placemark) = current.as_mut() {
543                    match name.as_str() {
544                        "Point" if placemark.point.is_none() && placemark.line_string.is_none() => {}
545                        "LineString" if placemark.line_string.is_none() && placemark.multi_geometry.is_none() => {}
546                        "MultiGeometry" if placemark.multi_geometry.is_none() => {
547                            placemark.multi_geometry = Some(Vec::new());
548                        }
549                        "coordinates" => text.clear(),
550                        _ => {}
551                    }
552                }
553                element_stack.push(name);
554            }
555            Ok(Event::Text(e)) => {
556                if element_stack.last().is_some_and(|tag| tag == "coordinates") {
557                    text.push_str(&e.unescape().map_err(|err| ConvertError::Xml(err.to_string()))?);
558                } else if element_stack.last().is_some_and(|tag| tag == "name" || tag == "description") {
559                    if let Some(placemark) = current.as_mut() {
560                        let value = e
561                            .unescape()
562                            .map_err(|err| ConvertError::Xml(err.to_string()))?
563                            .into_owned();
564                        match element_stack.last().map(String::as_str) {
565                            Some("name") => placemark.name = Some(value),
566                            Some("description") => placemark.description = Some(value),
567                            _ => {}
568                        }
569                    }
570                }
571            }
572            Ok(Event::End(e)) => {
573                let name = local_name_end(&e);
574                if name == "coordinates" {
575                    if let Some(placemark) = current.as_mut() {
576                        let coords = text.trim().to_string();
577                        if element_stack.iter().any(|tag| tag == "MultiGeometry") {
578                            if let Some(lines) = placemark.multi_geometry.as_mut() {
579                                lines.push(coords);
580                            }
581                        } else if element_stack.iter().any(|tag| tag == "LineString") {
582                            placemark.line_string = Some(coords);
583                        } else if element_stack.iter().any(|tag| tag == "Point") {
584                            placemark.point = Some(coords);
585                        }
586                    }
587                    text.clear();
588                }
589                if name == "Placemark" {
590                    if let Some(placemark) = current.take() {
591                        placemarks.push(placemark);
592                    }
593                }
594                element_stack.pop();
595            }
596            Ok(Event::Eof) => break,
597            Ok(_) => {}
598            Err(err) => return Err(ConvertError::Xml(err.to_string())),
599        }
600    }
601
602    Ok(placemarks)
603}
604
605fn local_name_start(event: &quick_xml::events::BytesStart<'_>) -> String {
606    String::from_utf8_lossy(event.local_name().as_ref()).into_owned()
607}
608
609fn local_name_end(event: &quick_xml::events::BytesEnd<'_>) -> String {
610    String::from_utf8_lossy(event.local_name().as_ref()).into_owned()
611}
612
613fn parse_kml_coordinates(raw: &str) -> Result<Vec<Waypoint>, ConvertError> {
614    let mut points = Vec::new();
615    for token in raw.split_whitespace() {
616        let parts: Vec<&str> = token.split(',').collect();
617        if parts.len() < 2 {
618            return Err(ConvertError::InvalidCoordinates(format!(
619                "expected lon,lat[,ele], got `{token}`"
620            )));
621        }
622        let lon = parts[0]
623            .trim()
624            .parse::<f64>()
625            .map_err(|_| ConvertError::InvalidCoordinates(format!("invalid longitude `{token}`")))?;
626        let lat = parts[1]
627            .trim()
628            .parse::<f64>()
629            .map_err(|_| ConvertError::InvalidCoordinates(format!("invalid latitude `{token}`")))?;
630        let ele = if parts.len() > 2 && !parts[2].trim().is_empty() {
631            Some(
632                parts[2]
633                    .trim()
634                    .parse::<f64>()
635                    .map_err(|_| ConvertError::InvalidCoordinates(format!("invalid elevation `{token}`")))?,
636            )
637        } else {
638            None
639        };
640        points.push(waypoint(lat, lon, ele));
641    }
642    Ok(points)
643}
644
645fn kml_string(gpx: &Gpx, pretty: bool) -> String {
646    let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
647    if pretty {
648        out.push_str("<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n  <Document>\n");
649    } else {
650        out.push_str("<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>");
651    }
652
653    if let Some(metadata) = &gpx.metadata {
654        if let Some(name) = &metadata.name {
655            write_kml_text("name", name, &mut out, pretty, 2);
656        }
657        if let Some(desc) = &metadata.desc {
658            write_kml_text("description", desc, &mut out, pretty, 2);
659        }
660    }
661
662    for waypoint in &gpx.waypoints {
663        write_kml_waypoint_placemark(waypoint, &mut out, pretty);
664    }
665    for route in &gpx.routes {
666        write_kml_route_placemark(route, &mut out, pretty);
667    }
668    for track in &gpx.tracks {
669        write_kml_track_placemark(track, &mut out, pretty);
670    }
671
672    if pretty {
673        out.push_str("  </Document>\n</kml>\n");
674    } else {
675        out.push_str("</Document></kml>");
676    }
677    out
678}
679
680fn write_kml_waypoint_placemark(waypoint: &Waypoint, out: &mut String, pretty: bool) {
681    write_kml_placemark_start(out, pretty, 2);
682    write_kml_optional_text("name", waypoint.name.as_deref(), out, pretty, 3);
683    write_kml_optional_text("description", waypoint.desc.as_deref(), out, pretty, 3);
684    write_kml_tag_open("Point", out, pretty, 3);
685    write_kml_coordinates(std::slice::from_ref(waypoint), out, pretty, 4);
686    write_kml_tag_close("Point", out, pretty, 3);
687    write_kml_placemark_end(out, pretty, 2);
688}
689
690fn write_kml_route_placemark(route: &Route, out: &mut String, pretty: bool) {
691    write_kml_placemark_start(out, pretty, 2);
692    write_kml_optional_text("name", route.name.as_deref(), out, pretty, 3);
693    write_kml_optional_text("description", route.desc.as_deref(), out, pretty, 3);
694    write_kml_tag_open("LineString", out, pretty, 3);
695    write_kml_coordinates(&route.points, out, pretty, 4);
696    write_kml_tag_close("LineString", out, pretty, 3);
697    write_kml_placemark_end(out, pretty, 2);
698}
699
700fn write_kml_track_placemark(track: &Track, out: &mut String, pretty: bool) {
701    write_kml_placemark_start(out, pretty, 2);
702    write_kml_optional_text("name", track.name.as_deref(), out, pretty, 3);
703    write_kml_optional_text("description", track.desc.as_deref(), out, pretty, 3);
704    if track.segments.len() == 1 {
705        write_kml_tag_open("LineString", out, pretty, 3);
706        write_kml_coordinates(&track.segments[0].points, out, pretty, 4);
707        write_kml_tag_close("LineString", out, pretty, 3);
708    } else {
709        write_kml_tag_open("MultiGeometry", out, pretty, 3);
710        for segment in &track.segments {
711            write_kml_tag_open("LineString", out, pretty, 4);
712            write_kml_coordinates(&segment.points, out, pretty, 5);
713            write_kml_tag_close("LineString", out, pretty, 4);
714        }
715        write_kml_tag_close("MultiGeometry", out, pretty, 3);
716    }
717    write_kml_placemark_end(out, pretty, 2);
718}
719
720fn write_kml_placemark_start(out: &mut String, pretty: bool, depth: usize) {
721    write_kml_tag_open("Placemark", out, pretty, depth);
722}
723
724fn write_kml_placemark_end(out: &mut String, pretty: bool, depth: usize) {
725    write_kml_tag_close("Placemark", out, pretty, depth);
726}
727
728fn write_kml_coordinates(points: &[Waypoint], out: &mut String, pretty: bool, depth: usize) {
729    let coords = points
730        .iter()
731        .map(|point| match point.ele {
732            Some(ele) => format!("{},{},{}", point.lon, point.lat, ele),
733            None => format!("{},{}", point.lon, point.lat),
734        })
735        .collect::<Vec<_>>()
736        .join(if pretty { "\n" } else { " " });
737    write_kml_text("coordinates", &coords, out, pretty, depth);
738}
739
740fn write_kml_optional_text(tag: &str, value: Option<&str>, out: &mut String, pretty: bool, depth: usize) {
741    if let Some(value) = value {
742        write_kml_text(tag, value, out, pretty, depth);
743    }
744}
745
746fn write_kml_text(tag: &str, value: &str, out: &mut String, pretty: bool, depth: usize) {
747    if pretty {
748        out.push_str(&"  ".repeat(depth));
749    }
750    out.push('<');
751    out.push_str(tag);
752    out.push('>');
753    out.push_str(&escape_xml(value));
754    out.push_str("</");
755    out.push_str(tag);
756    out.push('>');
757    if pretty {
758        out.push('\n');
759    }
760}
761
762fn write_kml_tag_open(tag: &str, out: &mut String, pretty: bool, depth: usize) {
763    if pretty {
764        out.push_str(&"  ".repeat(depth));
765    }
766    out.push('<');
767    out.push_str(tag);
768    out.push('>');
769    if pretty {
770        out.push('\n');
771    }
772}
773
774fn write_kml_tag_close(tag: &str, out: &mut String, pretty: bool, depth: usize) {
775    if pretty {
776        out.push_str(&"  ".repeat(depth));
777    }
778    out.push_str("</");
779    out.push_str(tag);
780    out.push('>');
781    if pretty {
782        out.push('\n');
783    }
784}
785
786fn escape_xml(value: &str) -> String {
787    value
788        .replace('&', "&amp;")
789        .replace('<', "&lt;")
790        .replace('>', "&gt;")
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn detect_format_extensions() {
799        assert_eq!(detect_format(Path::new("a.gpx")), Some("gpx"));
800        assert_eq!(detect_format(Path::new("a.geojson")), Some("geojson"));
801        assert_eq!(detect_format(Path::new("a.json")), Some("json"));
802        assert_eq!(detect_format(Path::new("a.kml")), Some("kml"));
803        assert_eq!(detect_format(Path::new("a.txt")), None);
804    }
805
806    #[test]
807    fn geojson_point_to_waypoint() {
808        let gpx = parse_geojson(
809            r#"{"type":"Feature","geometry":{"type":"Point","coordinates":[-122.3,47.6,12.0]},"properties":{"name":"Test"}}"#,
810        )
811        .unwrap();
812        assert_eq!(gpx.waypoints.len(), 1);
813        assert!((gpx.waypoints[0].lon - (-122.3)).abs() < f64::EPSILON);
814        assert!((gpx.waypoints[0].lat - 47.6).abs() < f64::EPSILON);
815        assert_eq!(gpx.waypoints[0].ele, Some(12.0));
816        assert_eq!(gpx.waypoints[0].name.as_deref(), Some("Test"));
817    }
818
819    #[test]
820    fn geojson_linestring_to_route() {
821        let gpx = parse_geojson(
822            r#"{"type":"Feature","geometry":{"type":"LineString","coordinates":[[-122.3,47.6],[-122.4,47.7]]}}"#,
823        )
824        .unwrap();
825        assert_eq!(gpx.routes.len(), 1);
826        assert_eq!(gpx.routes[0].points.len(), 2);
827    }
828
829    #[test]
830    fn geojson_multilinestring_to_track() {
831        let gpx = parse_geojson(
832            r#"{"type":"Feature","geometry":{"type":"MultiLineString","coordinates":[[[-122.3,47.6],[-122.4,47.7]],[[-122.5,47.8],[-122.6,47.9]]]}}"#,
833        )
834        .unwrap();
835        assert_eq!(gpx.tracks.len(), 1);
836        assert_eq!(gpx.tracks[0].segments.len(), 2);
837    }
838
839    #[test]
840    fn kml_point_and_linestring() {
841        let kml = r#"<?xml version="1.0" encoding="UTF-8"?>
842<kml xmlns="http://www.opengis.net/kml/2.2">
843  <Document>
844    <Placemark>
845      <name>Point A</name>
846      <Point><coordinates>-122.3,47.6,10</coordinates></Point>
847    </Placemark>
848    <Placemark>
849      <name>Route B</name>
850      <LineString><coordinates>-122.3,47.6 -122.4,47.7</coordinates></LineString>
851    </Placemark>
852    <Placemark>
853      <name>Track C</name>
854      <MultiGeometry>
855        <LineString><coordinates>-122.3,47.6 -122.4,47.7</coordinates></LineString>
856        <LineString><coordinates>-122.5,47.8 -122.6,47.9</coordinates></LineString>
857      </MultiGeometry>
858    </Placemark>
859  </Document>
860</kml>"#;
861        let gpx = parse_kml(kml).unwrap();
862        assert_eq!(gpx.waypoints.len(), 1);
863        assert_eq!(gpx.routes.len(), 1);
864        assert_eq!(gpx.tracks.len(), 1);
865        assert_eq!(gpx.tracks[0].segments.len(), 2);
866    }
867}