From 1bddbe5e3d17b6995c73473230ff12e6c3cf0912 Mon Sep 17 00:00:00 2001 From: Daniele Barbaro Date: Tue, 8 Sep 2026 14:54:09 +0200 Subject: [PATCH] feat(parser): support MultiGeometry placemarks GeometryType already declared MULTI_GEOMETRY, but the validator fed the element to validateGeometryCoordinates(), which looks for a coordinates child a MultiGeometry does not have. Every document containing one was rejected at load time with "Empty coordinates in geometry", and the parser had no handling for it either. MultiGeometry is what Google My Maps and ogr2ogr emit for a feature made of several shapes, so this covers a large slice of real world files. The validator now walks the nested geometries and validates each one, recursing when a MultiGeometry contains another, and rejects one that is empty. The parser returns the placemark as type MultiGeometry with a geometries list instead of coordinates, and toGeoJson() maps it onto a GeoJSON GeometryCollection, which nests the same way. Geometry parsing and GeoJSON emission were both open coded if/elseif chains; they are now dispatched off GeometryType so a new geometry is added in one place. Output for Point, LineString and Polygon is unchanged, byte for byte. --- src/KmlParser.php | 247 +++++++++++++++++++------------ src/Traits/ParsesCoordinates.php | 14 ++ src/Validators/KmlValidator.php | 40 ++++- tests/GeoJsonOutputTest.php | 87 +++++++++++ tests/MultiGeometryTest.php | 133 +++++++++++++++++ 5 files changed, 422 insertions(+), 99 deletions(-) create mode 100644 tests/GeoJsonOutputTest.php create mode 100644 tests/MultiGeometryTest.php diff --git a/src/KmlParser.php b/src/KmlParser.php index c899dd1..4b09a89 100755 --- a/src/KmlParser.php +++ b/src/KmlParser.php @@ -3,6 +3,7 @@ namespace PlinCode\KmlParser; use Exception; +use PlinCode\KmlParser\Enums\GeometryType; use PlinCode\KmlParser\Exceptions\KmlParserException; use PlinCode\KmlParser\Traits\ParsesCoordinates; use PlinCode\KmlParser\Validators\KmlValidator; @@ -60,7 +61,7 @@ public function loadFromString(string $content): self $this->xml->registerXPathNamespace('kml', $this->namespace); return $this; - } catch (\Exception $e) { + } catch (Exception $e) { libxml_clear_errors(); throw KmlParserException::failedToParse($e->getMessage()); } @@ -99,26 +100,16 @@ public function getPlacemarks(): array 'description' => (string) $placemarkXml->description, ]; - if ($placemarkXml->Point) { - $coords = (string) $placemarkXml->Point->coordinates; - $coordsArray = explode(',', trim($coords)); - $placemark['type'] = 'Point'; - $placemark['coordinates'] = [ - 'longitude' => (float) $coordsArray[0], - 'latitude' => (float) $coordsArray[1], - 'altitude' => isset($coordsArray[2]) ? (float) $coordsArray[2] : 0, - ]; - } + foreach (GeometryType::cases() as $type) { + if ($placemarkXml->{$type->value}) { + $geometry = $this->parseGeometry($type, $placemarkXml->{$type->value}); - if ($placemarkXml->LineString) { - $coords = (string) $placemarkXml->LineString->coordinates; - $placemark['type'] = 'LineString'; - $placemark['coordinates'] = $this->parseLineStringCoordinates($coords); - } + if ($geometry !== null) { + $placemark = array_merge($placemark, $geometry); + } - if ($placemarkXml->Polygon) { - $placemark['type'] = 'Polygon'; - $placemark['coordinates'] = $this->parsePolygonCoordinates($placemarkXml->Polygon); + break; + } } if ($placemarkXml->styleUrl) { @@ -141,6 +132,60 @@ public function getPlacemarks(): array return $placemarks; } + /** + * Turn one KML geometry element into its array representation. + * + * @return array|null + */ + protected function parseGeometry(GeometryType $type, SimpleXMLElement $geometry): ?array + { + return match ($type) { + GeometryType::POINT => [ + 'type' => $type->value, + 'coordinates' => $this->parsePointCoordinates((string) $geometry->coordinates), + ], + GeometryType::LINE_STRING => [ + 'type' => $type->value, + 'coordinates' => $this->parseLineStringCoordinates((string) $geometry->coordinates), + ], + GeometryType::POLYGON => [ + 'type' => $type->value, + 'coordinates' => $this->parsePolygonCoordinates($geometry), + ], + GeometryType::MULTI_GEOMETRY => [ + 'type' => $type->value, + 'geometries' => $this->parseMultiGeometry($geometry), + ], + }; + } + + /** + * A MultiGeometry holds nested geometries instead of coordinates, and KML + * allows those to be MultiGeometry elements in turn. + * + * @return array> + */ + protected function parseMultiGeometry(SimpleXMLElement $multiGeometry): array + { + $geometries = []; + + foreach ($multiGeometry->children() as $name => $child) { + $type = GeometryType::tryFrom((string) $name); + + if ($type === null) { + continue; + } + + $geometry = $this->parseGeometry($type, $child); + + if ($geometry !== null) { + $geometries[] = $geometry; + } + } + + return $geometries; + } + /** * Get Style Node from the KML * @@ -239,86 +284,32 @@ public function getStyleMaps(): array public function toGeoJson(): array { $features = []; - $placemarks = $this->getPlacemarks(); - - foreach ($placemarks as $placemark) { - if (isset($placemark['coordinates'])) { - $feature = [ - 'type' => 'Feature', - 'properties' => [ - 'name' => $placemark['name'], - 'description' => $placemark['description'], - ], - ]; - - // Set geometry based on type - if ($placemark['type'] === 'Point') { - $feature['geometry'] = [ - 'type' => 'Point', - 'coordinates' => [ - $placemark['coordinates']['longitude'], - $placemark['coordinates']['latitude'], - $placemark['coordinates']['altitude'], - ], - ]; - } elseif ($placemark['type'] === 'LineString') { - $coordinates = []; - foreach ($placemark['coordinates'] as $coord) { - $coordinates[] = [ - $coord['longitude'], - $coord['latitude'], - $coord['altitude'], - ]; - } - - $feature['geometry'] = [ - 'type' => 'LineString', - 'coordinates' => $coordinates, - ]; - } elseif ($placemark['type'] === 'Polygon') { - $outerCoordinates = []; - foreach ($placemark['coordinates']['outerBoundary'] as $coord) { - $outerCoordinates[] = [ - $coord['longitude'], - $coord['latitude'], - $coord['altitude'], - ]; - } - $innerCoordinates = []; - foreach ($placemark['coordinates']['innerBoundaries'] as $innerBoundary) { - $innerBoundaryCoords = []; - foreach ($innerBoundary as $coord) { - $innerBoundaryCoords[] = [ - $coord['longitude'], - $coord['latitude'], - $coord['altitude'], - ]; - } - $innerCoordinates[] = $innerBoundaryCoords; - } - - $allCoordinates = [$outerCoordinates]; - if (! empty($innerCoordinates)) { - $allCoordinates = array_merge($allCoordinates, $innerCoordinates); - } + foreach ($this->getPlacemarks() as $placemark) { + $geometry = $this->toGeoJsonGeometry($placemark); - $feature['geometry'] = [ - 'type' => 'Polygon', - 'coordinates' => $allCoordinates, - ]; - } + if ($geometry === null) { + continue; + } - if (isset($placemark['styleUrl'])) { - $feature['properties']['styleUrl'] = $placemark['styleUrl']; - } + $feature = [ + 'type' => 'Feature', + 'properties' => [ + 'name' => $placemark['name'], + 'description' => $placemark['description'], + ], + 'geometry' => $geometry, + ]; - if (isset($placemark['extendedData'])) { - $feature['properties']['extendedData'] = $placemark['extendedData']; - } + if (isset($placemark['styleUrl'])) { + $feature['properties']['styleUrl'] = $placemark['styleUrl']; + } - $features[] = $feature; + if (isset($placemark['extendedData'])) { + $feature['properties']['extendedData'] = $placemark['extendedData']; } + + $features[] = $feature; } return [ @@ -327,6 +318,76 @@ public function toGeoJson(): array ]; } + /** + * A KML MultiGeometry maps onto a GeoJSON GeometryCollection, which nests + * the same way, so this recurses alongside parseMultiGeometry(). + * + * @param array $geometry + * @return array|null + */ + protected function toGeoJsonGeometry(array $geometry): ?array + { + return match ($geometry['type'] ?? null) { + GeometryType::POINT->value => [ + 'type' => 'Point', + 'coordinates' => $this->toGeoJsonPosition($geometry['coordinates']), + ], + GeometryType::LINE_STRING->value => [ + 'type' => 'LineString', + 'coordinates' => array_map( + fn (array $position) => $this->toGeoJsonPosition($position), + $geometry['coordinates'], + ), + ], + GeometryType::POLYGON->value => [ + 'type' => 'Polygon', + 'coordinates' => $this->toGeoJsonRings($geometry['coordinates']), + ], + GeometryType::MULTI_GEOMETRY->value => [ + 'type' => 'GeometryCollection', + 'geometries' => array_values(array_filter(array_map( + fn (array $child) => $this->toGeoJsonGeometry($child), + $geometry['geometries'], + ))), + ], + default => null, + }; + } + + /** + * @param array{longitude: float, latitude: float, altitude: float} $position + * @return array + */ + protected function toGeoJsonPosition(array $position): array + { + return [$position['longitude'], $position['latitude'], $position['altitude']]; + } + + /** + * GeoJSON puts the outer ring first and every inner ring after it. + * + * @param array{outerBoundary: array>, innerBoundaries: array>>} $boundaries + * @return array>> + */ + protected function toGeoJsonRings(array $boundaries): array + { + $rings = [ + array_map( + fn (array $position) => $this->toGeoJsonPosition($position), + $boundaries['outerBoundary'], + ), + ]; + + foreach ($boundaries['innerBoundaries'] as $innerBoundary) { + $rings[] = array_map( + fn (array $position) => $this->toGeoJsonPosition($position), + $innerBoundary, + ); + } + + return $rings; + } + /** * Get Document Node from the KML * diff --git a/src/Traits/ParsesCoordinates.php b/src/Traits/ParsesCoordinates.php index 752873e..926c41b 100644 --- a/src/Traits/ParsesCoordinates.php +++ b/src/Traits/ParsesCoordinates.php @@ -6,6 +6,20 @@ trait ParsesCoordinates { + /** + * @return array{longitude: float, latitude: float, altitude: float} + */ + protected function parsePointCoordinates(string $coordinates): array + { + $parts = explode(',', trim($coordinates)); + + return [ + 'longitude' => (float) $parts[0], + 'latitude' => (float) ($parts[1] ?? 0), + 'altitude' => isset($parts[2]) ? (float) $parts[2] : 0, + ]; + } + protected function parseLineStringCoordinates(string $coordinates): array { $coords = []; diff --git a/src/Validators/KmlValidator.php b/src/Validators/KmlValidator.php index 079eef0..f814b13 100644 --- a/src/Validators/KmlValidator.php +++ b/src/Validators/KmlValidator.php @@ -47,17 +47,45 @@ public function validate(string $content): void protected function validatePlacemark(SimpleXMLElement $placemark): void { - $hasGeometry = false; foreach (GeometryType::cases() as $type) { if ($placemark->{$type->value}) { - $hasGeometry = true; - $this->validateGeometryCoordinates($placemark->{$type->value}, $type->value); - break; + $this->validateGeometry($placemark->{$type->value}, $type); + + return; + } + } + + throw new KmlException('Found Placemark without valid geometry'); + } + + protected function validateGeometry(SimpleXMLElement $geometry, GeometryType $type): void + { + if ($type === GeometryType::MULTI_GEOMETRY) { + $this->validateMultiGeometry($geometry); + + return; + } + + $this->validateGeometryCoordinates($geometry, $type->value); + } + + /** + * A MultiGeometry carries no coordinates of its own, only nested + * geometries, and KML allows those to be MultiGeometry elements in turn. + */ + protected function validateMultiGeometry(SimpleXMLElement $multiGeometry): void + { + $found = false; + + foreach (GeometryType::cases() as $type) { + foreach ($multiGeometry->{$type->value} as $child) { + $found = true; + $this->validateGeometry($child, $type); } } - if (! $hasGeometry) { - throw new KmlException('Found Placemark without valid geometry'); + if (! $found) { + throw new KmlException('Found MultiGeometry without any geometry'); } } diff --git a/tests/GeoJsonOutputTest.php b/tests/GeoJsonOutputTest.php new file mode 100644 index 0000000..55e4679 --- /dev/null +++ b/tests/GeoJsonOutputTest.php @@ -0,0 +1,87 @@ + + + + + Line + A line + #shared + + 7.1,45.1,5 7.2,45.2,6 + + + + Ring + A polygon with a hole + + 12 + + + + + 7.0,45.0,0 7.4,45.0,0 7.4,45.4,0 7.0,45.0,0 + + + + + 7.1,45.1,0 7.2,45.1,0 7.2,45.2,0 7.1,45.1,0 + + + + + + +XML; + +it('emits a LineString feature unchanged', function () use ($shapesKml) { + $feature = (new KmlParser)->loadFromString($shapesKml)->toGeoJson()['features'][0]; + + expect($feature)->toBe([ + 'type' => 'Feature', + 'properties' => [ + 'name' => 'Line', + 'description' => 'A line', + 'styleUrl' => '#shared', + ], + 'geometry' => [ + 'type' => 'LineString', + 'coordinates' => [ + [7.1, 45.1, 5.0], + [7.2, 45.2, 6.0], + ], + ], + ]); +}); + +it('emits a Polygon feature with the outer ring first', function () use ($shapesKml) { + $feature = (new KmlParser)->loadFromString($shapesKml)->toGeoJson()['features'][1]; + + expect($feature)->toBe([ + 'type' => 'Feature', + 'properties' => [ + 'name' => 'Ring', + 'description' => 'A polygon with a hole', + 'extendedData' => ['area' => '12'], + ], + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => [ + [[7.0, 45.0, 0.0], [7.4, 45.0, 0.0], [7.4, 45.4, 0.0], [7.0, 45.0, 0.0]], + [[7.1, 45.1, 0.0], [7.2, 45.1, 0.0], [7.2, 45.2, 0.0], [7.1, 45.1, 0.0]], + ], + ], + ]); +}); + +it('parses the polygon boundaries into outer and inner sets', function () use ($shapesKml) { + $placemark = (new KmlParser)->loadFromString($shapesKml)->getPlacemarks()[1]; + + expect($placemark['type'])->toBe('Polygon') + ->and($placemark['coordinates']['outerBoundary'])->toHaveCount(4) + ->and($placemark['coordinates']['innerBoundaries'])->toHaveCount(1) + ->and($placemark['coordinates']['innerBoundaries'][0])->toHaveCount(4); +}); diff --git a/tests/MultiGeometryTest.php b/tests/MultiGeometryTest.php new file mode 100644 index 0000000..b4e5fbc --- /dev/null +++ b/tests/MultiGeometryTest.php @@ -0,0 +1,133 @@ + + + + + Mixed + One placemark, three geometries + + + 7.7,45.8,10 + + + 7.1,45.1,0 7.2,45.2,0 + + + + + 7.0,45.0,0 7.1,45.0,0 7.1,45.1,0 7.0,45.0,0 + + + + + + + +XML; + +it('loads a document containing a MultiGeometry', function () use ($multiGeometryKml) { + expect(fn () => (new KmlParser)->loadFromString($multiGeometryKml)) + ->not->toThrow(KmlException::class); +}); + +it('parses every geometry nested in a MultiGeometry', function () use ($multiGeometryKml) { + $placemark = (new KmlParser)->loadFromString($multiGeometryKml)->getPlacemarks()[0]; + + expect($placemark['name'])->toBe('Mixed') + ->and($placemark['type'])->toBe('MultiGeometry') + ->and($placemark)->not->toHaveKey('coordinates') + ->and($placemark['geometries'])->toHaveCount(3); + + [$point, $line, $polygon] = $placemark['geometries']; + + expect($point['type'])->toBe('Point') + ->and($point['coordinates'])->toBe(['longitude' => 7.7, 'latitude' => 45.8, 'altitude' => 10.0]) + ->and($line['type'])->toBe('LineString') + ->and($line['coordinates'])->toHaveCount(2) + ->and($polygon['type'])->toBe('Polygon') + ->and($polygon['coordinates']['outerBoundary'])->toHaveCount(4) + ->and($polygon['coordinates']['innerBoundaries'])->toBe([]); +}); + +it('emits a MultiGeometry as a GeoJSON GeometryCollection', function () use ($multiGeometryKml) { + $feature = (new KmlParser)->loadFromString($multiGeometryKml)->toGeoJson()['features'][0]; + + expect($feature['geometry']['type'])->toBe('GeometryCollection') + ->and($feature['geometry']['geometries'])->toHaveCount(3) + ->and($feature['geometry']['geometries'][0])->toBe([ + 'type' => 'Point', + 'coordinates' => [7.7, 45.8, 10.0], + ]) + ->and($feature['geometry']['geometries'][1]['coordinates'])->toBe([ + [7.1, 45.1, 0.0], + [7.2, 45.2, 0.0], + ]) + ->and($feature['geometry']['geometries'][2]['type'])->toBe('Polygon') + ->and($feature['geometry']['geometries'][2]['coordinates'])->toHaveCount(1); +}); + +it('handles a MultiGeometry nested inside another one', function () { + $nested = <<<'XML' + + + + + + + + 7.7,45.8,0 + + + + + + +XML; + + $geometry = (new KmlParser)->loadFromString($nested)->toGeoJson()['features'][0]['geometry']; + + expect($geometry['type'])->toBe('GeometryCollection') + ->and($geometry['geometries'][0]['type'])->toBe('GeometryCollection') + ->and($geometry['geometries'][0]['geometries'][0]['type'])->toBe('Point'); +}); + +it('rejects a MultiGeometry that holds no geometry', function () { + $empty = <<<'XML' + + + + + + + + +XML; + + expect(fn () => (new KmlParser)->loadFromString($empty)) + ->toThrow(KmlException::class, 'Found MultiGeometry without any geometry'); +}); + +it('still validates the coordinates nested in a MultiGeometry', function () { + $badLatitude = <<<'XML' + + + + + + + 7.7,91,0 + + + + + +XML; + + expect(fn () => (new KmlParser)->loadFromString($badLatitude)) + ->toThrow(KmlException::class, 'Invalid latitude value: 91'); +});