diff --git a/README.md b/README.md index aa1618f..3551da7 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ KML is a large format and this package covers a subset of it. What that subset i | `LineStyle` | yes | `color`, `width` | | `PolyStyle` | yes | `color`, `fill`, `outline` | | `ExtendedData` | yes | both `Data` pairs and `SchemaData/SimpleData` entries | -| `Folder` | no | folders are flattened, the hierarchy is lost | +| `Folder` | yes | the list stays flat, each placemark carries the path of folders containing it | | `NetworkLink` | no | | | `GroundOverlay`, `ScreenOverlay`, `PhotoOverlay` | no | | | `TimeStamp`, `TimeSpan` | no | | @@ -87,7 +87,7 @@ Anything in the "no" column is ignored rather than rejected. A document using th ### `getPlacemarks(): array` -A list, one entry per Placemark, in document order. `name` and `description` are always present, `styleUrl` and `extendedData` only when the Placemark declares them. +A list, one entry per Placemark, in document order. `name`, `description` and `folder` are always present, `styleUrl` and `extendedData` only when the Placemark declares them. A `Point` carries a single position: @@ -95,6 +95,7 @@ A `Point` carries a single position: [ 'name' => 'Lago Blu', 'description' => 'A lake', + 'folder' => ['Piemonte', 'Laghi'], 'type' => 'Point', 'coordinates' => [ 'longitude' => 7.7, @@ -106,12 +107,22 @@ A `Point` carries a single position: ] ``` +`folder` is the names of the `Folder` elements containing the Placemark, outermost first, and an empty array for a Placemark sitting directly under the `Document`. The list itself stays flat, so grouping is yours to do: + +```php +collect($parser->getPlacemarks()) + ->groupBy(fn (array $placemark) => implode('/', $placemark['folder'])); +``` + +A `Folder` without a name contributes an empty string rather than being skipped, so the length of the path always matches the real nesting depth. + A `LineString` carries a list of them: ```php [ 'name' => 'Route', 'description' => '', + 'folder' => [], 'type' => 'LineString', 'coordinates' => [ ['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0], @@ -126,6 +137,7 @@ A `Polygon` splits its rings: [ 'name' => 'Area', 'description' => '', + 'folder' => [], 'type' => 'Polygon', 'coordinates' => [ 'outerBoundary' => [ @@ -150,6 +162,7 @@ A `MultiGeometry` has no `coordinates` of its own. It carries `geometries` inste [ 'name' => 'Mixed', 'description' => '', + 'folder' => [], 'type' => 'MultiGeometry', 'geometries' => [ ['type' => 'Point', 'coordinates' => [...]], @@ -206,7 +219,7 @@ The `name` and `description` of the first `Document` element, or `null` when abs ### `toGeoJson(): array` -A `FeatureCollection`. Positions come out as `[longitude, latitude, altitude]`, which is the GeoJSON order, and altitude is always present, `0` when the file omits it. `styleUrl` and `extendedData` are carried into `properties`. +A `FeatureCollection`. Positions come out as `[longitude, latitude, altitude]`, which is the GeoJSON order, and altitude is always present, `0` when the file omits it. `folder`, `styleUrl` and `extendedData` are carried into `properties`, each only when the Placemark has one. ```php [ diff --git a/src/KmlParser.php b/src/KmlParser.php index 1061449..44b2c66 100755 --- a/src/KmlParser.php +++ b/src/KmlParser.php @@ -19,6 +19,8 @@ class KmlParser protected string $namespace = 'http://www.opengis.net/kml/2.2'; + protected string $documentNamespace = 'http://www.opengis.net/kml/2.2'; + protected KmlValidator $validator; public function __construct() @@ -75,7 +77,8 @@ public function loadFromString(string $content): self $this->validator->validateDocument($xml); $this->xml = $xml; - $this->xml->registerXPathNamespace('kml', $this->validator->documentNamespace()); + $this->documentNamespace = $this->validator->documentNamespace(); + $this->xml->registerXPathNamespace('kml', $this->documentNamespace); return $this; } @@ -111,6 +114,7 @@ public function getPlacemarks(): array $placemark = [ 'name' => (string) $placemarkXml->name, 'description' => (string) $placemarkXml->description, + 'folder' => $this->folderPath($placemarkXml), ]; foreach (GeometryType::cases() as $type) { @@ -139,6 +143,31 @@ public function getPlacemarks(): array return $placemarks; } + /** + * The Folder elements containing a Placemark, outermost first. + * + * Placemarks are collected with a flat //kml:Placemark query, which is + * what makes a Folder invisible in the result. Rather than walking the + * tree twice, each Placemark is asked for its own ancestors. + * + * A Folder without a name contributes an empty string, so the length of + * the path always matches the real nesting depth. + * + * @return array + */ + protected function folderPath(SimpleXMLElement $placemark): array + { + $placemark->registerXPathNamespace('kml', $this->documentNamespace); + + $path = []; + + foreach ($placemark->xpath('ancestor::kml:Folder') ?: [] as $folder) { + $path[] = (string) $folder->name; + } + + return $path; + } + /** * Turn one KML geometry element into its array representation. * @@ -403,6 +432,10 @@ public function toGeoJson(): array 'geometry' => $geometry, ]; + if ($placemark['folder'] !== []) { + $feature['properties']['folder'] = $placemark['folder']; + } + if (isset($placemark['styleUrl'])) { $feature['properties']['styleUrl'] = $placemark['styleUrl']; } diff --git a/tests/FolderHierarchyTest.php b/tests/FolderHierarchyTest.php new file mode 100644 index 0000000..0868984 --- /dev/null +++ b/tests/FolderHierarchyTest.php @@ -0,0 +1,105 @@ + + + + Regions + + Loose + 7.0,45.0,0 + + + Piemonte + + Torino + 7.1,45.1,0 + + + Laghi + + Lago Blu + 7.2,45.2,0 + + + + + Unnamed folder + 7.3,45.3,0 + + + + + Liguria + + Genova + 8.9,44.4,0 + + + + +XML; +} + +function folderPaths(string $kml): array +{ + $placemarks = (new KmlParser)->loadFromString($kml)->getPlacemarks(); + + return array_combine( + array_column($placemarks, 'name'), + array_column($placemarks, 'folder'), + ); +} + +it('reports an empty path for a placemark outside every folder', function () { + expect(folderPaths(foldersKml())['Loose'])->toBe([]); +}); + +it('reports the folder a placemark sits in', function () { + expect(folderPaths(foldersKml())['Torino'])->toBe(['Piemonte']); +}); + +it('reports nested folders outermost first', function () { + expect(folderPaths(foldersKml())['Lago Blu'])->toBe(['Piemonte', 'Laghi']); +}); + +it('keeps the depth when a folder has no name', function () { + expect(folderPaths(foldersKml())['Unnamed folder'])->toBe(['Piemonte', '']); +}); + +it('keeps sibling folders apart', function () { + expect(folderPaths(foldersKml())['Genova'])->toBe(['Liguria']); +}); + +it('still returns every placemark in document order', function () { + $placemarks = (new KmlParser)->loadFromString(foldersKml())->getPlacemarks(); + + expect(array_column($placemarks, 'name')) + ->toBe(['Loose', 'Torino', 'Lago Blu', 'Unnamed folder', 'Genova']); +}); + +it('resolves folders in a legacy namespace too', function () { + expect(folderPaths(foldersKml('http://earth.google.com/kml/2.1'))['Lago Blu']) + ->toBe(['Piemonte', 'Laghi']); +}); + +it('carries the folder into the GeoJSON properties', function () { + $features = (new KmlParser)->loadFromString(foldersKml())->toGeoJson()['features']; + + $byName = array_combine(array_column(array_column($features, 'properties'), 'name'), $features); + + expect($byName['Lago Blu']['properties']['folder'])->toBe(['Piemonte', 'Laghi']); +}); + +it('leaves the folder out of the GeoJSON properties when there is none', function () { + $features = (new KmlParser)->loadFromString(foldersKml())->toGeoJson()['features']; + + expect($features[0]['properties'])->toBe([ + 'name' => 'Loose', + 'description' => '', + ]); +});