kml-path-parser 1.0.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/LICENSE +21 -0
  4. data/README.md +106 -0
  5. data/lib/kml/path/fixtures/reject/empty_archive.kmz +0 -0
  6. data/lib/kml/path/fixtures/reject/empty_document.kml +4 -0
  7. data/lib/kml/path/fixtures/reject/empty_linestring.kml +11 -0
  8. data/lib/kml/path/fixtures/reject/point_only.kml +11 -0
  9. data/lib/kml/path/fixtures/reject/polygon_only.kml +20 -0
  10. data/lib/kml/path/fixtures/reject/single_point.kml +11 -0
  11. data/lib/kml/path/fixtures/valid/cdata_name.kml +14 -0
  12. data/lib/kml/path/fixtures/valid/coordinates_extra_whitespace.kml +16 -0
  13. data/lib/kml/path/fixtures/valid/coordinates_no_altitude.kml +15 -0
  14. data/lib/kml/path/fixtures/valid/coordinates_single_line.kml +11 -0
  15. data/lib/kml/path/fixtures/valid/crlf_line_endings.kml +15 -0
  16. data/lib/kml/path/fixtures/valid/document_name_priority.kml +15 -0
  17. data/lib/kml/path/fixtures/valid/gx_multitrack_first_wins.kml +18 -0
  18. data/lib/kml/path/fixtures/valid/gx_track.kml +13 -0
  19. data/lib/kml/path/fixtures/valid/gx_track_with_timestamps.kml +16 -0
  20. data/lib/kml/path/fixtures/valid/invalid_coords_filtered.kml +16 -0
  21. data/lib/kml/path/fixtures/valid/kmz_custom_kml_name.kmz +0 -0
  22. data/lib/kml/path/fixtures/valid/kmz_with_macosx_junk.kmz +0 -0
  23. data/lib/kml/path/fixtures/valid/linestring_attributes.kml +18 -0
  24. data/lib/kml/path/fixtures/valid/linestring_before_point.kml +17 -0
  25. data/lib/kml/path/fixtures/valid/many_points.kml +22 -0
  26. data/lib/kml/path/fixtures/valid/multi_geometry.kml +22 -0
  27. data/lib/kml/path/fixtures/valid/multiple_placemarks_first_wins.kml +24 -0
  28. data/lib/kml/path/fixtures/valid/namespace_less.kml +15 -0
  29. data/lib/kml/path/fixtures/valid/nested_folder.kml +19 -0
  30. data/lib/kml/path/fixtures/valid/nested_multigeometry_folder.kml +19 -0
  31. data/lib/kml/path/fixtures/valid/placemark_name.kml +15 -0
  32. data/lib/kml/path/fixtures/valid/qgis_export.kml +22 -0
  33. data/lib/kml/path/fixtures/valid/sample_route.kml +15 -0
  34. data/lib/kml/path/fixtures/valid/sample_route.kmz +0 -0
  35. data/lib/kml/path/fixtures/valid/two_points.kml +14 -0
  36. data/lib/kml/path/fixtures/valid/unicode_name.kml +14 -0
  37. data/lib/kml/path/fixtures/valid/unnamed.kml +13 -0
  38. data/lib/kml/path/fixtures.rb +63 -0
  39. data/lib/kml/path/parse_error.rb +8 -0
  40. data/lib/kml/path/parser.rb +197 -0
  41. data/lib/kml/path/result.rb +24 -0
  42. data/lib/kml/path/version.rb +7 -0
  43. data/lib/kml.rb +12 -0
  44. metadata +115 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1ba7b9a23862285aed743abf581d2c6d93d72558ebb7679730560f3626f3051d
