Skip to main content

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