feat(gpx): lenient point parsing + route (<rte>) support

Task groups 1–2 of gpx-parser-robustness. The parser trusted its input:
`parseFloat(attr ?? "0")` turned a missing lat/lon into a 0,0 Null Island
point (which passes range validation) and garbage into NaN that poisoned
distance and gain/loss totals; `<rte>`/`rtept` files (Garmin courses, many
exporters) parsed to zero track points and were rejected.

- `parsePoint`: skip a trkpt/rtept whose lat/lon is missing or non-finite
  (no more 0,0 default); a non-finite `<ele>` becomes `undefined` so it
  never leaks NaN into totals. Parsing stays parseFloat-lenient (trailing
  junk like `471.0m` still accepted), gated by Number.isFinite.
- Drop segments left with fewer than 2 points (render nothing / break
  distance math).
- Parse `<rte>` as track segments appended after `<trk>` segments, rtept
  handled identically — route-only files now import.

No GpxData shape change; well-formed files parse identically. Updated the
geom single-point test to the new drop-invariant.

Verified: gpx typecheck + lint clean, 76/76 tests pass, journal + planner
typecheck unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-14 00:06:35 +02:00
parent e2f25e1d27
commit 99278fd305
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
4 changed files with 184 additions and 26 deletions

View file

@ -56,7 +56,10 @@ describe("GPX to geometry coordinates", () => {
const gpxData = await parseGpxAsync(singlePointGpx);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
expect(coords).toHaveLength(1);
// Caller should check coords.length >= 2 before creating LineString
// The parser now drops segments left with fewer than 2 points
// (gpx-parser-robustness "Invalid point handling"), so a lone point
// yields no track segment at all — the "insufficient for LineString"
// guard is enforced at the parser boundary rather than left to callers.
expect(coords).toHaveLength(0);
});
});

View file

@ -68,3 +68,132 @@ describe("parseGpxAsync", () => {
await expect(parseGpxAsync("not xml at all <<<<")).rejects.toThrow();
});
});
describe("parseGpxAsync — invalid point handling", () => {
const gpx = (body: string) =>
`<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`;
it("skips a point with a missing lat/lon instead of defaulting to Null Island", async () => {
const result = await parseGpxAsync(
gpx(`<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lon="13.74"><ele>113</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg></trk>`),
);
expect(result.tracks[0]).toHaveLength(2);
// No 0,0 point leaked in.
expect(result.tracks[0]!.some((p) => p.lat === 0 && p.lon === 0)).toBe(false);
});
it("skips a point with garbage coords and keeps distance finite", async () => {
const result = await parseGpxAsync(
gpx(`<trk><trkseg>
<trkpt lat="52.52" lon="13.405"></trkpt>
<trkpt lat="abc" lon="13.74"></trkpt>
<trkpt lat="48.137" lon="11.576"></trkpt>
</trkseg></trk>`),
);
expect(result.tracks[0]).toHaveLength(2);
expect(Number.isFinite(result.distance)).toBe(true);
expect(result.distance).toBeGreaterThan(0);
});
it("treats unparseable <ele> as undefined so gain/loss stay finite", async () => {
const result = await parseGpxAsync(
gpx(`<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>34</ele></trkpt>
<trkpt lat="51.05" lon="13.74"><ele>NaN</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg></trk>`),
);
expect(result.tracks[0]![1]!.ele).toBeUndefined();
expect(Number.isFinite(result.elevation.gain)).toBe(true);
expect(Number.isFinite(result.elevation.loss)).toBe(true);
});
it("tolerates trailing junk on a numeric value (parseFloat lenience)", async () => {
const result = await parseGpxAsync(
gpx(`<trk><trkseg>
<trkpt lat="52.52" lon="13.405"><ele>471.0m</ele></trkpt>
<trkpt lat="48.137" lon="11.576"><ele>519</ele></trkpt>
</trkseg></trk>`),
);
expect(result.tracks[0]![0]!.ele).toBe(471);
});
it("drops a segment left with fewer than 2 points", async () => {
const result = await parseGpxAsync(
gpx(`<trk>
<trkseg><trkpt lat="52.52" lon="13.405"></trkpt></trkseg>
<trkseg>
<trkpt lat="52.52" lon="13.405"></trkpt>
<trkpt lat="48.137" lon="11.576"></trkpt>
</trkseg>
</trk>`),
);
expect(result.tracks).toHaveLength(1);
expect(result.tracks[0]).toHaveLength(2);
});
it("leaves a well-formed file's output unchanged", async () => {
const result = await parseGpxAsync(sampleGpx);
expect(result.tracks).toEqual([
[
{ lat: 52.52, lon: 13.405, ele: 34, time: undefined },
{ lat: 51.05, lon: 13.74, ele: 113, time: undefined },
{ lat: 48.137, lon: 11.576, ele: 519, time: undefined },
],
]);
});
});
describe("parseGpxAsync — route (<rte>) support", () => {
const gpx = (body: string) =>
`<?xml version="1.0" encoding="UTF-8"?>\n<gpx version="1.1" xmlns="http://www.topografix.com/GPX/1/1">${body}</gpx>`;
it("parses a route-only file into one segment", async () => {
const result = await parseGpxAsync(
gpx(`<rte><name>My Course</name>
<rtept lat="52.52" lon="13.405"><ele>34</ele></rtept>
<rtept lat="51.05" lon="13.74"><ele>113</ele></rtept>
<rtept lat="48.137" lon="11.576"><ele>519</ele></rtept>
</rte>`),
);
expect(result.tracks).toHaveLength(1);
expect(result.tracks[0]).toHaveLength(3);
expect(result.distance).toBeGreaterThan(0);
});
it("preserves rtept ele and time", async () => {
const result = await parseGpxAsync(
gpx(`<rte>
<rtept lat="52.52" lon="13.405"><ele>34</ele><time>2026-01-01T10:00:00Z</time></rtept>
<rtept lat="48.137" lon="11.576"><ele>519</ele><time>2026-01-01T11:00:00Z</time></rtept>
</rte>`),
);
expect(result.tracks[0]![0]).toEqual({
lat: 52.52,
lon: 13.405,
ele: 34,
time: "2026-01-01T10:00:00Z",
});
});
it("appends route segments after track segments", async () => {
const result = await parseGpxAsync(
gpx(`<trk><trkseg>
<trkpt lat="52.52" lon="13.405"></trkpt>
<trkpt lat="51.05" lon="13.74"></trkpt>
</trkseg></trk>
<rte>
<rtept lat="10.0" lon="10.0"></rtept>
<rtept lat="11.0" lon="11.0"></rtept>
</rte>`),
);
expect(result.tracks).toHaveLength(2);
// Track first, route second.
expect(result.tracks[0]![0]!.lat).toBe(52.52);
expect(result.tracks[1]![0]!.lat).toBe(10.0);
});
});

