From 32b8a96eed6e98532cbed427553bf08105d17d6e Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Wed, 9 Sep 2026 08:43:37 +0200 Subject: [PATCH] feat(parser): carry the Folder path on every placemark Placemarks are collected with a flat //kml:Placemark query, so a document organised into folders came back as one undifferentiated list and the structure the author put there was lost. Anyone grouping a map by folder had to parse the file a second time themselves. Each placemark now carries a folder key holding the names of the Folder elements containing it, outermost first, and an empty array when it sits directly under the Document. The list stays flat, so nothing is duplicated and grouping is a one-liner for the caller. 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. Rather than walking the tree twice, each placemark is asked for its own ancestors, which also keeps this working on the legacy namespaces since the prefix is registered per node. toGeoJson() carries the path into properties, but only for a placemark that is actually in a folder, so output for flat documents is unchanged. --- README.md | 19 +++++- src/KmlParser.php | 35 +++++++++++- tests/FolderHierarchyTest.php | 105 ++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 tests/FolderHierarchyTest.php 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' => '', + ]); +});