Skip to content

Commit 1f057b6

Browse files
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.
1 parent 5167864 commit 1f057b6

3 files changed

Lines changed: 155 additions & 4 deletions

File tree

README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ KML is a large format and this package covers a subset of it. What that subset i
7272
| `LineStyle` | yes | `color`, `width` |
7373
| `PolyStyle` | yes | `color`, `fill`, `outline` |
7474
| `ExtendedData` | yes | both `Data` pairs and `SchemaData/SimpleData` entries |
75-
| `Folder` | no | folders are flattened, the hierarchy is lost |
75+
| `Folder` | yes | the list stays flat, each placemark carries the path of folders containing it |
7676
| `NetworkLink` | no | |
7777
| `GroundOverlay`, `ScreenOverlay`, `PhotoOverlay` | no | |
7878
| `TimeStamp`, `TimeSpan` | no | |
@@ -87,14 +87,15 @@ Anything in the "no" column is ignored rather than rejected. A document using th
8787

8888
### `getPlacemarks(): array`
8989

90-
A list, one entry per Placemark, in document order. `name` and `description` are always present, `styleUrl` and `extendedData` only when the Placemark declares them.
90+
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.
9191

9292
A `Point` carries a single position:
9393

9494
```php
9595
[
9696
'name' => 'Lago Blu',
9797
'description' => 'A lake',
98+
'folder' => ['Piemonte', 'Laghi'],
9899
'type' => 'Point',
99100
'coordinates' => [
100101
'longitude' => 7.7,
@@ -106,12 +107,22 @@ A `Point` carries a single position:
106107
]
107108
```
108109

