diff --git a/gpx-rs/engine/src/core/io/mod.rs b/gpx-rs/engine/src/core/io/mod.rs index 4f42021da..b6d5f5f77 100644 --- a/gpx-rs/engine/src/core/io/mod.rs +++ b/gpx-rs/engine/src/core/io/mod.rs @@ -1,3 +1,5 @@ mod parse; +mod write; pub use parse::*; +pub use write::*; diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index f3e8a9064..b5b62bf77 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -4,6 +4,7 @@ use crate::{ }; use chrono::DateTime; use quick_xml::Error; +use quick_xml::escape::resolve_predefined_entity; use quick_xml::events::Event; use quick_xml::events::attributes::Attributes; use quick_xml::reader::Reader; @@ -50,6 +51,208 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat { coordinates } +/// 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::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() { + trk.info.color = Some(e.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(); + } + } + _ => (), + } +} + /// 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 { @@ -59,6 +262,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result = vec![]; let mut trkpt_chunk = TrackpointChunk::default(); let mut wpt_chunk = WaypointChunk::default(); + // 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() { @@ -136,235 +341,101 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result (), }, - Ok(Event::End(e)) => match e.name().as_ref() { - "gpx" => { - if !wpt_chunk.wpt.is_empty() { - 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); } } - "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() { - match stack.last_mut() { - Some(GPXElement::Author(author)) => { - author.link = Some(link); - } - Some(GPXElement::Track(trk)) => { - trk.info.link = Some(link); - } - Some(GPXElement::Waypoint(wpt)) => { - wpt.link = Some(link); - } - _ => (), - } - } - } - "trk" => { - if let Some(GPXElement::Track(trk)) = stack.pop() { - gpx.trk.push(trk); - } - } - "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() { + match e.name().as_ref() { + "gpx" => { + if !wpt_chunk.wpt.is_empty() { gpx.wpt.push(std::mem::take(&mut wpt_chunk)); } } - } - _ => (), - }, - Ok(Event::Text(e)) => match stack.last_mut() { - Some(GPXElement::Name) => { - stack.pop(); - match stack.last_mut() { - Some(GPXElement::Metadata) => { - gpx.info.name = e.to_string(); + "metadata" => { + stack.pop(); + } + "author" => { + if let Some(GPXElement::Author(author)) = stack.pop() { + gpx.info.author = Some(author); } - Some(GPXElement::Author(author)) => { - author.name = Some(e.to_string()); + } + "link" => { + if let Some(GPXElement::Link(link)) = stack.pop() { + match stack.last_mut() { + Some(GPXElement::Author(author)) => { + author.link = Some(link); + } + Some(GPXElement::Track(trk)) => { + trk.info.link = Some(link); + } + Some(GPXElement::Waypoint(wpt)) => { + wpt.link = Some(link); + } + _ => (), + } } - Some(GPXElement::Track(trk)) => { - trk.info.name = Some(e.to_string()); + } + "trk" => { + if let Some(GPXElement::Track(trk)) = stack.pop() { + gpx.trk.push(trk); } - Some(GPXElement::Waypoint(wpt)) => { - wpt.name = Some(e.to_string()); + } + "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); } - _ => (), } - } - Some(GPXElement::Comment) => { - stack.pop(); - match stack.last_mut() { - Some(GPXElement::Track(trk)) => { - trk.info.cmt = Some(e.to_string()); + "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)); + } } - Some(GPXElement::Waypoint(wpt)) => { - wpt.cmt = Some(e.to_string()); + } + "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); } } - Some(GPXElement::Description) => { - stack.pop(); - match stack.last_mut() { - Some(GPXElement::Metadata) => { - gpx.info.desc = Some(e.to_string()); - } - Some(GPXElement::Track(trk)) => { - trk.info.desc = Some(e.to_string()); - } - Some(GPXElement::Waypoint(wpt)) => { - wpt.desc = Some(e.to_string()); - } - _ => (), - } - } - Some(GPXElement::Source) => { - stack.pop(); - if let Some(GPXElement::Track(trk)) = stack.last_mut() { - trk.info.src = Some(e.to_string()); - } - } - Some(GPXElement::Text) => { - stack.pop(); - if let Some(GPXElement::Link(link)) = stack.last_mut() { - link.text = Some(e.to_string()); - } - } - 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.as_ref()) - .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_string()); - } - } - Some(GPXElement::Type) => { - stack.pop(); - match stack.last_mut() { - Some(GPXElement::Track(trk)) => { - trk.info.type_ = Some(e.to_string()); - } - Some(GPXElement::Waypoint(wpt)) => { - wpt.type_ = Some(e.to_string()); - } - _ => (), - } - } - Some(GPXElement::Color) => { - stack.pop(); - if let Some(GPXElement::Track(trk)) = stack.last_mut() { - trk.info.color = Some(e.to_string()); - } - } - 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(); - } - } - _ => (), - }, + } Ok(Event::Eof) => break, Err(e) => return Err(e), _ => (), @@ -386,6 +457,26 @@ mod tests { .unwrap() } + #[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"); diff --git a/gpx-rs/engine/src/core/io/write.rs b/gpx-rs/engine/src/core/io/write.rs new file mode 100644 index 000000000..ec9d1cfee --- /dev/null +++ b/gpx-rs/engine/src/core/io/write.rs @@ -0,0 +1,490 @@ +use std::io; + +use chrono::DateTime; +use quick_xml::Writer; +use quick_xml::events::{BytesDecl, BytesText, Event}; + +use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint}; + +/// What the written file contains: the data of the trackpoints that are `false` are left out. +/// The same type tells which data a file has, see [`File::exportable_data`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExportOptions { + pub time: bool, + pub hr: bool, + pub cad: bool, + pub atemp: bool, + pub power: bool, + /// The OpenStreetMap data: surface, highway, SAC scale and MTB scale. + pub osm: bool, +} + +impl ExportOptions { + pub const ALL: Self = Self::of(true); + pub const NONE: Self = Self::of(false); + + const fn of(value: bool) -> Self { + Self { + time: value, + hr: value, + cad: value, + atemp: value, + power: value, + osm: value, + } + } + + /// The options that are on in `self` or in `other`. + pub fn union(self, other: Self) -> Self { + Self { + time: self.time || other.time, + hr: self.hr || other.hr, + cad: self.cad || other.cad, + atemp: self.atemp || other.atemp, + power: self.power || other.power, + osm: self.osm || other.osm, + } + } +} + +impl File { + /// The data that the trackpoints of the file have (and waypoints for the time), which the + /// options of [`write`] can leave out. + pub fn exportable_data(&self) -> ExportOptions { + let mut data = ExportOptions::NONE; + for point in self + .trk + .iter() + .flat_map(|trk| &trk.trkseg) + .flat_map(|seg| seg.iter()) + { + data.time |= point.time.is_some(); + data.hr |= point.hr.is_some(); + data.cad |= point.cad.is_some(); + data.atemp |= point.atemp.is_some(); + data.power |= point.power.is_some(); + data.osm |= point.surface.is_some() + || point.highway.is_some() + || point.sac_scale.is_some() + || point.mtb_scale.is_some(); + if data == ExportOptions::ALL { + break; + } + } + data + } +} + +const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"; + +type XmlWriter = Writer>; + +/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are +/// the names of their codes in `categories`. +pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec { + let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); + // writing to memory does not fail + write_file(&mut writer, file, categories, options).expect("writing to memory"); + writer.into_inner() +} + +fn write_file( + w: &mut XmlWriter, + file: &File, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + w.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + w.create_element("gpx") + .with_attributes([ + ("creator", "https://gpx.studio"), + ("version", "1.1"), + ("xmlns", "http://www.topografix.com/GPX/1/1"), + ("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"), + ("xsi:schemaLocation", SCHEMA_LOCATION), + ( + "xmlns:gpxtpx", + "http://www.garmin.com/xmlschemas/TrackPointExtension/v1", + ), + ( + "xmlns:gpxx", + "http://www.garmin.com/xmlschemas/GpxExtensions/v3", + ), + ( + "xmlns:gpxpx", + "http://www.garmin.com/xmlschemas/PowerExtension/v1", + ), + ( + "xmlns:gpx_style", + "http://www.topografix.com/GPX/gpx_style/0/2", + ), + ]) + .write_inner_content(|w| { + write_metadata(w, file, options)?; + for wpt in file.wpt.iter() { + write_waypoint(w, wpt, options)?; + } + for trk in &file.trk { + // a single unnamed track is named like the file + let name = match (&trk.info.name, file.trk.len()) { + (Some(name), _) if !name.is_empty() => Some(name.as_str()), + (_, 1) => Some(file.info.name.as_str()), + _ => None, + }; + write_track(w, trk, name, categories, options)?; + } + Ok(()) + })?; + Ok(()) +} + +fn text(w: &mut XmlWriter, name: &str, value: &str) -> io::Result<()> { + if !value.is_empty() { + w.create_element(name) + .write_text_content(BytesText::new(value))?; + } + Ok(()) +} + +fn optional_text(w: &mut XmlWriter, name: &str, value: &Option) -> io::Result<()> { + text(w, name, value.as_deref().unwrap_or_default()) +} + +fn number(w: &mut XmlWriter, name: &str, value: impl std::fmt::Display) -> io::Result<()> { + text(w, name, &value.to_string()) +} + +fn time(w: &mut XmlWriter, millis: Option) -> io::Result<()> { + if let Some(time) = millis.and_then(DateTime::from_timestamp_millis) { + text( + w, + "time", + &time.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + )?; + } + Ok(()) +} + +fn link(w: &mut XmlWriter, link: &Option) -> io::Result<()> { + if let Some(link) = link.as_ref().filter(|link| !link.href.is_empty()) { + w.create_element("link") + .with_attribute(("href", link.href.as_str())) + .write_inner_content(|w| optional_text(w, "text", &link.text))?; + } + Ok(()) +} + +fn write_metadata(w: &mut XmlWriter, file: &File, options: ExportOptions) -> io::Result<()> { + let info = &file.info; + let time = options.time.then_some(info.time).flatten(); + if info.name.is_empty() + && info.desc.is_none() + && info.author.is_none() + && info.link.is_none() + && time.is_none() + { + return Ok(()); + } + w.create_element("metadata").write_inner_content(|w| { + text(w, "name", &info.name)?; + optional_text(w, "desc", &info.desc)?; + if let Some(author) = &info.author { + w.create_element("author").write_inner_content(|w| { + optional_text(w, "name", &author.name)?; + if let Some((id, domain)) = author.email.as_deref().and_then(|e| e.split_once('@')) + { + w.create_element("email") + .with_attributes([("id", id), ("domain", domain)]) + .write_empty()?; + } + link(w, &author.link) + })?; + } + link(w, &info.link)?; + self::time(w, time) + })?; + Ok(()) +} + +fn write_coordinates<'a>( + w: &'a mut XmlWriter, + name: &'a str, + coordinates: crate::LngLat, +) -> quick_xml::writer::ElementWriter<'a, Vec> { + w.create_element(name).with_attributes([ + ("lat", coordinates.lat.to_string().as_str()), + ("lon", coordinates.lng.to_string().as_str()), + ]) +} + +fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> io::Result<()> { + write_coordinates(w, "wpt", wpt.coordinates).write_inner_content(|w| { + number(w, "ele", wpt.ele)?; + if options.time { + time(w, wpt.time)?; + } + optional_text(w, "name", &wpt.name)?; + optional_text(w, "cmt", &wpt.cmt)?; + optional_text(w, "desc", &wpt.desc)?; + link(w, &wpt.link)?; + optional_text(w, "sym", &wpt.sym)?; + optional_text(w, "type", &wpt.type_) + })?; + Ok(()) +} + +fn write_track( + w: &mut XmlWriter, + trk: &Track, + name: Option<&str>, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + let info = &trk.info; + w.create_element("trk").write_inner_content(|w| { + text(w, "name", name.unwrap_or_default())?; + optional_text(w, "cmt", &info.cmt)?; + optional_text(w, "desc", &info.desc)?; + optional_text(w, "src", &info.src)?; + link(w, &info.link)?; + optional_text(w, "type", &info.type_)?; + if info.color.is_some() || info.opacity.is_some() || info.width.is_some() { + w.create_element("extensions").write_inner_content(|w| { + w.create_element("gpx_style:line") + .write_inner_content(|w| { + optional_text(w, "gpx_style:color", &info.color)?; + if let Some(opacity) = info.opacity { + number(w, "gpx_style:opacity", opacity)?; + } + if let Some(width) = info.width { + number(w, "gpx_style:width", width)?; + } + Ok(()) + })?; + Ok(()) + })?; + } + for segment in &trk.trkseg { + w.create_element("trkseg").write_inner_content(|w| { + for point in segment.iter() { + write_trackpoint(w, point, categories, options)?; + } + Ok(()) + })?; + } + Ok(()) + })?; + Ok(()) +} + +fn write_trackpoint( + w: &mut XmlWriter, + point: &Trackpoint, + categories: &TrackpointCategories, + options: ExportOptions, +) -> io::Result<()> { + let atemp = point.atemp.filter(|_| options.atemp); + let hr = point.hr.filter(|_| options.hr); + let cad = point.cad.filter(|_| options.cad); + let power = point.power.filter(|_| options.power); + let osm = [ + ("surface", point.surface, &categories.surface), + ("highway", point.highway, &categories.highway), + ("sac_scale", point.sac_scale, &categories.sac_scale), + ("mtb_scale", point.mtb_scale, &categories.mtb_scale), + ] + .map(|(name, code, categories)| { + let value = code + .filter(|_| options.osm) + .and_then(|code| categories.name(code)); + (name, value) + }); + let has_osm = osm.iter().any(|(_, value)| value.is_some()); + let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm; + + write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| { + number(w, "ele", point.ele)?; + if options.time { + time(w, point.time)?; + } + if !has_track_point_extension && power.is_none() { + return Ok(()); + } + w.create_element("extensions").write_inner_content(|w| { + if has_track_point_extension { + w.create_element("gpxtpx:TrackPointExtension") + .write_inner_content(|w| { + if let Some(atemp) = atemp { + number(w, "gpxtpx:atemp", atemp)?; + } + if let Some(hr) = hr { + number(w, "gpxtpx:hr", hr)?; + } + if let Some(cad) = cad { + number(w, "gpxtpx:cad", cad)?; + } + if has_osm { + w.create_element("gpxtpx:Extensions") + .write_inner_content(|w| { + for (name, value) in osm { + text(w, name, value.unwrap_or_default())?; + } + Ok(()) + })?; + } + Ok(()) + })?; + } + if let Some(power) = power { + w.create_element("gpxpx:PowerExtension") + .write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?; + } + Ok(()) + })?; + Ok(()) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::parse; + + use super::*; + + fn read(name: &str) -> (File, TrackpointCategories) { + let mut categories = TrackpointCategories::default(); + let data = std::fs::read(format!("data/{name}.gpx")).unwrap(); + (parse(&data, &mut categories).unwrap(), categories) + } + + fn written_text( + file: &File, + categories: &TrackpointCategories, + options: ExportOptions, + ) -> String { + String::from_utf8(write(file, categories, options)).unwrap() + } + + /// Writes a file and reads it again. + fn round_trip(name: &str, options: ExportOptions) -> (File, File, TrackpointCategories) { + let (file, mut categories) = read(name); + let written = write(&file, &categories, options); + let again = parse(&written, &mut categories).unwrap(); + (file, again, categories) + } + + fn points(file: &File) -> Vec { + file.trk + .iter() + .flat_map(|trk| &trk.trkseg) + .flat_map(|seg| seg.iter().cloned()) + .collect() + } + + fn same_points(a: &[Trackpoint], b: &[Trackpoint]) { + assert_eq!(a.len(), b.len()); + for (a, b) in a.iter().zip(b) { + assert_eq!(a.coordinates.lat, b.coordinates.lat); + assert_eq!(a.coordinates.lng, b.coordinates.lng); + assert_eq!(a.ele, b.ele); + assert_eq!(a.time, b.time); + assert_eq!(a.atemp, b.atemp); + assert_eq!(a.hr, b.hr); + assert_eq!(a.cad, b.cad); + assert_eq!(a.power, b.power); + assert_eq!(a.surface, b.surface); + assert_eq!(a.highway, b.highway); + assert_eq!(a.sac_scale, b.sac_scale); + assert_eq!(a.mtb_scale, b.mtb_scale); + } + } + + #[test] + fn test_files_survive_a_round_trip() { + for name in [ + "simple", + "with_time", + "with_hr", + "with_cad", + "with_temp", + "with_power_1", + "with_power_2", + "with_surface", + "with_highway", + "with_style", + "with_waypoint", + "with_tracks_and_segments", + "self_closing_points", + ] { + let (file, again, _) = round_trip(name, ExportOptions::ALL); + assert_eq!(file.info.name, again.info.name, "{name}"); + assert_eq!(file.info.desc, again.info.desc, "{name}"); + assert_eq!(file.info.author, again.info.author, "{name}"); + assert_eq!(file.trk.len(), again.trk.len(), "{name}"); + for (a, b) in file.trk.iter().zip(&again.trk) { + assert_eq!(a.info, b.info, "{name}"); + assert_eq!(a.trkseg.len(), b.trkseg.len(), "{name}"); + } + same_points(&points(&file), &points(&again)); + let waypoints: Vec<_> = file.wpt.iter().collect(); + let again_waypoints: Vec<_> = again.wpt.iter().collect(); + assert_eq!(waypoints.len(), again_waypoints.len(), "{name}"); + for (a, b) in waypoints.iter().zip(&again_waypoints) { + assert!(a.same_content(b), "{name}"); + } + } + } + + #[test] + fn test_options_leave_data_out() { + let (file, categories) = read("with_hr"); + assert!(points(&file).iter().any(|p| p.hr.is_some())); + let options = ExportOptions { + hr: false, + ..ExportOptions::ALL + }; + let written = written_text(&file, &categories, options); + assert!(!written.contains("hr>")); + + let (file, categories) = read("with_time"); + let options = ExportOptions { + time: false, + ..ExportOptions::ALL + }; + assert!(!written_text(&file, &categories, options).contains("