Contents

outdooractive/gis-tools-gpx

GPX 1.1 (GPS Exchange Format) read and write support for Swift, built on top of gis-tools. Parses waypoints, routes, and tracks into typed FeatureCollection objects — and writes them back as valid GPX XML.

Features

  • Reads and writes GPX 1.1 files, with read-only support for GPX 1.0
  • Waypoints (<wpt>) → Feature<Point>, Routes (<rte>) → Feature<LineString>, Tracks (<trk>) → Feature<MultiLineString>
  • Typed convenience API on Feature and FeatureCollection — no manual dictionary casting
  • Full <metadata> block (name, author, copyright, bounds, keywords, time, links)
  • Garmin TrackPointExtension v2 (gpxtpx): hr, cad, power, speed, course, bearing, temperature, depth
  • Garmin GpxExtensions v3 (gpxx): Address, Categories, PhoneNumber, DisplayColor, IsAutoNamed, rpt
  • Structured extension values: AddressGPXAddress, Categories[String], PhoneNumber[GPXPhoneNumber]
  • Automatic type inference for extension values (booleans, integers, floating-point, strings)
  • Per-point sensor arrays: heart rate, cadence, power, speed, temperature — stored as parallel arrays
  • gpxPointFeatures(): expand track points into individual Point features with per-point sensor data
  • Time-window and distance-window slicing of point features
  • GPX 1.0-specific fields: course/speed on track points, flat url/urlname links
  • Round-trip fidelity: write → read preserves element types, coordinates, properties, and extensions

Requirements

Swift 6.1 or higher. Compiles on iOS (≥ iOS 15), macOS (≥ macOS 15), tvOS (≥ tvOS 15), watchOS (≥ watchOS 7), Linux, Android and Wasm. No external dependencies beyond the base gis-tools package.

Installation with Swift Package Manager

dependencies: [
    .package(url: "https://github.com/Outdooractive/gis-tools-gpx", from: "1.0.0"),
    .package(url: "https://github.com/Outdooractive/gis-tools", from: "2.0.0"),
],
targets: [
    .target(name: "MyTarget", dependencies: [
        .product(name: "GISToolsGPX", package: "gis-tools-gpx"),
        .product(name: "GISTools", package: "gis-tools"),
    ]),
]

Usage

Reading

import GISTools
import GISToolsGPX

let url = URL(fileURLWithPath: "/path/to/file.gpx")
let fc = try GPXCoder.read(from: url)

// Or via the convenience init:
guard let fc = FeatureCollection(gpx: url) else { return }

Writing

try fc.writeGPX(to: outputURL)

// Or via the coder directly:
try GPXCoder.write(fc, to: outputURL)

Inspecting GPX element types

Each Feature carries a gpxType property identifying its origin:

for feature in fc.features {
    switch feature.gpxType {
    case .waypoint: print("Waypoint: \(feature.gpxName ?? "unnamed")")
    case .route:    print("Route: \(feature.gpxName ?? "unnamed")")
    case .track:    print("Track: \(feature.gpxName ?? "unnamed")")
    case nil:       print("Unknown GPX type")
    }
}

Waypoint properties

let waypoint = fc.features.first!
print(waypoint.gpxName)            // "Reichstag (Berlin)"
print(waypoint.gpxSymbol)          // "City"
print(waypoint.gpxFix)             // .threeDimensional
print(waypoint.gpxSatellites)      // 12
print(waypoint.gpxHDOP)            // 1.5
print(waypoint.gpxTime)            // Optional<Date>

// Coordinate access
let coord = (waypoint.geometry as! Point).coordinate
print(coord.latitude)              // 52.518611
print(coord.longitude)             // 13.376111
print(coord.altitude)              // 35.0

Track and route geometry

let track = fc.features[1]
let multiLine = track.geometry as! MultiLineString
for (i, segment) in multiLine.lineStrings.enumerated() {
    print("Segment \(i): \(segment.coordinates.count) points")
}

let route = fc.features[2]
let lineString = route.geometry as! LineString
print("Route has \(lineString.coordinates.count) waypoints")

Garmin fitness extensions (gpxtpx)

Track-point-level fitness data via typed properties:

var pointFeature = Feature(Point(Coordinate3D(latitude: 52.5, longitude: 13.3)))
pointFeature.gpxHeartRate = 145
pointFeature.gpxCadence = 90
pointFeature.gpxPower = 220        // watts
pointFeature.gpxSpeed = 12.5       // m/s
pointFeature.gpxCourse = 45.0      // degrees true
pointFeature.gpxAirTemperature = 18.5
pointFeature.gpxDepth = 2.0        // meters