View file

@ -69,28 +69,54 @@ function parseWaypoints(doc: Document): Waypoint[] {
});
}
function parseTracks(doc: Document): TrackPoint[][] {
const tracks: TrackPoint[][] = [];
const trksegs = doc.querySelectorAll("trk > trkseg");
/**
* Parse one `trkpt`/`rtept` element into a TrackPoint, or null if it is
* unusable. Parsing stays `parseFloat`-lenient (accepts leading `+`,
* tolerates trailing junk like `471.0m` that real exporters emit), but a
* point whose `lat`/`lon` is missing or does not parse to a finite number
* is skipped rather than defaulted to `0,0` (which would land on Null
* Island and pass range validation) spec: gpx-parser-robustness
* "Invalid point handling". A non-finite `<ele>` becomes `undefined` (the
* existing "no elevation" representation) so it never poisons gain/loss
* totals with `NaN`.
*/
function parsePoint(pt: Element): TrackPoint | null {
const lat = parseFloat(pt.getAttribute("lat") ?? "");
const lon = parseFloat(pt.getAttribute("lon") ?? "");
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const eleText = pt.querySelector("ele")?.textContent;
const ele = eleText != null ? parseFloat(eleText) : NaN;
const time = pt.querySelector("time")?.textContent ?? undefined;
return { lat, lon, ele: Number.isFinite(ele) ? ele : undefined, time };
}
for (const seg of trksegs) {
const points: TrackPoint[] = [];
for (const pt of seg.querySelectorAll("trkpt")) {
const lat = parseFloat(pt.getAttribute("lat") ?? "0");
const lon = parseFloat(pt.getAttribute("lon") ?? "0");
const eleText = pt.querySelector("ele")?.textContent;
const time = pt.querySelector("time")?.textContent ?? undefined;
points.push({
lat,
lon,
ele: eleText ? parseFloat(eleText) : undefined,
time,
});
}
tracks.push(points);
function parseSegmentPoints(pts: ArrayLike<Element>): TrackPoint[] {
const points: TrackPoint[] = [];
for (const pt of Array.from(pts)) {
const parsed = parsePoint(pt);
if (parsed) points.push(parsed);
}
return points;
}
function parseTracks(doc: Document): TrackPoint[][] {
const segments: TrackPoint[][] = [];
// Standard tracks: <trk><trkseg><trkpt>.
for (const seg of Array.from(doc.querySelectorAll("trk > trkseg"))) {
segments.push(parseSegmentPoints(seg.querySelectorAll("trkpt")));
}
// Routes: <rte><rtept>. Many exporters (Garmin Connect courses,
// gpx.studio, planner exports) emit only routes; each becomes one
// segment appended after the track segments, with rtept handled
// identically to trkpt — spec: gpx-parser-robustness "Route support".
for (const rte of Array.from(doc.querySelectorAll("rte"))) {
segments.push(parseSegmentPoints(rte.querySelectorAll("rtept")));
}
return tracks;
// Drop empty or single-point segments: they render nothing and break
// distance-math assumptions (spec: "Invalid point handling").
return segments.filter((seg) => seg.length >= 2);
}
function parseNoGoAreas(doc: Document): NoGoArea[] {