4
+ data.tar.gz: 82e88798ec57e4644616fb9718f7dcebd8fdeca773a81c2b2a567d8d479bba3a
5
+ SHA512:
6
+ metadata.gz: 85669be59563792510ac0ea60bfdb5bf81650ec7226503b9dccebb88b8e9c013fda3e19a926862076d444f7c7c94b647b1315ceff0fc7f4b97ce7da3ba2c2ee1
7
+ data.tar.gz: 41b78088cac7f4c57e75492ec631954e403883a4e898312b0aa0bc14706a46085de810a02caaf1c0a415c180c05bb1c4fcf18745968c80f981ea2b1c5ba9f47b
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # 1.0.0
2
+
3
+ Initial release.
4
+
5
+ - Parse `LineString` and `gx:Track` geometry from KML and KMZ uploads
6
+ - `#parse` returns a `Kml::Path::Result`; `#parse!` raises `Kml::Path::ParseError` on failure
7
+ - Shipped fixture files and `Kml::Path::Fixtures` helper for consumer tests
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh McArthur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # kml-path-parser
2
+
3
+ Extract path geometry from user-uploaded KML and KMZ files.
4
+
5
+ `kml-path-parser` is a focused library for fitness and mapping apps that need to import route tracks from real-world exports (Google Earth, My Maps, QGIS, and similar tools). It is **not** a general-purpose KML codec: it only extracts `LineString` and `gx:Track` paths.
6
+
7
+ Extracted from [VirtualTrails](https://virtualtrails.app).
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ gem "kml-path-parser", "~> 1.0"
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ The parser expects a file-like upload object responding to `read`, `rewind`, `original_filename`, and `content_type` (as Rails `ActionDispatch::Http::UploadedFile` and `Rack::Test::UploadedFile` do).
18
+
19
+ ### `#parse` — non-exceptional failures
20
+
21
+ ```ruby
22
+ require "kml"
23
+
24
+ parser = Kml::Path::Parser.new(file: upload)
25
+ result = parser.parse
26
+
27
+ if result.success?
28
+ puts result.name
29
+ result.coordinates.each do |longitude, latitude, altitude|
30
+ puts [longitude, latitude, altitude]
31
+ end
32
+ else
33
+ puts result.error
34
+ end
35
+ ```
36
+
37
+ ### `#parse!` — raises on failure
38
+
39
+ ```ruby
40
+ result = parser.parse!
41
+ # => Kml::Path::Result
42
+
43
+ # On invalid input:
44
+ # Kml::Path::ParseError: must contain a LineString or gx:Track
45
+ ```
46
+
47
+ ### Lazy accessors
48
+
49
+ `#content` returns the extracted KML string (from a `.kml` file or from inside a `.kmz` archive). `#name` resolves the route name without requiring a successful coordinate parse.
50
+
51
+ ## Shipped fixtures
52
+
53
+ The gem includes real-world KML/KMZ fixtures for testing and documentation:
54
+
55
+ ```ruby
56
+ fixture_path = Kml::Path::Fixtures.path("valid/sample_route.kml")
57
+ catalog = Kml::Path::Fixtures::CATALOG
58
+ ```
59
+
60
+ Fixtures live under `lib/kml/path/fixtures/` (`valid/` and `reject/`).
61
+
62
+ ## Supported formats
63
+
64
+ | Format | Support |
65
+ |--------|---------|
66
+ | KML (`.kml`) | Yes |
67
+ | KMZ (`.kmz`) | Yes — uses `doc.kml` when present, otherwise the first `.kml` entry (skips `__MACOSX/`) |
68
+
69
+ ## Parsing policies
70
+
71
+ - **Geometry:** first `LineString` in the document wins; otherwise first `gx:Track` (first track in `gx:MultiTrack`)
72
+ - **Name priority:** Document name → Placemark name → any `name` element → upload filename → `"Untitled"`
73
+ - **Coordinates:** returned as `[longitude, latitude, altitude]` arrays; altitude may be `nil`
74
+ - **Invalid coordinates:** lat/lon pairs outside valid ranges are filtered silently
75
+
76
+ ## Rejected inputs
77
+
78
+ These return a failed `Result` (or raise `ParseError` via `#parse!`):
79
+
80
+ - Empty documents
81
+ - Point-only geometry
82
+ - Polygon-only geometry
83
+ - Empty `LineString` elements
84
+ - KMZ archives with no KML file
85
+ - Corrupt KMZ archives (`could not be parsed: …`)
86
+
87
+ A single-point `LineString` parses successfully; callers that need a minimum path length should enforce that themselves.
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ bundle install
93
+ bundle exec rake test
94
+ bundle exec rubocop
95
+ ```
96
+
97
+ ## Releasing
98
+
99
+ 1. Bump `Kml::Path::VERSION` in `lib/kml/path/version.rb`
100
+ 2. Update `CHANGELOG.md`
101
+ 3. Commit, tag (`v1.0.1`), and push the tag
102
+ 4. GitHub Actions publishes to RubyGems when `RUBYGEMS_API_KEY` is configured
103
+
104
+ ## License
105
+
106
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document></Document>
4
+ </kml>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Empty LineString</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates></coordinates>
8
+ </LineString>
9
+ </Placemark>
10
+ </Document>
11
+ </kml>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Point Only</name>
5
+ <Placemark>
6
+ <Point>
7
+ <coordinates>144.9631,-37.8136,0</coordinates>
8
+ </Point>
9
+ </Placemark>
10
+ </Document>
11
+ </kml>
@@ -0,0 +1,20 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Polygon Only</name>
5
+ <Placemark>
6
+ <Polygon>
7
+ <outerBoundaryIs>
8
+ <LinearRing>
9
+ <coordinates>
10
+ 144.9631,-37.8136,0
11
+ 144.9731,-37.8236,0
12
+ 144.9831,-37.8336,0
13
+ 144.9631,-37.8136,0
14
+ </coordinates>
15
+ </LinearRing>
16
+ </outerBoundaryIs>
17
+ </Polygon>
18
+ </Placemark>
19
+ </Document>
20
+ </kml>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>One Point</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>144.9631,-37.8136,0</coordinates>
8
+ </LineString>
9
+ </Placemark>
10
+ </Document>
11
+ </kml>
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name><![CDATA[Trail & "Path" <special>]]></name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ </coordinates>
11
+ </LineString>
12
+ </Placemark>
13
+ </Document>
14
+ </kml>
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Whitespace</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631, -37.8136 , 0
9
+ 144.9731,-37.8236,0
10
+
11
+ 144.9831,-37.8336,0
12
+ </coordinates>
13
+ </LineString>
14
+ </Placemark>
15
+ </Document>
16
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>No Altitude</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136
9
+ 144.9731,-37.8236
10
+ 144.9831,-37.8336
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Single Line</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>144.9631,-37.8136,0 144.9731,-37.8236,0 144.9831,-37.8336,0</coordinates>
8
+ </LineString>
9
+ </Placemark>
10
+ </Document>
11
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Sample Trail</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ 144.9831,-37.8336,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Official Name</name>
5
+ <Placemark>
6
+ <name>Ignored Placemark Name</name>
7
+ <LineString>
8
+ <coordinates>
9
+ 144.9631,-37.8136,0
10
+ 144.9731,-37.8236,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,18 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
3
+ <Document>
4
+ <name>Multi Track</name>
5
+ <Placemark>
6
+ <gx:MultiTrack>
7
+ <gx:Track>
8
+ <gx:coord>144.9631 -37.8136 0</gx:coord>
9
+ <gx:coord>144.9731 -37.8236 0</gx:coord>
10
+ </gx:Track>
11
+ <gx:Track>
12
+ <gx:coord>145.0000 -38.0000 0</gx:coord>
13
+ <gx:coord>145.0100 -38.0100 0</gx:coord>
14
+ </gx:Track>
15
+ </gx:MultiTrack>
16
+ </Placemark>
17
+ </Document>
18
+ </kml>
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
3
+ <Document>
4
+ <name>GPS Track</name>
5
+ <Placemark>
6
+ <gx:Track>
7
+ <gx:coord>144.9631 -37.8136 0</gx:coord>
8
+ <gx:coord>144.9731 -37.8236 0</gx:coord>
9
+ <gx:coord>144.9831 -37.8336 0</gx:coord>
10
+ </gx:Track>
11
+ </Placemark>
12
+ </Document>
13
+ </kml>
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
3
+ <Document>
4
+ <name>Timestamped Track</name>
5
+ <Placemark>
6
+ <gx:Track>
7
+ <when>2024-01-01T00:00:00Z</when>
8
+ <when>2024-01-01T00:05:00Z</when>
9
+ <when>2024-01-01T00:10:00Z</when>
10
+ <gx:coord>144.9631 -37.8136 0</gx:coord>
11
+ <gx:coord>144.9731 -37.8236 0</gx:coord>
12
+ <gx:coord>144.9831 -37.8336 0</gx:coord>
13
+ </gx:Track>
14
+ </Placemark>
15
+ </Document>
16
+ </kml>
@@ -0,0 +1,16 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Filtered Coords</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 200.0000,100.0000,0
10
+ 144.9731,-37.8236,0
11
+ 144.9831,-37.8336,0
12
+ </coordinates>
13
+ </LineString>
14
+ </Placemark>
15
+ </Document>
16
+ </kml>
@@ -0,0 +1,18 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Attributed Line</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <extrude>1</extrude>
8
+ <tessellate>1</tessellate>
9
+ <altitudeMode>clampToGround</altitudeMode>
10
+ <coordinates>
11
+ 144.9631,-37.8136,0
12
+ 144.9731,-37.8236,0
13
+ 144.9831,-37.8336,0
14
+ </coordinates>
15
+ </LineString>
16
+ </Placemark>
17
+ </Document>
18
+ </kml>
@@ -0,0 +1,17 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Line Before Point</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ </coordinates>
11
+ </LineString>
12
+ <Point>
13
+ <coordinates>145.0000,-38.0000,0</coordinates>
14
+ </Point>
15
+ </Placemark>
16
+ </Document>
17
+ </kml>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Many Points</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9600,-37.8100,0
9
+ 144.9610,-37.8110,0
10
+ 144.9620,-37.8120,0
11
+ 144.9630,-37.8130,0
12
+ 144.9640,-37.8140,0
13
+ 144.9650,-37.8150,0
14
+ 144.9660,-37.8160,0
15
+ 144.9670,-37.8170,0
16
+ 144.9680,-37.8180,0
17
+ 144.9690,-37.8190,0
18
+ </coordinates>
19
+ </LineString>
20
+ </Placemark>
21
+ </Document>
22
+ </kml>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Split Route</name>
5
+ <Placemark>
6
+ <MultiGeometry>
7
+ <LineString>
8
+ <coordinates>
9
+ 144.9631,-37.8136,0
10
+ 144.9731,-37.8236,0
11
+ </coordinates>
12
+ </LineString>
13
+ <LineString>
14
+ <coordinates>
15
+ 145.0000,-38.0000,0
16
+ 145.0100,-38.0100,0
17
+ </coordinates>
18
+ </LineString>
19
+ </MultiGeometry>
20
+ </Placemark>
21
+ </Document>
22
+ </kml>
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Multiple Placemarks</name>
5
+ <Placemark>
6
+ <name>First Trail</name>
7
+ <LineString>
8
+ <coordinates>
9
+ 144.9631,-37.8136,0
10
+ 144.9731,-37.8236,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ <Placemark>
15
+ <name>Second Trail</name>
16
+ <LineString>
17
+ <coordinates>
18
+ 145.0000,-38.0000,0
19
+ 145.0100,-38.0100,0
20
+ </coordinates>
21
+ </LineString>
22
+ </Placemark>
23
+ </Document>
24
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml>
3
+ <Document>
4
+ <name>Legacy Export</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ 144.9831,-37.8336,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Nested Folder</name>
5
+ <Folder>
6
+ <name>Trails</name>
7
+ <Placemark>
8
+ <name>Inner Trail</name>
9
+ <LineString>
10
+ <coordinates>
11
+ 144.9631,-37.8136,0
12
+ 144.9731,-37.8236,0
13
+ 144.9831,-37.8336,0
14
+ </coordinates>
15
+ </LineString>
16
+ </Placemark>
17
+ </Folder>
18
+ </Document>
19
+ </kml>
@@ -0,0 +1,19 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Nested MultiGeometry</name>
5
+ <Folder>
6
+ <Placemark>
7
+ <MultiGeometry>
8
+ <LineString>
9
+ <coordinates>
10
+ 144.9631,-37.8136,0
11
+ 144.9731,-37.8236,0
12
+ 144.9831,-37.8336,0
13
+ </coordinates>
14
+ </LineString>
15
+ </MultiGeometry>
16
+ </Placemark>
17
+ </Folder>
18
+ </Document>
19
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <Placemark>
5
+ <name>Coastal Walk</name>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ 144.9831,-37.8336,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>QGIS Export</name>
5
+ <description>Exported from QGIS</description>
6
+ <ExtendedData>
7
+ <Data name="layer">
8
+ <value>trails</value>
9
+ </Data>
10
+ </ExtendedData>
11
+ <Placemark>
12
+ <name>trail_1</name>
13
+ <LineString>
14
+ <coordinates>
15
+ 144.9631,-37.8136,0
16
+ 144.9731,-37.8236,0
17
+ 144.9831,-37.8336,0
18
+ </coordinates>
19
+ </LineString>
20
+ </Placemark>
21
+ </Document>
22
+ </kml>
@@ -0,0 +1,15 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Sample Trail</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ 144.9831,-37.8336,0
11
+ </coordinates>
12
+ </LineString>
13
+ </Placemark>
14
+ </Document>
15
+ </kml>
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Minimum Path</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ </coordinates>
11
+ </LineString>
12
+ </Placemark>
13
+ </Document>
14
+ </kml>
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <name>Øresundsstien 日本語</name>
5
+ <Placemark>
6
+ <LineString>
7
+ <coordinates>
8
+ 144.9631,-37.8136,0
9
+ 144.9731,-37.8236,0
10
+ </coordinates>
11
+ </LineString>
12
+ </Placemark>
13
+ </Document>
14
+ </kml>
@@ -0,0 +1,13 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <kml xmlns="http://www.opengis.net/kml/2.2">
3
+ <Document>
4
+ <Placemark>
5
+ <LineString>
6
+ <coordinates>
7
+ 144.9631,-37.8136,0
8
+ 144.9731,-37.8236,0
9
+ </coordinates>
10
+ </LineString>
11
+ </Placemark>
12
+ </Document>
13
+ </kml>
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kml
4
+ module Path
5
+ module Fixtures
6
+ ROOT = File.expand_path("fixtures", __dir__).freeze
7
+
8
+ def self.path(relative)
9
+ File.join(ROOT, relative)
10
+ end
11
+
12
+ def self.read(relative)
13
+ File.read(path(relative))
14
+ end
15
+
16
+ CATALOG = {
17
+ valid: {
18
+ "valid/sample_route.kml" => { name: "Sample Trail", point_count: 3 },
19
+ "valid/sample_route.kmz" => { name: "Sample Trail", point_count: 3 },
20
+ "valid/placemark_name.kml" => { name: "Coastal Walk", point_count: 3 },
21
+ "valid/gx_track.kml" => { name: "GPS Track", point_count: 3 },
22
+ "valid/multi_geometry.kml" => { name: "Split Route", point_count: 2, first_point: [144.9631, -37.8136] },
23
+ "valid/namespace_less.kml" => { name: "Legacy Export", point_count: 3 },
24
+ "valid/document_name_priority.kml" => { name: "Official Name", point_count: 2 },
25
+ "valid/two_points.kml" => { name: "Minimum Path", point_count: 2 },
26
+ "valid/coordinates_no_altitude.kml" => { name: "No Altitude", point_count: 3 },
27
+ "valid/coordinates_single_line.kml" => { name: "Single Line", point_count: 3 },
28
+ "valid/coordinates_extra_whitespace.kml" => { name: "Whitespace", point_count: 3 },
29
+ "valid/nested_folder.kml" => { name: "Nested Folder", point_count: 3 },
30
+ "valid/multiple_placemarks_first_wins.kml" => {
31
+ name: "Multiple Placemarks",
32
+ point_count: 2,
33
+ first_point: [144.9631, -37.8136]
34
+ },
35
+ "valid/unicode_name.kml" => { name: "Øresundsstien 日本語", point_count: 2 },
36
+ "valid/cdata_name.kml" => { name: "Trail & \"Path\" <special>", point_count: 2 },
37
+ "valid/crlf_line_endings.kml" => { name: "Sample Trail", point_count: 3 },
38
+ "valid/gx_track_with_timestamps.kml" => { name: "Timestamped Track", point_count: 3 },
39
+ "valid/gx_multitrack_first_wins.kml" => {
40
+ name: "Multi Track",
41
+ point_count: 2,
42
+ first_point: [144.9631, -37.8136]
43
+ },
44
+ "valid/linestring_attributes.kml" => { name: "Attributed Line", point_count: 3 },
45
+ "valid/qgis_export.kml" => { name: "QGIS Export", point_count: 3 },
46
+ "valid/invalid_coords_filtered.kml" => { name: "Filtered Coords", point_count: 3 },
47
+ "valid/nested_multigeometry_folder.kml" => { name: "Nested MultiGeometry", point_count: 3 },
48
+ "valid/many_points.kml" => { name: "Many Points", point_count: 10 },
49
+ "valid/linestring_before_point.kml" => { name: "Line Before Point", point_count: 2 },
50
+ "valid/kmz_custom_kml_name.kmz" => { name: "Minimum Path", point_count: 2 },
51
+ "valid/kmz_with_macosx_junk.kmz" => { name: "Sample Trail", point_count: 3 }
52
+ }.freeze,
53
+ reject: {
54
+ "reject/empty_document.kml" => "must contain a LineString or gx:Track",
55
+ "reject/point_only.kml" => "must contain a LineString or gx:Track",
56
+ "reject/polygon_only.kml" => "must contain a LineString or gx:Track",
57
+ "reject/empty_linestring.kml" => "must contain a LineString or gx:Track",
58
+ "reject/empty_archive.kmz" => "must contain a KML file"
59
+ }.freeze
60
+ }.freeze
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kml
4
+ module Path
5
+ class ParseError < StandardError
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kml
4
+ module Path
5
+ class Parser
6
+ KMZ_CONTENT_TYPES = %w[application/vnd.google-earth.kmz application/zip].freeze
7
+ KML_NAMESPACES = {
8
+ "kml" => "http://www.opengis.net/kml/2.2",
9
+ "gx" => "http://www.google.com/kml/ext/2.2"
10
+ }.freeze
11
+ NAME_XPATHS = [
12
+ ["//kml:Document/kml:name", KML_NAMESPACES],
13
+ ["//kml:Placemark/kml:name", KML_NAMESPACES],
14
+ ["//kml:name", KML_NAMESPACES],
15
+ ["//Document/name", nil],
16
+ ["//Placemark/name", nil]
17
+ ].freeze
18
+ MISSING_GEOMETRY = "must contain a LineString or gx:Track"
19
+ MISSING_KML = "must contain a KML file"
20
+
21
+ def initialize(file:)
22
+ @file = file
23
+ end
24
+
25
+ def parse
26
+ @parse ||= build_parse_result
27
+ end
28
+
29
+ def parse!
30
+ result = parse
31
+ raise ParseError, result.error if result.failure?
32
+
33
+ result
34
+ end
35
+
36
+ def name
37
+ parse.success? ? parse.name : extract_name
38
+ end
39
+
40
+ def content
41
+ parse
42
+ @content
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :file
48
+
49
+ def build_parse_result
50
+ kml_content = extract_kml_content
51
+ return failure(MISSING_KML) if kml_content.nil? || kml_content.empty?
52
+
53
+ @content = kml_content
54
+ coordinates = extract_coordinates
55
+ return failure(MISSING_GEOMETRY) if coordinates.nil? || coordinates.empty?
56
+
57
+ Result.new(success: true, name: extract_name, coordinates:, error: nil)
58
+ rescue Zip::Error => e
59
+ failure("could not be parsed: #{e.message}")
60
+ end
61
+
62
+ def failure(message)
63
+ Result.new(success: false, name: nil, coordinates: nil, error: message)
64
+ end
65
+
66
+ def extract_kml_content
67
+ file_content = read_file
68
+ return file_content unless kmz_file?
69
+
70
+ extract_kml_from_kmz(file_content)
71
+ end
72
+
73
+ def read_file
74
+ @read_file ||= begin
75
+ file.rewind if file.respond_to?(:rewind)
76
+ file.read
77
+ end
78
+ end
79
+
80
+ def kmz_file?
81
+ filename = file.original_filename.to_s.downcase
82
+ return true if filename.end_with?(".kmz")
83
+
84
+ KMZ_CONTENT_TYPES.include?(file.content_type.to_s)
85
+ end
86
+
87
+ def extract_kml_from_kmz(content)
88
+ kml = nil
89
+
90
+ Zip::File.open_buffer(content) do |archive|
91
+ entry_name = archive.find_entry("doc.kml")&.name || find_kml_entry_name(archive)
92
+ kml = archive.get_input_stream(entry_name, &:read) if entry_name
93
+ end
94
+
95
+ kml
96
+ end
97
+
98
+ def find_kml_entry_name(archive)
99
+ archive.entries.map(&:name).reject do |name|
100
+ name.start_with?("__MACOSX/") || !name.downcase.end_with?(".kml")
101
+ end.first
102
+ end
103
+
104
+ def extract_name
105
+ NAME_XPATHS.each do |xpath, namespaces|
106
+ document_name = xpath_value(xpath, namespaces)
107
+ return document_name if document_name
108
+ end
109
+
110
+ present_string(File.basename(file.original_filename.to_s, ".*")) || "Untitled"
111
+ end
112
+
113
+ def extract_coordinates
114
+ coordinates_node = line_string_coordinates_node || gx_track_coordinate_node
115
+ return unless coordinates_node
116
+
117
+ coordinates_from_node(coordinates_node)
118
+ end
119
+
120
+ def document
121
+ @document ||= Nokogiri::XML(@content)
122
+ end
123
+
124
+ def coordinates_from_node(node)
125
+ case node.name
126
+ when "coordinates"
127
+ parse_coordinates_text(node.text)
128
+ when "coord"
129
+ parse_gx_coords(gx_track_coordinate_nodes)
130
+ end
131
+ end
132
+
133
+ def xpath_value(xpath, namespaces)
134
+ node = namespaces ? document.at_xpath(xpath, namespaces) : document.at_xpath(xpath)
135
+ present_string(node&.text)
136
+ end
137
+
138
+ def line_string_coordinates_node
139
+ document.at_xpath("//kml:LineString/kml:coordinates", KML_NAMESPACES) ||
140
+ document.at_xpath("//LineString/coordinates")
141
+ end
142
+
143
+ def gx_track_coordinate_node
144
+ first_gx_track&.at_xpath("gx:coord", KML_NAMESPACES) ||
145
+ first_gx_track&.at_xpath("*[local-name()='coord']")
146
+ end
147
+
148
+ def gx_track_coordinate_nodes
149
+ track = first_gx_track
150
+ return document.xpath("//none") unless track
151
+
152
+ nodes = track.xpath("gx:coord", KML_NAMESPACES)
153
+ return nodes if nodes.any?
154
+
155
+ track.xpath("*[local-name()='coord']")
156
+ end
157
+
158
+ def first_gx_track
159
+ document.at_xpath("//gx:Track", KML_NAMESPACES) ||
160
+ document.at_xpath("//*[local-name()='Track']")
161
+ end
162
+
163
+ def parse_coordinates_text(text)
164
+ normalized = text.gsub(/\s*,\s*/, ",")
165
+ normalized.strip.split(/\s+/).filter_map do |coordinate|
166
+ parse_coordinate_values(coordinate.split(","))
167
+ end
168
+ end
169
+
170
+ def parse_gx_coords(nodes)
171
+ nodes.filter_map { |node| parse_coordinate_values(node.text.strip.split) }
172
+ end
173
+
174
+ def parse_coordinate_values(values)
175
+ return if values.length < 2
176
+
177
+ longitude = values[0].to_f
178
+ latitude = values[1].to_f
179
+ altitude = values[2]&.to_f
180
+ return unless valid_coordinate?(latitude, longitude)
181
+
182
+ [longitude, latitude, altitude]
183
+ end
184
+
185
+ def valid_coordinate?(latitude, longitude)
186
+ latitude.between?(-90, 90) && longitude.between?(-180, 180)
187
+ end
188
+
189
+ def present_string(value)
190
+ return if value.nil?
191
+
192
+ stripped = value.to_s.strip
193
+ stripped.empty? ? nil : stripped
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kml
4
+ module Path
5
+ class Result
6
+ attr_reader :name, :coordinates, :error
7
+
8
+ def initialize(success:, name:, coordinates:, error:)
9
+ @success = success
10
+ @name = name
11
+ @coordinates = coordinates
12
+ @error = error
13
+ end
14
+
15
+ def success?
16
+ @success
17
+ end
18
+
19
+ def failure?
20
+ !success?
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kml
4
+ module Path
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/lib/kml.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "zip"
5
+ require_relative "kml/path/version"
6
+ require_relative "kml/path/parse_error"
7
+ require_relative "kml/path/result"
8
+ require_relative "kml/path/fixtures"
9
+ require_relative "kml/path/parser"
10
+
11
+ module Kml
12
+ end
metadata ADDED
@@ -0,0 +1,115 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kml-path-parser
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Josh McArthur
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rubyzip
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.3'
40
+ description: Parse LineString and gx:Track geometry from real-world KML and KMZ exports
41
+ (Google Earth, My Maps, and similar tools) into path names and coordinate arrays.
42
+ email:
43
+ - joshua.mcarthur@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - LICENSE
50
+ - README.md
51
+ - lib/kml.rb
52
+ - lib/kml/path/fixtures.rb
53
+ - lib/kml/path/fixtures/reject/empty_archive.kmz
54
+ - lib/kml/path/fixtures/reject/empty_document.kml
55
+ - lib/kml/path/fixtures/reject/empty_linestring.kml
56
+ - lib/kml/path/fixtures/reject/point_only.kml
57
+ - lib/kml/path/fixtures/reject/polygon_only.kml
58
+ - lib/kml/path/fixtures/reject/single_point.kml
59
+ - lib/kml/path/fixtures/valid/cdata_name.kml
60
+ - lib/kml/path/fixtures/valid/coordinates_extra_whitespace.kml
61
+ - lib/kml/path/fixtures/valid/coordinates_no_altitude.kml
62
+ - lib/kml/path/fixtures/valid/coordinates_single_line.kml
63
+ - lib/kml/path/fixtures/valid/crlf_line_endings.kml
64
+ - lib/kml/path/fixtures/valid/document_name_priority.kml
65
+ - lib/kml/path/fixtures/valid/gx_multitrack_first_wins.kml
66
+ - lib/kml/path/fixtures/valid/gx_track.kml
67
+ - lib/kml/path/fixtures/valid/gx_track_with_timestamps.kml
68
+ - lib/kml/path/fixtures/valid/invalid_coords_filtered.kml
69
+ - lib/kml/path/fixtures/valid/kmz_custom_kml_name.kmz
70
+ - lib/kml/path/fixtures/valid/kmz_with_macosx_junk.kmz
71
+ - lib/kml/path/fixtures/valid/linestring_attributes.kml
72
+ - lib/kml/path/fixtures/valid/linestring_before_point.kml
73
+ - lib/kml/path/fixtures/valid/many_points.kml
74
+ - lib/kml/path/fixtures/valid/multi_geometry.kml
75
+ - lib/kml/path/fixtures/valid/multiple_placemarks_first_wins.kml
76
+ - lib/kml/path/fixtures/valid/namespace_less.kml
77
+ - lib/kml/path/fixtures/valid/nested_folder.kml
78
+ - lib/kml/path/fixtures/valid/nested_multigeometry_folder.kml
79
+ - lib/kml/path/fixtures/valid/placemark_name.kml
80
+ - lib/kml/path/fixtures/valid/qgis_export.kml
81
+ - lib/kml/path/fixtures/valid/sample_route.kml
82
+ - lib/kml/path/fixtures/valid/sample_route.kmz
83
+ - lib/kml/path/fixtures/valid/two_points.kml
84
+ - lib/kml/path/fixtures/valid/unicode_name.kml
85
+ - lib/kml/path/fixtures/valid/unnamed.kml
86
+ - lib/kml/path/parse_error.rb
87
+ - lib/kml/path/parser.rb
88
+ - lib/kml/path/result.rb
89
+ - lib/kml/path/version.rb
90
+ homepage: https://github.com/joshmcarthur/kml-path-parser
91
+ licenses:
92
+ - MIT
93
+ metadata:
94
+ rubygems_mfa_required: 'true'
95
+ homepage_uri: https://github.com/joshmcarthur/kml-path-parser
96
+ source_code_uri: https://github.com/joshmcarthur/kml-path-parser
97
+ changelog_uri: https://github.com/joshmcarthur/kml-path-parser/blob/main/CHANGELOG.md
98
+ rdoc_options: []
99
+ require_paths:
100
+ - lib
101
+ required_ruby_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '3.2'
106
+ required_rubygems_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubygems_version: 4.0.10
113
+ specification_version: 4
114
+ summary: Extract paths from user-uploaded KML and KMZ files
115
+ test_files: []