// Reading back:
let hr = feature.gpxHeartRate      // Int? (145)
let cad = feature.gpxCadence       // Int? (90)
let speed = feature.gpxSpeed       // Double? (12.5)

Per-point sensor arrays (track Features only)

Track-level fitness data (gpxtpx extensions on <trkpt>) is accumulated into parallel arrays on the track Feature:

let track = fc.features.first { $0.gpxType == .track }!

let hr = track.gpxHeartRates       // [Int?]? — [120, 145, 160, ...]
let cad = track.gpxCadences         // [Int?]? — [80, 90, 95, ...]
let pw = track.gpxPowers            // [Int?]? — [150, 220, ...] (nil = missing)
let spd = track.gpxSpeeds           // [Double?]? — [12.5, ...]
let tmp = track.gpxAirTemperatures  // [Double?]? — [22.5, ...]
let elev = track.gpxElevations      // [Double?]? — [35.0, 38.0, ...]

// Arrays align with coordinates — nil where no data was recorded
for i in 0..<(track.gpxHeartRates?.count ?? 0) {
    if let hr = hr?[i], let cad = cad?[i] {
        print("Point \(i): HR=\(hr), cad=\(cad)")
    }
}

Converting track points to individual Features

Expand the MultiLineString track into individual Point features, each carrying its own sensor data:

let pts = track.gpxPointFeatures()
pts.features.count                  // 3 (one per track point)
pts.features[0].gpxHeartRate        // 120 (via convenience accessor)
pts.features[2].gpxSpeed            // 12.5 (reads from extensions["gpxtpx"])

// Time-window slicing
let morning = track.gpxPointFeatures(from: morningDate, to: noonDate)

// Distance-window slicing (meters along track)
let lastKm = track.gpxPointFeatures(from: totalM - 1000, to: totalM)

// Fractional-window slicing (0.0–1.0)
let middle = track.gpxPointFeatures(fraction: 0.33, to: 0.66)

Reconstructing a track from point features

Convert a FeatureCollection of Point features back into a Feature<MultiLineString> track with per-point sensor arrays rebuilt:

let pts = track.gpxPointFeatures()

// Via FeatureCollection:
let rebuilt = pts.gpxTrackFromPointFeatures()
rebuilt?.gpxHeartRates?[0]   // 120
rebuilt?.gpxSpeeds?[2]        // 12.5

// Via Feature convenience init (also reads extensions["gpxtpx"]):
let track = Feature(gpxTrackFrom: pts)
track?.gpxCadences?[1]        // 90
track?.gpxHeartRate           // nil (track Feature has arrays, not single values)

Non-Point features are silently skipped. Returns nil if no Point features exist. The gpxtpx extension data (hr, cad, power, speed, atemp) is extracted from each point and re-accumulated into parallel arrays, so round-trips preserve sensor data. The track has a single segment (no lap splitting).

Garmin waypoint extensions (gpxx)

Address, categories, and phone numbers as structured types:

let address = waypoint.gpxAddress
print(address?.streetAddresses)    // ["Teststrasse 123", "Floor 4"]
print(address?.city)               // "Berlin"
print(address?.country)            // "Germany"
print(address?.postalCode)         // "10115"

let categories = waypoint.gpxCategories
print(categories)                  // ["Office", "Technology", "Outdoor"]

for phone in waypoint.gpxPhoneNumbers {
    print("\(phone.category ?? "other"): \(phone.value)")
    // Work: +49-30-555-1234
    // Mobile: +49-170-555-5678
}

let isAutoNamed = route.gpxIsAutoNamed  // Bool?
let color = route.gpxDisplayColor       // String? ("Red", "Blue", etc.)

Round-trip: build from scratch

import GISTools
import GISToolsGPX

var fc = FeatureCollection()

// Add metadata
fc.gpxMetadataName = "My GPX File"
fc.gpxMetadataKeywords = "hiking, alps"
fc.gpxMetadataAuthor = GPXPerson(
    name: "Jane Hiker",
    email: "jane@example.com")
fc.gpxMetadataBounds = BoundingBox(
    southWest: Coordinate3D(latitude: 47.0, longitude: 10.0),
    northEast: Coordinate3D(latitude: 48.0, longitude: 11.0))

