Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand All @@ -87,14 +87,15 @@ 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:

```php
[
'name' => 'Lago Blu',
'description' => 'A lake',
'folder' => ['Piemonte', 'Laghi'],
'type' => 'Point',
'coordinates' => [
'longitude' => 7.7,
Expand All @@ -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],
Expand All @@ -126,6 +137,7 @@ A `Polygon` splits its rings:
[
'name' => 'Area',
'description' => '',
'folder' => [],
'type' => 'Polygon',
'coordinates' => [
'outerBoundary' => [
Expand All @@ -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' => [...]],
Expand Down Expand Up @@ -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
[
Expand Down
35 changes: 34 additions & 1 deletion src/KmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<int, string>
*/
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.
*
Expand Down Expand Up @@ -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'];
}
Expand Down
105 changes: 105 additions & 0 deletions tests/FolderHierarchyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

use PlinCode\KmlParser\KmlParser;

function foldersKml(string $namespace = 'http://www.opengis.net/kml/2.2'): string
{
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="{$namespace}">
<Document>
<name>Regions</name>
<Placemark>
<name>Loose</name>
<Point><coordinates>7.0,45.0,0</coordinates></Point>
</Placemark>
<Folder>
<name>Piemonte</name>
<Placemark>
<name>Torino</name>
<Point><coordinates>7.1,45.1,0</coordinates></Point>
</Placemark>
<Folder>
<name>Laghi</name>
<Placemark>
<name>Lago Blu</name>
<Point><coordinates>7.2,45.2,0</coordinates></Point>
</Placemark>
</Folder>
<Folder>
<Placemark>
<name>Unnamed folder</name>
<Point><coordinates>7.3,45.3,0</coordinates></Point>
</Placemark>
</Folder>
</Folder>
<Folder>
<name>Liguria</name>
<Placemark>
<name>Genova</name>
<Point><coordinates>8.9,44.4,0</coordinates></Point>
</Placemark>
</Folder>
</Document>
</kml>
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' => '',
]);
});