110+
`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:
111+
112+
```php
113+
collect($parser->getPlacemarks())
114+
->groupBy(fn (array $placemark) => implode('/', $placemark['folder']));
115+
```
116+
117+
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.
118+
109119
A `LineString` carries a list of them:
110120

111121
```php
112122
[
113123
'name' => 'Route',
114124
'description' => '',
125+
'folder' => [],
115126
'type' => 'LineString',
116127
'coordinates' => [
117128
['longitude' => 7.1, 'latitude' => 45.1, 'altitude' => 0.0],
@@ -126,6 +137,7 @@ A `Polygon` splits its rings:
126137
[
127138
'name' => 'Area',
128139
'description' => '',
140+
'folder' => [],
129141
'type' => 'Polygon',
130142
'coordinates' => [
131143
'outerBoundary' => [
@@ -150,6 +162,7 @@ A `MultiGeometry` has no `coordinates` of its own. It carries `geometries` inste
150162
[
151163
'name' => 'Mixed',
152164
'description' => '',
165+
'folder' => [],
153166
'type' => 'MultiGeometry',
154167
'geometries' => [
155168
['type' => 'Point', 'coordinates' => [...]],
@@ -206,7 +219,7 @@ The `name` and `description` of the first `Document` element, or `null` when abs
206219

207220
### `toGeoJson(): array`
208221

209-
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`.
222+
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.
210223

211224
```php
212225
[

src/KmlParser.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class KmlParser
1919

2020
protected string $namespace = 'http://www.opengis.net/kml/2.2';
2121

22+
protected string $documentNamespace = 'http://www.opengis.net/kml/2.2';
23+
2224
protected KmlValidator $validator;
2325

2426
public function __construct()
@@ -75,7 +77,8 @@ public function loadFromString(string $content): self
7577
$this->validator->validateDocument($xml);
7678

7779
$this->xml = $xml;
78-
$this->xml->registerXPathNamespace('kml', $this->validator->documentNamespace());
80+
$this->documentNamespace = $this->validator->documentNamespace();
81+
$this->xml->registerXPathNamespace('kml', $this->documentNamespace);
7982

8083
return $this;
8184
}
@@ -111,6 +114,7 @@ public function getPlacemarks(): array
111114
$placemark = [
112115
'name' => (string) $placemarkXml->name,
113116
'description' => (string) $placemarkXml->description,
117+
'folder' => $this->folderPath($placemarkXml),
114118
];
115119

116120
foreach (GeometryType::cases() as $type) {
@@ -139,6 +143,31 @@ public function getPlacemarks(): array
139143
return $placemarks;
140144
}
141145

146+
/**
147+
* The Folder elements containing a Placemark, outermost first.
148+
*
149+
* Placemarks are collected with a flat //kml:Placemark query, which is
150+
* what makes a Folder invisible in the result. Rather than walking the
151+
* tree twice, each Placemark is asked for its own ancestors.
152+
*
153+
* A Folder without a name contributes an empty string, so the length of
154+
* the path always matches the real nesting depth.
155+
*
156+
* @return array<int, string>
157+
*/
158+
protected function folderPath(SimpleXMLElement $placemark): array
159+
{
160+
$placemark->registerXPathNamespace('kml', $this->documentNamespace);
161+
162+
$path = [];
163+
164+
foreach ($placemark->xpath('ancestor::kml:Folder') ?: [] as $folder) {
165+
$path[] = (string) $folder->name;
166+
}
167+
168+
return $path;
169+
}
170+
142171
/**
143172
* Turn one KML geometry element into its array representation.
144173
*
@@ -403,6 +432,10 @@ public function toGeoJson(): array
403432
'geometry' => $geometry,
404433
];
405434

435+
if ($placemark['folder'] !== []) {
436+
$feature['properties']['folder'] = $placemark['folder'];
437+
}
438+
406439
if (isset($placemark['styleUrl'])) {
407440
$feature['properties']['styleUrl'] = $placemark['styleUrl'];
408441
}

tests/FolderHierarchyTest.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
use PlinCode\KmlParser\KmlParser;
4+
5+
function foldersKml(string $namespace = 'http://www.opengis.net/kml/2.2'): string
6+
{
7+
return <<<XML
8+
<?xml version="1.0" encoding="UTF-8"?>
9+
<kml xmlns="{$namespace}">
10+
<Document>
11+
<name>Regions</name>
12+
<Placemark>
13+
<name>Loose</name>
14+
<Point><coordinates>7.0,45.0,0</coordinates></Point>
15+
</Placemark>
16+
<Folder>
17+
<name>Piemonte</name>
18+
<Placemark>
19+
<name>Torino</name>
20+
<Point><coordinates>7.1,45.1,0</coordinates></Point>
21+
</Placemark>
22+
<Folder>
23+
<name>Laghi</name>
24+
<Placemark>
25+
<name>Lago Blu</name>
26+
<Point><coordinates>7.2,45.2,0</coordinates></Point>
27+
</Placemark>
28+
</Folder>
29+
<Folder>
30+
<Placemark>
31+
<name>Unnamed folder</name>
32+
<Point><coordinates>7.3,45.3,0</coordinates></Point>
33+
</Placemark>
34+
</Folder>
35+
</Folder>
36+
<Folder>
37+
<name>Liguria</name>
38+
<Placemark>
39+
<name>Genova</name>
40+
<Point><coordinates>8.9,44.4,0</coordinates></Point>
41+
</Placemark>
42+
</Folder>
43+
</Document>
44+
</kml>
45+
XML;
46+
}
47+
48+
function folderPaths(string $kml): array
49+
{
50+
$placemarks = (new KmlParser)->loadFromString($kml)->getPlacemarks();
51+
52+
return array_combine(
53+
array_column($placemarks, 'name'),
54+
array_column($placemarks, 'folder'),
55+
);
56+
}
57+
58+
it('reports an empty path for a placemark outside every folder', function () {
59+
expect(folderPaths(foldersKml())['Loose'])->toBe([]);
60+
});
61+
62+
it('reports the folder a placemark sits in', function () {
63+
expect(folderPaths(foldersKml())['Torino'])->toBe(['Piemonte']);
64+
});
65+
66+
it('reports nested folders outermost first', function () {
67+
expect(folderPaths(foldersKml())['Lago Blu'])->toBe(['Piemonte', 'Laghi']);
68+
});
69+
70+
it('keeps the depth when a folder has no name', function () {
71+
expect(folderPaths(foldersKml())['Unnamed folder'])->toBe(['Piemonte', '']);
72+
});
73+
74+
it('keeps sibling folders apart', function () {
75+
expect(folderPaths(foldersKml())['Genova'])->toBe(['Liguria']);
76+
});
77+
78+
it('still returns every placemark in document order', function () {
79+
$placemarks = (new KmlParser)->loadFromString(foldersKml())->getPlacemarks();
80+
81+
expect(array_column($placemarks, 'name'))
82+
->toBe(['Loose', 'Torino', 'Lago Blu', 'Unnamed folder', 'Genova']);
83+
});
84+
85+
it('resolves folders in a legacy namespace too', function () {
86+
expect(folderPaths(foldersKml('http://earth.google.com/kml/2.1'))['Lago Blu'])
87+
->toBe(['Piemonte', 'Laghi']);
88+
});
89+
90+
it('carries the folder into the GeoJSON properties', function () {
91+
$features = (new KmlParser)->loadFromString(foldersKml())->toGeoJson()['features'];
92+
93+
$byName = array_combine(array_column(array_column($features, 'properties'), 'name'), $features);
94+
95+
expect($byName['Lago Blu']['properties']['folder'])->toBe(['Piemonte', 'Laghi']);
96+
});
97+
98+
it('leaves the folder out of the GeoJSON properties when there is none', function () {
99+
$features = (new KmlParser)->loadFromString(foldersKml())->toGeoJson()['features'];
100+
101+
expect($features[0]['properties'])->toBe([
102+
'name' => 'Loose',
103+
'description' => '',
104+
]);
105+
});

0 commit comments

Comments
 (0)