gpx_doctor 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/gpx_doctor/errors.rb +6 -0
- data/lib/gpx_doctor/geojson_builder.rb +136 -0
- data/lib/gpx_doctor/parser.rb +2 -1
- data/lib/gpx_doctor/validator.rb +63 -0
- data/lib/gpx_doctor/version.rb +1 -1
- data/lib/gpx_doctor.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 367e09fc58af88bd756703fd5e2647632fd9991f753f3581e3bcb439f7733ce3
|
|
4
|
+
data.tar.gz: 3a7922964bb774be1eb1a1d2af78aee4a7d56b868b7652c481fb186cc6438bce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9bad3d27880e55e001fea734b7d481f19569f0e0d202958d4ae893a1722eef5b0fb7a5431030f970872358c812e5a83a492f99c945096652e9547f0945238624
|
|
7
|
+
data.tar.gz: 0a4a032459ed9916bc8cd99c64d38676822efbed6e1a470d86a9806878f3ee65f5239827f1830eeb6dbdac573f845a64bcea70b7f24f5b686bc8ef440fb29325
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module GpxDoctor
|
|
6
|
+
# Builds GeoJSON output from parsed GPX data according to RFC 7946.
|
|
7
|
+
#
|
|
8
|
+
# RFC 7946 compliance:
|
|
9
|
+
# - Coordinate order: [longitude, latitude, elevation]
|
|
10
|
+
# - LineString geometries require minimum 2 positions
|
|
11
|
+
# - MultiLineString segments require minimum 2 positions each
|
|
12
|
+
# - Empty or invalid geometries are filtered out
|
|
13
|
+
# - Properties member is null when no properties exist
|
|
14
|
+
class GeoJsonBuilder
|
|
15
|
+
class << self
|
|
16
|
+
def build(result)
|
|
17
|
+
new(result).build
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_file(result, file_path)
|
|
21
|
+
json = build(result)
|
|
22
|
+
File.write(file_path, json)
|
|
23
|
+
json
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(result)
|
|
28
|
+
@result = result
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build
|
|
32
|
+
features = []
|
|
33
|
+
features.concat(waypoint_features)
|
|
34
|
+
features.concat(route_features)
|
|
35
|
+
features.concat(track_features)
|
|
36
|
+
|
|
37
|
+
JSON.generate(
|
|
38
|
+
type: 'FeatureCollection',
|
|
39
|
+
features: features
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def waypoint_features
|
|
46
|
+
@result.waypoints.map { |wpt| point_feature(wpt) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def route_features
|
|
50
|
+
@result.routes.map do |route|
|
|
51
|
+
coords = route.points.map { |pt| coordinate(pt) }
|
|
52
|
+
# RFC 7946: LineString must have 2 or more positions
|
|
53
|
+
next if coords.length < 2
|
|
54
|
+
|
|
55
|
+
props = route_properties(route)
|
|
56
|
+
{
|
|
57
|
+
type: 'Feature',
|
|
58
|
+
properties: props.empty? ? nil : props,
|
|
59
|
+
geometry: {
|
|
60
|
+
type: 'LineString',
|
|
61
|
+
coordinates: coords
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def track_features
|
|
68
|
+
@result.tracks.map do |track|
|
|
69
|
+
# RFC 7946: Each LineString in MultiLineString must have 2+ positions
|
|
70
|
+
coords = track.segments.map do |seg|
|
|
71
|
+
seg_coords = seg.points.map { |pt| coordinate(pt) }
|
|
72
|
+
seg_coords if seg_coords.length >= 2
|
|
73
|
+
end.compact
|
|
74
|
+
|
|
75
|
+
# Skip tracks with no valid segments
|
|
76
|
+
next if coords.empty?
|
|
77
|
+
|
|
78
|
+
props = track_properties(track)
|
|
79
|
+
{
|
|
80
|
+
type: 'Feature',
|
|
81
|
+
properties: props.empty? ? nil : props,
|
|
82
|
+
geometry: {
|
|
83
|
+
type: 'MultiLineString',
|
|
84
|
+
coordinates: coords
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end.compact
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def point_feature(wpt)
|
|
91
|
+
props = waypoint_properties(wpt)
|
|
92
|
+
{
|
|
93
|
+
type: 'Feature',
|
|
94
|
+
properties: props.empty? ? nil : props,
|
|
95
|
+
geometry: {
|
|
96
|
+
type: 'Point',
|
|
97
|
+
coordinates: coordinate(wpt)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def coordinate(point)
|
|
103
|
+
coord = [point.lon, point.lat]
|
|
104
|
+
coord << point.ele if point.ele
|
|
105
|
+
coord
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def waypoint_properties(wpt)
|
|
109
|
+
props = {}
|
|
110
|
+
props[:name] = wpt.name if wpt.name
|
|
111
|
+
props[:desc] = wpt.desc if wpt.desc
|
|
112
|
+
props[:sym] = wpt.sym if wpt.sym
|
|
113
|
+
props[:type] = wpt.type if wpt.type
|
|
114
|
+
props[:time] = wpt.time.iso8601 if wpt.time
|
|
115
|
+
props
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def route_properties(route)
|
|
119
|
+
props = {}
|
|
120
|
+
props[:name] = route.name if route.name
|
|
121
|
+
props[:desc] = route.desc if route.desc
|
|
122
|
+
props[:type] = route.type if route.type
|
|
123
|
+
props[:number] = route.number if route.number
|
|
124
|
+
props
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def track_properties(track)
|
|
128
|
+
props = {}
|
|
129
|
+
props[:name] = track.name if track.name
|
|
130
|
+
props[:desc] = track.desc if track.desc
|
|
131
|
+
props[:type] = track.type if track.type
|
|
132
|
+
props[:number] = track.number if track.number
|
|
133
|
+
props
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/gpx_doctor/parser.rb
CHANGED
|
@@ -20,7 +20,8 @@ module GpxDoctor
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def parse_string(xml_string, params: {})
|
|
23
|
-
|
|
23
|
+
Validator.validate!(xml_string)
|
|
24
|
+
doc = Nokogiri::XML(xml_string) { |config| config.nonet }
|
|
24
25
|
ns = detect_namespace(doc)
|
|
25
26
|
|
|
26
27
|
new(doc, ns, params: params).parse
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module GpxDoctor
|
|
6
|
+
class Validator
|
|
7
|
+
ALLOWED_VERSIONS = %w[1.0 1.1].freeze
|
|
8
|
+
|
|
9
|
+
# Matches a DOCTYPE declaration anywhere in the string (case-insensitive).
|
|
10
|
+
# DOCTYPE is rejected entirely because it can carry external-entity
|
|
11
|
+
# references (XXE) or internal entity bombs (billion-laughs).
|
|
12
|
+
DOCTYPE_PATTERN = /<!DOCTYPE/i.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def validate!(xml_string)
|
|
16
|
+
new(xml_string).validate!
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(xml_string)
|
|
21
|
+
@xml_string = xml_string
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Raises GpxDoctor::InvalidGpxError if the input is not a valid, safe GPX document.
|
|
25
|
+
def validate!
|
|
26
|
+
reject_doctype!
|
|
27
|
+
doc = parse_xml!
|
|
28
|
+
validate_root!(doc)
|
|
29
|
+
validate_version!(doc)
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def reject_doctype!
|
|
36
|
+
return unless @xml_string.match?(DOCTYPE_PATTERN)
|
|
37
|
+
|
|
38
|
+
raise InvalidGpxError, 'DOCTYPE declarations are not allowed'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_xml!
|
|
42
|
+
doc = Nokogiri::XML(@xml_string) { |config| config.nonet }
|
|
43
|
+
return doc if doc.errors.empty?
|
|
44
|
+
|
|
45
|
+
raise InvalidGpxError, "Invalid XML: #{doc.errors.map(&:message).join('; ')}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_root!(doc)
|
|
49
|
+
root = doc.root
|
|
50
|
+
return if root&.name == 'gpx'
|
|
51
|
+
|
|
52
|
+
raise InvalidGpxError, 'Root element must be <gpx>'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validate_version!(doc)
|
|
56
|
+
version = doc.root['version']
|
|
57
|
+
return if ALLOWED_VERSIONS.include?(version)
|
|
58
|
+
|
|
59
|
+
raise InvalidGpxError,
|
|
60
|
+
"Unsupported GPX version: #{version.inspect}. Supported versions: #{ALLOWED_VERSIONS.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
data/lib/gpx_doctor/version.rb
CHANGED
data/lib/gpx_doctor.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'gpx_doctor/version'
|
|
4
|
+
require 'gpx_doctor/errors'
|
|
4
5
|
require 'gpx_doctor/configuration'
|
|
6
|
+
require 'gpx_doctor/validator'
|
|
5
7
|
require 'gpx_doctor/models/email'
|
|
6
8
|
require 'gpx_doctor/models/link'
|
|
7
9
|
require 'gpx_doctor/models/copyright'
|
|
@@ -19,6 +21,7 @@ require 'gpx_doctor/segment_splitter'
|
|
|
19
21
|
require 'gpx_doctor/point_selector'
|
|
20
22
|
require 'gpx_doctor/parser'
|
|
21
23
|
require 'gpx_doctor/builder'
|
|
24
|
+
require 'gpx_doctor/geojson_builder'
|
|
22
25
|
|
|
23
26
|
module GpxDoctor
|
|
24
27
|
class << self
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gpx_doctor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Poltrax
|
|
@@ -49,6 +49,8 @@ files:
|
|
|
49
49
|
- lib/gpx_doctor/configuration.rb
|
|
50
50
|
- lib/gpx_doctor/distance_calculator.rb
|
|
51
51
|
- lib/gpx_doctor/elevation_client.rb
|
|
52
|
+
- lib/gpx_doctor/errors.rb
|
|
53
|
+
- lib/gpx_doctor/geojson_builder.rb
|
|
52
54
|
- lib/gpx_doctor/models/bounds.rb
|
|
53
55
|
- lib/gpx_doctor/models/copyright.rb
|
|
54
56
|
- lib/gpx_doctor/models/email.rb
|
|
@@ -63,6 +65,7 @@ files:
|
|
|
63
65
|
- lib/gpx_doctor/point_selector.rb
|
|
64
66
|
- lib/gpx_doctor/segment_splitter.rb
|
|
65
67
|
- lib/gpx_doctor/statistics_enhancer.rb
|
|
68
|
+
- lib/gpx_doctor/validator.rb
|
|
66
69
|
- lib/gpx_doctor/version.rb
|
|
67
70
|
homepage: https://github.com/Poltrax-live/gpx-doctor
|
|
68
71
|
licenses:
|