use crate::{ Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint, TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance, }; use chrono::DateTime; use quick_xml::{Error, XmlVersion}; use quick_xml::escape::resolve_predefined_entity; use quick_xml::events::Event; use quick_xml::events::attributes::{Attribute, Attributes}; use quick_xml::reader::Reader; enum GPXElement { Metadata, Name, Comment, Description, Source, Author(Author), Link(Link), Text, /// A track, or a route, whose information is read the same way. Track(Track), Segment(TrackSegment), Trackpoint(Trackpoint), RoutePoint(LngLat), Waypoint(Waypoint), Elevation, Time, Temperature, Heartrate, Cadence, Power, Surface, Highway, SacScale, MtbScale, Symbol, Type, Color, /// The color of a track or a route in the Garmin extensions (`gpxx:DisplayColor`). DisplayColor, Opacity, Width, } fn parse_coordinates(attributes: Attributes<'_>) -> LngLat { let mut coordinates = LngLat::default(); for attr in attributes.flatten() { match attr.key.as_ref() { "lat" => coordinates.lat = attr.value.parse().unwrap_or_default(), "lon" => coordinates.lng = attr.value.parse().unwrap_or_default(), _ => (), } } coordinates } /// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`). fn is_route_point_extension(name: &str) -> bool { name == "rpt" || name.ends_with(":rpt") } /// The color of a track (`RRGGBB`, the format of the style schema) and its opacity, from a color /// that may start with `#` and may have an alpha channel first (`AARRGGBB`), as some applications /// write it. A color in any other format is kept as it is, without opacity. fn parse_color(value: &str) -> (String, Option) { let value = value.trim(); let hex = value.strip_prefix('#').unwrap_or(value); if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return (value.to_owned(), None); } match hex.len() { 6 => (hex.to_owned(), None), 8 => { let alpha = u8::from_str_radix(&hex[..2], 16).unwrap_or(u8::MAX); let opacity = (f64::from(alpha) / 255.0 * 100.0).round() / 100.0; (hex[2..].to_owned(), Some(opacity)) } _ => (value.to_owned(), None), } } /// The hexadecimal color of a value of `DisplayColor_t` in the Garmin extensions. None for /// `Transparent` and for what is not part of the schema. fn garmin_display_color(name: &str) -> Option<&'static str> { match name.trim() { "Black" => Some("000000"), "DarkRed" => Some("800000"), "DarkGreen" => Some("008000"), "DarkYellow" => Some("808000"), "DarkBlue" => Some("000080"), "DarkMagenta" => Some("800080"), "DarkCyan" => Some("008080"), "LightGray" => Some("c0c0c0"), "DarkGray" => Some("808080"), "Red" => Some("ff0000"), "Green" => Some("00ff00"), "Yellow" => Some("ffff00"), "Blue" => Some("0000ff"), "Magenta" => Some("ff00ff"), "Cyan" => Some("00ffff"), "White" => Some("ffffff"), _ => None, } } /// Whether the element holds a value, which is its text. fn holds_text(element: Option<&GPXElement>) -> bool { matches!( element, Some( GPXElement::Name | GPXElement::Comment | GPXElement::Description | GPXElement::Source | GPXElement::Text | GPXElement::Elevation | GPXElement::Time | GPXElement::Temperature | GPXElement::Heartrate | GPXElement::Cadence | GPXElement::Power | GPXElement::Surface | GPXElement::Highway | GPXElement::SacScale | GPXElement::MtbScale | GPXElement::Symbol | GPXElement::Type | GPXElement::Color | GPXElement::DisplayColor | GPXElement::Opacity | GPXElement::Width ) ) } /// Gives the text `e` of the element at the top of the stack, which is removed, to what holds it. fn apply_text( stack: &mut Vec, gpx: &mut File, categories: &mut TrackpointCategories, e: &str, ) { match stack.last_mut() { Some(GPXElement::Name) => { stack.pop(); match stack.last_mut() { Some(GPXElement::Metadata) => { gpx.info.name = e.to_owned(); } Some(GPXElement::Author(author)) => { author.name = Some(e.to_owned()); } Some(GPXElement::Track(trk)) => { trk.info.name = Some(e.to_owned()); } Some(GPXElement::Waypoint(wpt)) => { wpt.name = Some(e.to_owned()); } _ => (), } } Some(GPXElement::Comment) => { stack.pop(); match stack.last_mut() { Some(GPXElement::Track(trk)) => { trk.info.cmt = Some(e.to_owned()); } Some(GPXElement::Waypoint(wpt)) => { wpt.cmt = Some(e.to_owned()); } _ => (), } } Some(GPXElement::Description) => { stack.pop(); match stack.last_mut() { Some(GPXElement::Metadata) => { gpx.info.desc = Some(e.to_owned()); } Some(GPXElement::Track(trk)) => { trk.info.desc = Some(e.to_owned()); } Some(GPXElement::Waypoint(wpt)) => { wpt.desc = Some(e.to_owned()); } _ => (), } } Some(GPXElement::Source) => { stack.pop(); if let Some(GPXElement::Track(trk)) = stack.last_mut() { trk.info.src = Some(e.to_owned()); } } Some(GPXElement::Text) => { stack.pop(); if let Some(GPXElement::Link(link)) = stack.last_mut() { link.text = Some(e.to_owned()); } } Some(GPXElement::Elevation) => { stack.pop(); match stack.last_mut() { Some(GPXElement::Trackpoint(trkpt)) => { trkpt.ele = e.parse().unwrap_or_default(); } Some(GPXElement::Waypoint(wpt)) => { wpt.ele = e.parse().unwrap_or_default(); } _ => (), } } Some(GPXElement::Time) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.time = DateTime::parse_from_rfc3339(e) .ok() .map(|time| time.timestamp_millis()); } } Some(GPXElement::Temperature) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.atemp = e.parse().ok(); } } Some(GPXElement::Heartrate) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.hr = e.parse().ok(); } } Some(GPXElement::Cadence) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.cad = e.parse().ok(); } } Some(GPXElement::Power) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.power = e.parse().ok(); } } Some(GPXElement::Surface) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.surface = categories.surface.code(e); } } Some(GPXElement::Highway) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.highway = categories.highway.code(e); } } Some(GPXElement::SacScale) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.sac_scale = categories.sac_scale.code(e); } } Some(GPXElement::MtbScale) => { stack.pop(); if let Some(GPXElement::Trackpoint(trkpt)) = stack.last_mut() { trkpt.mtb_scale = categories.mtb_scale.code(e); } } Some(GPXElement::Symbol) => { stack.pop(); if let Some(GPXElement::Waypoint(wpt)) = stack.last_mut() { wpt.sym = Some(e.to_owned()); } } Some(GPXElement::Type) => { stack.pop(); match stack.last_mut() { Some(GPXElement::Track(trk)) => { trk.info.type_ = Some(e.to_owned()); } Some(GPXElement::Waypoint(wpt)) => { wpt.type_ = Some(e.to_owned()); } _ => (), } } Some(GPXElement::Color) => { stack.pop(); if let Some(GPXElement::Track(trk)) = stack.last_mut() { let (color, opacity) = parse_color(e); trk.info.color = Some(color); // The opacity of the style schema has priority, wherever it is in the extensions if trk.info.opacity.is_none() { trk.info.opacity = opacity; } } } // The color of the style schema has priority, wherever it is in the extensions Some(GPXElement::DisplayColor) => { stack.pop(); if let Some(GPXElement::Track(trk)) = stack.last_mut() && trk.info.color.is_none() { trk.info.color = garmin_display_color(e).map(str::to_owned); } } Some(GPXElement::Opacity) => { stack.pop(); if let Some(GPXElement::Track(trk)) = stack.last_mut() { trk.info.opacity = e.parse().ok(); } } Some(GPXElement::Width) => { stack.pop(); if let Some(GPXElement::Track(trk)) = stack.last_mut() { trk.info.width = e.parse().ok(); } } _ => (), } } /// The text of an attribute, without its escaping (`&` is `&`), which is written back when the /// file is. As it is in the file if it cannot be read. fn attribute_text(attr: &Attribute<'_>) -> String { attr.normalized_value(XmlVersion::Implicit1_0) .map_or_else(|_| attr.value.to_string(), |text| text.into_owned()) } /// The email of the author, given as the `id` and `domain` attributes of the element. fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) { let Some(GPXElement::Author(author)) = stack.last_mut() else { return; }; let (mut id, mut domain) = (None, None); for attr in attributes.flatten() { match attr.key.as_ref() { "id" => id = Some(attribute_text(&attr)), "domain" => domain = Some(attribute_text(&attr)), _ => (), } } if let (Some(id), Some(domain)) = (id, domain) { author.email = Some(format!("{id}@{domain}")); } } /// The link with the given `href`, if it is an attribute of the element. fn parse_link(attributes: Attributes<'_>) -> Link { let mut link = Link::default(); for attr in attributes.flatten() { if attr.key.as_ref() == "href" { link.href = attribute_text(&attr); } } link } /// Gives a link to what holds it, which is at the top of the stack. fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) { match stack.last_mut() { // the author has a single link, the other elements have as many as they want Some(GPXElement::Author(author)) => author.link = Some(link), Some(GPXElement::Track(trk)) => trk.info.links.push(link), Some(GPXElement::Waypoint(wpt)) => wpt.links.push(link), Some(GPXElement::Metadata) => gpx.info.links.push(link), _ => (), } } /// Builds the segment of a route. /// /// The route points are the anchors of the segment, and are the only ones that the file gives /// (no anchor is computed). The points of the detailed path between a route point and the next one /// (`gpxx:rpt`, which only have a position) are regular trackpoints, with the elevation worked out /// from the one of the route points around them. #[derive(Default)] struct RouteReader { segment: TrackSegment, chunk: TrackpointChunk, /// The intermediate points that follow the last route point, up to the next one. pending: Vec, /// Position and elevation of the last route point. previous: Option<(LngLat, f64)>, } impl RouteReader { fn push(&mut self, point: Trackpoint) { self.chunk.trkpt.push(point); if self.chunk.is_full() { self.segment.push(std::mem::take(&mut self.chunk)); } } /// Adds a route point, after the intermediate points of the previous one. `following` are the /// intermediate points that lead from it to the next one. fn push_route_point(&mut self, mut rtept: Trackpoint, following: Vec) { rtept.anchor = Some(0); let here = (rtept.coordinates, rtept.ele); self.flush(Some(here)); self.previous = Some(here); self.push(rtept); self.pending = following; } /// Adds the pending intermediate points, which lead to the route point `next` if there is one. fn flush(&mut self, next: Option<(LngLat, f64)>) { let mut pending = std::mem::take(&mut self.pending); if let Some(previous) = self.previous { interpolate_elevations(&mut pending, previous, next); } for point in pending { self.push(point); } } fn finish(mut self) -> TrackSegment { self.flush(None); self.segment.push(std::mem::take(&mut self.chunk)); self.segment.ensure_end_anchors(); self.segment } } /// Gives the points of a path from `from` to `to` an elevation that goes from the one of the first /// to the one of the second, in proportion to the distance along the path. Without `to`, they have /// the elevation of `from`. fn interpolate_elevations( points: &mut [Trackpoint], from: (LngLat, f64), to: Option<(LngLat, f64)>, ) { let Some((to_position, to_ele)) = to else { points.iter_mut().for_each(|point| point.ele = from.1); return; }; let mut distances = Vec::with_capacity(points.len() + 1); let mut total = 0.0; let mut last = from.0; for position in points .iter() .map(|point| point.coordinates) .chain([to_position]) { total += distance(last, position); distances.push(total); last = position; } for (point, along) in points.iter_mut().zip(distances) { point.ele = if total > 0.0 { from.1 + (to_ele - from.1) * along / total } else { from.1 }; } } /// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of /// `categories`, which learns the values it does not know yet. pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { let mut reader = Reader::from_reader(data); let mut buf = vec![]; let mut gpx = File::default(); let mut stack: Vec = vec![]; let mut trkpt_chunk = TrackpointChunk::default(); let mut wpt_chunk = WaypointChunk::default(); // The segment of a route is read by `RouteReader`. The points of the detailed // path that a route point comes with (`gpxx:rpt`) are collected until the route point is read. let mut route = RouteReader::default(); let mut rpt_points: Vec = vec![]; // the text of the element at the top of the stack, up to now let mut text = String::new(); loop { match reader.read_event_into(&mut buf) { Ok(Event::Start(e)) => match e.name().as_ref() { "metadata" => stack.push(GPXElement::Metadata), "name" => stack.push(GPXElement::Name), "cmt" => stack.push(GPXElement::Comment), "desc" => stack.push(GPXElement::Description), "src" => stack.push(GPXElement::Source), "author" => stack.push(GPXElement::Author(Author::default())), "link" => stack.push(GPXElement::Link(parse_link(e.attributes()))), "email" => set_email(&mut stack, e.attributes()), "text" => stack.push(GPXElement::Text), "trk" | "rte" => stack.push(GPXElement::Track(Track::default())), "trkseg" => { stack.push(GPXElement::Segment(TrackSegment::default())); } "trkpt" | "rtept" => { stack.push(GPXElement::Trackpoint(Trackpoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() })); } "wpt" => { stack.push(GPXElement::Waypoint(Waypoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() })); } name if is_route_point_extension(name) => { stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes()))); } "ele" => stack.push(GPXElement::Elevation), "time" => stack.push(GPXElement::Time), e if e.ends_with("atemp") => stack.push(GPXElement::Temperature), e if e.ends_with("hr") => stack.push(GPXElement::Heartrate), e if e.ends_with("cad") => stack.push(GPXElement::Cadence), e if e.ends_with("power") => stack.push(GPXElement::Power), e if e.ends_with("PowerInWatts") => stack.push(GPXElement::Power), "surface" => stack.push(GPXElement::Surface), "highway" => stack.push(GPXElement::Highway), "sac_scale" => stack.push(GPXElement::SacScale), "mtb_scale" => stack.push(GPXElement::MtbScale), "sym" => stack.push(GPXElement::Symbol), "type" => stack.push(GPXElement::Type), e if e.ends_with("color") => stack.push(GPXElement::Color), e if e.ends_with("DisplayColor") => stack.push(GPXElement::DisplayColor), e if e.ends_with("opacity") => stack.push(GPXElement::Opacity), e if e.ends_with("width") => stack.push(GPXElement::Width), _ => (), }, // Self-closing points (``), which have no children Ok(Event::Empty(e)) => match e.name().as_ref() { "link" => attach_link(&mut stack, &mut gpx, parse_link(e.attributes())), "email" => set_email(&mut stack, e.attributes()), "trkpt" => { if let Some(GPXElement::Segment(trkseg)) = stack.last_mut() { trkpt_chunk.trkpt.push(Trackpoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() }); if trkpt_chunk.is_full() { trkseg.push(std::mem::take(&mut trkpt_chunk)); } } } "rtept" => { if let Some(GPXElement::Track(_)) = stack.last() { route.push_route_point( Trackpoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() }, vec![], ); } } name if is_route_point_extension(name) => { if let Some(GPXElement::Trackpoint(_)) = stack.last() { rpt_points.push(Trackpoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() }); } } "wpt" => { wpt_chunk.wpt.push(Waypoint { coordinates: parse_coordinates(e.attributes()), ..Default::default() }); if wpt_chunk.is_full() { gpx.wpt.push(std::mem::take(&mut wpt_chunk)); } } _ => (), }, Ok(Event::End(e)) => { if holds_text(stack.last()) { let value = std::mem::take(&mut text); if value.is_empty() { stack.pop(); } else { apply_text(&mut stack, &mut gpx, categories, &value); } } match e.name().as_ref() { "gpx" => { if !wpt_chunk.wpt.is_empty() { gpx.wpt.push(std::mem::take(&mut wpt_chunk)); } } "metadata" => { stack.pop(); } "author" => { if let Some(GPXElement::Author(author)) = stack.pop() { gpx.info.author = Some(author); } } "link" => { if let Some(GPXElement::Link(link)) = stack.pop() { attach_link(&mut stack, &mut gpx, link); } } "trk" => { if let Some(GPXElement::Track(trk)) = stack.pop() { gpx.trk.push(trk); } } "rte" => { if let Some(GPXElement::Track(trk)) = stack.pop() { gpx.rte.push(Route { info: trk.info, seg: std::mem::take(&mut route).finish(), ..Default::default() }); } } "rtept" => { if let Some(GPXElement::Trackpoint(rtept)) = stack.pop() && let Some(GPXElement::Track(_)) = stack.last() { route.push_route_point(rtept, std::mem::take(&mut rpt_points)); } } name if is_route_point_extension(name) => { if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() { rpt_points.push(Trackpoint { coordinates, ..Default::default() }); } } "trkseg" => { if let Some(GPXElement::Segment(mut trkseg)) = stack.pop() && let Some(GPXElement::Track(trk)) = stack.last_mut() { // `push` ignores the chunk if it is empty trkseg.push(std::mem::take(&mut trkpt_chunk)); trkseg.compute_anchors(); trk.trkseg.push(trkseg); } } "trkpt" => { if let Some(GPXElement::Trackpoint(trkpt)) = stack.pop() && let Some(GPXElement::Segment(trkseg)) = stack.last_mut() { trkpt_chunk.trkpt.push(trkpt); if trkpt_chunk.is_full() { trkseg.push(std::mem::take(&mut trkpt_chunk)); } } } "wpt" => { if let Some(GPXElement::Waypoint(wpt)) = stack.pop() { wpt_chunk.wpt.push(wpt); if wpt_chunk.is_full() { gpx.wpt.push(std::mem::take(&mut wpt_chunk)); } } } _ => (), } } // The text of an element comes in pieces: the entities (`&`) are events of their own Ok(Event::Text(e)) => { if holds_text(stack.last()) { text.push_str(&e); } } Ok(Event::CData(e)) => { if holds_text(stack.last()) { text.push_str(&e); } } Ok(Event::GeneralRef(e)) => { if holds_text(stack.last()) { if let Some(c) = e.resolve_char_ref()? { text.push(c); } else if let Some(entity) = resolve_predefined_entity(&e) { text.push_str(entity); } } } Ok(Event::Eof) => break, Err(e) => return Err(e), _ => (), } buf.clear(); } Ok(gpx) } #[cfg(test)] mod tests { use super::*; fn parse_data(name: &str) -> File { parse( &std::fs::read(format!("data/{name}.gpx")).unwrap(), &mut Default::default(), ) .unwrap() } #[test] fn test_link_href_is_unescaped_and_written_back_once() { let data = r#""#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); assert_eq!(gpx.info.links[0].href, "https://a.example/?x=1&y=2"); } #[test] fn test_parse_entities_and_cdata_in_text() { let data = r#"Tom & Jerry <3 éA "x" 'y' & c]]> and > more 3& t & u4 "#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); assert_eq!(gpx.info.name, "Tom & Jerry <3 éA \"x\" 'y'"); assert_eq!(gpx.info.desc.as_deref(), Some("a & c and > more")); let wpt = gpx.wpt.iter().next().unwrap(); // empty elements leave the value unknown, and do not get in the way of the next ones assert_eq!(wpt.name, None); assert_eq!(wpt.desc, None); assert_eq!(wpt.ele, 3.0); assert_eq!(wpt.cmt.as_deref(), Some("&")); assert_eq!(gpx.trk[0].info.name.as_deref(), Some("t & u")); assert_eq!(gpx.trk[0].trkseg[0][0].ele, 4.0); } #[test] fn test_parse_self_closing_points() { let gpx = parse_data("self_closing_points"); assert_eq!(gpx.info.name, "self closing points"); let wpt: Vec<_> = gpx.wpt.iter().collect(); assert_eq!(wpt.len(), 3); assert_eq!(wpt[0].coordinates.lat, 50.0); assert_eq!(wpt[0].name, None); assert_eq!(wpt[1].name.as_deref(), Some("with children")); assert_eq!(wpt[2].coordinates.lng, 4.2); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 2); let seg = &trk.trkseg[0]; assert_eq!(seg.len(), 3); assert_eq!(seg[0].coordinates.lat, 50.0); assert_eq!(seg[0].ele, 0.0); assert_eq!(seg[1].ele, 12.5); assert_eq!(seg[2].coordinates.lng, 4.02); assert_eq!(trk.trkseg[1].len(), 1); } #[test] fn test_parse_simple() { let gpx = parse_data("simple"); assert_eq!(gpx.info.name, "simple"); assert!(gpx.info.desc.is_some_and(|d| d == "description")); assert!(gpx.info.author.is_some()); let author = gpx.info.author.as_ref().unwrap(); assert!(author.name.as_ref().is_some_and(|n| n == "gpx.studio")); assert!(author.link.is_some()); let link = author.link.as_ref().unwrap(); assert_eq!(link.href, "https://gpx.studio"); assert!(link.text.as_ref().is_some_and(|t| t == "link text")); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert!(trk.info.name.as_ref().is_some_and(|n| n == "track name")); assert!(trk.info.cmt.as_ref().is_some_and(|c| c == "track comment")); assert!( trk.info .desc .as_ref() .is_some_and(|d| d == "track description") ); assert!(trk.info.src.as_ref().is_some_and(|s| s == "track source")); assert_eq!(trk.info.links.len(), 1); let link = &trk.info.links[0]; assert_eq!(link.href, "https://gpx.studio"); assert!(link.text.as_ref().is_some_and(|t| t == "track link text")); assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 80); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); } #[test] fn test_parse_tracks() { let gpx = parse_data("with_tracks"); assert_eq!(gpx.trk.len(), 2); let trk = &gpx.trk[0]; assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 1")); assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 49); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); let trk = &gpx.trk[1]; assert!(trk.info.name.as_ref().is_some_and(|n| n == "track 2")); assert!(trk.info.type_.as_ref().is_some_and(|c| c == "Cycling")); assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 28); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.782212); assert_eq!(trkpt.coordinates.lng, 4.406377); assert_eq!(trkpt.ele, 115.5); } #[test] fn test_parse_routes() { let gpx = parse_data("with_routes"); assert!(gpx.trk.is_empty()); assert_eq!(gpx.rte.len(), 2); let rte = &gpx.rte[0]; assert_eq!(rte.info.name.as_deref(), Some("route 1")); assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); assert_eq!(rte.seg.len(), 49); let trkpt = &rte.seg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); let rte = &gpx.rte[1]; assert_eq!(rte.info.name.as_deref(), Some("route 2")); assert_eq!(rte.seg.len(), 28); assert_ne!(gpx.rte[0].id, rte.id); } #[test] fn test_parse_route_point_extensions() { let gpx = parse_data("with_route_extensions"); // a route without points has an empty segment assert_eq!(gpx.rte.len(), 2); assert!(gpx.rte[1].seg.is_empty()); let rte = &gpx.rte[0]; assert_eq!(rte.info.name.as_deref(), Some("with extensions")); let points: Vec<_> = rte.seg.iter().collect(); // the route points are kept, the points of the detailed path follow the one they come with let coordinates: Vec<_> = points .iter() .map(|p| (p.coordinates.lat, p.coordinates.lng)) .collect(); assert_eq!( coordinates, [(1.0, 1.0), (1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)] ); assert_eq!(points[3].ele, 20.0); assert!(points[3].time.is_some()); } #[test] fn test_route_points_are_the_anchors_and_nothing_else_is() { let gpx = parse_data("with_route_extensions"); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]); // no anchor is computed for the points of a route that has none in between let gpx = parse_data("with_routes"); for rte in &gpx.rte { assert!(rte.seg.iter().all(|p| p.anchor == Some(0))); } // while a track gets the anchors that its shape needs let track = parse_data("with_tracks"); let segment = &track.trk[0].trkseg[0]; let anchors = segment.iter().filter(|p| p.anchor.is_some()).count(); assert!(anchors >= 2 && anchors < segment.len()); } #[test] fn test_parse_the_points_of_the_detailed_path_of_a_route() { let gpx = parse_data("with_route_points"); assert_eq!(gpx.rte.len(), 1); let rte = &gpx.rte[0]; assert_eq!(rte.info.name.as_deref(), Some("Lausanne")); assert_eq!(rte.info.cmt.as_deref(), Some("comment")); assert_eq!(rte.info.desc.as_deref(), Some("description")); assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); let points: Vec<_> = rte.seg.iter().cloned().collect(); // 3 route points and 4 points between them, the repeated one being kept assert_eq!(points.len(), 7); let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect(); assert_eq!(kinds, [true, false, false, false, true, false, true]); assert_eq!(points[0].coordinates.lat, 46.54462809674442); assert_eq!(points[0].coordinates.lng, 6.658079791814089); assert_eq!(points[1].coordinates.lat, 46.54448392800987); assert_eq!(points[3].coordinates.lng, points[2].coordinates.lng); assert_eq!(points[4].coordinates.lat, 46.5441); assert_eq!(points[5].coordinates.lat, 46.5439); // the points in between have no elevation of their own: it goes from one route point to // the next let ele: Vec<_> = points.iter().map(|p| p.ele).collect(); assert_eq!((ele[0], ele[4], ele[6]), (500.0, 510.0, 505.0)); assert!(ele[0] < ele[1] && ele[1] < ele[2] && ele[2] <= ele[3] && ele[3] < ele[4]); assert!(ele[5] < 510.0 && ele[5] > 505.0); } #[test] fn test_the_last_point_of_a_route_is_an_anchor_even_after_a_path() { // the last route point comes with a path that leads nowhere let data = r#" 10 20 "#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect(); let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect(); assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]); // the route point is an anchor, and so is the end of the path let anchors: Vec<_> = points.iter().map(|p| p.anchor).collect(); assert_eq!(anchors, [Some(0), Some(0), None, Some(0)]); // with the elevation of the last route point, as there is no next one assert_eq!(points[2].ele, 20.0); assert_eq!(points[3].ele, 20.0); // a path after a single route point let data = r#" "#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); assert_eq!(anchors, [Some(0), Some(0)]); } #[test] fn test_elevations_between_route_points() { let here = |lng: f64| LngLat { lng, lat: 0.0 }; let point = |lng: f64| Trackpoint { coordinates: here(lng), ..Default::default() }; // evenly spaced points go up evenly let mut points = vec![point(1.0), point(2.0), point(3.0)]; interpolate_elevations(&mut points, (here(0.0), 100.0), Some((here(4.0), 200.0))); let ele: Vec<_> = points.iter().map(|p| p.ele.round()).collect(); assert_eq!(ele, [125.0, 150.0, 175.0]); // the distance counts, not the number of points let mut points = vec![point(1.0)]; interpolate_elevations(&mut points, (here(0.0), 0.0), Some((here(4.0), 400.0))); assert!((points[0].ele - 100.0).abs() < 1e-6); // after the last route point, the elevation does not change let mut points = vec![point(1.0), point(2.0)]; interpolate_elevations(&mut points, (here(0.0), 321.0), None); assert!(points.iter().all(|p| p.ele == 321.0)); // route points at the same place let mut points = vec![point(0.0)]; interpolate_elevations(&mut points, (here(0.0), 10.0), Some((here(0.0), 20.0))); assert_eq!(points[0].ele, 10.0); // nothing between them is fine interpolate_elevations(&mut [], (here(0.0), 1.0), Some((here(1.0), 2.0))); } #[test] fn test_parse_every_link_and_the_email() { let gpx = parse_data("with_links"); // the file has as many links as it says, self-closing or not let hrefs = |links: &[Link]| links.iter().map(|l| l.href.clone()).collect::>(); assert_eq!( hrefs(&gpx.info.links), ["https://example.com/file-1", "https://example.com/file-2"] ); assert_eq!(gpx.info.links[0].text.as_deref(), Some("first")); assert_eq!(gpx.info.links[1].text, None); // the author has one link and an email, which is given by two attributes let author = gpx.info.author.as_ref().unwrap(); assert_eq!(author.name.as_deref(), Some("someone")); assert_eq!(author.email.as_deref(), Some("someone@example.com")); assert_eq!( author.link.as_ref().unwrap().href, "https://example.com/author" ); let waypoints: Vec<_> = gpx.wpt.iter().collect(); assert_eq!( hrefs(&waypoints[0].links), [ "https://example.com/wpt-1", "https://example.com/wpt-2", "https://example.com/wpt-3" ] ); assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three")); assert!(waypoints[1].links.is_empty()); // tracks, and routes assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1)); assert_eq!( hrefs(&gpx.trk[0].info.links), ["https://example.com/trk-1", "https://example.com/trk-2"] ); assert_eq!( hrefs(&gpx.rte[0].info.links), ["https://example.com/rte-1", "https://example.com/rte-2"] ); } #[test] fn test_parse_email_variants() { let author = |xml: &str| { let data = format!("{xml}"); parse(data.as_bytes(), &mut Default::default()) .unwrap() .info .author .unwrap() }; assert_eq!( author(r#""#) .email .as_deref(), Some("a@b.c") ); assert_eq!( author(r#"n"#) .email .as_deref(), Some("a@b.c") ); // an email that is not complete is not one assert_eq!(author(r#""#).email, None); assert_eq!(author("n").email, None); // outside of an author it means nothing let gpx = parse( br#"n"#, &mut Default::default(), ) .unwrap(); assert!(gpx.info.author.is_none()); } #[test] fn test_parse_segments() { let gpx = parse_data("with_segments"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 2); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 49); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); let trkseg = &trk.trkseg[1]; assert_eq!(trkseg.len(), 28); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.782212); assert_eq!(trkpt.coordinates.lng, 4.406377); assert_eq!(trkpt.ele, 115.5); } #[test] fn test_parse_tracks_and_segments() { let gpx = parse_data("with_tracks_and_segments"); assert_eq!(gpx.trk.len(), 2); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 2); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 16); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); let trkseg = &trk.trkseg[1]; assert_eq!(trkseg.len(), 34); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.78727108169855); assert_eq!(trkpt.coordinates.lng, 4.406133681127736); assert_eq!(trkpt.ele, 115.0); let trk = &gpx.trk[1]; assert_eq!(trk.trkseg.len(), 2); let trkseg = &trk.trkseg[0]; assert_eq!(trkseg.len(), 19); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.782212); assert_eq!(trkpt.coordinates.lng, 4.406377); assert_eq!(trkpt.ele, 115.5); let trkseg = &trk.trkseg[1]; assert_eq!(trkseg.len(), 10); let trkpt = &trkseg[0]; assert_eq!(trkpt.coordinates.lat, 50.77906316558724); assert_eq!(trkpt.coordinates.lng, 4.412547477922485); assert_eq!(trkpt.ele, 133.3); } #[test] fn test_parse_waypoint() { let gpx = parse_data("with_waypoint"); assert_eq!(gpx.wpt.len(), 1); let wpt = &gpx.wpt[0]; assert_eq!(wpt.coordinates.lat, 50.7836710064975); assert_eq!(wpt.coordinates.lng, 4.410764082658738); assert!(wpt.name.as_ref().is_some_and(|n| n == "waypoint name")); assert!(wpt.cmt.as_ref().is_some_and(|c| c == "waypoint comment")); assert!( wpt.desc .as_ref() .is_some_and(|d| d == "waypoint description") ); assert_eq!(wpt.links.len(), 1); let link = &wpt.links[0]; assert_eq!(link.href, "https://gpx.studio"); assert!( link.text .as_ref() .is_some_and(|t| t == "waypoint link text") ); assert!(wpt.sym.as_ref().is_some_and(|s| s == "Bike Trail")); assert!(wpt.type_.as_ref().is_some_and(|t| t == "Bike Trail")); } #[test] fn test_parse_trackpoint_time() { let gpx = parse_data("with_time"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.time.is_some_and(|t| t == 1704063600000)); } #[test] fn test_parse_trackpoint_hr() { let gpx = parse_data("with_hr"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.hr.is_some_and(|h| h == 150)); } fn parse_data_with_categories(name: &str) -> (File, TrackpointCategories) { let mut categories = TrackpointCategories::default(); let file = parse( &std::fs::read(format!("data/{name}.gpx")).unwrap(), &mut categories, ) .unwrap(); (file, categories) } #[test] fn test_parse_trackpoint_surface() { let (gpx, categories) = parse_data_with_categories("with_surface"); let trkseg = &gpx.trk[0].trkseg[0]; assert_eq!(trkseg.len(), 80); // the codes follow the order of appearance in the file assert_eq!(categories.surface.names(), ["asphalt", "cobblestone"]); let asphalt = Some(0); let cobblestone = Some(1); assert_eq!(trkseg.iter().filter(|p| p.surface == asphalt).count(), 79); assert_eq!( trkseg.iter().filter(|p| p.surface == cobblestone).count(), 1 ); // no highway in this file assert!(trkseg.iter().all(|trkpt| trkpt.highway.is_none())); assert!(categories.highway.names().is_empty()); } #[test] fn test_parse_trackpoint_surface_highway_and_scales() { let (gpx, categories) = parse_data_with_categories("with_highway"); let trkseg = &gpx.trk[0].trkseg[0]; let names = |code: Option, names: &crate::Categories| { code.and_then(|code| names.name(code)).map(str::to_owned) }; let values: Vec<_> = trkseg .iter() .map(|trkpt| { [ names(trkpt.surface, &categories.surface), names(trkpt.highway, &categories.highway), names(trkpt.sac_scale, &categories.sac_scale), names(trkpt.mtb_scale, &categories.mtb_scale), ] }) .collect(); let some = |s: &str| Some(s.to_owned()); assert_eq!( values, [ [some("asphalt"), some("residential"), None, None], [some("asphalt"), some("residential"), None, None], [None, None, None, None], [ some("gravel"), some("track"), some("mountain_hiking"), some("1") ], [some("gravel"), None, some("hiking"), some("1")], ] ); // the tables are separate assert_eq!(categories.sac_scale.names(), ["mountain_hiking", "hiking"]); assert_eq!(categories.mtb_scale.names(), ["1"]); } #[test] fn test_parse_shares_the_categories_between_files() { let mut categories = TrackpointCategories::default(); let mut parsed = |name: &str| { parse( &std::fs::read(format!("data/{name}.gpx")).unwrap(), &mut categories, ) .unwrap() }; let first = parsed("with_highway"); let second = parsed("with_surface"); let code = |file: &File, i: usize| file.trk[0].trkseg[0][i].surface; // "asphalt" is the same in both, "cobblestone" comes after "gravel" assert_eq!(code(&first, 0), Some(0)); assert_eq!(code(&second, 0), Some(0)); assert_eq!( categories.surface.names(), ["asphalt", "gravel", "cobblestone"] ); } #[test] fn test_parse_trackpoint_cad() { let gpx = parse_data("with_cad"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.cad.is_some_and(|c| c == 80)); } #[test] fn test_parse_trackpoint_power_1() { let gpx = parse_data("with_power_1"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.power.is_some_and(|p| p == 200)); } #[test] fn test_parse_trackpoint_power_2() { let gpx = parse_data("with_power_2"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.power.is_some_and(|p| p == 200)); } #[test] fn test_parse_trackpoint_power_3() { let gpx = parse_data("with_power_3"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.power.is_some_and(|p| p == 200)); } #[test] fn test_parse_trackpoint_atemp() { let gpx = parse_data("with_temp"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); let trkseg = &trk.trkseg[0]; assert!(!trkseg.is_empty()); let trkpt = &trkseg[0]; assert!(trkpt.atemp.is_some_and(|t| t == 21)); } #[test] fn test_parse_track_style() { let gpx = parse_data("with_style"); assert_eq!(gpx.trk.len(), 1); let trk = &gpx.trk[0]; assert_eq!(trk.trkseg.len(), 1); assert!(trk.info.color.as_ref().is_some_and(|c| c == "2d3ee9")); assert!(trk.info.opacity.is_some_and(|o| o == 0.5)); assert!(trk.info.width.is_some_and(|w| w == 6.0)); } fn parse_text(text: &str) -> File { parse(text.as_bytes(), &mut Default::default()).unwrap() } #[test] fn test_parse_garmin_display_color_of_tracks_and_routes() { let gpx = parse_text( r#" Blue DarkRed Green "#, ); assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000ff")); assert_eq!(gpx.trk[1].info.color.as_deref(), Some("800000")); assert_eq!(gpx.rte[0].info.color.as_deref(), Some("00ff00")); } #[test] fn test_garmin_display_color_without_a_color_leaves_the_track_uncolored() { let gpx = parse_text( r#" Transparent Mauve "#, ); assert_eq!(gpx.trk[0].info.color, None); assert_eq!(gpx.trk[1].info.color, None); } #[test] fn test_style_color_has_priority_over_garmin_display_color() { let gpx = parse_text( r#" Red 2d3ee9 2d3ee9 Red "#, ); assert_eq!(gpx.trk[0].info.color.as_deref(), Some("2d3ee9")); assert_eq!(gpx.trk[1].info.color.as_deref(), Some("2d3ee9")); } #[test] fn test_parse_color_with_hash_and_alpha() { assert_eq!(parse_color("2d3ee9"), ("2d3ee9".into(), None)); assert_eq!(parse_color("#0000FF"), ("0000FF".into(), None)); assert_eq!(parse_color("#800000FF"), ("0000FF".into(), Some(0.5))); assert_eq!(parse_color("FF0000FF"), ("0000FF".into(), Some(1.0))); assert_eq!(parse_color("#00ff0000"), ("ff0000".into(), Some(0.0))); assert_eq!(parse_color("red"), ("red".into(), None)); assert_eq!(parse_color("#abc"), ("#abc".into(), None)); } #[test] fn test_parse_track_color_with_alpha() { let gpx = parse_text( r#" #400000FF 4 #800000FF 0.3 #800000FF #800000FF 0.3 "#, ); assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000FF")); assert_eq!(gpx.trk[0].info.opacity, Some(0.5)); assert_eq!(gpx.trk[0].info.width, Some(4.0)); assert_eq!(gpx.trk[1].info.opacity, Some(0.3)); assert_eq!(gpx.trk[2].info.opacity, Some(0.3)); } }