A spec-compliant GPX (GPS Exchange Format) library for MoonBit: parse, validate, serialize and migrate GPX 1.0 / 1.1 with line-accurate diagnostics.
MoonBit 的 GPX(GPS Exchange Format)规范实现:解析、校验、序列化、版本迁移。
| 模块 | 状态 |
|---|---|
| 坐标 / 数值类型(latitudeType / longitudeType / degreesType / dgpsStationType) | ✅ 已完成 |
| 根元素 + metadata + 子元素顺序校验 | ✅ 已完成 |
| wpt / rte / trk / trkseg 完整模型(含 wptType 全部 19 字段) | ✅ 已完成 |
| extensions 无损保留(原始 XML 切片) | ✅ 已完成 |
| 带行列号的诊断 | ✅ 已完成 |
| 序列化 + 往返一致性 | ✅ 已完成 |
| 独立的 validate_gpx(只校验不建模,含 warning 级语义检查) | ✅ 已完成 |
| GPX 1.0 解析 + 升级为 1.1 模型 | ✅ 已完成 |
| 轨迹统计(距离 / 爬升 / 包围盒 / 时长) | ✅ 已完成 |
| 坑 | 后果 |
|---|---|
| 根元素子元素顺序被 schema 写死(metadata → wpt → rte → trk → extensions) | 顺序错了就是非法文档,但多数解析器不报错,静默给出错误结果 |
| 经度上界是排他的(-180 ≤ lon < 180),纬度是闭区间(-90 ≤ lat ≤ 90) | 两端不对称:lat="90" 合法,lon="180" 非法 |
| version 是固定值 1.1 | 写 1.10、1.1.0 都非法 |
| creator 属性必填 | 忘了就不合规 |
| wpt/trkpt 有 19 个可选子元素且顺序固定 | 位置 → 描述 → 精度三段顺序错了静默失败 |
| fix 是枚举:none/2d/3d/dgps/pps | 写 2D(大写)非法 |
| dgpsid 范围 0–1023 | 越界非法 |
| GPX 1.0 与 1.1 不兼容 | 老设备导出的 1.0 文件用 1.1 解析器直接读不了 |
| 回写时 & 不转义 / extensions 被二次转义 | 前者产出非法 XML,后者把用户的私有扩展数据变成字面文本 |
| 回写时 \r 不转义成 | 被 XML 换行规范化折成 LF,数据静默改变 |
| xsd:dateTime 带时区偏移 | 09:00+08:00 与 01:00Z 是同一时刻,但字符串比较会得出相反的结论 |
| 相邻轨迹点完全重合 | 设备卡顿时常见,不做检查会让距离 / 配速统计凭空多出一段 |
moon add 2973181701/moongpxfn main {
// 纬度:闭区间 [-90, 90]
match @moongpx.Latitude::from_string("31.2304") {
Ok(lat) => println(lat.to_gpx_string()) // "31.2304"
Err(e) => println(e.message())
}
// 经度:半开区间 [-180, 180) —— 180 非法
match @moongpx.Longitude::from_string("180") {
Ok(_) => println("不应该到这里")
Err(e) => println(e.message())
// longitude out of range [-180, 180): 180
}
}parse_gpx(String) -> Result[Gpx, Array[Diagnostic]]let src = "<gpx version=\"1.0\" creator=\"x\"><wpt lon=\"180\"/></gpx>"
match @moongpx.parse_gpx(src) {
Ok(gpx) => println(gpx.creator)
Err(diags) => println(@moongpx.format_diagnostics(diags))
}
// 1:6 error E1003 version 必须是 "1.1"(schema 固定值),实际是 "1.0"
// 1:29 error E1009 <wpt> 缺少必填属性 latto_gpx(Gpx) -> String // 带 XML 声明与官方命名空间的完整文档
to_gpx_fragment(Gpx) -> String // 不带声明与命名空间,便于嵌入别的 XMLlet gpx = match @moongpx.parse_gpx(src) {
Ok(g) => g
Err(d) => { println(@moongpx.format_diagnostics(d)); return }
}
println(@moongpx.to_gpx(gpx))<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1" creator="moongpx">
<metadata>
<name>测试轨迹</name>
<bounds minlat="31" minlon="121" maxlat="31.5" maxlon="121.5"/>
</metadata>
<wpt lat="31.2304" lon="121.4737">
<name>起点</name>
</wpt>
</gpx>parse_gpx(to_gpx(g)) == g // 模型完全相等
to_gpx(parse_gpx(to_gpx(g))) == to_gpx(g) // 幂等validate_gpx(String) -> Array[Diagnostic] // 全部诊断,空数组 = 干净
is_valid_gpx(String) -> Bool // 连 warning 都没有才算通过
has_schema_errors(String) -> Bool // 只看 error
validate_gpx_report(String) -> String // 直接可打印的报告
diagnostics_of_severity(Array[Diagnostic], Severity) -> Array[Diagnostic]| parse_gpx | validate_gpx | |
|---|---|---|
| 关心 | 能不能用 | 完不完美 |
| 有 error 时 | 返回 Err,丢掉已解析的部分 | 照常报出,连同 warning 一起 |
| 有 warning 时 | 成功路径上丢弃 | 报出 |
| 返回 | Result[Gpx, ...] | Array[Diagnostic](空 = 干净) |
let src = "<gpx version=\"1.1\" creator=\"x\">" +
"<wpt lat=\"1\" lon=\"1\"><ele>99999</ele><fix>bogus</fix></wpt>" +
"<trk><name>空的</name></trk></gpx>"
println(@moongpx.validate_gpx_report(src))
// 共 3 条(1 错误 / 2 警告):
// 1:69 error E1016 <fix> 取值非法:"bogus"(只允许 none / 2d / 3d / dgps / pps,小写)
// 1:32 warning W1005 <ele> = 99999 m 超出地球表面合理范围(约 -11000 ~ 9000 m),请确认单位是否为米
// 1:91 warning W1006 <trk> 没有任何 <trkseg>let ok_but_suspicious = "<gpx version=\"1.1\" creator=\"x\"><wpt lat=\"1\" lon=\"1\"><ele>99999</ele></wpt></gpx>"
@moongpx.parse_gpx(ok_but_suspicious) is Ok(_) // true —— 文档合法
@moongpx.has_schema_errors(ok_but_suspicious) // false
@moongpx.is_valid_gpx(ok_but_suspicious) // false —— 但有可疑之处
@moongpx.validate_gpx(ok_but_suspicious).length() // 1if @moongpx.has_schema_errors(src) {
// 不合格,把问题列给用户看,不往下走
println(@moongpx.validate_gpx_report(src))
} else {
// 合格;warning 只是提醒,照常解析
match @moongpx.parse_gpx(src) { Ok(g) => use(g), Err(_) => () }
}parse_gpx_1_0(String) -> Result[Gpx, Array[Diagnostic]] // 解析 1.0 并升级为 1.1 模型
parse_gpx_any(String) -> Result[Gpx, Array[Diagnostic]] // 按 version 属性自动分派
detect_gpx_version(String) -> String? // 只读 version 属性,不校验| 差异 | GPX 1.0 | GPX 1.1 |
|---|---|---|
| 元数据位置 | name desc author email url urlname time keywords bounds 直接挂在根下 | 全部包在 <metadata> 里 |
| 链接 | <url> + <urlname> | <link href> + <link><text> |
| 作者 | <author> 与 <email> 都是纯文本 | personType(含 name / email / link) |
| 邮箱 | 完整地址 me@example.com | 拆成 id + domain 两个必填属性 |
| 朝向 / 速度 | <course> <speed> 是 trkpt 的正式子元素 | 没有(改放 extensions) |
| 命名空间 | .../GPX/1/0 | .../GPX/1/1 |
| 1.0 | → 1.1 |
|---|---|
| 根级 name desc time keywords bounds | metadata 对应字段 |
| <author>张三</author> | metadata.author.name |
| <email>me@example.com</email> | Email { id: "me", domain: "example.com" }(按第一个 @ 拆) |
| <url> + <urlname> | Link { href, text } |
| trkpt 的 <course> / <speed> | Waypoint.extensions 原文 |
haversine_m(Waypoint, Waypoint) -> Double // 半正矢大圆距离
path_distance_m(Array[Waypoint]) -> Double
track_distance_m(Track) -> Double
climb_m(Array[Waypoint], Double) -> (Double, Double) // (爬升, 下降)
bounds_of(Array[Waypoint]) -> Bounds?
track_stats(Track) -> TrackStatslet s = @moongpx.track_stats(track)
// s.distance_m / s.ascend_m / s.descend_m / s.min_ele / s.max_ele
// s.bounds / s.start_time / s.end_time / s.duration_sWaypoint::new(lat, lon).with_ele(Some(10.0)).with_time(Some("2020-01-01T00:00:00Z"))
TrackSegment::new(points)
Track::new(segments)
Bounds::new(min_lat, min_lon, max_lat, max_lon)
Gpx::new(creator)pub(all) enum Severity { Error; Warning }
pub struct Diagnostic {
severity : Severity
code : String // 稳定代码,如 "E1009"
message : String
line : Int // 1-based
column : Int // 1-based
}
format_diagnostics(Array[Diagnostic]) -> String
has_errors(Array[Diagnostic]) -> Bool| 代码 | 含义 |
|---|---|
| E1000 | XML 语法错误 |
| E1001 | 根元素不是 <gpx> |
| E1002 / E1003 | version 缺失 / 不是 "1.1" |
| E1004 | creator 缺失 |
| E1005 / E1006 | 根元素 / metadata 子元素顺序错误 |
| E1007 | metadata 重复出现 |
| E1009 | 缺少必填属性 |
| E1010 / E1011 | 非法 xsd:dateTime / xsd:decimal |
| E1013 / E1014 / E1015 | wpt / rte / trk 子元素顺序错误 |
| E1016 | fix 取值非法 |
| E1017 | version 不是 "1.0"(parse_gpx_1_0 专用) |
| 代码 | 含义 |
|---|---|
| W1001 | bounds 的 min 大于 max |
| W1002 | 同一 trkseg 内后一个点的时间早于前一个点 |
| W1003 | 相邻两个点的 lat / lon / ele 完全相同 |
| W1004 | fix 与 sat 自相矛盾(如 fix=none 却 sat=9) |
| W1005 | ele 超出地球表面合理范围(约 -11000 ~ 9000 m) |
| W1006 | trk / trkseg / rte 没有任何点 |
| W1007 | 点位落在 metadata/bounds 声明的范围之外 |
| 类型 | 区间 | 规范名 |
|---|---|---|
| Latitude | [-90, 90] 闭区间 | latitudeType |
| Longitude | [-180, 180) 半开 | longitudeType |
| Degrees | [0, 360) 半开 | degreesType |
| DgpsStationId | [0, 1023] 整数 | dgpsStationType |
Latitude::from_string(String) -> Result[Latitude, GeoError]
Latitude::from_double(Double) -> Result[Latitude, GeoError]
Latitude::to_double(Self) -> Double
Latitude::to_gpx_string(Self) -> Stringparse_decimal(String) -> Result[Double, GeoError] // 严格 xsd:decimal
format_decimal(Double) -> String // 保证不含指数记法pub(all) enum GeoError {
NotADecimal(String)
LatitudeOutOfRange(String)
LongitudeOutOfRange(String)
DegreesOutOfRange(String)
DgpsStationOutOfRange(String)
}
GeoError::message(Self) -> String(\+|-)? ( [0-9]+ (\.[0-9]*)? | \.[0-9]+ )1.0e-7 → "1e-7" ← 非法 xsd:decimal
1.0e21 → "1e+21" ← 非法 xsd:decimalError = 把这份文档丢进官方 XSD 校验器,它会判不合格。 Warning = 校验器会放行,但人看一眼就知道有问题。
| 现象 | 级别 | 为什么 |
|---|---|---|
| <wpt> 缺 lat 属性 | Error | schema 里 lat 是 use="required" |
| <fix>bogus</fix> | Error | fixType 是枚举,没有这个值 |
| <ele>99999</ele> | Warning | xsd:decimal 接受任意小数,schema 不管物理合理性 |
| <fix>none</fix> 配 <sat>9</sat> | Warning | 两个字段各自合法,放一起才荒谬 |
| 轨迹点时间倒流 | Warning | schema 里 time 只是可选字段,没有任何顺序约束 |
| <trk> 里没有 <trkseg> | Warning | trkseg 的 minOccurs 是 0 |
@xml 的 offset 是 UTF-16 码元偏移,不是 Unicode 码点偏移。
| 位置 | 必须转义 | 原因 |
|---|---|---|
| 文本内容 | & < > | 不转义直接产出非法 XML |
| 属性值 | & < > " ' | 同上,且引号会提前结束属性 |
| 文本内容 | \r → | XML 解析器把字面 CR 规范化成 LF,不转义等于静默改数据 |
| 属性值 | \t \n → 	 | 属性值规范化把字面 tab/换行折成空格 |
| extensions 内部 | 什么都不转义 | 那里已经是合法 XML,再转一次会变成 <foo/> 字面文本 |
| 依赖行为 | 对本库的影响 |
|---|---|
| 默认命名空间 xmlns="..." 被自动剥离 | 根元素名仍是 gpx,正常解析 |
| 命名空间前缀不剥离(<g:gpx> → 名字是 "g:gpx") | 已知限制,见上文 |
| 预定义实体 & < " 被解码 | 序列化侧必须转义,两侧配对 |
| 数字字符引用 被解码为 CR | 序列化侧必须把 CR 转义回 |
| CDATA 内容被取出为纯文本 | 一次序列化后变成转义文本(值相同、字节不同) |
| Double::to_string 对 1e-7 / 1e+21 输出指数 | 这是 format_decimal 存在的唯一理由 |
| Double::to_string 对整数值省略小数点(90.0 → "90") | 往返比较的是模型,不受影响 |
moon check # 类型检查
moon test # 单元测试
moon fmt # 格式化
moon run cmd/main # 可运行示例mkdir -p .github/workflows && cp ci/ci.yml .github/workflows/ci.ymlpub struct Waypoint {
lat : Latitude
lon : Longitude
ele : Double?
time : String?
magvar : Degrees?
geoid_height : Double?
name : String?
cmt : String?
desc : String?
src : String?
links : Array[Link]
sym : String?
wpt_type : String?
fix : FixType?
sat : Int?
hdop : Double?
vdop : Double?
pdop : Double?
age_of_dgps_data : Double?
dgps_id : DgpsStationId?
extensions : String?
} derive(Eq, Debug)let code_elevation_implausible : Stringlet code_fix_sat_conflict : Stringlet code_version_not_1_0 : Stringfn datetime_compare(a : String, b : String) -> Int?fn datetime_to_epoch(text : String) -> Double?let default_climb_threshold_m : Doublefn detect_gpx_version(src : String) -> String?let earth_radius_m : Doublefn format_decimal(v : Double) -> Stringlet gpx_namespace : Stringfn is_valid_datetime(text : String) -> Bool-? YYYY(YYYY*) '-' MM '-' DD 'T' hh ':' mm ':' ss ('.' s+)? ( 'Z' | ('+'|'-') hh ':' mm )?fn is_valid_gpx(src : String) -> Boolfn validate_gpx_report(src : String) -> StringInstall
Download zipA spec-compliant GPX (GPS Exchange Format) library for MoonBit: parse, validate, serialize and migrate GPX 1.0 / 1.1 with line-accurate diagnostics.