Skip to main content

datui_lib/gps/
gpx.rs

1//! GPX tracks, routes and waypoints: one row per `trkpt`, `rtept` or `wpt`.
2//!
3//! GPX is XML, read here by a small scanner of its own rather than an XML crate. The
4//! one in the tree is optional (it comes with the cloud feature), and its streaming
5//! reader buffers each event whole, so a text node of gigabytes is gigabytes. GPX
6//! needs a sliver of XML: elements, attributes, text, the five entities and character
7//! references. Everything else (comments, CDATA, processing instructions, a DOCTYPE)
8//! is passed over, and entities a DOCTYPE declares are never expanded.
9//!
10//! Every length is bounded by the reader: markup (a tag with its attributes, a
11//! comment) is at most [`MAX_MARKUP`] bytes, text is kept to [`MAX_TEXT`] per value,
12//! elements nest at most [`MAX_DEPTH`] deep, and a file adds at most [`MAX_FIELDS`]
13//! columns. Text between tags is never buffered past what is kept, however long.
14
15use polars::prelude::*;
16
17use super::table::{Builder, Cell, Kind};
18
19/// The longest tag, comment or other markup read whole.
20pub const MAX_MARKUP: usize = 1 << 20;
21/// The most of one value kept. A waypoint's description is a sentence.
22pub const MAX_TEXT: usize = 4096;
23/// How deep elements may nest. GPX's own structure is five deep.
24pub const MAX_DEPTH: usize = 64;
25/// The most columns a file's extension and other fields may add.
26pub const MAX_FIELDS: usize = 256;
27/// The longest field name made a column.
28const MAX_NAME: usize = 64;
29/// Rows held before they are handed over as a batch.
30pub const BATCH_ROWS: usize = 65_536;
31/// Text held before rows are handed over, however few: a point may carry a few
32/// hundred fields of [`MAX_TEXT`] each.
33pub const BATCH_TEXT: usize = 32 << 20;
34
35/// The columns every GPX table has, in order. Fields found in the file follow.
36pub const CORE: [(&str, Kind); 9] = [
37    ("time", Kind::Time),
38    ("lat", Kind::F64),
39    ("lon", Kind::F64),
40    ("ele", Kind::F64),
41    ("kind", Kind::Str),
42    ("track", Kind::U32),
43    ("track_name", Kind::Str),
44    ("segment", Kind::U32),
45    ("gap", Kind::F64),
46];
47
48/// What an element is to the reader, by where it sits.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum Role {
51    Gpx,
52    Track,
53    Route,
54    Segment,
55    /// A track's or route's `name`.
56    TrackName,
57    Point,
58    /// A child of a point: `ele`, `time`, `name`, `sat`, `link`…
59    PointField,
60    Extensions,
61    /// Anything inside `extensions`; a leaf is a column.
62    ExtField,
63    Other,
64}
65
66#[derive(Debug)]
67struct Frame {
68    name: String,
69    role: Role,
70    /// Its text, raw (entities still escaped), up to [`MAX_TEXT`].
71    text: Vec<u8>,
72    children: bool,
73    /// A `link`'s `href`.
74    href: Option<String>,
75}
76
77#[derive(Debug, Default)]
78struct Point {
79    kind: &'static str,
80    lat: Option<f64>,
81    lon: Option<f64>,
82    ele: Option<f64>,
83    time: Option<i64>,
84    fields: Vec<(usize, String)>,
85}
86
87/// What one field column held, to type it once the file is read.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct FieldColumn {
90    pub name: String,
91    /// Every value seen parses as an integer.
92    pub integers: bool,
93    /// Every value seen parses as a number.
94    pub numbers: bool,
95}
96
97/// What a read counted.
98#[derive(Debug, Default, Clone, PartialEq, Eq)]
99pub struct Stats {
100    pub points: u64,
101    pub tracks: u64,
102    pub routes: u64,
103    pub waypoints: u64,
104    /// Fields not made columns: past [`MAX_FIELDS`], or with names too long.
105    pub fields_dropped: u64,
106    /// Times that are not ISO 8601, left empty.
107    pub bad_times: u64,
108    /// The file ended inside an element.
109    pub truncated: bool,
110}
111
112/// A GPX file read a piece at a time.
113pub struct GpxReader {
114    buf: Vec<u8>,
115    pos: usize,
116    stack: Vec<Frame>,
117    root_seen: bool,
118    rows: Builder,
119    fields: Vec<FieldColumn>,
120    point: Option<Point>,
121    track: Option<u32>,
122    track_name: Option<String>,
123    segment: Option<u32>,
124    segments: u32,
125    stats: Stats,
126    /// The time of the last point of the segment being read, for the next one's `gap`.
127    last_time: Option<i64>,
128    /// Bytes of text in the rows not yet taken.
129    held: usize,
130}
131
132impl Default for GpxReader {
133    fn default() -> Self {
134        Self::new()
135    }
136}
137
138/// Whether the first bytes of a file are GPX: XML whose first element is `gpx`.
139pub fn looks_like(head: &[u8]) -> bool {
140    let head = head.strip_prefix(b"\xef\xbb\xbf").unwrap_or(head);
141    let mut rest = head.trim_ascii_start();
142    // The declaration, comments and processing instructions before the root.
143    loop {
144        if let Some(after) = rest.strip_prefix(b"<?") {
145            let Some(end) = find(after, b"?>") else {
146                return false;
147            };
148            rest = after[end + 2..].trim_ascii_start();
149        } else if let Some(after) = rest.strip_prefix(b"<!--") {
150            let Some(end) = find(after, b"-->") else {
151                return false;
152            };
153            rest = after[end + 3..].trim_ascii_start();
154        } else {
155            break;
156        }
157    }
158    rest.strip_prefix(b"<gpx")
159        .and_then(|r| r.first())
160        .is_some_and(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
161}
162
163fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
164    hay.windows(needle.len()).position(|w| w == needle)
165}
166
167/// Text with its entities and character references replaced. An entity that is not
168/// one of XML's five is left as written.
169fn unescape(raw: &[u8]) -> String {
170    let text = String::from_utf8_lossy(raw);
171    if !text.contains('&') {
172        return text.into_owned();
173    }
174    let mut out = String::with_capacity(text.len());
175    let mut rest = &*text;
176    while let Some(amp) = rest.find('&') {
177        out.push_str(&rest[..amp]);
178        rest = &rest[amp..];
179        // A reference is short; the window ends on a character, not inside one.
180        let Some(semi) = rest[..rest.floor_char_boundary(12)].find(';') else {
181            out.push('&');
182            rest = &rest[1..];
183            continue;
184        };
185        let entity = &rest[1..semi];
186        let ch = match entity {
187            "lt" => Some('<'),
188            "gt" => Some('>'),
189            "amp" => Some('&'),
190            "quot" => Some('"'),
191            "apos" => Some('\''),
192            _ => entity
193                .strip_prefix("#x")
194                .or_else(|| entity.strip_prefix("#X"))
195                .map(|hex| u32::from_str_radix(hex, 16))
196                .or_else(|| entity.strip_prefix('#').map(str::parse::<u32>))
197                .and_then(|n| n.ok())
198                .and_then(char::from_u32),
199        };
200        match ch {
201            Some(ch) => {
202                out.push(ch);
203                rest = &rest[semi + 1..];
204            }
205            None => {
206                out.push('&');
207                rest = &rest[1..];
208            }
209        }
210    }
211    out.push_str(rest);
212    out
213}
214
215/// A time as GPX writes it, ISO 8601 (`2024-05-01T12:00:00Z`, with fractions or an
216/// offset), as milliseconds since the epoch. One with no offset is UTC.
217pub fn parse_time(s: &str) -> Option<i64> {
218    let s = s.trim();
219    if let Ok(t) = chrono::DateTime::parse_from_rfc3339(s) {
220        return Some(t.timestamp_millis());
221    }
222    // ISO 8601's basic offset, `+0200`, which RFC 3339 does not allow.
223    if let Ok(t) = chrono::DateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f%z") {
224        return Some(t.timestamp_millis());
225    }
226    ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"]
227        .iter()
228        .find_map(|f| chrono::NaiveDateTime::parse_from_str(s, f).ok())
229        .map(|t| t.and_utc().timestamp_millis())
230}
231
232fn coordinate(s: Option<&str>, limit: f64) -> Option<f64> {
233    s?.trim()
234        .parse::<f64>()
235        .ok()
236        .filter(|v| v.is_finite() && v.abs() <= limit)
237}
238
239/// The name without its namespace prefix: `gpxtpx:hr` is `hr`.
240fn local(name: &str) -> &str {
241    name.rsplit_once(':').map_or(name, |(_, l)| l)
242}
243
244/// A tag's name and attributes.
245struct Tag<'a> {
246    name: &'a str,
247    attrs: Vec<(&'a str, String)>,
248    closing: bool,
249    empty: bool,
250}
251
252/// Parse the markup between `<` and `>`, both excluded.
253fn parse_tag(inner: &[u8]) -> Option<Tag<'_>> {
254    let text = std::str::from_utf8(inner).ok()?;
255    let (closing, text) = match text.strip_prefix('/') {
256        Some(rest) => (true, rest),
257        None => (false, text),
258    };
259    let (empty, text) = match text.strip_suffix('/') {
260        Some(rest) => (true, rest),
261        None => (false, text),
262    };
263    let end = text
264        .find(|c: char| c.is_ascii_whitespace())
265        .unwrap_or(text.len());
266    let name = &text[..end];
267    if name.is_empty() {
268        return None;
269    }
270    let mut attrs = Vec::new();
271    let mut rest = &text[end..];
272    while let Some(eq) = rest.find('=') {
273        let key = rest[..eq].trim();
274        let after = rest[eq + 1..].trim_start();
275        let quote = after.chars().next()?;
276        if quote != '"' && quote != '\'' {
277            return None;
278        }
279        let close = after[1..].find(quote)?;
280        attrs.push((local(key), unescape(&after.as_bytes()[1..1 + close])));
281        rest = &after[close + 2..];
282    }
283    Some(Tag {
284        name: local(name),
285        attrs,
286        closing,
287        empty,
288    })
289}
290
291/// The end of the markup starting at `bytes[0] == b'<'`, just past its `>`; `None`
292/// when it has not all arrived.
293fn markup_end(bytes: &[u8]) -> Option<usize> {
294    let after = |start: usize, close: &[u8]| {
295        bytes
296            .get(start..)
297            .and_then(|b| find(b, close))
298            .map(|at| start + at + close.len())
299    };
300    if bytes.starts_with(b"<!--") {
301        after(4, b"-->")
302    } else if bytes.starts_with(b"<![CDATA[") {
303        after(9, b"]]>")
304    } else if bytes.starts_with(b"<?") {
305        after(2, b"?>")
306    } else if bytes.starts_with(b"<!") {
307        // A DOCTYPE, whose internal subset holds `>` of its own.
308        let gt = bytes.iter().position(|&b| b == b'>')?;
309        let Some(open) = bytes[..gt].iter().position(|&b| b == b'[') else {
310            return Some(gt + 1);
311        };
312        // The subset ends at a `]` followed, past any whitespace, by the `>`.
313        let mut at = open;
314        loop {
315            at += 1 + bytes.get(at + 1..)?.iter().position(|&b| b == b']')?;
316            let rest = &bytes[at + 1..];
317            let space = rest.iter().take_while(|b| b.is_ascii_whitespace()).count();
318            match rest.get(space) {
319                Some(b'>') => return Some(at + 1 + space + 1),
320                Some(_) => {}
321                None => return None,
322            }
323        }
324    } else {
325        // A tag: its `>`, outside quoted attribute values.
326        let mut quote = None;
327        for (i, &b) in bytes.iter().enumerate().skip(1) {
328            match (quote, b) {
329                (None, b'"' | b'\'') => quote = Some(b),
330                (Some(q), b) if b == q => quote = None,
331                (None, b'>') => return Some(i + 1),
332                _ => {}
333            }
334        }
335        None
336    }
337}
338
339/// Whether `bytes` could still become one of the longer markup openings once more
340/// arrives: a `<!` or `<!-` that is not yet a comment, CDATA or DOCTYPE.
341fn undecided(bytes: &[u8]) -> bool {
342    bytes.len() < 9 && (b"<![CDATA[".starts_with(bytes) || b"<!--".starts_with(bytes))
343}
344
345impl GpxReader {
346    pub fn new() -> Self {
347        Self {
348            buf: Vec::new(),
349            pos: 0,
350            stack: Vec::new(),
351            root_seen: false,
352            rows: Builder::new(&CORE),
353            fields: Vec::new(),
354            point: None,
355            track: None,
356            track_name: None,
357            segment: None,
358            segments: 0,
359            stats: Stats::default(),
360            held: 0,
361            last_time: None,
362        }
363    }
364
365    pub fn stats(&self) -> &Stats {
366        &self.stats
367    }
368
369    /// The columns the file's fields made, in order after [`CORE`].
370    pub fn fields(&self) -> &[FieldColumn] {
371        &self.fields
372    }
373
374    /// Read `bytes`, the next piece of the file.
375    pub fn push(&mut self, bytes: &[u8]) -> Result<(), String> {
376        // What was read is let go before the buffer grows.
377        if self.pos > 0 {
378            self.buf.drain(..self.pos);
379            self.pos = 0;
380        }
381        self.buf.extend_from_slice(bytes);
382        self.scan(false)
383    }
384
385    /// A full batch, once one is held: [`BATCH_ROWS`] rows, or [`BATCH_TEXT`] of text.
386    pub fn take_batch(&mut self) -> PolarsResult<Option<DataFrame>> {
387        if self.rows.len() < BATCH_ROWS && self.held < BATCH_TEXT {
388            return Ok(None);
389        }
390        self.held = 0;
391        self.rows.take().map(Some)
392    }
393
394    /// The end of the file: what is left, and the rows not yet taken.
395    pub fn finish(&mut self) -> Result<DataFrame, String> {
396        self.scan(true)?;
397        if !self.root_seen {
398            return Err("Not a GPX file: it has no <gpx> element.".to_string());
399        }
400        if !self.stack.is_empty() || self.pos < self.buf.len() {
401            self.stats.truncated = true;
402        }
403        self.rows.take().map_err(|e| e.to_string())
404    }
405
406    fn scan(&mut self, at_end: bool) -> Result<(), String> {
407        while self.pos < self.buf.len() {
408            let rest = &self.buf[self.pos..];
409            if rest[0] != b'<' {
410                let end = rest.iter().position(|&b| b == b'<').unwrap_or(rest.len());
411                let (start, stop) = (self.pos, self.pos + end);
412                self.text(start, stop, false);
413                self.pos = stop;
414                continue;
415            }
416            if !at_end && undecided(rest) {
417                return Ok(());
418            }
419            let Some(len) = markup_end(rest) else {
420                if rest.len() > MAX_MARKUP {
421                    return Err(format!(
422                        "The GPX file has a tag or comment longer than {} MiB.",
423                        MAX_MARKUP >> 20
424                    ));
425                }
426                // The rest has not arrived, or never will: `finish` says so.
427                return Ok(());
428            };
429            if len > MAX_MARKUP {
430                return Err(format!(
431                    "The GPX file has a tag or comment longer than {} MiB.",
432                    MAX_MARKUP >> 20
433                ));
434            }
435            let (start, stop) = (self.pos, self.pos + len);
436            self.pos = stop;
437            if self.buf[start..stop].starts_with(b"<![CDATA[") {
438                self.text(start + 9, stop - 3, true);
439            } else if !(self.buf[start + 1] == b'!' || self.buf[start + 1] == b'?') {
440                let inner = self.buf[start + 1..stop - 1].to_vec();
441                if let Some(tag) = parse_tag(&inner) {
442                    if tag.closing {
443                        self.end(tag.name);
444                    } else {
445                        self.start(&tag)?;
446                        if tag.empty {
447                            self.end(tag.name);
448                        }
449                    }
450                }
451            }
452        }
453        Ok(())
454    }
455
456    /// Text from `buf[start..stop]`, kept by the element it is in if that element
457    /// is a value. CDATA is escaped as it is kept, so unescaping gives it back.
458    fn text(&mut self, start: usize, stop: usize, cdata: bool) {
459        let Some(frame) = self.stack.last_mut() else {
460            return;
461        };
462        if !matches!(
463            frame.role,
464            Role::TrackName | Role::PointField | Role::ExtField
465        ) {
466            return;
467        }
468        let room = MAX_TEXT.saturating_sub(frame.text.len());
469        if room == 0 {
470            return;
471        }
472        let piece = &self.buf[start..stop];
473        if cdata {
474            for &b in piece {
475                if frame.text.len() >= MAX_TEXT {
476                    break;
477                }
478                match b {
479                    b'&' => frame.text.extend_from_slice(b"&amp;"),
480                    b => frame.text.push(b),
481                }
482            }
483        } else {
484            frame
485                .text
486                .extend_from_slice(&piece[..piece.len().min(room)]);
487        }
488    }
489
490    fn start(&mut self, tag: &Tag) -> Result<(), String> {
491        if self.stack.len() >= MAX_DEPTH {
492            return Err(format!(
493                "The GPX file nests elements more than {MAX_DEPTH} deep."
494            ));
495        }
496        if !self.root_seen {
497            if tag.name != "gpx" {
498                return Err(format!(
499                    "Not a GPX file: its first element is <{}>.",
500                    tag.name.chars().take(40).collect::<String>()
501                ));
502            }
503            self.root_seen = true;
504        } else if self.stack.is_empty() {
505            // A second root, after the first closed: not GPX's, passed over.
506            self.stack.push(Frame::new(tag.name, Role::Other));
507            return Ok(());
508        }
509        let parent = self.stack.last_mut().map(|frame| {
510            frame.children = true;
511            frame.role
512        });
513        let role = match (parent, tag.name) {
514            (None, _) => Role::Gpx,
515            (Some(Role::Gpx), "trk") => {
516                self.track = Some(self.stats.tracks as u32);
517                self.stats.tracks += 1;
518                self.track_name = None;
519                self.segments = 0;
520                Role::Track
521            }
522            (Some(Role::Gpx), "rte") => {
523                self.track = Some(self.stats.routes as u32);
524                self.stats.routes += 1;
525                self.track_name = None;
526                Role::Route
527            }
528            (Some(Role::Gpx), "wpt") => self.point_starts("waypoint", tag),
529            (Some(Role::Track), "trkseg") => {
530                self.segment = Some(self.segments);
531                self.last_time = None;
532                self.segments = self.segments.saturating_add(1);
533                Role::Segment
534            }
535            (Some(Role::Track | Role::Route), "name") => Role::TrackName,
536            (Some(Role::Route), "rtept") => self.point_starts("route", tag),
537            (Some(Role::Segment), "trkpt") => self.point_starts("track", tag),
538            (Some(Role::Point), "extensions") => Role::Extensions,
539            (Some(Role::Point), _) => Role::PointField,
540            (Some(Role::Extensions | Role::ExtField), _) => Role::ExtField,
541            _ => Role::Other,
542        };
543        let mut frame = Frame::new(tag.name, role);
544        if role == Role::PointField && tag.name == "link" {
545            frame.href = tag
546                .attrs
547                .iter()
548                .find(|(k, _)| *k == "href")
549                .map(|(_, v)| v.clone());
550        }
551        self.stack.push(frame);
552        Ok(())
553    }
554
555    fn point_starts(&mut self, kind: &'static str, tag: &Tag) -> Role {
556        let attr = |name: &str| {
557            tag.attrs
558                .iter()
559                .find(|(k, _)| *k == name)
560                .map(|(_, v)| v.as_str())
561        };
562        self.point = Some(Point {
563            kind,
564            lat: coordinate(attr("lat"), 90.0),
565            lon: coordinate(attr("lon"), 180.0),
566            ..Point::default()
567        });
568        Role::Point
569    }
570
571    fn end(&mut self, name: &str) {
572        // An end tag closes its element and any left open inside it; one that closes
573        // nothing open is passed over.
574        let Some(at) = self.stack.iter().rposition(|frame| frame.name == name) else {
575            return;
576        };
577        while self.stack.len() > at {
578            let frame = self.stack.pop().expect("above `at`");
579            self.close(frame);
580        }
581    }
582
583    fn close(&mut self, frame: Frame) {
584        match frame.role {
585            Role::Track | Role::Route => {
586                self.track = None;
587                self.track_name = None;
588                self.segment = None;
589            }
590            Role::Segment => self.segment = None,
591            Role::TrackName => {
592                let name = unescape(&frame.text).trim().to_string();
593                self.track_name = (!name.is_empty()).then_some(name);
594            }
595            Role::Point => self.point_ends(),
596            Role::PointField => {
597                let value = match frame.href {
598                    Some(href) => href,
599                    None => unescape(&frame.text).trim().to_string(),
600                };
601                let Some(point) = self.point.as_mut() else {
602                    return;
603                };
604                match frame.name.as_str() {
605                    "ele" => point.ele = value.parse().ok().filter(|v: &f64| v.is_finite()),
606                    "time" => {
607                        point.time = parse_time(&value);
608                        if point.time.is_none() && !value.is_empty() {
609                            self.stats.bad_times += 1;
610                        }
611                    }
612                    name => self.field(name, value),
613                }
614            }
615            Role::ExtField if !frame.children => {
616                let value = unescape(&frame.text).trim().to_string();
617                self.field(&frame.name, value);
618            }
619            _ => {}
620        }
621    }
622
623    /// A value for the point being read, in the column its name makes.
624    fn field(&mut self, name: &str, value: String) {
625        if value.is_empty() || self.point.is_none() {
626            return;
627        }
628        // A field named like a core column (an extension's own `time`) is told apart.
629        let name = if CORE.iter().any(|(core, _)| *core == name) {
630            format!("ext_{name}")
631        } else {
632            name.to_string()
633        };
634        let column = match self.fields.iter().position(|f| f.name == name) {
635            Some(at) => at,
636            None if self.fields.len() < MAX_FIELDS && name.len() <= MAX_NAME => {
637                self.rows.add_column(&name, Kind::Str);
638                self.fields.push(FieldColumn {
639                    name,
640                    integers: true,
641                    numbers: true,
642                });
643                self.fields.len() - 1
644            }
645            None => {
646                self.stats.fields_dropped += 1;
647                return;
648            }
649        };
650        let info = &mut self.fields[column];
651        info.integers &= value.parse::<i64>().is_ok();
652        info.numbers &= value.parse::<f64>().is_ok_and(f64::is_finite);
653        let point = self.point.as_mut().expect("checked above");
654        if !point.fields.iter().any(|(at, _)| *at == column) {
655            point.fields.push((column, value));
656        }
657    }
658
659    fn point_ends(&mut self) {
660        let Some(point) = self.point.take() else {
661            return;
662        };
663        self.stats.points += 1;
664        if point.kind == "waypoint" {
665            self.stats.waypoints += 1;
666        }
667        let in_track = point.kind != "waypoint";
668        // Seconds since the point before, in a track segment only: routes and
669        // waypoints are places, not a recording. None when time steps back further
670        // than a clock settling, as NMEA's.
671        let gap = match (point.kind, point.time) {
672            ("track", Some(time)) => self
673                .last_time
674                .replace(time)
675                .map(|last| time - last)
676                .filter(|ms| *ms >= -super::nmea::BACK_MS)
677                .map(|ms| ms as f64 / 1000.0),
678            _ => None,
679        };
680        let cells = [
681            Cell::Time(point.time),
682            Cell::F64(point.lat),
683            Cell::F64(point.lon),
684            Cell::F64(point.ele),
685            Cell::Str(Some(point.kind.to_string())),
686            Cell::U32(self.track.filter(|_| in_track)),
687            Cell::Str(self.track_name.clone().filter(|_| in_track)),
688            Cell::U32(self.segment.filter(|_| point.kind == "track")),
689            Cell::F64(gap),
690        ];
691        self.held += point.fields.iter().map(|(_, v)| v.len()).sum::<usize>()
692            + self.track_name.as_ref().map_or(0, String::len);
693        let core = cells.into_iter().enumerate();
694        let fields = point
695            .fields
696            .into_iter()
697            .map(|(at, value)| (CORE.len() + at, Cell::Str(Some(value))));
698        self.rows.push_sparse(core.chain(fields));
699    }
700}
701
702impl Frame {
703    fn new(name: &str, role: Role) -> Self {
704        Self {
705            name: name.chars().take(MAX_NAME * 2).collect(),
706            role,
707            text: Vec::new(),
708            children: false,
709            href: None,
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
719<!-- written by hand -->
720<gpx version="1.1" creator="test" xmlns:gpxtpx="http://example.com/tpx">
721  <metadata><name>Morning</name><time>2024-05-01T06:00:00Z</time></metadata>
722  <wpt lat="47.1" lon="8.5"><name>Start &amp; finish</name><sym>Flag</sym></wpt>
723  <trk>
724    <name><![CDATA[Ride & run]]></name>
725    <trkseg>
726      <trkpt lat="47.2" lon="8.6"><ele>410.5</ele><time>2024-05-01T06:00:01Z</time>
727        <extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>141</gpxtpx:hr><gpxtpx:cad>80</gpxtpx:cad></gpxtpx:TrackPointExtension></extensions>
728      </trkpt>
729      <trkpt lat='47.3' lon='8.7'><ele>411</ele><time>2024-05-01T08:00:02.500+02:00</time>
730        <extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>142</gpxtpx:hr><gpxtpx:cad>n/a</gpxtpx:cad></gpxtpx:TrackPointExtension></extensions>
731      </trkpt>
732    </trkseg>
733    <trkseg><trkpt lat="47.4" lon="8.8"/></trkseg>
734  </trk>
735  <rte><name>Way</name><rtept lat="1" lon="2"><link href="http://x/?a=1&amp;b=2"><text>t</text></link></rtept></rte>
736</gpx>"#;
737
738    fn read_in(text: &[u8], piece: usize) -> (DataFrame, GpxReader) {
739        let mut reader = GpxReader::new();
740        for chunk in text.chunks(piece) {
741            reader.push(chunk).unwrap();
742        }
743        let df = reader.finish().unwrap();
744        (df, reader)
745    }
746
747    fn strs(df: &DataFrame, name: &str) -> Vec<Option<String>> {
748        df.column(name)
749            .unwrap()
750            .str()
751            .unwrap()
752            .iter()
753            .map(|v| v.map(str::to_string))
754            .collect()
755    }
756
757    #[test]
758    fn every_point_is_a_row() {
759        // Whole, and a byte at a time: the pieces cut every token somewhere.
760        for piece in [SAMPLE.len(), 1, 5] {
761            let (df, reader) = read_in(SAMPLE.as_bytes(), piece);
762            assert_eq!(df.height(), 5, "{df}");
763            assert_eq!(
764                strs(&df, "kind"),
765                ["waypoint", "track", "track", "track", "route"].map(|s| Some(s.to_string()))
766            );
767            assert_eq!(
768                strs(&df, "track_name"),
769                [
770                    None,
771                    Some("Ride & run"),
772                    Some("Ride & run"),
773                    Some("Ride & run"),
774                    Some("Way")
775                ]
776                .map(|s| s.map(str::to_string))
777            );
778            let segment: Vec<_> = df
779                .column("segment")
780                .unwrap()
781                .u32()
782                .unwrap()
783                .iter()
784                .collect();
785            assert_eq!(segment, [None, Some(0), Some(0), Some(1), None]);
786            let gap: Vec<_> = df.column("gap").unwrap().f64().unwrap().iter().collect();
787            assert_eq!(gap, [None, None, Some(1.5), None, None], "within a segment");
788            let time: Vec<_> = df
789                .column("time")
790                .unwrap()
791                .datetime()
792                .unwrap()
793                .physical()
794                .iter()
795                .collect();
796            assert_eq!(time[1], Some(1_714_543_201_000));
797            assert_eq!(time[2], Some(1_714_543_202_500), "the offset is applied");
798            assert_eq!(
799                parse_time("2024-05-01T08:00:02.500+0200"),
800                time[2],
801                "an offset without its colon"
802            );
803            assert_eq!(
804                strs(&df, "hr")[1..3],
805                [Some("141".into()), Some("142".into())]
806            );
807            assert_eq!(strs(&df, "name")[0], Some("Start & finish".into()));
808            assert_eq!(strs(&df, "link")[4], Some("http://x/?a=1&b=2".into()));
809            let fields = reader.fields();
810            let hr = fields.iter().find(|f| f.name == "hr").unwrap();
811            assert!(hr.integers);
812            let cad = fields.iter().find(|f| f.name == "cad").unwrap();
813            assert!(!cad.numbers, "n/a is not a number");
814            assert_eq!(reader.stats().points, 5);
815            assert!(!reader.stats().truncated);
816        }
817    }
818
819    #[test]
820    fn not_gpx_and_cut_short() {
821        let mut reader = GpxReader::new();
822        assert!(reader.push(b"<kml><Document/></kml>").is_err());
823        let mut reader = GpxReader::new();
824        assert!(reader.finish().is_err(), "nothing at all");
825        let cut = &SAMPLE.as_bytes()[..SAMPLE.find("</trkseg>").unwrap()];
826        let (df, reader) = read_in(cut, 64);
827        assert_eq!(df.height(), 3, "the points before the cut");
828        assert!(reader.stats().truncated);
829        assert!(looks_like(SAMPLE.as_bytes()));
830        assert!(looks_like(b"<gpx>"));
831        assert!(!looks_like(b"<gpxx>"));
832        assert!(!looks_like(b"<?xml version='1.0'?><kml>"));
833    }
834
835    #[test]
836    fn hostile_input_is_bounded() {
837        // Points of long fields are handed over before their text passes BATCH_TEXT.
838        let mut reader = GpxReader::new();
839        reader.push(b"<gpx>").unwrap();
840        let wide: String = (0..200)
841            .map(|i| {
842                let text = "z".repeat(MAX_TEXT);
843                format!("<wpt lat=\"1\" lon=\"1\"><f{i}>{text}</f{i}></wpt>")
844            })
845            .collect();
846        let mut batches = 0;
847        for _ in 0..BATCH_TEXT / (200 * MAX_TEXT) + 2 {
848            reader.push(wide.as_bytes()).unwrap();
849            assert!(reader.rows.len() * MAX_TEXT <= BATCH_TEXT + 200 * MAX_TEXT);
850            batches += usize::from(reader.take_batch().unwrap().is_some());
851        }
852        assert!(batches > 0);
853        // A text node of megabytes keeps MAX_TEXT of it.
854        let mut text = b"<gpx><wpt lat=\"1\" lon=\"2\"><desc>".to_vec();
855        text.extend(std::iter::repeat_n(b'x', 3 * MAX_MARKUP));
856        text.extend_from_slice(b"</desc></wpt></gpx>");
857        let (df, _) = read_in(&text, 1 << 16);
858        assert_eq!(strs(&df, "desc")[0].as_ref().unwrap().len(), MAX_TEXT);
859        // A tag that never ends is refused once it passes MAX_MARKUP.
860        let mut reader = GpxReader::new();
861        reader.push(b"<gpx><wpt a=\"").unwrap();
862        let junk = vec![b'y'; 1 << 16];
863        let refused = (0..(MAX_MARKUP >> 16) + 2).any(|_| reader.push(&junk).is_err());
864        assert!(refused);
865        // Nesting past MAX_DEPTH is refused.
866        let deep = "<a>".repeat(MAX_DEPTH + 1);
867        let mut reader = GpxReader::new();
868        assert!(reader.push(format!("<gpx>{deep}").as_bytes()).is_err());
869        assert_eq!(unescape(b"&#x41;&#66;&bogus;&"), "AB&bogus;&");
870        // A DOCTYPE's entities are never expanded, wherever its subset closes.
871        let laughs = br#"<?xml version="1.0"?>
872<!DOCTYPE gpx [
873  <!ENTITY lol "lol">
874  <!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
875] >
876<gpx><wpt lat="1" lon="2"><name>&lol2;</name></wpt></gpx>"#;
877        let (df, reader) = read_in(laughs, 7);
878        assert_eq!(strs(&df, "name"), [Some("&lol2;".into())]);
879        assert!(!reader.stats().truncated);
880        // Not a reference, with a character of several bytes where its end would be.
881        assert_eq!(
882            unescape("a &\u{fffd}\u{fffd}\u{fffd}\u{fffd};".as_bytes()),
883            "a &\u{fffd}\u{fffd}\u{fffd}\u{fffd};"
884        );
885    }
886}