// Add a waypoint
var wpt = Feature(Point(Coordinate3D(latitude: 47.56, longitude: 10.22)))
wpt.gpxType = .waypoint
wpt.gpxName = "Alpine Lodge"
wpt.gpxSymbol = "Lodging"
wpt.gpxElevation = 1420.0
wpt.gpxAddress = GPXAddress(
    streetAddresses: ["Bergstrasse 1"],
    city: "Oberstdorf",
    country: "Germany",
    postalCode: "87561")
fc.features.append(wpt)

// Add a route
var rte = Feature(LineString(unchecked: [
    Coordinate3D(latitude: 47.56, longitude: 10.22),
    Coordinate3D(latitude: 47.57, longitude: 10.25),
    Coordinate3D(latitude: 47.59, longitude: 10.30),
]))
rte.gpxType = .route
rte.gpxName = "Summit Trail"
rte.gpxTypeName = "Hiking"
fc.features.append(rte)

// Write to file
try fc.writeGPX(to: outputURL)

Setting and writing extensions

var wpt = Feature(Point(Coordinate3D(latitude: 47.56, longitude: 10.22)))
wpt.gpxType = .waypoint
wpt.gpxName = "Checkpoint"

// Fitness data
wpt.gpxHeartRate = 120
wpt.gpxPower = 180

// Structured address
wpt.gpxAddress = GPXAddress(
    streetAddresses: ["Alpenstraße 1"],
    city: "Innsbruck",
    country: "Austria")

// Categories and phone numbers
wpt.gpxCategories = ["Hiking", "Rest Stop"]
wpt.gpxPhoneNumbers = [
    GPXPhoneNumber(value: "+43-512-555", category: "Info"),
]

// Route display properties
wpt.gpxDisplayColor = "Red"
wpt.gpxIsAutoNamed = false

// Write — all extension data round-trips
try FeatureCollection([wpt]).writeGPX(to: outputURL)

GPX 1.0 read support

GPX 1.0 files are parsed with backward-compatible handling:

let fc = try GPXCoder.read(from: gpx10URL)

// GPX 1.0 flat links (url/urlname) converted to GPXLink
let waypoint = fc.features.first!
for link in waypoint.gpxLinks {
    print("\(link.href): \(link.text ?? "")")
    // https://www.bundestag.de: Bundestag website
}

// GPX 1.0 course/speed on track points (not extensions)
// These are stored as gpxCourse10 / gpxSpeed10 on the waypoints
// within track segments, not on the track Feature itself.

OSM traces

OpenStreetMap GPS traces are standard GPX 1.1 files — they read and write like any other GPX:

let trace = try GPXCoder.read(from: osmURL)
let track = trace.features.first { $0.gpxType == .track }!
let multiline = track.geometry as! MultiLineString
print("\(multiline.lineStrings[0].coordinates.count) trace points")

// OSM traces often use cmt/desc for tagging
print(track.gpxDescription)  // e.g. "Walking trace through Berlin Mitte"

Raw extension access

If you need the raw extension dictionary (for namespaces not covered by the typed API):

let ext = feature.properties["extensions"] as? [String: Sendable]
let gpxtpx = ext?["gpxtpx"] as? [String: Sendable]
let gpxx = ext?["gpxx"] as? [String: Sendable]

// Direct key access
let hr = gpxtpx?["hr"] as? Int
let city = (gpxx?["Address"] as? [String: Sendable])?["City"] as? String

Typed API reference

On Feature:

| Property | Type | GPX Source | |---|---|---| | gpxType | GPXElementType? | .waypoint, .route, .track | | gpxName | String? | <name> | | gpxComment | String? | <cmt> | | gpxDescription | String? | <desc> | | gpxSource | String? | <src> | | gpxSymbol | String? | <sym> | | gpxTypeName | String? | <type> | | gpxFix | GPSFixType? | <fix> | | gpxSatellites | Int? | <sat> | | gpxHDOP | Double? | <hdop> | | gpxVDOP | Double? | <vdop> | | gpxPDOP | Double? | <pdop> | | gpxTime | Date? | <time> | | gpxLinks | [GPXLink] | <link> | | gpxCourse10 | Double? | <course> (GPX 1.0) | | gpxSpeed10 | Double? | <speed> (GPX 1.0) | | gpxHeartRate | Int? | gpxtpx hr | | gpxCadence | Int? | gpxtpx cad | | gpxPower | Int? | gpxtpx power | | gpxSpeed | Double? | gpxtpx speed | | gpxCourse | Double? | gpxtpx course | | gpxBearing | Double? | gpxtpx bearing | | gpxAirTemperature | Double? | gpxtpx atemp | | gpxWaterTemperature | Double? | gpxtpx wtemp | | gpxDepth | Double? | gpxtpx depth | | gpxHeartRates | [Int?]? | gpxtpx hr (per-point array) | | gpxCadences | [Int?]? | gpxtpx cad (per-point array) | | gpxPowers | [Int?]? | gpxtpx power (per-point array) | | gpxSpeeds | [Double?]? | gpxtpx speed (per-point array) | | gpxAirTemperatures | [Double?]? | gpxtpx atemp (per-point array) | | gpxElevations | [Double?]? | <ele> (per-point array) | | gpxTimes | [Date?]? | <time> (per-point array) | | gpxProximity | Double? | gpxx Proximity | | gpxDisplayMode | String? | gpxx DisplayMode | | gpxAddress | GPXAddress? | gpxx Address | | gpxCategories | [String] | gpxx Categories | | gpxPhoneNumbers | [GPXPhoneNumber] | gpxx PhoneNumber | | gpxIsAutoNamed | Bool? | gpxx IsAutoNamed | | gpxDisplayColor | String? | gpxx DisplayColor |

On FeatureCollection:

| Property | Type | GPX Source | |---|---|---| | gpxWaypoints | [Feature] | waypoints (filtered by gpxType) | | gpxRoutes | [Feature] | routes (filtered by gpxType) | | gpxTracks | [Feature] | tracks (filtered by gpxType) | | gpxMetadataName | String? | <metadata><name> | | gpxMetadataDescription | String? | <metadata><desc> | | gpxMetadataKeywords | String? | <metadata><keywords> | | gpxMetadataTime | Date? | <metadata><time> | | gpxMetadataAuthor | GPXPerson? | <metadata><author> | | gpxMetadataCopyright | GPXCopyright? | <metadata><copyright> | | gpxMetadataLinks | [GPXLink] | <metadata><link> | | gpxMetadataBounds | BoundingBox? | <metadata><bounds> |

Methods on FeatureCollection:

| Method | Returns | Description | |---|---|---| | gpxTrackFromPointFeatures() | Feature? | Reconstructs a Feature<MultiLineString> from Point features produced by gpxPointFeatures(). gpxtpx sensor data is re-accumulated into per-point arrays. Returns nil if no Point features exist. |

Convenience initializer on Feature:

| Initializer | Description | |---|---| | Feature(gpxTrackFrom:) | Creates a track Feature from a FeatureCollection of Point features. Same as gpxTrackFromPointFeatures(). |

Public model types:

| Type | Fields | |---|---| | GPXLink | href: String, text: String?, type: String? | | GPXPerson | name: String?, email: String?, link: GPXLink? | | GPXCopyright | author: String, year: Int?, license: String? | | GPXAddress | streetAddresses: [String], city: String?, state: String?, country: String?, postalCode: String? | | GPXPhoneNumber | value: String, category: String? |

Supported extension namespaces

| Prefix | Namespace URI | Elements | |---|---|---| | gpxtpx | http://www.garmin.com/xmlschemas/TrackPointExtension/v2 | hr, cad, power, speed, course, bearing, atemp, wtemp, depth | | gpxx | http://www.garmin.com/xmlschemas/GpxExtensions/v3 | Address, Categories, PhoneNumber, WaypointExtension, RouteExtension, TrackExtension, RoutePointExtension, TrackPointExtension, Proximity, Temperature, Depth, DisplayMode, DisplayColor, IsAutoNamed, Subclass, rpt |

Limitations

  • GPX 1.0 is read-only; written files always use GPX 1.1
  • List-type extensions (Categories, PhoneNumber) use generic element names on write; exact child-element names may differ on round-trip
  • Track-point-level and route-point-level extensions are lost during write because only track/route Feature objects are serialized (segment-level data is not individually written)
  • Subclass (hexBinary) and rpt autoroute points are parsed but not exposed via the typed API

Contributing

Please create an issue or open a pull request with a fix or enhancement.

License

MIT

Authors

Thomas Rasch, Outdooractive

Built on top of gis-tools.

Package Metadata

Repository: outdooractive/gis-tools-gpx

Default branch: main

README: README.md